Compare commits
21 Commits
252779c607
...
559fd38a52
| Author | SHA1 | Date | |
|---|---|---|---|
| 559fd38a52 | |||
| 488688aeac | |||
| 98ba578169 | |||
| d04183b066 | |||
| c29aaf9a2a | |||
| 0b24e26fb6 | |||
| e26284d072 | |||
| e061237496 | |||
| e23cd5e1bd | |||
| d23f7bca8f | |||
| de1117b0e6 | |||
| cdf67a200c | |||
| 54d106ca8b | |||
| 2fb7391a17 | |||
| 2bd31bbd55 | |||
| 75636c8ea3 | |||
| 0695c7958b | |||
| 52b5e90d92 | |||
| 9b858c5224 | |||
| b70857cc0e | |||
| b223cc5f81 |
@@ -600,9 +600,18 @@ abstract class AppText {
|
||||
String get inviteCopyLink; // 复制链接 / Copy link
|
||||
String get inviteRewardsTitle; // 奖励规则 / Rewards
|
||||
String get inviteRewardRegister; // 好友注册 · 双方各得 3 天 / Sign up · +3 days each
|
||||
String get inviteRewardFirstBuy; // 好友首购 · 双方各得 15 天 / First purchase · +15 days each
|
||||
String get inviteRewardFirstBuy; // 好友首购 · 双方各得 7 天 / First purchase · +7 days each
|
||||
String get inviteRewardJoinTg; // 加入 TG 频道 · 得 3 天 / Join TG · +3 days
|
||||
String get inviteProgressTitle; // 我的奖励 / My rewards
|
||||
String get inviteTasksTitle; // 更多得会员 / More ways to earn
|
||||
String get inviteJoinTgTitle; // 加入 Telegram 频道 +3 天 / Join Telegram channel
|
||||
String get inviteJoinTgBtn; // 加入频道 / Join channel
|
||||
String get inviteVerifyBtn; // 验证领取 / Verify & claim
|
||||
String get inviteTaskDone; // 已领 +3 天 / Claimed +3 days
|
||||
String get inviteStatInvited; // 已邀请 / Invited
|
||||
String get inviteStatConverted; // 已转化 / Converted
|
||||
String get inviteStatDays; // 累计获赠(天) / Days earned
|
||||
String get inviteCodeFieldLabel; // 邀请码(选填) / Invite code (optional)
|
||||
// ── 通知(Spec ③占位) ──
|
||||
String get notifTitle; // 通知 / Notifications
|
||||
String get notifEmpty; // 暂无通知 / No notifications
|
||||
|
||||
@@ -488,12 +488,30 @@ class StringsEn extends AppText {
|
||||
@override
|
||||
String get inviteRewardRegister => 'Sign up · +3 days each';
|
||||
@override
|
||||
String get inviteRewardFirstBuy => 'First purchase · +15 days each';
|
||||
String get inviteRewardFirstBuy => 'First purchase · +7 days each';
|
||||
@override
|
||||
String get inviteRewardJoinTg => 'Join TG · +3 days';
|
||||
@override
|
||||
String get inviteProgressTitle => 'My rewards';
|
||||
@override
|
||||
String get inviteTasksTitle => 'More ways to earn';
|
||||
@override
|
||||
String get inviteJoinTgTitle => 'Join Telegram channel';
|
||||
@override
|
||||
String get inviteJoinTgBtn => 'Join channel';
|
||||
@override
|
||||
String get inviteVerifyBtn => 'Verify & claim';
|
||||
@override
|
||||
String get inviteTaskDone => 'Claimed +3 days';
|
||||
@override
|
||||
String get inviteStatInvited => 'Invited';
|
||||
@override
|
||||
String get inviteStatConverted => 'Converted';
|
||||
@override
|
||||
String get inviteStatDays => 'Days earned';
|
||||
@override
|
||||
String get inviteCodeFieldLabel => 'Invite code (optional)';
|
||||
@override
|
||||
String get notifTitle => 'Notifications';
|
||||
@override
|
||||
String get notifEmpty => 'No notifications';
|
||||
|
||||
@@ -489,12 +489,30 @@ class StringsEs extends AppText {
|
||||
@override
|
||||
String get inviteRewardRegister => 'Registro de amigo · +3 días cada uno';
|
||||
@override
|
||||
String get inviteRewardFirstBuy => 'Primera compra del amigo · +15 días cada uno';
|
||||
String get inviteRewardFirstBuy => 'Primera compra del amigo · +7 días cada uno';
|
||||
@override
|
||||
String get inviteRewardJoinTg => 'Unirse al canal de TG · +3 días';
|
||||
@override
|
||||
String get inviteProgressTitle => 'Mis recompensas';
|
||||
@override
|
||||
String get inviteTasksTitle => 'Más formas de ganar';
|
||||
@override
|
||||
String get inviteJoinTgTitle => 'Unirse al canal de Telegram';
|
||||
@override
|
||||
String get inviteJoinTgBtn => 'Unirse al canal';
|
||||
@override
|
||||
String get inviteVerifyBtn => 'Verificar y reclamar';
|
||||
@override
|
||||
String get inviteTaskDone => 'Reclamado +3 días';
|
||||
@override
|
||||
String get inviteStatInvited => 'Invitados';
|
||||
@override
|
||||
String get inviteStatConverted => 'Convertidos';
|
||||
@override
|
||||
String get inviteStatDays => 'Días acumulados';
|
||||
@override
|
||||
String get inviteCodeFieldLabel => 'Código de invitación (opcional)';
|
||||
@override
|
||||
String get notifTitle => 'Notificaciones';
|
||||
@override
|
||||
String get notifEmpty => 'Aún no hay notificaciones';
|
||||
|
||||
@@ -489,12 +489,30 @@ class StringsJa extends AppText {
|
||||
@override
|
||||
String get inviteRewardRegister => '友達が登録 · 双方に3日間付与';
|
||||
@override
|
||||
String get inviteRewardFirstBuy => '友達が初回購入 · 双方に15日間付与';
|
||||
String get inviteRewardFirstBuy => '友達が初回購入 · 双方に7日間付与';
|
||||
@override
|
||||
String get inviteRewardJoinTg => 'TGチャンネルに参加 · 3日間付与';
|
||||
@override
|
||||
String get inviteProgressTitle => '獲得した特典';
|
||||
@override
|
||||
String get inviteTasksTitle => 'もっと会員特典を得る';
|
||||
@override
|
||||
String get inviteJoinTgTitle => 'Telegramチャンネルに参加';
|
||||
@override
|
||||
String get inviteJoinTgBtn => 'チャンネルに参加';
|
||||
@override
|
||||
String get inviteVerifyBtn => '確認して受け取る';
|
||||
@override
|
||||
String get inviteTaskDone => '受け取り済み +3日';
|
||||
@override
|
||||
String get inviteStatInvited => '招待した人数';
|
||||
@override
|
||||
String get inviteStatConverted => '転換した人数';
|
||||
@override
|
||||
String get inviteStatDays => '累計獲得日数';
|
||||
@override
|
||||
String get inviteCodeFieldLabel => '招待コード(任意)';
|
||||
@override
|
||||
String get notifTitle => '通知';
|
||||
@override
|
||||
String get notifEmpty => '通知はありません';
|
||||
|
||||
@@ -489,12 +489,30 @@ class StringsKo extends AppText {
|
||||
@override
|
||||
String get inviteRewardRegister => '친구 가입 · 양쪽 각 3일 지급';
|
||||
@override
|
||||
String get inviteRewardFirstBuy => '친구 첫 구매 · 양쪽 각 15일 지급';
|
||||
String get inviteRewardFirstBuy => '친구 첫 구매 · 양쪽 각 7일 지급';
|
||||
@override
|
||||
String get inviteRewardJoinTg => 'TG 채널 가입 · 3일 지급';
|
||||
@override
|
||||
String get inviteProgressTitle => '내 보상';
|
||||
@override
|
||||
String get inviteTasksTitle => '더 많은 혜택 받기';
|
||||
@override
|
||||
String get inviteJoinTgTitle => 'Telegram 채널 가입';
|
||||
@override
|
||||
String get inviteJoinTgBtn => '채널 가입';
|
||||
@override
|
||||
String get inviteVerifyBtn => '확인하고 받기';
|
||||
@override
|
||||
String get inviteTaskDone => '수령 완료 +3일';
|
||||
@override
|
||||
String get inviteStatInvited => '초대 수';
|
||||
@override
|
||||
String get inviteStatConverted => '전환 수';
|
||||
@override
|
||||
String get inviteStatDays => '누적 획득(일)';
|
||||
@override
|
||||
String get inviteCodeFieldLabel => '초대 코드(선택)';
|
||||
@override
|
||||
String get notifTitle => '알림';
|
||||
@override
|
||||
String get notifEmpty => '알림이 없습니다';
|
||||
|
||||
@@ -489,12 +489,30 @@ class StringsRu extends AppText {
|
||||
@override
|
||||
String get inviteRewardRegister => 'Регистрация друга · +3 дня каждому';
|
||||
@override
|
||||
String get inviteRewardFirstBuy => 'Первая покупка друга · +15 дней каждому';
|
||||
String get inviteRewardFirstBuy => 'Первая покупка друга · +7 дней каждому';
|
||||
@override
|
||||
String get inviteRewardJoinTg => 'Вступление в TG-канал · +3 дня';
|
||||
@override
|
||||
String get inviteProgressTitle => 'Мои награды';
|
||||
@override
|
||||
String get inviteTasksTitle => 'Больше способов заработать';
|
||||
@override
|
||||
String get inviteJoinTgTitle => 'Вступить в Telegram-канал';
|
||||
@override
|
||||
String get inviteJoinTgBtn => 'Вступить в канал';
|
||||
@override
|
||||
String get inviteVerifyBtn => 'Проверить и получить';
|
||||
@override
|
||||
String get inviteTaskDone => 'Получено +3 дня';
|
||||
@override
|
||||
String get inviteStatInvited => 'Приглашено';
|
||||
@override
|
||||
String get inviteStatConverted => 'Конвертировано';
|
||||
@override
|
||||
String get inviteStatDays => 'Всего получено (дней)';
|
||||
@override
|
||||
String get inviteCodeFieldLabel => 'Код приглашения (необязательно)';
|
||||
@override
|
||||
String get notifTitle => 'Уведомления';
|
||||
@override
|
||||
String get notifEmpty => 'Пока нет уведомлений';
|
||||
|
||||
@@ -482,12 +482,30 @@ class StringsZh extends AppText {
|
||||
@override
|
||||
String get inviteRewardRegister => '好友注册 · 双方各得 3 天';
|
||||
@override
|
||||
String get inviteRewardFirstBuy => '好友首购 · 双方各得 15 天';
|
||||
String get inviteRewardFirstBuy => '好友首购 · 双方各得 7 天';
|
||||
@override
|
||||
String get inviteRewardJoinTg => '加入 TG 频道 · 得 3 天';
|
||||
@override
|
||||
String get inviteProgressTitle => '我的奖励';
|
||||
@override
|
||||
String get inviteTasksTitle => '更多得会员';
|
||||
@override
|
||||
String get inviteJoinTgTitle => '加入 Telegram 频道';
|
||||
@override
|
||||
String get inviteJoinTgBtn => '加入频道';
|
||||
@override
|
||||
String get inviteVerifyBtn => '验证领取';
|
||||
@override
|
||||
String get inviteTaskDone => '已领 +3 天';
|
||||
@override
|
||||
String get inviteStatInvited => '已邀请';
|
||||
@override
|
||||
String get inviteStatConverted => '已转化';
|
||||
@override
|
||||
String get inviteStatDays => '累计获赠(天)';
|
||||
@override
|
||||
String get inviteCodeFieldLabel => '邀请码(选填)';
|
||||
@override
|
||||
String get notifTitle => '通知';
|
||||
@override
|
||||
String get notifEmpty => '暂无通知';
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
// invite_page.dart — 邀请好友(占位屏)
|
||||
// invite_page.dart — 邀请好友:邀请区(码/链接)+ 战绩 + TG 任务卡。
|
||||
//
|
||||
// TODO(spec-2): 邀请码/链接由后端签发,奖励进度来自 /v1/invite。当前码/链接/奖励进度
|
||||
// 均为静态占位,仅供 IA 走查——接后端前不接任何真实业务逻辑。
|
||||
// 数据来自 inviteProvider(Task 11,`/v1/invite`);未登录/加载/出错回退占位文案,
|
||||
// 不接任何静态 demo 数据。
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import '../l10n/app_text.dart';
|
||||
import '../pangolin_theme.dart';
|
||||
import '../services/channel_launch.dart';
|
||||
import '../services/invite_api.dart';
|
||||
import '../state/invite_provider.dart';
|
||||
import '../widgets/pangolin_button.dart';
|
||||
import '../widgets/pangolin_icons.dart';
|
||||
|
||||
const String _kDemoInviteCode = 'PANGOLIN-DEMO';
|
||||
const String _kDemoInviteLink = 'https://pangolin.app/i/PANGOLIN-DEMO';
|
||||
import '../widgets/pangolin_toast.dart';
|
||||
|
||||
class InviteScreen extends ConsumerWidget {
|
||||
const InviteScreen({super.key, required this.t, this.onBack, this.embedded = false});
|
||||
@@ -30,29 +33,113 @@ class InviteScreen extends ConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
// 验证领取:拉 deep_link 后外部打开。canLaunchUrl + try-catch 双保险,
|
||||
// 失败给可见 toast(与 payment_page._openRedirectUrl 同法)。
|
||||
Future<void> _verifyAndClaim(BuildContext context, WidgetRef ref) async {
|
||||
try {
|
||||
final link = await ref.read(inviteApiProvider).telegramStartLink();
|
||||
final uri = link.isEmpty ? null : Uri.tryParse(link);
|
||||
if (uri == null) {
|
||||
if (context.mounted) showPangolinToast(context, t.lang.loadFailedRetry);
|
||||
return;
|
||||
}
|
||||
final can = await canLaunchUrl(uri);
|
||||
if (!can) {
|
||||
if (context.mounted) showPangolinToast(context, t.lang.loadFailedRetry);
|
||||
return;
|
||||
}
|
||||
final ok = await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
if (!ok && context.mounted) showPangolinToast(context, t.lang.loadFailedRetry);
|
||||
if (ok) await ref.read(inviteProvider.notifier).refresh();
|
||||
} catch (_) {
|
||||
if (context.mounted) showPangolinToast(context, t.lang.loadFailedRetry);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final c = context.pangolin;
|
||||
final inviteAsync = ref.watch(inviteProvider);
|
||||
|
||||
Widget body = ListView(
|
||||
Widget body = inviteAsync.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (_, __) => _Placeholder(t: t),
|
||||
data: (info) => info == null
|
||||
? _Placeholder(t: t)
|
||||
: _InviteContent(t: t, info: info, onCopy: _copy, onVerifyAndClaim: (ctx) => _verifyAndClaim(ctx, ref)),
|
||||
);
|
||||
|
||||
if (embedded) return body;
|
||||
return Scaffold(
|
||||
backgroundColor: c.bg,
|
||||
body: SafeArea(
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 6, 16, 10),
|
||||
child: Row(children: [
|
||||
IconButton(
|
||||
onPressed: onBack ?? () => Navigator.of(context).maybePop(),
|
||||
icon: Icon(PangolinIcons.arrowLeft, size: 22, color: c.fg1),
|
||||
),
|
||||
Text(t.inviteTitle, style: PangolinText.h3.copyWith(color: c.fg1, fontWeight: FontWeight.w700)),
|
||||
]),
|
||||
),
|
||||
Expanded(child: body),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// loading/error/未登录 的统一回退占位(保留原 IA 走查态)。
|
||||
class _Placeholder extends StatelessWidget {
|
||||
const _Placeholder({required this.t});
|
||||
final AppText t;
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final c = context.pangolin;
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(mainAxisSize: MainAxisSize.min, children: [
|
||||
Icon(PangolinIcons.gift, size: 26, color: c.fg3),
|
||||
const SizedBox(height: 10),
|
||||
Text('—', style: PangolinText.body.copyWith(color: c.fg3)),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _InviteContent extends StatelessWidget {
|
||||
const _InviteContent({required this.t, required this.info, required this.onCopy, required this.onVerifyAndClaim});
|
||||
final AppText t;
|
||||
final InviteInfo info;
|
||||
final Future<void> Function(BuildContext, String, String) onCopy;
|
||||
final Future<void> Function(BuildContext) onVerifyAndClaim;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final c = context.pangolin;
|
||||
return ListView(
|
||||
padding: const EdgeInsets.fromLTRB(20, 4, 20, 24),
|
||||
children: [
|
||||
_CopyCard(
|
||||
icon: PangolinIcons.gift,
|
||||
label: t.inviteCodeLabel,
|
||||
value: _kDemoInviteCode,
|
||||
value: info.code,
|
||||
mono: true,
|
||||
copyLabel: t.inviteCopyCode,
|
||||
onCopy: () => _copy(context, _kDemoInviteCode, t.copied),
|
||||
onCopy: () => onCopy(context, info.code, t.copied),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
_CopyCard(
|
||||
icon: PangolinIcons.link,
|
||||
label: t.inviteLinkLabel,
|
||||
value: _kDemoInviteLink,
|
||||
value: info.link,
|
||||
mono: false,
|
||||
copyLabel: t.inviteCopyLink,
|
||||
onCopy: () => _copy(context, _kDemoInviteLink, t.copied),
|
||||
onCopy: () => onCopy(context, info.link, t.copied),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Padding(
|
||||
@@ -82,44 +169,120 @@ class InviteScreen extends ConsumerWidget {
|
||||
),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
color: c.surface,
|
||||
borderRadius: BorderRadius.circular(PangolinRadius.lg),
|
||||
border: Border.all(color: c.border),
|
||||
boxShadow: PangolinShadow.sm,
|
||||
),
|
||||
child: Column(children: [
|
||||
Icon(PangolinIcons.gift, size: 26, color: c.fg3),
|
||||
const SizedBox(height: 10),
|
||||
Text('—', style: PangolinText.body.copyWith(color: c.fg3)),
|
||||
child: Row(children: [
|
||||
_StatCell(value: '${info.invited}', label: t.inviteStatInvited),
|
||||
_StatDivider(color: c.border),
|
||||
_StatCell(value: '${info.converted}', label: t.inviteStatConverted),
|
||||
_StatDivider(color: c.border),
|
||||
_StatCell(value: '${info.earnedDays}', label: t.inviteStatDays),
|
||||
]),
|
||||
),
|
||||
if (info.tgEnabled) ...[
|
||||
const SizedBox(height: 20),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(2, 0, 2, 10),
|
||||
child: Text(t.inviteTasksTitle, style: PangolinText.sm.copyWith(color: c.fg2, fontWeight: FontWeight.w700, fontSize: 13)),
|
||||
),
|
||||
_TgTaskCard(t: t, info: info, onVerifyAndClaim: onVerifyAndClaim),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (embedded) return body;
|
||||
return Scaffold(
|
||||
backgroundColor: c.bg,
|
||||
body: SafeArea(
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 6, 16, 10),
|
||||
child: Row(children: [
|
||||
IconButton(
|
||||
onPressed: onBack ?? () => Navigator.of(context).maybePop(),
|
||||
icon: Icon(PangolinIcons.arrowLeft, size: 22, color: c.fg1),
|
||||
),
|
||||
Text(t.inviteTitle, style: PangolinText.h3.copyWith(color: c.fg1, fontWeight: FontWeight.w700)),
|
||||
]),
|
||||
),
|
||||
Expanded(child: body),
|
||||
class _StatCell extends StatelessWidget {
|
||||
const _StatCell({required this.value, required this.label});
|
||||
final String value;
|
||||
final String label;
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final c = context.pangolin;
|
||||
return Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 18),
|
||||
child: Column(children: [
|
||||
Text(value, style: PangolinText.h3.copyWith(color: c.fg1, fontWeight: FontWeight.w700)),
|
||||
const SizedBox(height: 4),
|
||||
Text(label, style: PangolinText.caption.copyWith(color: c.fg3)),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StatDivider extends StatelessWidget {
|
||||
const _StatDivider({required this.color});
|
||||
final Color color;
|
||||
@override
|
||||
Widget build(BuildContext context) => SizedBox(height: 48, child: VerticalDivider(width: 1, color: color));
|
||||
}
|
||||
|
||||
class _TgTaskCard extends StatelessWidget {
|
||||
const _TgTaskCard({required this.t, required this.info, required this.onVerifyAndClaim});
|
||||
final AppText t;
|
||||
final InviteInfo info;
|
||||
final Future<void> Function(BuildContext) onVerifyAndClaim;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final c = context.pangolin;
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(18),
|
||||
decoration: BoxDecoration(
|
||||
color: c.surface,
|
||||
borderRadius: BorderRadius.circular(PangolinRadius.xl),
|
||||
border: Border.all(color: c.border),
|
||||
boxShadow: PangolinShadow.sm,
|
||||
),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Row(children: [
|
||||
Container(
|
||||
width: 32, height: 32,
|
||||
decoration: BoxDecoration(color: c.accentSubtle, borderRadius: BorderRadius.circular(PangolinRadius.sm)),
|
||||
child: Icon(PangolinIcons.send, size: 17, color: c.accent),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(t.inviteJoinTgTitle,
|
||||
style: PangolinText.sm.copyWith(color: c.fg1, fontWeight: FontWeight.w700, fontSize: 13.5)),
|
||||
),
|
||||
]),
|
||||
const SizedBox(height: 14),
|
||||
if (info.tgJoined)
|
||||
PangolinButton(
|
||||
label: t.inviteTaskDone,
|
||||
icon: PangolinIcons.checkCircle,
|
||||
variant: PangolinButtonVariant.secondary,
|
||||
expand: true,
|
||||
onPressed: null,
|
||||
)
|
||||
else
|
||||
Column(children: [
|
||||
PangolinButton(
|
||||
label: t.inviteJoinTgBtn,
|
||||
variant: PangolinButtonVariant.secondary,
|
||||
expand: true,
|
||||
onPressed: () => launchChannel(info.channel),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
PangolinButton(
|
||||
label: t.inviteVerifyBtn,
|
||||
variant: PangolinButtonVariant.primary,
|
||||
expand: true,
|
||||
onPressed: () => onVerifyAndClaim(context),
|
||||
),
|
||||
]),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CopyCard extends StatelessWidget {
|
||||
const _CopyCard({
|
||||
required this.icon,
|
||||
|
||||
@@ -115,12 +115,14 @@ class AuthApi {
|
||||
required String code,
|
||||
required String password,
|
||||
Map<String, dynamic>? device,
|
||||
String? inviteCode,
|
||||
}) async {
|
||||
final resp = await _post('/v1/auth/register', {
|
||||
'email': email,
|
||||
'code': code,
|
||||
'password': password,
|
||||
if (device != null) 'device': device,
|
||||
if (inviteCode != null && inviteCode.isNotEmpty) 'invite_code': inviteCode,
|
||||
});
|
||||
if (resp.statusCode != 200 && resp.statusCode != 201) {
|
||||
_throwFromResponse(resp);
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
// invite_api.dart — 邀请/奖励任务代理端点封装(JWT 经 ApiClient 自动注入)。
|
||||
import 'api_client.dart';
|
||||
|
||||
class InviteInfo {
|
||||
const InviteInfo({
|
||||
required this.code, required this.link, required this.invited,
|
||||
required this.converted, required this.earnedDays,
|
||||
required this.tgEnabled, required this.tgJoined, required this.channel,
|
||||
});
|
||||
final String code, link, channel;
|
||||
final int invited, converted, earnedDays;
|
||||
final bool tgEnabled, tgJoined;
|
||||
|
||||
factory InviteInfo.fromJson(Map<String, dynamic> j) {
|
||||
final tg = (j['telegram'] as Map<String, dynamic>?) ?? const {};
|
||||
return InviteInfo(
|
||||
code: j['invite_code'] as String? ?? '',
|
||||
link: j['invite_link'] as String? ?? '',
|
||||
invited: (j['invited'] as num?)?.toInt() ?? 0,
|
||||
converted: (j['converted'] as num?)?.toInt() ?? 0,
|
||||
earnedDays: (j['earned_days'] as num?)?.toInt() ?? 0,
|
||||
tgEnabled: tg['enabled'] as bool? ?? false,
|
||||
tgJoined: tg['joined'] as bool? ?? false,
|
||||
channel: tg['channel'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class InviteApi {
|
||||
InviteApi(this._c);
|
||||
final ApiClient _c;
|
||||
Future<InviteInfo> fetch() async => InviteInfo.fromJson(await _c.getJson('/v1/invite'));
|
||||
Future<String> telegramStartLink() async =>
|
||||
(await _c.getJson('/v1/tasks/telegram/start'))['deep_link'] as String? ?? '';
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// invite_provider.dart — 邀请信息状态装配:复用 apiClientProvider,未登录不打网络。
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../services/invite_api.dart';
|
||||
import 'account_providers.dart';
|
||||
import 'auth_provider.dart';
|
||||
|
||||
final inviteApiProvider = Provider<InviteApi>((ref) => InviteApi(ref.watch(apiClientProvider)));
|
||||
|
||||
class InviteNotifier extends AsyncNotifier<InviteInfo?> {
|
||||
@override
|
||||
Future<InviteInfo?> build() async {
|
||||
// 未登录返回 null(不打网络);已登录拉取。
|
||||
final token = ref.watch(authProvider).accessToken;
|
||||
if (token == null || token.isEmpty) return null;
|
||||
return ref.read(inviteApiProvider).fetch();
|
||||
}
|
||||
|
||||
Future<void> refresh() async {
|
||||
state = const AsyncLoading();
|
||||
state = await AsyncValue.guard(() => ref.read(inviteApiProvider).fetch());
|
||||
}
|
||||
}
|
||||
|
||||
final inviteProvider = AsyncNotifierProvider<InviteNotifier, InviteInfo?>(InviteNotifier.new);
|
||||
@@ -24,10 +24,14 @@ const _easeOut = Cubic(0.22, 1, 0.36, 1);
|
||||
enum _AuthMode { login, register }
|
||||
|
||||
class AuthScreen extends ConsumerStatefulWidget {
|
||||
const AuthScreen({super.key, required this.onDone, required this.t});
|
||||
const AuthScreen({super.key, required this.onDone, required this.t, this.api});
|
||||
final VoidCallback onDone;
|
||||
final AppText t;
|
||||
|
||||
/// 测试可注入(见 test/widget/auth_invite_field_test.dart);生产不传,内部
|
||||
/// 用 kApiBaseUrl 现建一个,行为不变。
|
||||
final AuthApi? api;
|
||||
|
||||
@override
|
||||
ConsumerState<AuthScreen> createState() => _AuthScreenState();
|
||||
}
|
||||
@@ -44,6 +48,7 @@ class _AuthScreenState extends ConsumerState<AuthScreen>
|
||||
final _email = TextEditingController();
|
||||
final _code = TextEditingController();
|
||||
final _pw = TextEditingController();
|
||||
final _invite = TextEditingController();
|
||||
|
||||
final _emailFocus = FocusNode();
|
||||
final _pwFocus = FocusNode();
|
||||
@@ -54,7 +59,7 @@ class _AuthScreenState extends ConsumerState<AuthScreen>
|
||||
duration: const Duration(milliseconds: 620),
|
||||
);
|
||||
|
||||
late final AuthApi _api = AuthApi(baseUrl: kApiBaseUrl);
|
||||
late final AuthApi _api = widget.api ?? AuthApi(baseUrl: kApiBaseUrl);
|
||||
|
||||
bool get _emailValid => RegExp(r'\S+@\S+\.\S+').hasMatch(_email.text);
|
||||
|
||||
@@ -79,6 +84,7 @@ class _AuthScreenState extends ConsumerState<AuthScreen>
|
||||
_email.dispose();
|
||||
_code.dispose();
|
||||
_pw.dispose();
|
||||
_invite.dispose();
|
||||
_emailFocus.dispose();
|
||||
_pwFocus.dispose();
|
||||
_codeFocus.dispose();
|
||||
@@ -108,6 +114,7 @@ class _AuthScreenState extends ConsumerState<AuthScreen>
|
||||
code: _code.text.trim(),
|
||||
password: _pw.text,
|
||||
device: device,
|
||||
inviteCode: _invite.text.trim(),
|
||||
);
|
||||
await ref.read(tokenStoreProvider).saveLastEmail(_email.text.trim());
|
||||
await ref.read(authProvider.notifier).saveTokens(tokens);
|
||||
@@ -464,6 +471,11 @@ class _AuthScreenState extends ConsumerState<AuthScreen>
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_field(c, icon: PangolinIcons.lock, label: t.pwLabel, focused: _pwFocus.hasFocus, child: _pwInput(c, t.setPwPh, onSubmit: _submitRegister)),
|
||||
const SizedBox(height: 16),
|
||||
_field(c, icon: PangolinIcons.gift, label: t.inviteCodeFieldLabel, focused: false,
|
||||
child: TextField(key: const Key('invite-code-field'), controller: _invite,
|
||||
textInputAction: TextInputAction.done,
|
||||
decoration: _bare.copyWith(hintText: ''))),
|
||||
const SizedBox(height: 18),
|
||||
_primaryBtn(c, label: _loading ? '...' : t.doCreate,
|
||||
onPressed: (!_loading && _pw.text.length >= 6) ? _doRegister : null),
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:pangolin_vpn/services/api_client.dart';
|
||||
import 'package:pangolin_vpn/services/invite_api.dart';
|
||||
|
||||
ApiClient _c(MockClient m) => ApiClient(baseUrl: 'http://x', getToken: () => 't', refresh: () async => false, client: m);
|
||||
|
||||
void main() {
|
||||
test('fetch 解析邀请信息 + TG 任务态', () async {
|
||||
final api = InviteApi(_c(MockClient((req) async {
|
||||
expect(req.url.path, '/v1/invite');
|
||||
return http.Response('{"invite_code":"ABCD2345","invite_link":"https://x/i/ABCD2345",'
|
||||
'"invited":3,"converted":1,"earned_days":16,'
|
||||
'"telegram":{"enabled":true,"joined":false,"channel":"@pangolin_app"}}', 200);
|
||||
})));
|
||||
final info = await api.fetch();
|
||||
expect(info.code, 'ABCD2345');
|
||||
expect(info.invited, 3);
|
||||
expect(info.earnedDays, 16);
|
||||
expect(info.tgEnabled, true);
|
||||
expect(info.tgJoined, false);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
// auth_invite_field_test.dart — Task 13: 注册页「邀请码(选填)」输入框
|
||||
//
|
||||
// 驱动路径:切到注册 tab(step0)→ 填邮箱 → 点发送验证码(mock 网络 204)→
|
||||
// 填 6 位验证码 → 点下一步(进 step1 设密码步)→ 断言 step1 渲染出
|
||||
// key 为 invite-code-field 的 TextField,label 为 t.inviteCodeFieldLabel。
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:pangolin_vpn/l10n/strings_zh.dart';
|
||||
import 'package:pangolin_vpn/pangolin_theme.dart';
|
||||
import 'package:pangolin_vpn/services/auth_api.dart';
|
||||
import 'package:pangolin_vpn/services/token_store.dart';
|
||||
import 'package:pangolin_vpn/state/auth_provider.dart';
|
||||
import 'package:pangolin_vpn/widgets/auth_screen.dart';
|
||||
|
||||
import '../helpers/harness.dart';
|
||||
|
||||
class _NullTokenStore implements TokenStore {
|
||||
const _NullTokenStore();
|
||||
@override
|
||||
Future<void> saveTokens({required String access, required String refresh}) async {}
|
||||
@override
|
||||
Future<String?> loadAccessToken() async => null;
|
||||
@override
|
||||
Future<String?> loadRefreshToken() async => null;
|
||||
@override
|
||||
Future<void> clear() async {}
|
||||
@override
|
||||
Future<void> markOnboarded() async {}
|
||||
@override
|
||||
Future<bool> isOnboarded() async => true;
|
||||
@override
|
||||
Future<void> saveLastEmail(String email) async {}
|
||||
@override
|
||||
Future<String?> loadLastEmail() async => null;
|
||||
}
|
||||
|
||||
void main() {
|
||||
setUpAll(disableGoogleFontsFetching);
|
||||
const t = StringsZh();
|
||||
|
||||
Future<void> _driveToRegisterStep1(WidgetTester tester) async {
|
||||
final mockClient = MockClient((req) async {
|
||||
if (req.method == 'POST' && req.url.path == '/v1/auth/code') {
|
||||
return http.Response('', 204);
|
||||
}
|
||||
return http.Response(jsonEncode({'message_zh': 'unexpected'}), 404);
|
||||
});
|
||||
final api = AuthApi(baseUrl: 'http://test.invalid', client: mockClient);
|
||||
|
||||
await tester.pumpWidget(ProviderScope(
|
||||
overrides: [tokenStoreProvider.overrideWithValue(const _NullTokenStore())],
|
||||
child: MaterialApp(
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: PangolinTheme.light,
|
||||
home: AuthScreen(t: t, onDone: () {}, api: api),
|
||||
),
|
||||
));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// 切到注册 tab。
|
||||
await tester.tap(find.text(t.tabRegister));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// step0:填邮箱 → 点发送验证码(mock 204)。
|
||||
await tester.enterText(find.byType(TextField).first, 'invitee@example.com');
|
||||
await tester.pump();
|
||||
await tester.tap(find.text(t.sendCode));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// 填 6 位验证码 → 下一步,进 step1。
|
||||
await tester.enterText(find.byType(TextField).at(1), '123456');
|
||||
await tester.pump();
|
||||
await tester.tap(find.text(t.doNext));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
testWidgets('注册 step1(设密码步)渲染邀请码(选填)输入框', (tester) async {
|
||||
await _driveToRegisterStep1(tester);
|
||||
|
||||
expect(find.byKey(const Key('invite-code-field')), findsOneWidget);
|
||||
expect(find.text(t.inviteCodeFieldLabel), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('登录路径不含邀请码输入框', (tester) async {
|
||||
final api = AuthApi(baseUrl: 'http://test.invalid', client: MockClient((req) async => http.Response('', 404)));
|
||||
await tester.pumpWidget(ProviderScope(
|
||||
overrides: [tokenStoreProvider.overrideWithValue(const _NullTokenStore())],
|
||||
child: MaterialApp(
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: PangolinTheme.light,
|
||||
home: AuthScreen(t: t, onDone: () {}, api: api),
|
||||
),
|
||||
));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byKey(const Key('invite-code-field')), findsNothing);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pangolin_vpn/l10n/strings_en.dart';
|
||||
import 'package:pangolin_vpn/screens/invite_page.dart';
|
||||
import 'package:pangolin_vpn/services/invite_api.dart';
|
||||
import 'package:pangolin_vpn/state/invite_provider.dart';
|
||||
|
||||
import '../helpers/harness.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('邀请页显示真实码 + 战绩 + TG 任务卡', (tester) async {
|
||||
const info = InviteInfo(code: 'ABCD2345', link: 'https://x/i/ABCD2345',
|
||||
invited: 3, converted: 1, earnedDays: 16, tgEnabled: true, tgJoined: false, channel: '@pangolin_app');
|
||||
await tester.binding.setSurfaceSize(const Size(400, 1200));
|
||||
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||
await tester.pumpWidget(wrapThemed(
|
||||
InviteScreen(t: StringsEn()),
|
||||
overrides: [inviteProvider.overrideWith(() => _FakeNotifier(info))],
|
||||
));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('ABCD2345'), findsOneWidget);
|
||||
expect(find.text('Join channel'), findsOneWidget); // TG 任务卡
|
||||
});
|
||||
}
|
||||
|
||||
class _FakeNotifier extends InviteNotifier {
|
||||
_FakeNotifier(this._v);
|
||||
final InviteInfo _v;
|
||||
@override
|
||||
Future<InviteInfo?> build() async => _v;
|
||||
}
|
||||
@@ -44,6 +44,11 @@
|
||||
</div>
|
||||
|
||||
<h2>设计方案 / Specs</h2>
|
||||
<a class="doc" href="invite-task-rewards-design.html">
|
||||
<div class="t">邀请奖励 + 奖励任务(加入 TG 频道)(Spec ②)<span class="tag html">HTML</span></div>
|
||||
<div class="d">用「送 Pro 会员天数」驱动增长,两条并行途径共用发天数原语(applySubscription source=invite/task)。① 邀请两段式双方都得:注册各 +3、被邀请人首充双方再各 +7(每转化 +10/+10)。② 奖励任务首个「加入 Telegram 频道」+3,经 Bot getChatMember 真校验(token 绑号→/tg/webhook→查成员→发)。防刷:自邀请拦截 + invitee 唯一 + dp_uuid 设备去重 + 注册段月度封顶(首充不封)+ telegram_id 全局唯一领。数据:users +invite_code/first_paid_at、referrals、通用 reward_claims。不做现金/多级/退群回收;通知集成留接缝待 Spec③。</div>
|
||||
<div class="path">docs/invite-task-rewards-design.html</div>
|
||||
</a>
|
||||
<a class="doc" href="account-ia-reorg-design.html">
|
||||
<div class="t">账户 / 设置 / 联系 信息架构重构(Spec ①/3)<span class="tag html">HTML</span></div>
|
||||
<div class="d">去重 + 归位:App 自足、Web 退居次要;每功能一个家。桌面提 Account 为一级侧栏项,移动 Account tab 含设置/联系下钻。收敛联系渠道(×3→1)/语言主题(×2→1)/购买入口(散3→Account)。为「🔔 通知」(顶栏铃铛)+「邀请好友」(账户入口)预留落点。决策:奖励双方都得、兑换码保留、Web 链接保留。后续 ②推广激励 ③系统通知 另立 spec。</div>
|
||||
@@ -91,6 +96,11 @@
|
||||
</a>
|
||||
|
||||
<h2>实现计划 / Plans</h2>
|
||||
<a class="doc" href="invite-task-rewards-plan.html">
|
||||
<div class="t">邀请奖励 + 奖励任务 实现计划(Spec ②)<span class="tag html">HTML</span></div>
|
||||
<div class="d">阅读版;执行真相源 <code>docs/superpowers/plans/2026-07-12-invite-task-rewards.md</code>(14 任务 TDD,含 checkbox)。后端(1-10):迁移 000024(referrals/reward_claims + source 扩容) → codes.GrantRewardTx 发奖原语 → reward.Store/Service(邀请码+防刷) → 接注册 ReferralHook → 接首充 pay webhook Rewarder → GET /v1/invite → TG 绑定 token → TG webhook getChatMember 真校验 → main.go 装配。客户端(11-13):invite api/provider → invite_page 真实化+六语 l10n → 注册页邀请码输入(deep-link 预填单列可选后置)。设计见 invite-task-rewards-design.html。</div>
|
||||
<div class="path">docs/invite-task-rewards-plan.html · 真相源 docs/superpowers/plans/2026-07-12-invite-task-rewards.md</div>
|
||||
</a>
|
||||
<a class="doc" href="frontend-ds-refactor-plan.html">
|
||||
<div class="t">前端设计系统治理重构(ds-flow 全端)<span class="tag html">HTML</span></div>
|
||||
<div class="d">阅读版;执行真相源 <code>docs/superpowers/plans/2026-07-07-frontend-ds-refactor.md</code>(含 checkbox)。用 ds-flow 把 Flutter 五端 + 官网 + 用户中心收口到「设计单源·代码镜像·静态闸拦漂移·golden/fidelity 双级像素验收兜底」。<b>非从零 bootstrap(已约 65% 达标)</b>:补原型三件套(atoms.css/icons.js/index.html 登记页)+ Web 共享原子层去重(各自实现+同源闸)+ 硬编码色/fidelity 闸 + 启用 pre-commit。6 阶段:CLAUDE.md → 原型单源 → Web token 同源 → Web 原子对齐 → Flutter golden 补齐 → 闸挂满。主题保持 light/dark。</div>
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>邀请奖励 + 奖励任务(加入 TG 频道) — 设计</title>
|
||||
<style>
|
||||
:root{
|
||||
--bg:#0f1117; --panel:#171a22; --panel2:#1d2129; --fg:#e6e8ee; --fg2:#a8afbd;
|
||||
--accent:#e0884f; --accent2:#5fb0c9; --ok:#5ec27a; --bad:#e06a6a; --warn:#e0b84f;
|
||||
--border:#272c36; --mono:"SF Mono",ui-monospace,Menlo,Consolas,monospace;
|
||||
--sans:-apple-system,"PingFang SC","Helvetica Neue",Arial,sans-serif;
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;background:var(--bg);color:var(--fg);font-family:var(--sans);line-height:1.7;font-size:15px}
|
||||
.wrap{max-width:920px;margin:0 auto;padding:48px 24px 96px}
|
||||
h1{font-size:30px;line-height:1.3;margin:0 0 8px;letter-spacing:-.01em}
|
||||
.sub{color:var(--fg2);font-size:15px;margin:0 0 32px}
|
||||
h2{font-size:21px;margin:44px 0 14px;padding-bottom:8px;border-bottom:1px solid var(--border)}
|
||||
h3{font-size:16px;margin:26px 0 8px;color:var(--accent2)}
|
||||
p{margin:10px 0}
|
||||
code{font-family:var(--mono);font-size:.88em;background:var(--panel2);padding:1px 6px;border-radius:5px;color:#f0d9c4}
|
||||
pre{background:#0a0c11;border:1px solid var(--border);border-radius:10px;padding:14px 16px;overflow-x:auto;font-family:var(--mono);font-size:13px;line-height:1.55;color:#cdd3df}
|
||||
.kw{color:#c68bd6}.str{color:#b6d47a}.num{color:#e0b06a}.cm{color:#6b7280}.fn{color:#5fb0c9}
|
||||
.tag{display:inline-block;font-size:12px;font-weight:600;padding:2px 9px;border-radius:999px;vertical-align:middle}
|
||||
.tag.ok{background:rgba(94,194,122,.16);color:var(--ok)}
|
||||
.tag.warn{background:rgba(224,184,79,.16);color:var(--warn)}
|
||||
.tag.info{background:rgba(95,176,201,.16);color:var(--accent2)}
|
||||
.tag.bad{background:rgba(224,106,106,.16);color:var(--bad)}
|
||||
.card{background:var(--panel);border:1px solid var(--border);border-radius:12px;padding:18px 20px;margin:16px 0}
|
||||
.card.root{border-left:3px solid var(--accent)}
|
||||
.card h3{margin-top:0}
|
||||
table{width:100%;border-collapse:collapse;margin:16px 0;font-size:14px}
|
||||
th,td{text-align:left;padding:9px 12px;border-bottom:1px solid var(--border);vertical-align:top}
|
||||
th{color:var(--fg2);font-weight:600;font-size:13px}
|
||||
td code{font-size:.85em}
|
||||
ul,ol{padding-left:22px;margin:10px 0}
|
||||
li{margin:5px 0}
|
||||
.lead{background:linear-gradient(180deg,rgba(224,136,79,.10),transparent);border:1px solid var(--border);border-radius:12px;padding:18px 20px;margin:0 0 8px}
|
||||
.small{color:var(--fg2);font-size:13px}
|
||||
a{color:var(--accent2)}
|
||||
.back{display:inline-block;margin-bottom:24px;font-size:13px}
|
||||
b{color:#fff}
|
||||
.cols{display:grid;grid-template-columns:1fr 1fr;gap:16px}
|
||||
@media(max-width:680px){.cols{grid-template-columns:1fr}}
|
||||
.diagram{background:#0a0c11;border:1px solid var(--border);border-radius:10px;padding:12px;margin:16px 0;overflow-x:auto}
|
||||
.diagram svg{display:block;margin:0 auto;min-width:640px}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<a class="back" href="index.html">← 文档索引</a>
|
||||
<h1>邀请奖励 + 奖励任务</h1>
|
||||
<p class="sub">设计文档 · 2026-07-12 · Spec ②(推广激励)· 状态 <span class="tag info">待评审</span></p>
|
||||
|
||||
<div class="lead">
|
||||
<b>目标</b>:用「送 Pro 会员天数」驱动增长。两条并行的得分途径,共用同一条发天数链路:<br>
|
||||
① <b>邀请好友</b> —— 两段式,双方都得(注册各 +3 天,被邀请人首充双方再各 +7 天)。<br>
|
||||
② <b>奖励任务</b> —— 首个任务「加入 Telegram 频道」+3 天,经 Bot <code>getChatMember</code> 真校验。框架可扩展(后续「关注推特 / App 评分」复用)。<br>
|
||||
<b>发天数原语</b>:复用现有 <code>subscriptions</code> 延时逻辑(<code>applySubscription(..., source)</code> = <code>max(到期,now)+days</code>),与付费 / 兑换码同一条,不造新轮子。
|
||||
</div>
|
||||
|
||||
<h2>1 · 邀请奖励模型(两段式,双方都得)</h2>
|
||||
<table>
|
||||
<tr><th>阶段</th><th>触发</th><th>邀请人</th><th>被邀请人</th><th>封顶</th></tr>
|
||||
<tr><td><b>注册</b></td><td>被邀请人用邀请码注册成功</td><td>+3 天 Pro</td><td>+3 天 Pro</td><td>邀请人 <b>N 次/月</b>(默认 10)</td></tr>
|
||||
<tr><td><b>首充</b></td><td>被邀请人<b>首次成功付费</b>(任何档,含 ¥6 优惠)</td><td>+7 天 Pro</td><td>+7 天 Pro</td><td>不封顶</td></tr>
|
||||
</table>
|
||||
<p class="small">每转化一个付费用户:邀请人累计 +10 天、被邀请人累计 +10 天。注册段是唯一「免费面」故设月度封顶;首充段真金白银自带成本,不封顶。</p>
|
||||
|
||||
<h3>邀请码 / 链接 / 绑定</h3>
|
||||
<ul>
|
||||
<li><b>邀请码</b>:每用户一枚<b>永久</b>短码,从 <code>users.uuid</code> 派生 8 位 base32(去除易混字符 <code>0/O/1/I</code>),存 <code>users.invite_code</code>。</li>
|
||||
<li><b>邀请链接</b>:<code>https://pangolin.yanmeiai.com/i/<code></code> → 落地页引导下载 App;App 内 deep link 自动预填邀请码。</li>
|
||||
<li><b>绑定时机</b>:<b>仅注册时</b>(手动填码 或 点邀请链接注册,两条路都行),first-touch <b>永久不可改</b>;注册后不可补绑。</li>
|
||||
</ul>
|
||||
|
||||
<h2>2 · 防刷</h2>
|
||||
<ul>
|
||||
<li><b>自邀请拦截</b> —— 不能绑自己的码(<code>inviter_id ≠ invitee_id</code>)。</li>
|
||||
<li><b>新账户唯一绑定</b> —— 一个被邀请人一生只出现一次(<code>referrals.invitee_id</code> 唯一);老账户不可事后补绑。</li>
|
||||
<li><b>设备去重(复用 dp_uuid)</b> —— 注册携带的 <code>DeviceMeta.dp_uuid</code> 若<b>已注册过别的账户</b>,则关系照记但<b>注册奖励不发</b>(<code>status=rejected</code>)。</li>
|
||||
<li><b>注册段月度封顶</b> —— 每邀请人每自然月最多 <code>INVITE_REG_MONTHLY_CAP</code>(默认 10)次注册奖励;超出仍记关系、不发注册奖。首充奖励不受此限。</li>
|
||||
<li><b>TG 任务</b> —— 每账户一次 + <b>telegram_id 全局唯一领取</b>(挡「一个 TG 号刷多账户」)+ 真成员校验(见 §5)。</li>
|
||||
</ul>
|
||||
|
||||
<h2>3 · 数据模型</h2>
|
||||
<p><b>users 加一列</b>(SQLite / MySQL 两套迁移):</p>
|
||||
<table>
|
||||
<tr><th>列</th><th>类型</th><th>说明</th></tr>
|
||||
<tr><td><code>invite_code</code></td><td>varchar(16) UNIQUE</td><td>本用户的永久邀请码;注册/首次访问邀请页时惰性生成。</td></tr>
|
||||
<tr><td><code>first_paid_at</code></td><td>datetime NULL</td><td>首次成功付费时刻;判「首充」+ 幂等首充奖励。</td></tr>
|
||||
</table>
|
||||
|
||||
<p><b>referrals</b> —— 邀请关系(关系型,一对一绑定):</p>
|
||||
<table>
|
||||
<tr><th>列</th><th>类型</th><th>说明</th></tr>
|
||||
<tr><td><code>id</code></td><td>PK</td><td></td></tr>
|
||||
<tr><td><code>inviter_id</code></td><td>bigint</td><td>邀请人 user id(索引)。</td></tr>
|
||||
<tr><td><code>invitee_id</code></td><td>bigint <b>UNIQUE</b></td><td>被邀请人 user id;唯一 = 一人只被绑一次。</td></tr>
|
||||
<tr><td><code>device_uuid</code></td><td>varchar</td><td>被邀请人注册设备 dp_uuid(设备去重取证)。</td></tr>
|
||||
<tr><td><code>status</code></td><td>varchar</td><td><code>bound</code>→<code>reg_rewarded</code>→<code>paid_rewarded</code>,或 <code>rejected</code>(防刷命中)。</td></tr>
|
||||
<tr><td><code>reg_rewarded_at</code></td><td>datetime NULL</td><td>注册段奖励发放时刻(幂等)。</td></tr>
|
||||
<tr><td><code>paid_rewarded_at</code></td><td>datetime NULL</td><td>首充段奖励发放时刻(幂等)。</td></tr>
|
||||
<tr><td><code>created_at</code></td><td>datetime</td><td></td></tr>
|
||||
</table>
|
||||
|
||||
<p><b>reward_claims</b> —— 通用一次性任务领取(TG 及后续任务复用):</p>
|
||||
<table>
|
||||
<tr><th>列</th><th>类型</th><th>说明</th></tr>
|
||||
<tr><td><code>id</code></td><td>PK</td><td></td></tr>
|
||||
<tr><td><code>user_id</code></td><td>bigint</td><td>领取人。</td></tr>
|
||||
<tr><td><code>task_key</code></td><td>varchar</td><td>任务标识,如 <code>telegram_join</code>。</td></tr>
|
||||
<tr><td><code>external_ref</code></td><td>varchar</td><td>外部去重键;TG 用 <code>telegram_id</code>。</td></tr>
|
||||
<tr><td><code>granted_days</code></td><td>int</td><td>本次发放天数。</td></tr>
|
||||
<tr><td><code>granted_at</code></td><td>datetime</td><td></td></tr>
|
||||
<tr><td colspan="3"><b>约束</b>:<code>UNIQUE(user_id, task_key)</code>(每账户每任务一次)+ <code>UNIQUE(task_key, external_ref)</code>(同一 telegram_id 只领一次)。</td></tr>
|
||||
</table>
|
||||
<p class="small">奖励发放本身落现有 <code>subscriptions</code>(<code>source='invite'</code> / <code>'task'</code>)+ <code>sub_events</code> 审计,不新增奖励台账表。</p>
|
||||
|
||||
<h2>4 · 后端 API + 钩子</h2>
|
||||
<table>
|
||||
<tr><th>端点 / 钩子</th><th>动作</th></tr>
|
||||
<tr><td><code>Register(..., inviteCode)</code></td><td>注册流程加 <code>inviteCode</code> 参:建号成功后解析码 → 邀请人 → 建 <code>referrals(bound)</code> → 过防刷闸(自邀请 / 设备去重 / 月度封顶)→ 发注册段双方 +3(<code>reg_rewarded</code>)。</td></tr>
|
||||
<tr><td>pay <code>webhook.settle()</code></td><td>首充钩子:在 <code>GrantPaidSubscriptionTx</code> 之后,若 <code>users.first_paid_at</code> 本次由空转非空(=首充)且该用户是被邀请人(<code>referrals</code> 命中)→ 发首充段双方 +7(<code>paid_rewarded</code>,幂等)。</td></tr>
|
||||
<tr><td><code>GET /v1/invite</code></td><td>我的邀请码 / 链接 + 战绩:已邀请数、已转化(首充)数、累计获赠天数、明细列表;附奖励任务清单(TG 完成态)。</td></tr>
|
||||
<tr><td><code>GET /v1/tasks/telegram/start</code></td><td>签发绑定 token(10 分钟有效、绑当前账户),返回 bot 深链 <code>t.me/<reward_bot>?start=<token></code>。</td></tr>
|
||||
<tr><td><code>POST /v1/tg/webhook</code></td><td>Telegram Bot 更新回调(secret 校验,与 pay webhook 同款外部回调):收 <code>/start <token></code> → 校验成员 → 发 TG 任务 +3。详见 §5。</td></tr>
|
||||
</table>
|
||||
|
||||
<h2>5 · TG 任务验证流程(Bot getChatMember)</h2>
|
||||
<div class="diagram">
|
||||
<svg width="820" height="392" viewBox="0 0 820 392" xmlns="http://www.w3.org/2000/svg" font-family="-apple-system,PingFang SC,Arial" font-size="12.5">
|
||||
<defs>
|
||||
<marker id="ah" markerWidth="9" markerHeight="9" refX="7" refY="3" orient="auto">
|
||||
<path d="M0,0 L7,3 L0,6 Z" fill="#8b93a3"/>
|
||||
</marker>
|
||||
</defs>
|
||||
<!-- lifelines -->
|
||||
<g fill="#e6e8ee" text-anchor="middle" font-weight="600">
|
||||
<rect x="60" y="14" width="150" height="34" rx="7" fill="#1d2129" stroke="#3a4150"/>
|
||||
<text x="135" y="35">App(客户端)</text>
|
||||
<rect x="335" y="14" width="150" height="34" rx="7" fill="#1d2129" stroke="#3a4150"/>
|
||||
<text x="410" y="35">pangolin-server</text>
|
||||
<rect x="620" y="14" width="150" height="34" rx="7" fill="#1d2129" stroke="#3a4150"/>
|
||||
<text x="695" y="35">Telegram(Bot/API)</text>
|
||||
</g>
|
||||
<g stroke="#2b3140" stroke-dasharray="3 4">
|
||||
<line x1="135" y1="48" x2="135" y2="378"/>
|
||||
<line x1="410" y1="48" x2="410" y2="378"/>
|
||||
<line x1="695" y1="48" x2="695" y2="378"/>
|
||||
</g>
|
||||
<!-- messages -->
|
||||
<g stroke="#8b93a3" marker-end="url(#ah)"><line x1="135" y1="76" x2="405" y2="76"/></g>
|
||||
<text x="140" y="70" fill="#a8afbd">① GET /v1/tasks/telegram/start</text>
|
||||
<g stroke="#5ec27a" marker-end="url(#ah)"><line x1="410" y1="104" x2="140" y2="104"/></g>
|
||||
<text x="140" y="98" fill="#7fcf95">② 返回 t.me/<bot>?start=<token></text>
|
||||
<g stroke="#8b93a3" marker-end="url(#ah)"><line x1="135" y1="132" x2="690" y2="132"/></g>
|
||||
<text x="150" y="126" fill="#a8afbd">③ 拉起 bot,用户按 Start(/start token)</text>
|
||||
<g stroke="#8b93a3" marker-end="url(#ah)"><line x1="695" y1="160" x2="415" y2="160"/></g>
|
||||
<text x="420" y="154" fill="#a8afbd">④ POST /v1/tg/webhook(update:token + telegram_id)</text>
|
||||
<g stroke="#e0884f" marker-end="url(#ah)"><line x1="410" y1="188" x2="690" y2="188"/></g>
|
||||
<text x="415" y="182" fill="#e0a06a">⑤ getChatMember(频道, telegram_id)</text>
|
||||
<g stroke="#5ec27a" marker-end="url(#ah)"><line x1="695" y1="216" x2="415" y2="216"/></g>
|
||||
<text x="420" y="210" fill="#7fcf95">⑥ status = member / administrator</text>
|
||||
<!-- grant box -->
|
||||
<rect x="330" y="232" width="160" height="30" rx="6" fill="#17291d" stroke="#2e5738"/>
|
||||
<text x="410" y="251" fill="#7fcf95" text-anchor="middle">⑦ 发 +3 天(reward_claims)</text>
|
||||
<g stroke="#e0884f" marker-end="url(#ah)"><line x1="410" y1="288" x2="690" y2="288"/></g>
|
||||
<text x="415" y="282" fill="#e0a06a">⑧ sendMessage「✅ 已到账 +3 天」</text>
|
||||
<g stroke="#8b93a3" marker-end="url(#ah)"><line x1="135" y1="330" x2="405" y2="330"/></g>
|
||||
<text x="140" y="324" fill="#a8afbd">⑨ App 回前台 GET /v1/invite → 任务已完成</text>
|
||||
<!-- reject note -->
|
||||
<rect x="500" y="346" width="300" height="30" rx="6" fill="#2a1a1a" stroke="#5a2e2e"/>
|
||||
<text x="650" y="365" fill="#e08a8a" text-anchor="middle">非成员 → bot 回「请先加入频道再验证」,不发</text>
|
||||
</svg>
|
||||
</div>
|
||||
<ol>
|
||||
<li><b>token</b> 短时有效(10 分钟)、一次性,绑当前账户 —— 防止链接被转发后他人领取。</li>
|
||||
<li><b>webhook 安全</b>:设置 Telegram <code>secret_token</code>,服务端校验 <code>X-Telegram-Bot-Api-Secret-Token</code> 头;路径也可带 secret 段。控制面经 Cloudflare Tunnel 已是公网 HTTPS,Telegram 可达,<b>无需开入站端口</b>。</li>
|
||||
<li><b>成员判定</b>:<code>getChatMember</code> 返回 <code>member</code> / <code>administrator</code> / <code>creator</code> 视为已加入;<code>left</code> / <code>kicked</code> / 查询失败视为未加入。</li>
|
||||
<li><b>不追溯退群</b>:一次性发放,发后不因退群回收(回收体验差、收益低)。</li>
|
||||
</ol>
|
||||
|
||||
<h2>6 · 新基建(上线前置,需在 Telegram 侧配一次)</h2>
|
||||
<ul>
|
||||
<li>建一个<b>面向用户的 Bot</b>(与运维告警 bot 分开),token 存 <code>TG_REWARD_BOT_TOKEN</code>(Bitwarden,不入 git)。</li>
|
||||
<li>把该 Bot <b>设为频道管理员</b> —— 否则 <code>getChatMember</code> 查不到成员。</li>
|
||||
<li>配 <code>TG_REWARD_CHANNEL</code>(如 <code>@pangolin_app</code>)、<code>TG_WEBHOOK_SECRET</code>;向 Telegram <code>setWebhook</code> 指向 <code>https://api.yanmeiai.com/v1/tg/webhook</code>(路由挂在 <code>/v1</code> 组下,真实路径含 <code>/v1</code> 前缀)。</li>
|
||||
<li>未配 <code>TG_REWARD_BOT_TOKEN</code> 时,TG 任务卡在 App 里<b>整卡隐藏</b>(邀请功能不受影响,优雅降级)。</li>
|
||||
</ul>
|
||||
|
||||
<h2>7 · 客户端 UI</h2>
|
||||
<p>邀请页(占位屏已在)升级为「<b>邀请 + 任务</b>」两区,数据来自 <code>GET /v1/invite</code>:</p>
|
||||
<div class="cols">
|
||||
<div class="card"><h3>邀请区(上)</h3>
|
||||
<ul>
|
||||
<li>真实邀请码 + 邀请链接,复制 / 系统分享。</li>
|
||||
<li>规则说明:注册双方各 +3、首充双方各 +7。</li>
|
||||
<li>战绩台账:已邀请 / 已转化 / 累计获赠天数 + 明细列表。</li>
|
||||
</ul></div>
|
||||
<div class="card"><h3>任务区(下)「更多得会员」</h3>
|
||||
<ul>
|
||||
<li>首张卡:加入 Telegram 频道 +3 天。</li>
|
||||
<li>未完成 → 「加入频道」+「验证领取」两步按钮。</li>
|
||||
<li>已完成 → 「已领 +3 天」置灰。</li>
|
||||
<li>未配 bot → 整卡不显示。</li>
|
||||
</ul></div>
|
||||
</div>
|
||||
<p><b>注册页</b>:加「邀请码(选填)」输入框;deep link <code>…/i/<code></code> 打开 App 自动预填并锁定。l10n 六语。</p>
|
||||
|
||||
<h2>8 · 边界(YAGNI,本期不做)</h2>
|
||||
<ul>
|
||||
<li><span class="tag bad">不做</span> 现金 / 提现奖励 —— 只发会员天数。</li>
|
||||
<li><span class="tag bad">不做</span> 通知集成 —— 奖励发放留一个「事件」接缝,等 Spec ③ 通知系统建好再接;现阶段奖励在邀请页台账可见即可。</li>
|
||||
<li><span class="tag bad">不做</span> 多级分销(邀请人的邀请人也分成)—— 只做一级。</li>
|
||||
<li><span class="tag bad">不做</span> 注册后补填邀请码 —— 绑定仅注册时。</li>
|
||||
<li><span class="tag bad">不做</span> 退群回收已发天数。</li>
|
||||
</ul>
|
||||
|
||||
<h2>9 · 测试要点</h2>
|
||||
<ul>
|
||||
<li><b>注册段</b>:正常双方各 +3;自邀请拒;已注册设备拒(rejected 不发);月度封顶到点后只记不发;无效码正常注册(不报错、不发)。</li>
|
||||
<li><b>首充段</b>:首充双方各 +7;二次付费不再发(幂等,<code>first_paid_at</code> 已非空);未绑定用户首充不触发。</li>
|
||||
<li><b>TG 任务</b>:成员校验通过发 +3;非成员不发;同账户重复领拒;同 telegram_id 换账户领拒(UNIQUE);token 过期 / 伪造拒;webhook secret 校验。</li>
|
||||
<li><b>发天数原语</b>:<code>max(到期,now)+days</code> 语义,已是 Pro 的用户正确顺延(不缩短)。</li>
|
||||
<li>SQLite + MySQL 两套迁移与查询一致(遵项目多 DB 方言层,时间 Go 端算好传 <code>?</code>)。</li>
|
||||
</ul>
|
||||
|
||||
<h2>10 · 涉及文件(实现锚点)</h2>
|
||||
<pre><span class="cm"># 后端</span>
|
||||
server/internal/auth/service.go <span class="cm"># Register 加 inviteCode + 建 referrals + 注册段发奖</span>
|
||||
server/internal/reward/ <span class="cm"># 新包:邀请/任务发奖 + 防刷闸 + getChatMember</span>
|
||||
server/internal/pay/webhook.go <span class="cm"># settle() 首充钩子接首充段发奖</span>
|
||||
server/internal/httpapi/ <span class="cm"># GET /v1/invite、/v1/tasks/telegram/start、POST /v1/tg/webhook</span>
|
||||
server/migrations/{mysql,sqlite}/ <span class="cm"># users +2 列、referrals、reward_claims</span>
|
||||
<span class="cm"># 客户端</span>
|
||||
client/lib/screens/invite_page.dart <span class="cm"># 占位 → 真实(邀请区 + 任务区)</span>
|
||||
client/lib/widgets/auth_screen.dart <span class="cm"># 注册页加邀请码输入 + deep link 预填</span>
|
||||
client/lib/services/ + state/ <span class="cm"># invite api + provider(auth_api.Register 加 inviteCode)</span>
|
||||
client/lib/l10n/ <span class="cm"># 六语文案</span></pre>
|
||||
|
||||
<p class="small" style="margin-top:32px">下一步:定稿后进 <code>writing-plans</code> 出逐任务实现计划(<code>docs/superpowers/plans/2026-07-12-invite-task-rewards.md</code>),Subagent 驱动执行。TG 新基建(bot / 频道管理员 / webhook)在实现前由你在 Telegram 侧配好。</p>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,255 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>邀请奖励 + 奖励任务(加入 TG 频道) — 实现计划</title>
|
||||
<style>
|
||||
:root{
|
||||
--bg:#0f1117; --panel:#171a22; --panel2:#1d2129; --fg:#e6e8ee; --fg2:#a8afbd;
|
||||
--accent:#e0884f; --accent2:#5fb0c9; --ok:#5ec27a; --bad:#e06a6a; --warn:#e0b84f;
|
||||
--border:#272c36; --mono:"SF Mono",ui-monospace,Menlo,Consolas,monospace;
|
||||
--sans:-apple-system,"PingFang SC","Helvetica Neue",Arial,sans-serif;
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;background:var(--bg);color:var(--fg);font-family:var(--sans);line-height:1.7;font-size:15px}
|
||||
.wrap{max-width:920px;margin:0 auto;padding:48px 24px 96px}
|
||||
h1{font-size:30px;line-height:1.3;margin:0 0 8px;letter-spacing:-.01em}
|
||||
.sub{color:var(--fg2);font-size:15px;margin:0 0 32px}
|
||||
h2{font-size:21px;margin:44px 0 14px;padding-bottom:8px;border-bottom:1px solid var(--border)}
|
||||
h3{font-size:16px;margin:26px 0 8px;color:var(--accent2)}
|
||||
p{margin:10px 0}
|
||||
code{font-family:var(--mono);font-size:.88em;background:var(--panel2);padding:1px 6px;border-radius:5px;color:#f0d9c4}
|
||||
pre{background:#0a0c11;border:1px solid var(--border);border-radius:10px;padding:14px 16px;overflow-x:auto;font-family:var(--mono);font-size:13px;line-height:1.55;color:#cdd3df}
|
||||
.kw{color:#c68bd6}.str{color:#b6d47a}.num{color:#e0b06a}.cm{color:#6b7280}.fn{color:#5fb0c9}
|
||||
.tag{display:inline-block;font-size:12px;font-weight:600;padding:2px 9px;border-radius:999px;vertical-align:middle}
|
||||
.tag.ok{background:rgba(94,194,122,.16);color:var(--ok)}
|
||||
.tag.warn{background:rgba(224,184,79,.16);color:var(--warn)}
|
||||
.tag.info{background:rgba(95,176,201,.16);color:var(--accent2)}
|
||||
.tag.bad{background:rgba(224,106,106,.16);color:var(--bad)}
|
||||
.card{background:var(--panel);border:1px solid var(--border);border-radius:12px;padding:18px 20px;margin:16px 0}
|
||||
.card.root{border-left:3px solid var(--accent)}
|
||||
.card h3{margin-top:0}
|
||||
table{width:100%;border-collapse:collapse;margin:16px 0;font-size:14px}
|
||||
th,td{text-align:left;padding:9px 12px;border-bottom:1px solid var(--border);vertical-align:top}
|
||||
th{color:var(--fg2);font-weight:600;font-size:13px}
|
||||
td code{font-size:.85em}
|
||||
ul,ol{padding-left:22px;margin:10px 0}
|
||||
li{margin:5px 0}
|
||||
.lead{background:linear-gradient(180deg,rgba(224,136,79,.10),transparent);border:1px solid var(--border);border-radius:12px;padding:18px 20px;margin:0 0 8px}
|
||||
.small{color:var(--fg2);font-size:13px}
|
||||
a{color:var(--accent2)}
|
||||
.back{display:inline-block;margin-bottom:24px;font-size:13px}
|
||||
b{color:#fff}
|
||||
.cols{display:grid;grid-template-columns:1fr 1fr;gap:16px}
|
||||
@media(max-width:680px){.cols{grid-template-columns:1fr}}
|
||||
.task-num{display:inline-flex;align-items:center;justify-content:center;width:26px;height:26px;border-radius:8px;background:var(--panel2);color:var(--accent2);font-weight:700;font-size:13px;margin-right:10px;flex:none}
|
||||
.task-head{display:flex;align-items:center;margin-bottom:4px}
|
||||
.task-head h3{margin:0;color:var(--fg)}
|
||||
.files{font-family:var(--mono);font-size:12.5px;color:var(--fg2);margin:8px 0}
|
||||
.files b{color:#f0d9c4;font-weight:400}
|
||||
.accept{margin:6px 0 0}
|
||||
.accept li{color:var(--fg2)}
|
||||
.accept li b{color:var(--fg)}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<a class="back" href="index.html">← 文档索引</a>
|
||||
<h1>邀请奖励 + 奖励任务(加入 TG 频道)</h1>
|
||||
<p class="sub">实现计划阅读版 · 2026-07-12 · Spec ② · 14 任务 TDD · 状态 <span class="tag info">待执行</span></p>
|
||||
<p class="small">配套设计文档 <a href="invite-task-rewards-design.html">邀请奖励 + 奖励任务 — 设计</a>。执行真相源(含 checkbox,驱动 <code>subagent-driven-development</code>/<code>executing-plans</code>):<code>docs/superpowers/plans/2026-07-12-invite-task-rewards.md</code>。</p>
|
||||
|
||||
<div class="lead">
|
||||
<b>Goal</b>:用「送 Pro 会员天数」驱动增长 —— 邀请两段式(注册双方各 +3、被邀请人首充双方各 +7)+ 奖励任务(加入 TG 频道 +3,Bot 真校验),全部复用现有 subscriptions 发天数链路,不造新轮子。<br><br>
|
||||
<b>Architecture</b>:新增 <code>server/internal/reward</code> 包承载发奖 + 防刷;发天数复用 <code>codes.Service</code>(新增 <code>GrantRewardTx</code>)。注册段在 <code>auth.Service.Register</code> 后 best-effort 触发(自有 tx,不拖累注册主流程);首充段挂 <code>pay/webhook.settle</code> 同事务内(幂等、失败即整笔回滚重试);TG 走 <code>POST /tg/webhook</code>(Telegram <code>getChatMember</code> 真校验)。客户端 <code>invite_page</code> 由占位转真实(邀请区 + 任务区),注册页加邀请码输入。<br><br>
|
||||
<b>Tech Stack</b>:Go(chi + 裸 SQL + golang-migrate 双 DB)、Redis(TG 绑定 token)、Flutter/Riverpod、Telegram Bot API。
|
||||
</div>
|
||||
|
||||
<h2>Global Constraints</h2>
|
||||
<ul>
|
||||
<li>发天数一律复用 <code>codes.Service</code>(Pro plan);迁移须把 <code>subscriptions.source</code> CHECK/ENUM 从 <code>('trial','code','pay')</code> 扩到 <code>('trial','code','pay','invite','task')</code>(sqlite 重建表 / mysql <code>MODIFY</code>)。</li>
|
||||
<li>奖励天数:注册段各 <b>3</b>、首充段各 <b>7</b>、TG 任务 <b>3</b>;注册段邀请人<b>月度封顶默认 10</b>(env <code>INVITE_REG_MONTHLY_CAP</code>)。</li>
|
||||
<li>审计一律走 <code>audit_log</code>(<code>store.WriteAuditLog</code>),<b>无 sub_events 表</b>。</li>
|
||||
<li>多 DB:<code>server/migrations/{mysql,sqlite}/</code> 两套一一对应;裸 SQL + <code>internal/db</code> 方言层;时间一律 Go 端 <code>time.Now().UTC()</code> 传 <code>?</code>,<b>禁</b> <code>NOW()</code>/<code>UTC_TIMESTAMP()</code>。</li>
|
||||
<li>邀请码 = <b>8 位 base32 大写、去 <code>0/O/1/I</code></b>;<code>users.invite_code</code> UNIQUE、惰性生成。</li>
|
||||
<li>绑定<b>仅注册时</b>、first-touch 永久不可改。防刷四道:自邀请拦截 + <code>referrals.invitee_id</code> UNIQUE + 设备去重(<code>devices.uuid</code> 已属别人则注册奖励不发、记 <code>rejected</code>)+ 注册段月度封顶 + <code>reward_claims</code> 双唯一 <code>(user_id,task_key)</code>/<code>(task_key,external_ref)</code>。</li>
|
||||
<li>TG:<code>getChatMember</code> 返回 <code>member</code>/<code>administrator</code>/<code>creator</code> 视为已加入;bot 未配(<code>TG_REWARD_BOT_TOKEN</code> 空)则 <code>/tg/webhook</code> 404 且 App 任务卡隐藏。webhook 校验 <code>X-Telegram-Bot-Api-Secret-Token</code>。</li>
|
||||
<li>客户端 l10n <b>无 codegen</b>:新 string 须 <code>app_text.dart</code> 加抽象 getter + 6 个 <code>strings_{zh,en,es,ja,ko,ru}.dart</code> 各加实现。</li>
|
||||
<li><b>deep-link 自动预填 = 可选后置任务(Task 13 Step 6)</b>;MVP 走注册页手填邀请码。</li>
|
||||
<li>TG 新基建(reward bot / 设为频道管理员 / <code>setWebhook</code>)由用户在实现前于 Telegram 侧配好。</li>
|
||||
<li>命令:后端 <code>cd server && go test ./...</code>;客户端 <code>cd client && flutter analyze && flutter test</code>。</li>
|
||||
</ul>
|
||||
|
||||
<h3>File Structure(总览)</h3>
|
||||
<pre><span class="cm"># 后端(新建)</span>
|
||||
server/migrations/{mysql,sqlite}/000024_invite_rewards.{up,down}.sql
|
||||
server/internal/reward/{store,service,telegram,handler}.go
|
||||
<span class="cm"># 后端(修改)</span>
|
||||
server/internal/codes/paygrant.go <span class="cm"># + GrantRewardTx</span>
|
||||
server/internal/auth/{service,handler}.go <span class="cm"># Register 加 inviteCode + ReferralHook</span>
|
||||
server/internal/pay/webhook.go <span class="cm"># settle 首充钩子 + Rewarder</span>
|
||||
server/cmd/server/main.go <span class="cm"># 装配 + 路由 + env</span>
|
||||
<span class="cm"># 客户端(新建/修改)</span>
|
||||
client/lib/services/invite_api.dart · state/invite_provider.dart
|
||||
client/lib/screens/invite_page.dart <span class="cm"># 占位转真实</span>
|
||||
client/lib/widgets/auth_screen.dart + services/auth_api.dart <span class="cm"># 邀请码输入</span>
|
||||
client/lib/l10n/app_text.dart + strings_*.dart ×6</pre>
|
||||
|
||||
<h2>后端(Task 1–10)</h2>
|
||||
|
||||
<div class="card root">
|
||||
<div class="task-head"><span class="task-num">1</span><h3>迁移 000024 — 新表 + source 扩容</h3></div>
|
||||
<p>建 <code>referrals</code>(邀请关系,一对一绑定)、<code>reward_claims</code>(通用一次性任务领取)两张表;<code>users</code> 加 <code>invite_code</code>/<code>first_paid_at</code>;<code>subscriptions.source</code> CHECK/ENUM 扩容到含 <code>invite</code>/<code>task</code>(SQLite 需重建表,MySQL 直接 <code>MODIFY</code>)。sqlite/mysql 各一对 up/down,四个文件。</p>
|
||||
<div class="files"><b>新建</b> server/migrations/{sqlite,mysql}/000024_invite_rewards.{up,down}.sql</div>
|
||||
<ul class="accept">
|
||||
<li><b>验收</b>:<code>server/internal/store/migrate_sqlite_test.go</code> 现有 up→down→up 全量测试(<code>run_sqlite_test.sh</code>)通过。</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="task-head"><span class="task-num">2</span><h3><code>codes.Service.GrantRewardTx</code> — 发奖天数原语</h3></div>
|
||||
<p>在 <code>codes</code> 包加一个薄封装:复用既有 <code>applySubscription</code>(<code>max(到期,now)+days</code> 顺延语义)发 Pro 天数,<code>source∈{invite,task}</code>,并写一条 <code>audit_log</code>。这是后续所有发奖调用的唯一入口。</p>
|
||||
<div class="files"><b>改</b> server/internal/codes/paygrant.go · <b>新建</b> reward_grant_sqlite_test.go</div>
|
||||
<p><b>接口</b>:<code>GrantRewardTx(ctx, tx, userID, days, source, auditAction, ref) (subID, expiresAt, err)</code></p>
|
||||
<ul class="accept">
|
||||
<li><b>验收</b>:新用户发奖建订阅、<code>source</code> 落对、<code>audit_log</code> 命中 1 条。</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="task-head"><span class="task-num">3</span><h3><code>reward.Store</code> — 数据访问层</h3></div>
|
||||
<p>新建 <code>reward</code> 包的纯数据访问层:邀请码惰性生成/解析、设备复用查询、月度计数、referrals/reward_claims 的 CRUD,唯一冲突统一映射为 <code>ErrClaimExists</code>。</p>
|
||||
<div class="files"><b>新建</b> server/internal/reward/{store.go, store_sqlite_test.go}</div>
|
||||
<p><b>关键方法</b>:<code>EnsureInviteCode</code> · <code>ResolveInviteCode</code> · <code>DeviceUsedByOther</code> · <code>RegRewardCountThisMonth</code> · <code>InsertReferralTx</code> · <code>MarkFirstPaidTx</code> · <code>ReferralByInvitee</code> · <code>MarkPaidRewardedTx</code> · <code>InsertClaimTx</code> · <code>Summary</code> · <code>TelegramClaimed</code></p>
|
||||
<ul class="accept">
|
||||
<li><b>验收</b>:邀请码生成幂等(二次 ensure 不变);同用户/同 telegram_id 重复领取皆命中 <code>ErrClaimExists</code>。</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="task-head"><span class="task-num">4</span><h3><code>reward.Service</code> — 邀请码生成、注册段发奖 + 防刷</h3></div>
|
||||
<p>建 <code>Service</code>(依赖 <code>Store</code> + <code>Granter</code> 接口,由 <code>*codes.Service</code> 满足)。核心 <code>OnRegister</code>:best-effort(自有事务,失败只 log、不回滚注册)解析邀请码 → 自邀请/设备复用/月度封顶三道防刷判定 → 建 <code>referrals</code> → 未被拒则双方各发注册段天数。</p>
|
||||
<div class="files"><b>新建</b> server/internal/reward/{service.go, service_sqlite_test.go}</div>
|
||||
<p><b>关键方法</b>:<code>GenInviteCode()</code>(8 位 base32,去 0/O/1/I)· <code>EnsureCode</code> · <code>OnRegister(ctx, inviteeID, inviteCode, deviceUUID)</code></p>
|
||||
<ul class="accept">
|
||||
<li><b>验收</b>:正常注册双方各得 <code>RegDays</code>;自邀请不建关系;复用他人设备时 status=<code>rejected</code> 且不发奖。</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="task-head"><span class="task-num">5</span><h3>接入注册 — <code>auth.Register</code> 加 inviteCode</h3></div>
|
||||
<p><code>auth.Service.Register</code> 签名加 <code>inviteCode</code> 参数;新增 <code>ReferralHook</code> 接口 + <code>SetReferralHook</code> setter,注册成功(<code>recordLogin</code> 之后)best-effort 调用钩子。<code>handler.go</code> 的 <code>registerRequest</code> 加 <code>invite_code</code> 字段并透传。</p>
|
||||
<div class="files"><b>改</b> server/internal/auth/{service.go, handler.go} · <b>新建</b> register_invite_test.go</div>
|
||||
<ul class="accept">
|
||||
<li><b>验收</b>:注册后 hook 收到正确 <code>inviteeID/code/deviceID</code>;全库搜 <code>.Register(</code> 更新所有调用点后包内测试全绿。</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="task-head"><span class="task-num">6</span><h3>首充钩子 — pay webhook 接首充段发奖</h3></div>
|
||||
<p><code>reward.Service</code> 加 <code>OnFirstPaidTx</code>(同事务内调用):先 <code>MarkFirstPaidTx</code> 判是否真首充,是则查 <code>ReferralByInvitee</code>,未发过/未拒则双方各发首充段天数并置 <code>paid_rewarded</code>。<code>pay/webhook.go</code> 加 <code>Rewarder</code> 接口 + <code>SetRewarder</code>,在 <code>GrantPaidSubscriptionTx</code> 之后、<code>tx.Commit()</code> 之前调用 —— 失败则整笔回滚,靠 webhook 重试保证最终发放。</p>
|
||||
<div class="files"><b>改</b> server/internal/reward/service.go(追加)、server/internal/pay/webhook.go · <b>新建</b> webhook_referral_sqlite_test.go</div>
|
||||
<ul class="accept">
|
||||
<li><b>验收</b>:首充双方各得 <code>PaidDays</code>、<code>referrals.status→paid_rewarded</code>、<code>users.first_paid_at</code> 落值;二次付费不再重复发(幂等)。</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="task-head"><span class="task-num">7</span><h3><code>GET /v1/invite</code> 端点</h3></div>
|
||||
<p>新建 <code>reward.Handler</code>:惰性拿邀请码 + 邀请战绩汇总(已邀请/已转化/累计获赠天数)+ TG 任务态(enabled/joined/channel)一次性打包返回。</p>
|
||||
<div class="files"><b>新建</b> server/internal/reward/handler.go(GetInvite 部分)、handler_invite_test.go</div>
|
||||
<p><b>响应</b>:<code>{invite_code, invite_link, invited, converted, earned_days, telegram:{enabled, joined, channel}}</code></p>
|
||||
<ul class="accept">
|
||||
<li><b>验收</b>:返回 200,<code>invite_code</code> 非空,<code>telegram.enabled</code> 与构造参数一致。</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="task-head"><span class="task-num">8</span><h3>TG 绑定 token — <code>GET /v1/tasks/telegram/start</code></h3></div>
|
||||
<p>签发一次性 10 分钟 token 绑定当前账户(Redis <code>SETEX</code>,Redis 为 nil 时内存 map 兜底供测试)、消费即失效(<code>GETDEL</code>)。端点返回 bot 深链 <code>t.me/<bot>?start=<token></code>。</p>
|
||||
<div class="files"><b>新建</b> server/internal/reward/telegram.go(token 部分)· <b>改</b> handler.go · <b>新建</b> telegram_token_test.go</div>
|
||||
<ul class="accept">
|
||||
<li><b>验收</b>:token 签发后可消费一次拿回正确 userID,二次消费返回 not-ok。</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="task-head"><span class="task-num">9</span><h3>TG webhook — getChatMember 真校验 + 发奖</h3></div>
|
||||
<p><code>ChatMemberChecker</code> 接口(默认实现打 Telegram <code>getChatMember</code> API,测试可注入 fake)。<code>ClaimTelegram</code>:真是成员 → <code>InsertClaimTx</code>(唯一守卫)+ <code>GrantRewardTx(source='task')</code>。<code>Handler.TelegramWebhook</code>:校验 <code>X-Telegram-Bot-Api-Secret-Token</code> → 解析 <code>/start <token></code> → 消费 token → 领取 → <code>sendMessage</code> 回执(成功/已领/非成员话术不同)。</p>
|
||||
<div class="files"><b>改</b> server/internal/reward/{telegram.go, handler.go} · <b>新建</b> telegram_webhook_test.go</div>
|
||||
<ul class="accept">
|
||||
<li><b>验收</b>:成员一次性发 <code>TgDays</code>,二次领取不再发;非成员不发;全包 <code>go test ./internal/reward/...</code> 绿。</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="task-head"><span class="task-num">10</span><h3>装配 main.go — 构造 reward svc + 注入 + 路由 + env</h3></div>
|
||||
<p>纯装配任务,无独立单测(逻辑单测已在各包)。读 <code>INVITE_REG_MONTHLY_CAP</code>/<code>TG_REWARD_BOT_TOKEN</code>/<code>TG_REWARD_BOT_USER</code>/<code>TG_REWARD_CHANNEL</code>/<code>TG_WEBHOOK_SECRET</code> 构造 <code>rewardSvc</code>;注入 <code>authSvc.SetReferralHook</code> + <code>payWebhook.SetRewarder</code>;挂路由 <code>protected: GET /invite, GET /tasks/telegram/start</code>、<code>public v1: POST /tg/webhook</code>。</p>
|
||||
<div class="files"><b>改</b> server/cmd/server/main.go</div>
|
||||
<ul class="accept">
|
||||
<li><b>验收</b>:<code>go build ./... && go vet ./cmd/server/</code> 无错;<code>go test ./...</code> 全绿。</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<h2>客户端(Task 11–13)</h2>
|
||||
|
||||
<div class="card root">
|
||||
<div class="task-head"><span class="task-num">11</span><h3>客户端 invite api + provider</h3></div>
|
||||
<p>新建 <code>InviteApi</code>(<code>fetch()</code> 拉 <code>GET /v1/invite</code>、<code>telegramStartLink()</code> 拉深链)+ <code>InviteInfo</code> 数据类;Riverpod <code>InviteNotifier</code>(未登录返回 null、不打网络)。<code>auth_api.dart</code> 的 <code>register</code> 加可选 <code>inviteCode</code> 参并入请求体。</p>
|
||||
<div class="files"><b>新建</b> client/lib/services/invite_api.dart · state/invite_provider.dart · <b>改</b> services/auth_api.dart</div>
|
||||
<ul class="accept">
|
||||
<li><b>验收</b>:<code>invite_api_test.dart</code> 用 <code>MockClient</code> 断言 <code>/v1/invite</code> 响应正确解析到 <code>InviteInfo</code> 各字段。</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="task-head"><span class="task-num">12</span><h3>客户端 invite_page 真实化 + l10n</h3></div>
|
||||
<p>邀请页占位转真实:邀请区(真实码/链接 + 复制/分享 + 战绩三格)+ 任务区「更多得会员」(TG 任务卡:未完成显「加入频道」+「验证领取」两步,已完成显「已领 +3 天」置灰,bot 未配整卡隐藏)。先加 9 个 l10n getter(<code>app_text.dart</code> + 6 语言实现),再改页面引用。颜色一律走 token,禁硬编码。</p>
|
||||
<div class="files"><b>改</b> client/lib/screens/invite_page.dart · l10n/app_text.dart + strings_{zh,en,es,ja,ko,ru}.dart</div>
|
||||
<ul class="accept">
|
||||
<li><b>验收</b>:<code>flutter test test/widget/invite_page_test.dart</code> 通过(注入 fake InviteInfo,断言邀请码文本 + TG 任务卡文案可见);<code>flutter analyze</code> 对改动文件无 issue。</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="task-head"><span class="task-num">13</span><h3>注册页加邀请码输入 + deep-link 预填(可选后置)</h3></div>
|
||||
<p>Step 1–5(必做):<code>auth_screen.dart</code> 注册表单密码步下方加一个可选「邀请码(选填)」输入框(key <code>invite-code-field</code>),提交时传 <code>inviteCode</code> 给 <code>auth_api.register</code>。</p>
|
||||
<p>Step 6(<span class="tag warn">可选后置</span>,MVP 不含):<code>app_links</code> 依赖 + 四端原生配置(Android intent-filter / iOS associated domains / macOS URL scheme),解析邀请链接自动预填并锁定注册页邀请码字段。单列为独立后续任务。</p>
|
||||
<div class="files"><b>改</b> client/lib/widgets/auth_screen.dart · <b>新建</b> test/widget/auth_invite_field_test.dart</div>
|
||||
<ul class="accept">
|
||||
<li><b>验收</b>:widget 测试断言注册表单存在邀请码输入框;<code>flutter analyze</code> 对该文件无 issue。</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<h2>文档(Task 14)</h2>
|
||||
|
||||
<div class="card">
|
||||
<div class="task-head"><span class="task-num">14</span><h3>计划 HTML 阅读版 + 索引登记</h3></div>
|
||||
<p>即本文档:按项目「设计/计划双产物」规范,把 <code>docs/superpowers/plans/2026-07-12-invite-task-rewards.md</code>(执行真相源,含 checkbox)生成同内容 HTML 阅读版,并登记进 <code>docs/index.html</code>「实现计划 / Plans」分类、与设计文档互链。</p>
|
||||
<div class="files"><b>新建</b> docs/invite-task-rewards-plan.html · <b>改</b> docs/index.html</div>
|
||||
<ul class="accept">
|
||||
<li><b>验收</b>:两文件登记完整、互链可点;<code>.md</code> 保持执行真相源不变(供 subagent-driven-development 继续驱动 Task 1–13)。</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<h2>验收(端到端)</h2>
|
||||
<ul>
|
||||
<li>后端:<code>cd server && go test ./...</code> 全绿(含 reward 包 + auth/pay 回归)。</li>
|
||||
<li>客户端:<code>cd client && flutter analyze && flutter test</code> 全绿。</li>
|
||||
<li>真机联调(需 TG 新基建就绪):A 注册拿邀请码 → B 用 A 的码注册 → A/B 各 +3;B 用人民币下一单付成 → A/B 各再 +7;B 在 App 点「加入频道」+「验证领取」→ bot 校验成员 → +3;重复领被拒。</li>
|
||||
<li>防刷:自邀请无关系;同设备第二账号注册奖励被拒(status=rejected);同一 telegram_id 换账户领被拒。</li>
|
||||
</ul>
|
||||
|
||||
<h2>不在本轮(YAGNI)</h2>
|
||||
<ul>
|
||||
<li><span class="tag bad">不做</span> 现金/提现、多级分销、退群回收、注册后补填邀请码。</li>
|
||||
<li><span class="tag bad">不做</span> deep-link 自动预填(Task 13 Step 6 单列后续)。</li>
|
||||
<li><span class="tag bad">不做</span> 通知集成(奖励事件接缝留给 Spec ③)。</li>
|
||||
</ul>
|
||||
|
||||
<p class="small" style="margin-top:32px">本页为阅读版,不含逐步 TDD 代码细节;完整测试代码/实现片段见执行真相源 <code>docs/superpowers/plans/2026-07-12-invite-task-rewards.md</code>。</p>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -38,6 +38,7 @@ import (
|
||||
"github.com/wangjia/pangolin/server/internal/provision"
|
||||
"github.com/wangjia/pangolin/server/internal/provision/providers"
|
||||
"github.com/wangjia/pangolin/server/internal/redisutil"
|
||||
"github.com/wangjia/pangolin/server/internal/reward"
|
||||
"github.com/wangjia/pangolin/server/internal/scheduler"
|
||||
"github.com/wangjia/pangolin/server/internal/scheduler/probe"
|
||||
"github.com/wangjia/pangolin/server/internal/sessions"
|
||||
@@ -279,6 +280,7 @@ func mountV1(r chi.Router, sqlDB *sql.DB, rdb *redis.Client, nodeSvc *nodes.Serv
|
||||
|
||||
// ── Auth ──────────────────────────────────────────────────────────────────
|
||||
var authHandler *auth.Handler
|
||||
var authSvc *auth.Service
|
||||
if tm != nil {
|
||||
var mailer auth.Mailer
|
||||
if smtpHost := os.Getenv("SMTP_HOST"); smtpHost != "" {
|
||||
@@ -294,7 +296,7 @@ func mountV1(r chi.Router, sqlDB *sql.DB, rdb *redis.Client, nodeSvc *nodes.Serv
|
||||
}
|
||||
rl := auth.NewRateLimiter(rdb, nil)
|
||||
authStore := auth.NewSQLStore(sqlDB)
|
||||
authSvc := auth.NewService(authStore, rdb, rl, tm, mailer, auth.ServiceConfig{}, nil)
|
||||
authSvc = auth.NewService(authStore, rdb, rl, tm, mailer, auth.ServiceConfig{}, nil)
|
||||
authSvc.SetDeviceRegistrar(authDeviceRegistrar{svc: devicesSvc})
|
||||
authSvc.SetSessionStore(sessionStore)
|
||||
authHandler = auth.NewHandler(authSvc)
|
||||
@@ -319,6 +321,35 @@ func mountV1(r chi.Router, sqlDB *sql.DB, rdb *redis.Client, nodeSvc *nodes.Serv
|
||||
webhookHandler := codes.NewWebhookHandler(codesStore, rdb,
|
||||
os.Getenv("WEBHOOK_SECRET"), 5*time.Minute, 15*time.Minute)
|
||||
|
||||
// ── Reward(邀请奖励 + 奖励任务)──────────────────────────────────────────
|
||||
regCap := 10
|
||||
if v := os.Getenv("INVITE_REG_MONTHLY_CAP"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
||||
regCap = n
|
||||
}
|
||||
}
|
||||
rewardStore := reward.NewStore(sqlDB)
|
||||
rewardSvc := reward.NewService(sqlDB, rewardStore, codesSvc,
|
||||
reward.Config{RegDays: 3, PaidDays: 7, TgDays: 3, RegMonthlyCap: regCap}, nil)
|
||||
rewardSvc.SetRedis(rdb)
|
||||
tgBotToken := os.Getenv("TG_REWARD_BOT_TOKEN")
|
||||
tgEnabled := tgBotToken != ""
|
||||
if tgEnabled {
|
||||
tgWebhookSecret := os.Getenv("TG_WEBHOOK_SECRET")
|
||||
if tgWebhookSecret == "" {
|
||||
log.Fatal("TG_REWARD_BOT_TOKEN 已设置但 TG_WEBHOOK_SECRET 为空,/v1/tg/webhook 将无鉴权,拒绝启动")
|
||||
}
|
||||
rewardSvc.SetTelegram(
|
||||
getenvDefault("TG_REWARD_BOT_USER", "pangolin_reward_bot"),
|
||||
getenvDefault("TG_REWARD_CHANNEL", "@pangolin_app"),
|
||||
tgBotToken, tgWebhookSecret)
|
||||
}
|
||||
rewardHandler := reward.NewHandler(rewardSvc, rewardStore, tgEnabled,
|
||||
getenvDefault("TG_REWARD_CHANNEL", "@pangolin_app"))
|
||||
if authSvc != nil {
|
||||
authSvc.SetReferralHook(rewardSvc)
|
||||
}
|
||||
|
||||
// ── Pay(pay v2 统一支付网关;PAY_BASE_URL 未配则整组不挂载)──────────────
|
||||
var payHandler *pay.Handler
|
||||
var payWebhook *pay.WebhookHandler
|
||||
@@ -333,6 +364,7 @@ func mountV1(r chi.Router, sqlDB *sql.DB, rdb *redis.Client, nodeSvc *nodes.Serv
|
||||
payHandler = pay.NewHandler(payClient, payStore, sqlDB)
|
||||
payWebhook = pay.NewWebhookHandler(payStore, codesSvc, sqlDB, rdb,
|
||||
paySystem, paySecret, 5*time.Minute, 15*time.Minute)
|
||||
payWebhook.SetRewarder(rewardSvc)
|
||||
} else {
|
||||
log.Printf("PAY_BASE_URL 未配置 — /v1/pay 支付端点不挂载")
|
||||
}
|
||||
@@ -421,6 +453,7 @@ func mountV1(r chi.Router, sqlDB *sql.DB, rdb *redis.Client, nodeSvc *nodes.Serv
|
||||
if payWebhook != nil {
|
||||
v1.Post("/webhook/pay", payWebhook.ServeHTTP)
|
||||
}
|
||||
v1.Post("/tg/webhook", rewardHandler.TelegramWebhook)
|
||||
|
||||
// Protected: all routes that require a valid Bearer JWT.
|
||||
if tm != nil {
|
||||
@@ -458,6 +491,8 @@ func mountV1(r chi.Router, sqlDB *sql.DB, rdb *redis.Client, nodeSvc *nodes.Serv
|
||||
protected.Post("/pay/orders/{orderNo}/retry", payHandler.Retry)
|
||||
protected.Post("/pay/orders/{orderNo}/cancel", payHandler.Cancel)
|
||||
}
|
||||
protected.Get("/invite", rewardHandler.GetInvite)
|
||||
protected.Get("/tasks/telegram/start", rewardHandler.TelegramStart)
|
||||
|
||||
if nodeAPI != nil {
|
||||
protected.Get("/nodes", nodeAPI.ListNodes)
|
||||
|
||||
@@ -43,7 +43,7 @@ func TestService_RegisterDevice_OnRegisterAndLogin(t *testing.T) {
|
||||
t.Fatalf("SendCode: %v", err)
|
||||
}
|
||||
code := codeInRedis(t, svc, email)
|
||||
if _, e := svc.Register(ctx, email, code, pw, "1.2.3.4", meta); e != nil {
|
||||
if _, e := svc.Register(ctx, email, code, pw, "1.2.3.4", meta, ""); e != nil {
|
||||
t.Fatalf("Register: %v", e)
|
||||
}
|
||||
if len(reg.calls) != 1 || reg.calls[0].meta.DeviceID != "dev-uuid-1" || reg.calls[0].meta.Platform != "macos" {
|
||||
@@ -79,7 +79,7 @@ func TestService_Login_SurfacesDeviceLimit(t *testing.T) {
|
||||
t.Fatalf("SendCode: %v", err)
|
||||
}
|
||||
code := codeInRedis(t, svc, email)
|
||||
if _, e := svc.Register(ctx, email, code, pw, "", meta); e != nil {
|
||||
if _, e := svc.Register(ctx, email, code, pw, "", meta, ""); e != nil {
|
||||
t.Fatalf("Register: %v", e)
|
||||
}
|
||||
out, _, e := svc.Login(ctx, email, pw, "", meta)
|
||||
@@ -104,7 +104,7 @@ func TestService_RegisterDevice_BestEffort(t *testing.T) {
|
||||
t.Fatalf("SendCode: %v", err)
|
||||
}
|
||||
code := codeInRedis(t, svc, email)
|
||||
if _, e := svc.Register(ctx, email, code, "supersecret", "", DeviceMeta{DeviceID: "x", Platform: "windows"}); e != nil {
|
||||
if _, e := svc.Register(ctx, email, code, "supersecret", "", DeviceMeta{DeviceID: "x", Platform: "windows"}, ""); e != nil {
|
||||
t.Fatalf("Register must succeed despite registrar error: %v", e)
|
||||
}
|
||||
}
|
||||
@@ -120,7 +120,7 @@ func TestService_RegisterDevice_NoMeta(t *testing.T) {
|
||||
t.Fatalf("SendCode: %v", err)
|
||||
}
|
||||
code := codeInRedis(t, svc, email)
|
||||
if _, e := svc.Register(ctx, email, code, "supersecret", "", DeviceMeta{}); e != nil {
|
||||
if _, e := svc.Register(ctx, email, code, "supersecret", "", DeviceMeta{}, ""); e != nil {
|
||||
t.Fatalf("Register: %v", e)
|
||||
}
|
||||
if len(reg.calls) != 0 {
|
||||
|
||||
@@ -61,10 +61,11 @@ func (d deviceBody) toMeta() DeviceMeta {
|
||||
}
|
||||
|
||||
type registerRequest struct {
|
||||
Email string `json:"email"`
|
||||
Code string `json:"code"`
|
||||
Password string `json:"password"`
|
||||
Device deviceBody `json:"device"`
|
||||
Email string `json:"email"`
|
||||
Code string `json:"code"`
|
||||
Password string `json:"password"`
|
||||
Device deviceBody `json:"device"`
|
||||
InviteCode string `json:"invite_code"` // 选填:邀请码,注册后 best-effort 归因
|
||||
}
|
||||
|
||||
type loginRequest struct {
|
||||
@@ -104,7 +105,7 @@ func (h *Handler) Register(w http.ResponseWriter, r *http.Request) {
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
pair, apiErr := h.svc.Register(r.Context(), req.Email, req.Code, req.Password, clientIP(r), req.Device.toMeta())
|
||||
pair, apiErr := h.svc.Register(r.Context(), req.Email, req.Code, req.Password, clientIP(r), req.Device.toMeta(), req.InviteCode)
|
||||
if apiErr != nil {
|
||||
writeAPIErr(w, apiErr, 0)
|
||||
return
|
||||
|
||||
@@ -140,7 +140,7 @@ func TestIntegration_FullChain(t *testing.T) {
|
||||
}
|
||||
|
||||
// 2. Register → trial subscription must exist for 7 days.
|
||||
pair, apiErr := svc.Register(ctx, email, code, pw, "203.0.113.10", DeviceMeta{})
|
||||
pair, apiErr := svc.Register(ctx, email, code, pw, "203.0.113.10", DeviceMeta{}, "")
|
||||
if apiErr != nil {
|
||||
t.Fatalf("Register: %v", apiErr)
|
||||
}
|
||||
@@ -170,7 +170,7 @@ func TestIntegration_FullChain(t *testing.T) {
|
||||
}
|
||||
// Force a fresh code regardless of rate limit.
|
||||
_ = rdb.Set(ctx, codeKey(email), code, 10*time.Minute).Err()
|
||||
if _, e := svc.Register(ctx, email, code, pw, "203.0.113.10", DeviceMeta{}); e == nil || e.Code != ErrCodeInvalid.Code {
|
||||
if _, e := svc.Register(ctx, email, code, pw, "203.0.113.10", DeviceMeta{}, ""); e == nil || e.Code != ErrCodeInvalid.Code {
|
||||
t.Fatalf("want code_invalid (anti-enumeration), got %v", e)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// fakeHook captures the arguments passed to OnRegister so tests can assert on
|
||||
// them without depending on the real reward.Service.
|
||||
type fakeHook struct {
|
||||
gotInvitee int64
|
||||
gotCode string
|
||||
gotDev string
|
||||
}
|
||||
|
||||
func (f *fakeHook) OnRegister(_ context.Context, inviteeID int64, code, dev string) {
|
||||
f.gotInvitee, f.gotCode, f.gotDev = inviteeID, code, dev
|
||||
}
|
||||
|
||||
// TestRegister_CallsReferralHookWithInviteCode verifies that a successful
|
||||
// registration best-effort invokes the ReferralHook with the invite code and
|
||||
// this registration's device UUID (not some stale/historical value).
|
||||
func TestRegister_CallsReferralHookWithInviteCode(t *testing.T) {
|
||||
svc, _, _ := newService(t, ServiceConfig{})
|
||||
ctx := context.Background()
|
||||
const email = "new@x.com"
|
||||
|
||||
if _, err := svc.SendCode(ctx, email, "1.2.3.4"); err != nil {
|
||||
t.Fatalf("SendCode: %v", err)
|
||||
}
|
||||
code := codeInRedis(t, svc, email)
|
||||
|
||||
h := &fakeHook{}
|
||||
svc.SetReferralHook(h)
|
||||
|
||||
_, apiErr := svc.Register(ctx, email, code, "password123", "1.2.3.4", DeviceMeta{DeviceID: "dev-1"}, "INVCODE9")
|
||||
if apiErr != nil {
|
||||
t.Fatalf("Register: %v", apiErr)
|
||||
}
|
||||
if h.gotCode != "INVCODE9" || h.gotDev != "dev-1" || h.gotInvitee == 0 {
|
||||
t.Fatalf("hook got invitee=%d code=%q dev=%q", h.gotInvitee, h.gotCode, h.gotDev)
|
||||
}
|
||||
}
|
||||
@@ -132,12 +132,22 @@ type Service struct {
|
||||
now func() time.Time
|
||||
devReg DeviceRegistrar // nil until wired; registration is best-effort
|
||||
sessions SessionStore // nil until wired; session recording is best-effort
|
||||
referral ReferralHook // nil until wired; referral attribution is best-effort
|
||||
}
|
||||
|
||||
// SetDeviceRegistrar / SetSessionStore wire collaborators after construction
|
||||
// (main keeps auth, devices and sessions decoupled). Call once during startup.
|
||||
// ReferralHook is invoked after a successful registration to attribute the new
|
||||
// account to the invite code it registered with (best-effort; implemented by
|
||||
// reward.Service). Defined consumer-side to avoid an import cycle.
|
||||
type ReferralHook interface {
|
||||
OnRegister(ctx context.Context, inviteeID int64, inviteCode, deviceUUID string)
|
||||
}
|
||||
|
||||
// SetDeviceRegistrar / SetSessionStore / SetReferralHook wire collaborators
|
||||
// after construction (main keeps auth, devices, sessions and reward decoupled).
|
||||
// Call once during startup.
|
||||
func (s *Service) SetDeviceRegistrar(r DeviceRegistrar) { s.devReg = r }
|
||||
func (s *Service) SetSessionStore(st SessionStore) { s.sessions = st }
|
||||
func (s *Service) SetReferralHook(h ReferralHook) { s.referral = h }
|
||||
|
||||
// recordLogin registers the device and binds a session to the freshly-issued
|
||||
// refresh JTI. Best-effort: a registrar/session error (device cap, transient DB)
|
||||
@@ -267,7 +277,7 @@ func (s *Service) SendCode(ctx context.Context, rawEmail, ip string) (retryAfter
|
||||
|
||||
// Register verifies the code (one-time), creates the account plus a 7-day PRO
|
||||
// trial in a single transaction, and returns a fresh token pair.
|
||||
func (s *Service) Register(ctx context.Context, rawEmail, code, password, ip string, device DeviceMeta) (*TokenPair, *apierr.Error) {
|
||||
func (s *Service) Register(ctx context.Context, rawEmail, code, password, ip string, device DeviceMeta, inviteCode string) (*TokenPair, *apierr.Error) {
|
||||
email := NormalizeEmail(rawEmail)
|
||||
if !ValidEmail(email) || len(password) < 8 || len(code) != 6 {
|
||||
return nil, ErrInvalidRequest
|
||||
@@ -301,6 +311,9 @@ func (s *Service) Register(ctx context.Context, rawEmail, code, password, ip str
|
||||
return nil, ErrInternal
|
||||
}
|
||||
s.recordLogin(ctx, user.ID, jti, ip, device)
|
||||
if s.referral != nil {
|
||||
s.referral.OnRegister(ctx, user.ID, inviteCode, device.DeviceID) // best-effort
|
||||
}
|
||||
return pair, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ func TestService_RegisterFullFlow(t *testing.T) {
|
||||
}
|
||||
code := codeInRedis(t, svc, email)
|
||||
|
||||
pair, apiErr := svc.Register(ctx, email, code, "supersecret", "", DeviceMeta{})
|
||||
pair, apiErr := svc.Register(ctx, email, code, "supersecret", "", DeviceMeta{}, "")
|
||||
if apiErr != nil {
|
||||
t.Fatalf("Register: %v", apiErr)
|
||||
}
|
||||
@@ -84,7 +84,7 @@ func TestService_DuplicateEmailConflict(t *testing.T) {
|
||||
|
||||
// First registration.
|
||||
_, _ = svc.SendCode(ctx, email, "")
|
||||
if _, e := svc.Register(ctx, email, codeInRedis(t, svc, email), "password1", "", DeviceMeta{}); e != nil {
|
||||
if _, e := svc.Register(ctx, email, codeInRedis(t, svc, email), "password1", "", DeviceMeta{}, ""); e != nil {
|
||||
t.Fatalf("first register: %v", e)
|
||||
}
|
||||
|
||||
@@ -103,7 +103,7 @@ func TestService_DuplicateEmailConflict(t *testing.T) {
|
||||
if err := svc.rdb.Set(ctx, codeKey(email), "654321", 10*time.Minute).Err(); err != nil {
|
||||
t.Fatalf("force code: %v", err)
|
||||
}
|
||||
_, apiErr := svc.Register(ctx, email, "654321", "password2", "", DeviceMeta{})
|
||||
_, apiErr := svc.Register(ctx, email, "654321", "password2", "", DeviceMeta{}, "")
|
||||
if apiErr == nil || apiErr.Code != ErrCodeInvalid.Code {
|
||||
t.Fatalf("want code_invalid (anti-enumeration), got %v", apiErr)
|
||||
}
|
||||
@@ -115,7 +115,7 @@ func TestService_CodeWrong(t *testing.T) {
|
||||
const email = "wrong@example.com"
|
||||
_, _ = svc.SendCode(ctx, email, "")
|
||||
|
||||
_, apiErr := svc.Register(ctx, email, "000000", "password1", "", DeviceMeta{})
|
||||
_, apiErr := svc.Register(ctx, email, "000000", "password1", "", DeviceMeta{}, "")
|
||||
if apiErr == nil || apiErr.Code != ErrCodeInvalid.Code {
|
||||
t.Fatalf("want code_invalid, got %v", apiErr)
|
||||
}
|
||||
@@ -131,7 +131,7 @@ func TestService_CodeExpired(t *testing.T) {
|
||||
// Expire the code key.
|
||||
svc.rdb.Del(ctx, codeKey(email))
|
||||
|
||||
_, apiErr := svc.Register(ctx, email, code, "password1", "", DeviceMeta{})
|
||||
_, apiErr := svc.Register(ctx, email, code, "password1", "", DeviceMeta{}, "")
|
||||
if apiErr == nil || apiErr.Code != ErrCodeInvalid.Code {
|
||||
t.Fatalf("want code_invalid after expiry, got %v", apiErr)
|
||||
}
|
||||
@@ -144,11 +144,11 @@ func TestService_CodeReuseRejected(t *testing.T) {
|
||||
_, _ = svc.SendCode(ctx, email, "")
|
||||
code := codeInRedis(t, svc, email)
|
||||
|
||||
if _, e := svc.Register(ctx, email, code, "password1", "", DeviceMeta{}); e != nil {
|
||||
if _, e := svc.Register(ctx, email, code, "password1", "", DeviceMeta{}, ""); e != nil {
|
||||
t.Fatalf("first register: %v", e)
|
||||
}
|
||||
// Re-using the consumed code must fail.
|
||||
_, apiErr := svc.Register(ctx, "other@example.com", code, "password1", "", DeviceMeta{})
|
||||
_, apiErr := svc.Register(ctx, "other@example.com", code, "password1", "", DeviceMeta{}, "")
|
||||
if apiErr == nil || apiErr.Code != ErrCodeInvalid.Code {
|
||||
t.Fatalf("want code_invalid on reuse, got %v", apiErr)
|
||||
}
|
||||
@@ -163,12 +163,12 @@ func TestService_CodeBruteForceBurned(t *testing.T) {
|
||||
|
||||
// 3 wrong attempts burn the code.
|
||||
for i := 0; i < 3; i++ {
|
||||
if _, e := svc.Register(ctx, email, "999999", "password1", "", DeviceMeta{}); e == nil {
|
||||
if _, e := svc.Register(ctx, email, "999999", "password1", "", DeviceMeta{}, ""); e == nil {
|
||||
t.Fatal("wrong code should fail")
|
||||
}
|
||||
}
|
||||
// Even the correct code no longer works.
|
||||
if _, e := svc.Register(ctx, email, good, "password1", "", DeviceMeta{}); e == nil || e.Code != ErrCodeInvalid.Code {
|
||||
if _, e := svc.Register(ctx, email, good, "password1", "", DeviceMeta{}, ""); e == nil || e.Code != ErrCodeInvalid.Code {
|
||||
t.Fatalf("burned code should reject correct value, got %v", e)
|
||||
}
|
||||
}
|
||||
@@ -205,7 +205,7 @@ func TestService_LoginAndLockout(t *testing.T) {
|
||||
const pw = "rightpassword"
|
||||
|
||||
_, _ = svc.SendCode(ctx, email, "")
|
||||
if _, e := svc.Register(ctx, email, codeInRedis(t, svc, email), pw, "", DeviceMeta{}); e != nil {
|
||||
if _, e := svc.Register(ctx, email, codeInRedis(t, svc, email), pw, "", DeviceMeta{}, ""); e != nil {
|
||||
t.Fatalf("register: %v", e)
|
||||
}
|
||||
|
||||
@@ -247,7 +247,7 @@ func TestService_BannedUserRejected(t *testing.T) {
|
||||
const pw = "password1"
|
||||
|
||||
_, _ = svc.SendCode(ctx, email, "")
|
||||
if _, e := svc.Register(ctx, email, codeInRedis(t, svc, email), pw, "", DeviceMeta{}); e != nil {
|
||||
if _, e := svc.Register(ctx, email, codeInRedis(t, svc, email), pw, "", DeviceMeta{}, ""); e != nil {
|
||||
t.Fatalf("register: %v", e)
|
||||
}
|
||||
store.setStatus(email, "banned")
|
||||
@@ -264,7 +264,7 @@ func TestService_RefreshRotation(t *testing.T) {
|
||||
const email = "refresh@example.com"
|
||||
|
||||
_, _ = svc.SendCode(ctx, email, "")
|
||||
pair, e := svc.Register(ctx, email, codeInRedis(t, svc, email), "password1", "", DeviceMeta{})
|
||||
pair, e := svc.Register(ctx, email, codeInRedis(t, svc, email), "password1", "", DeviceMeta{}, "")
|
||||
if e != nil {
|
||||
t.Fatalf("register: %v", e)
|
||||
}
|
||||
|
||||
@@ -29,3 +29,21 @@ func (svc *Service) GrantPaidSubscriptionTx(
|
||||
_ = svc.store.WriteAuditLog(ctx, tx, fmt.Sprintf("user:%d", userID), "pay_grant", ref, string(meta))
|
||||
return subID, expiresAt, nil
|
||||
}
|
||||
|
||||
// GrantRewardTx 发放奖励会员天数(Pro,source∈{invite,task}),与付费/兑换码同一条
|
||||
// applySubscription 延时逻辑(max(到期,now)+days;已有活跃 pro 则原地顺延)。审计走 audit_log。
|
||||
func (svc *Service) GrantRewardTx(
|
||||
ctx context.Context, tx *sql.Tx, userID int64, days int, source, auditAction, ref string,
|
||||
) (int64, time.Time, error) {
|
||||
planID, err := svc.store.GetPlanIDTx(ctx, tx, PlanPro)
|
||||
if err != nil {
|
||||
return 0, time.Time{}, err
|
||||
}
|
||||
subID, expiresAt, err := svc.applySubscription(ctx, tx, userID, planID, days, source)
|
||||
if err != nil {
|
||||
return 0, time.Time{}, err
|
||||
}
|
||||
meta, _ := json.Marshal(map[string]any{"days": days, "source": source, "sub_id": subID})
|
||||
_ = svc.store.WriteAuditLog(ctx, tx, fmt.Sprintf("user:%d", userID), auditAction, ref, string(meta))
|
||||
return subID, expiresAt, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package codes_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/wangjia/pangolin/server/internal/codes"
|
||||
)
|
||||
|
||||
// 无活跃 pro 的新用户:GrantRewardTx 新建一行 source='invite',并写一条 audit_log。
|
||||
func TestGrantRewardTx_FreshUserCreatesInviteSub(t *testing.T) {
|
||||
db := openMigratedSQLite(t)
|
||||
seedUser(t, db, 7)
|
||||
store := codes.NewStore(db)
|
||||
svc := codes.NewService(store, nil, 5, time.Hour)
|
||||
ctx := context.Background()
|
||||
|
||||
tx, err := store.BeginTx(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("BeginTx: %v", err)
|
||||
}
|
||||
subID, exp, err := svc.GrantRewardTx(ctx, tx, 7, 3, "invite", "invite_reward", "ref-1")
|
||||
if err != nil {
|
||||
t.Fatalf("grant: %v", err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if subID == 0 || exp.Before(time.Now()) {
|
||||
t.Fatalf("bad sub %d exp %v", subID, exp)
|
||||
}
|
||||
|
||||
var src string
|
||||
if err := db.QueryRow(`SELECT source FROM subscriptions WHERE id=?`, subID).Scan(&src); err != nil {
|
||||
t.Fatalf("query source: %v", err)
|
||||
}
|
||||
if src != "invite" {
|
||||
t.Fatalf("source = %q, want invite", src)
|
||||
}
|
||||
var n int
|
||||
if err := db.QueryRow(`SELECT COUNT(*) FROM audit_log WHERE action='invite_reward' AND target='ref-1'`).Scan(&n); err != nil {
|
||||
t.Fatalf("query audit_log: %v", err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Fatalf("audit rows = %d, want 1", n)
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,7 @@ type WebhookHandler struct {
|
||||
tolerance time.Duration
|
||||
nonceTTL time.Duration
|
||||
now func() time.Time // 测试注入
|
||||
rewarder Rewarder // 可选:首充邀请奖励钩子(nil 则跳过)
|
||||
}
|
||||
|
||||
func NewWebhookHandler(store *Store, granter Granter, db *sql.DB, rdb *redis.Client,
|
||||
@@ -43,6 +44,14 @@ func NewWebhookHandler(store *Store, granter Granter, db *sql.DB, rdb *redis.Cli
|
||||
system: system, secret: secret, tolerance: tolerance, nonceTTL: nonceTTL, now: time.Now}
|
||||
}
|
||||
|
||||
// Rewarder(可选)在首充同事务内发放邀请首充奖励;nil 则跳过。
|
||||
type Rewarder interface {
|
||||
OnFirstPaidTx(ctx context.Context, tx *sql.Tx, inviteeID int64, now time.Time) error
|
||||
}
|
||||
|
||||
// SetRewarder 挂载首充邀请奖励钩子(reward.Service 满足此接口)。
|
||||
func (h *WebhookHandler) SetRewarder(r Rewarder) { h.rewarder = r }
|
||||
|
||||
// webhookEvent 对应 pay settle.go::enqueuePaymentSucceeded 的 payload
|
||||
// (注意:payment.succeeded 无 refund_id 字段)。
|
||||
type webhookEvent struct {
|
||||
@@ -172,5 +181,10 @@ func (h *WebhookHandler) settle(ctx context.Context, ev *webhookEvent) error {
|
||||
if err := h.store.MarkPaidTx(ctx, tx, purchaseID, ev.AmountMinor, ev.Currency, ev.Channel, subID, paidAt); err != nil {
|
||||
return err
|
||||
}
|
||||
if h.rewarder != nil {
|
||||
if err := h.rewarder.OnFirstPaidTx(ctx, tx, userID, h.now().UTC()); err != nil {
|
||||
return err // 同事务:发奖失败则整笔回滚,webhook 重试
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package pay
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/wangjia/pangolin/server/internal/codes"
|
||||
"github.com/wangjia/pangolin/server/internal/reward"
|
||||
)
|
||||
|
||||
func TestSettle_FirstPaidGrantsReferralBonus(t *testing.T) {
|
||||
h, db, _ := newWebhookRig(t) // 复用本包 rig;rig 里 seedUser(1)
|
||||
seedUser(t, db, 2, "invitee-uuid") // 被邀请人 = 付费者
|
||||
// 建 referral: inviter=1, invitee=2, 已 reg_rewarded
|
||||
now := time.Now().UTC()
|
||||
if _, err := db.Exec(`INSERT INTO referrals (inviter_id,invitee_id,status,reg_rewarded_at,created_at) VALUES (1,2,'reg_rewarded',?,?)`, now, now); err != nil {
|
||||
t.Fatalf("seed referral: %v", err)
|
||||
}
|
||||
// 装 rewarder
|
||||
g := codes.NewService(codes.NewStore(db), nil, 5, time.Hour)
|
||||
rw := reward.NewService(db, reward.NewStore(db), g, reward.Config{RegDays: 3, PaidDays: 7, TgDays: 3, RegMonthlyCap: 10}, time.Now)
|
||||
h.SetRewarder(rw)
|
||||
|
||||
// 触发 settle(照本包已有 webhook 测试构造 ev:invitee=2 的一笔 paid;biz_ref=其 uuid)
|
||||
err := h.settle(context.Background(), &webhookEvent{
|
||||
ProductBizCode: "pro_month", OutTradeNo: "ot-1", BizRef: "invitee-uuid",
|
||||
Channel: "nezha", AmountMinor: 600, Currency: "CNY", PaidAt: now.Format(time.RFC3339),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("settle: %v", err)
|
||||
}
|
||||
|
||||
var status string
|
||||
if err := db.QueryRow(`SELECT status FROM referrals WHERE invitee_id=2`).Scan(&status); err != nil {
|
||||
t.Fatalf("query referral status: %v", err)
|
||||
}
|
||||
if status != "paid_rewarded" {
|
||||
t.Fatalf("status=%q, want paid_rewarded", status)
|
||||
}
|
||||
var fp int
|
||||
if err := db.QueryRow(`SELECT COUNT(*) FROM users WHERE id=2 AND first_paid_at IS NOT NULL`).Scan(&fp); err != nil {
|
||||
t.Fatalf("query first_paid_at: %v", err)
|
||||
}
|
||||
if fp != 1 {
|
||||
t.Fatalf("first_paid_at not set")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package reward
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/wangjia/pangolin/server/internal/apierr"
|
||||
"github.com/wangjia/pangolin/server/internal/auth"
|
||||
)
|
||||
|
||||
const inviteLinkBase = "https://pangolin.yanmeiai.com/i/"
|
||||
|
||||
type Handler struct {
|
||||
svc *Service
|
||||
st *Store
|
||||
tgEnabled bool
|
||||
channel string
|
||||
}
|
||||
|
||||
func NewHandler(s *Service, st *Store, tgEnabled bool, channel string) *Handler {
|
||||
return &Handler{svc: s, st: st, tgEnabled: tgEnabled, channel: channel}
|
||||
}
|
||||
|
||||
func (h *Handler) GetInvite(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
uid, ok := auth.UserIDFromContext(ctx)
|
||||
if !ok {
|
||||
apierr.WriteJSON(w, http.StatusUnauthorized, apierr.ErrUnauthorized)
|
||||
return
|
||||
}
|
||||
code, err := h.svc.EnsureCode(ctx, uid)
|
||||
if err != nil {
|
||||
apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal)
|
||||
return
|
||||
}
|
||||
invited, converted, earned, err := h.st.Summary(ctx, uid)
|
||||
if err != nil {
|
||||
apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal)
|
||||
return
|
||||
}
|
||||
joined, _ := h.st.TelegramClaimed(ctx, uid)
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"invite_code": code,
|
||||
"invite_link": inviteLinkBase + code,
|
||||
"invited": invited,
|
||||
"converted": converted,
|
||||
"earned_days": earned,
|
||||
"telegram": map[string]any{
|
||||
"enabled": h.tgEnabled,
|
||||
"joined": joined,
|
||||
"channel": h.channel,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) TelegramStart(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
uid, ok := auth.UserIDFromContext(ctx)
|
||||
if !ok {
|
||||
apierr.WriteJSON(w, http.StatusUnauthorized, apierr.ErrUnauthorized)
|
||||
return
|
||||
}
|
||||
if !h.tgEnabled {
|
||||
apierr.WriteJSON(w, http.StatusNotFound, apierr.ErrNotFound)
|
||||
return
|
||||
}
|
||||
tok, err := h.svc.IssueTelegramToken(ctx, uid)
|
||||
if err != nil {
|
||||
apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"deep_link": h.svc.TelegramDeepLink(tok)})
|
||||
}
|
||||
|
||||
// TelegramWebhook 处理 Telegram Bot API 回调:校验 secret 头 → 解析 `/start <token>`
|
||||
// → ConsumeTelegramToken → ClaimTelegram → sendMessage 回执。Telegram 只要求 200 即不重投,
|
||||
// 业务结果通过 sendMessage 异步告知用户。
|
||||
func (h *Handler) TelegramWebhook(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.tgEnabled || r.Header.Get("X-Telegram-Bot-Api-Secret-Token") != h.svc.WebhookSecret() {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
var upd struct {
|
||||
Message struct {
|
||||
Text string `json:"text"`
|
||||
From struct {
|
||||
ID int64 `json:"id"`
|
||||
} `json:"from"`
|
||||
} `json:"message"`
|
||||
}
|
||||
_ = json.NewDecoder(http.MaxBytesReader(w, r.Body, 16<<10)).Decode(&upd)
|
||||
w.WriteHeader(http.StatusOK) // Telegram 只要 200;业务异步在下方
|
||||
text, fromID := upd.Message.Text, upd.Message.From.ID
|
||||
if !strings.HasPrefix(text, "/start ") || fromID == 0 {
|
||||
return
|
||||
}
|
||||
token := strings.TrimSpace(strings.TrimPrefix(text, "/start "))
|
||||
ctx := r.Context()
|
||||
uid, ok, _ := h.svc.ConsumeTelegramToken(ctx, token)
|
||||
if !ok {
|
||||
h.svc.SendTelegram(ctx, fromID, "链接已失效,请回 App 重新点「验证领取」。")
|
||||
return
|
||||
}
|
||||
granted, err := h.svc.ClaimTelegram(ctx, uid, strconv.FormatInt(fromID, 10))
|
||||
switch {
|
||||
case err != nil:
|
||||
h.svc.SendTelegram(ctx, fromID, "验证出错,请稍后重试。")
|
||||
case granted:
|
||||
h.svc.SendTelegram(ctx, fromID, "已到账 +3 天 Pro,感谢加入!")
|
||||
default:
|
||||
h.svc.SendTelegram(ctx, fromID, "请先加入频道 "+h.svc.Channel()+" 再点验证;若已加入且领取过则无需重复。")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package reward
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/wangjia/pangolin/server/internal/codes"
|
||||
)
|
||||
|
||||
func TestGetInvite_ReturnsCodeAndSummary(t *testing.T) {
|
||||
db := openDB(t)
|
||||
seedU(t, db, 1, "u1")
|
||||
st := NewStore(db)
|
||||
g := codes.NewService(codes.NewStore(db), nil, 5, time.Hour)
|
||||
svc := NewService(db, st, g, Config{RegDays: 3, PaidDays: 7, TgDays: 3, RegMonthlyCap: 10}, time.Now)
|
||||
h := NewHandler(svc, st, true, "@pangolin_app")
|
||||
|
||||
r := httptest.NewRequest(http.MethodGet, "/v1/invite", nil)
|
||||
r = r.WithContext(context.WithValue(r.Context(), codes.CtxKeyUserID, int64(1)))
|
||||
w := httptest.NewRecorder()
|
||||
h.GetInvite(w, r) // 直接调 handler,不经 router
|
||||
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("code=%d body=%s", w.Code, w.Body)
|
||||
}
|
||||
var got map[string]any
|
||||
json.Unmarshal(w.Body.Bytes(), &got)
|
||||
if got["invite_code"] == "" || got["invite_code"] == nil {
|
||||
t.Fatalf("no invite_code: %v", got)
|
||||
}
|
||||
tg, _ := got["telegram"].(map[string]any)
|
||||
if tg == nil || tg["enabled"] != true {
|
||||
t.Fatalf("telegram block wrong: %v", got["telegram"])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package reward
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
type Granter interface {
|
||||
GrantRewardTx(ctx context.Context, tx *sql.Tx, userID int64, days int, source, auditAction, ref string) (int64, time.Time, error)
|
||||
}
|
||||
|
||||
type Config struct{ RegDays, PaidDays, TgDays, RegMonthlyCap int }
|
||||
|
||||
type Service struct {
|
||||
db *sql.DB
|
||||
st *Store
|
||||
g Granter
|
||||
cfg Config
|
||||
now func() time.Time
|
||||
|
||||
rdb *redis.Client
|
||||
tg tgConfig
|
||||
checker ChatMemberChecker
|
||||
memMu sync.Mutex
|
||||
memTok map[string]int64
|
||||
}
|
||||
|
||||
func NewService(db *sql.DB, st *Store, g Granter, cfg Config, now func() time.Time) *Service {
|
||||
if now == nil {
|
||||
now = func() time.Time { return time.Now().UTC() }
|
||||
}
|
||||
return &Service{db: db, st: st, g: g, cfg: cfg, now: now}
|
||||
}
|
||||
|
||||
const inviteAlphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" // 去 0/O/1/I,32 字符
|
||||
|
||||
// GenInviteCode 返回 8 位随机码。crypto/rand 失败时用 now 派生兜底(避免 panic)。
|
||||
func GenInviteCode() string {
|
||||
b := make([]byte, 8)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
t := time.Now().UnixNano()
|
||||
for i := range b {
|
||||
b[i] = byte(t >> (8 * i))
|
||||
}
|
||||
}
|
||||
out := make([]byte, 8)
|
||||
for i, x := range b {
|
||||
out[i] = inviteAlphabet[int(x)%len(inviteAlphabet)]
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
func (s *Service) EnsureCode(ctx context.Context, userID int64) (string, error) {
|
||||
return s.st.EnsureInviteCode(ctx, userID, GenInviteCode)
|
||||
}
|
||||
|
||||
// OnRegister best-effort:与建号解耦(自有事务,失败只 log,不回滚注册)。
|
||||
func (s *Service) OnRegister(ctx context.Context, inviteeID int64, inviteCode, deviceUUID string) {
|
||||
if inviteCode == "" {
|
||||
return
|
||||
}
|
||||
inviterID, ok, err := s.st.ResolveInviteCode(ctx, inviteCode)
|
||||
if err != nil || !ok {
|
||||
if err != nil {
|
||||
slog.Warn("reward: resolve invite code failed", "err", err)
|
||||
}
|
||||
return // 无效码:静默,不报错(注册已成功)
|
||||
}
|
||||
if inviterID == inviteeID {
|
||||
return // 自邀请:不建关系
|
||||
}
|
||||
now := s.now()
|
||||
|
||||
// 防刷判定:设备复用 or 月度封顶 → 记关系但 status=rejected、不发注册奖。
|
||||
rejected := false
|
||||
if used, e := s.st.DeviceUsedByOther(ctx, deviceUUID, inviteeID); e == nil && used {
|
||||
rejected = true
|
||||
}
|
||||
if !rejected {
|
||||
monthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC)
|
||||
if cnt, e := s.st.RegRewardCountThisMonth(ctx, inviterID, monthStart); e == nil && cnt >= s.cfg.RegMonthlyCap {
|
||||
rejected = true
|
||||
}
|
||||
}
|
||||
|
||||
tx, err := s.db.BeginTx(ctx, &sql.TxOptions{})
|
||||
if err != nil {
|
||||
slog.Warn("reward: begin tx failed", "err", err)
|
||||
return
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
status := "reg_rewarded"
|
||||
var regAt *time.Time
|
||||
if rejected {
|
||||
status = "rejected"
|
||||
} else {
|
||||
regAt = &now
|
||||
}
|
||||
if err := s.st.InsertReferralTx(ctx, tx, inviterID, inviteeID, deviceUUID, status, regAt, now); err != nil {
|
||||
if err != ErrClaimExists {
|
||||
slog.Warn("reward: insert referral failed", "err", err)
|
||||
}
|
||||
return // invitee 已绑定过(唯一约束)→ 放弃
|
||||
}
|
||||
if !rejected {
|
||||
if _, _, err := s.g.GrantRewardTx(ctx, tx, inviterID, s.cfg.RegDays, "invite", "invite_reg_inviter", "ref-inviter"); err != nil {
|
||||
slog.Warn("reward: grant inviter reg failed", "err", err)
|
||||
return
|
||||
}
|
||||
if _, _, err := s.g.GrantRewardTx(ctx, tx, inviteeID, s.cfg.RegDays, "invite", "invite_reg_invitee", "ref-invitee"); err != nil {
|
||||
slog.Warn("reward: grant invitee reg failed", "err", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
slog.Warn("reward: commit failed", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// OnFirstPaidTx 在 pay webhook 的同一事务内调用:标记 first_paid_at,若确为首充且该用户
|
||||
// 是被邀请人(referral 命中且未发过首充奖)→ 发首充段双方各 PaidDays 天。幂等。
|
||||
func (s *Service) OnFirstPaidTx(ctx context.Context, tx *sql.Tx, inviteeID int64, now time.Time) error {
|
||||
first, err := s.st.MarkFirstPaidTx(ctx, tx, inviteeID, now)
|
||||
if err != nil || !first {
|
||||
return err // 非首充 → 不发
|
||||
}
|
||||
inviterID, status, ok, err := s.st.ReferralByInvitee(ctx, tx, inviteeID)
|
||||
if err != nil || !ok || status == "paid_rewarded" || status == "rejected" {
|
||||
return err // 无邀请关系 / 已发过 / 被拒 → 不发
|
||||
}
|
||||
if _, _, err := s.g.GrantRewardTx(ctx, tx, inviterID, s.cfg.PaidDays, "invite", "invite_paid_inviter", "ref-paid-inviter"); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, _, err := s.g.GrantRewardTx(ctx, tx, inviteeID, s.cfg.PaidDays, "invite", "invite_paid_invitee", "ref-paid-invitee"); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.st.MarkPaidRewardedTx(ctx, tx, inviteeID, now)
|
||||
}
|
||||
|
||||
// TelegramDeepLink 拼 t.me 深链,携带绑定 token。
|
||||
func (s *Service) TelegramDeepLink(tok string) string {
|
||||
return "https://t.me/" + s.tg.botUser + "?start=" + tok
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package reward
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/wangjia/pangolin/server/internal/codes"
|
||||
)
|
||||
|
||||
func newSvc(t *testing.T, db *sql.DB) *Service {
|
||||
g := codes.NewService(codes.NewStore(db), nil, 5, time.Hour)
|
||||
return NewService(db, NewStore(db), g, Config{RegDays: 3, PaidDays: 7, TgDays: 3, RegMonthlyCap: 10}, time.Now)
|
||||
}
|
||||
|
||||
func proDays(t *testing.T, db *sql.DB, userID int64) int { // 该用户 pro 剩余天数(粗算)
|
||||
var exp sql.NullTime
|
||||
db.QueryRow(`SELECT expires_at FROM subscriptions s JOIN plans p ON p.id=s.plan_id
|
||||
WHERE s.user_id=? AND p.code='pro' ORDER BY expires_at DESC LIMIT 1`, userID).Scan(&exp)
|
||||
if !exp.Valid {
|
||||
return 0
|
||||
}
|
||||
return int(time.Until(exp.Time).Hours()/24) + 1
|
||||
}
|
||||
|
||||
func TestOnRegister_BothGetThreeDays(t *testing.T) {
|
||||
db := openDB(t)
|
||||
seedU(t, db, 1, "inviter")
|
||||
seedU(t, db, 2, "invitee")
|
||||
s := newSvc(t, db)
|
||||
code, _ := s.EnsureCode(context.Background(), 1)
|
||||
s.OnRegister(context.Background(), 2, code, "dev-2")
|
||||
|
||||
var status string
|
||||
db.QueryRow(`SELECT status FROM referrals WHERE invitee_id=2`).Scan(&status)
|
||||
if status != "reg_rewarded" {
|
||||
t.Fatalf("status=%q", status)
|
||||
}
|
||||
if d := proDays(t, db, 1); d < 3 {
|
||||
t.Fatalf("inviter pro days=%d, want>=3", d)
|
||||
}
|
||||
if d := proDays(t, db, 2); d < 3 {
|
||||
t.Fatalf("invitee pro days=%d, want>=3", d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOnRegister_SelfInviteRejected(t *testing.T) {
|
||||
db := openDB(t)
|
||||
seedU(t, db, 1, "u1")
|
||||
s := newSvc(t, db)
|
||||
code, _ := s.EnsureCode(context.Background(), 1)
|
||||
s.OnRegister(context.Background(), 1, code, "dev-1") // 自邀请
|
||||
var n int
|
||||
db.QueryRow(`SELECT COUNT(*) FROM referrals`).Scan(&n)
|
||||
if n != 0 {
|
||||
t.Fatalf("self-invite created %d referrals, want 0", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOnRegister_DeviceReusedRejectsReward(t *testing.T) {
|
||||
db := openDB(t)
|
||||
seedU(t, db, 1, "inv")
|
||||
seedU(t, db, 2, "old")
|
||||
seedU(t, db, 3, "new")
|
||||
// old(2) 的设备 dev-X 已在 devices
|
||||
db.Exec(`INSERT INTO devices (uuid,user_id,name,platform,last_seen,created_at)
|
||||
VALUES ('dev-X',2,'n','android',?,?)`, time.Now().UTC(), time.Now().UTC())
|
||||
s := newSvc(t, db)
|
||||
code, _ := s.EnsureCode(context.Background(), 1)
|
||||
s.OnRegister(context.Background(), 3, code, "dev-X") // 新用户复用老设备
|
||||
var status string
|
||||
db.QueryRow(`SELECT status FROM referrals WHERE invitee_id=3`).Scan(&status)
|
||||
if status != "rejected" {
|
||||
t.Fatalf("status=%q, want rejected", status)
|
||||
}
|
||||
if d := proDays(t, db, 1); d >= 3 {
|
||||
t.Fatalf("inviter got reward despite device reuse: %d", d)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
// Package reward 承载邀请奖励与奖励任务的数据访问 + 发奖 + 防刷。
|
||||
package reward
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var ErrClaimExists = errors.New("reward: claim already exists")
|
||||
|
||||
type Store struct{ db *sql.DB }
|
||||
|
||||
func NewStore(db *sql.DB) *Store { return &Store{db: db} }
|
||||
|
||||
// EnsureInviteCode 惰性生成邀请码;已有则返回旧值。gen 生成候选码(冲突时重试到成功)。
|
||||
func (s *Store) EnsureInviteCode(ctx context.Context, userID int64, gen func() string) (string, error) {
|
||||
var existing sql.NullString
|
||||
if err := s.db.QueryRowContext(ctx, `SELECT invite_code FROM users WHERE id=?`, userID).Scan(&existing); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if existing.Valid && existing.String != "" {
|
||||
return existing.String, nil
|
||||
}
|
||||
for i := 0; i < 5; i++ {
|
||||
code := gen()
|
||||
_, err := s.db.ExecContext(ctx, `UPDATE users SET invite_code=? WHERE id=? AND invite_code IS NULL`, code, userID)
|
||||
if err != nil {
|
||||
if isDup(err) {
|
||||
continue
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
// 读回(并发下可能是别的并发写入的值)
|
||||
var got sql.NullString
|
||||
if err := s.db.QueryRowContext(ctx, `SELECT invite_code FROM users WHERE id=?`, userID).Scan(&got); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if got.Valid && got.String != "" {
|
||||
return got.String, nil
|
||||
}
|
||||
}
|
||||
return "", errors.New("reward: invite code generation exhausted")
|
||||
}
|
||||
|
||||
func (s *Store) ResolveInviteCode(ctx context.Context, code string) (int64, bool, error) {
|
||||
var id int64
|
||||
err := s.db.QueryRowContext(ctx, `SELECT id FROM users WHERE invite_code=? AND status='active'`, code).Scan(&id)
|
||||
if err == sql.ErrNoRows {
|
||||
return 0, false, nil
|
||||
}
|
||||
return id, err == nil, err
|
||||
}
|
||||
|
||||
func (s *Store) DeviceUsedByOther(ctx context.Context, deviceUUID string, exceptUserID int64) (bool, error) {
|
||||
if deviceUUID == "" {
|
||||
return false, nil
|
||||
}
|
||||
var n int
|
||||
err := s.db.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM devices WHERE uuid=? AND user_id<>?`, deviceUUID, exceptUserID).Scan(&n)
|
||||
return n > 0, err
|
||||
}
|
||||
|
||||
func (s *Store) RegRewardCountThisMonth(ctx context.Context, inviterID int64, since time.Time) (int, error) {
|
||||
var n int
|
||||
err := s.db.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM referrals WHERE inviter_id=? AND status='reg_rewarded' AND reg_rewarded_at>=?`,
|
||||
inviterID, since).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (s *Store) InsertReferralTx(ctx context.Context, tx *sql.Tx, inviterID, inviteeID int64,
|
||||
deviceUUID, status string, regRewardedAt *time.Time, now time.Time) error {
|
||||
_, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO referrals (inviter_id,invitee_id,device_uuid,status,reg_rewarded_at,created_at)
|
||||
VALUES (?,?,?,?,?,?)`, inviterID, inviteeID, deviceUUID, status, regRewardedAt, now)
|
||||
if isDup(err) {
|
||||
return ErrClaimExists
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) ReferralByInvitee(ctx context.Context, tx *sql.Tx, inviteeID int64) (int64, string, bool, error) {
|
||||
var inviter int64
|
||||
var status string
|
||||
err := tx.QueryRowContext(ctx, `SELECT inviter_id,status FROM referrals WHERE invitee_id=?`, inviteeID).
|
||||
Scan(&inviter, &status)
|
||||
if err == sql.ErrNoRows {
|
||||
return 0, "", false, nil
|
||||
}
|
||||
return inviter, status, err == nil, err
|
||||
}
|
||||
|
||||
func (s *Store) MarkFirstPaidTx(ctx context.Context, tx *sql.Tx, userID int64, at time.Time) (bool, error) {
|
||||
res, err := tx.ExecContext(ctx,
|
||||
`UPDATE users SET first_paid_at=? WHERE id=? AND first_paid_at IS NULL`, at, userID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
return n == 1, nil
|
||||
}
|
||||
|
||||
func (s *Store) MarkPaidRewardedTx(ctx context.Context, tx *sql.Tx, inviteeID int64, at time.Time) error {
|
||||
_, err := tx.ExecContext(ctx,
|
||||
`UPDATE referrals SET status='paid_rewarded', paid_rewarded_at=? WHERE invitee_id=?`, at, inviteeID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) InsertClaimTx(ctx context.Context, tx *sql.Tx, userID int64, taskKey, externalRef string,
|
||||
days int, now time.Time) error {
|
||||
_, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO reward_claims (user_id,task_key,external_ref,granted_days,granted_at)
|
||||
VALUES (?,?,?,?,?)`, userID, taskKey, externalRef, days, now)
|
||||
if isDup(err) {
|
||||
return ErrClaimExists
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) TelegramClaimed(ctx context.Context, userID int64) (bool, error) {
|
||||
var n int
|
||||
err := s.db.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM reward_claims WHERE user_id=? AND task_key='telegram_join'`, userID).Scan(&n)
|
||||
return n > 0, err
|
||||
}
|
||||
|
||||
// Summary: invited=已绑定人数;converted=已首充奖励人数;earnedDays=本人从奖励得到的总天数(audit_log 累加)。
|
||||
func (s *Store) Summary(ctx context.Context, userID int64) (invited, converted, earnedDays int, err error) {
|
||||
if err = s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM referrals WHERE inviter_id=?`, userID).Scan(&invited); err != nil {
|
||||
return
|
||||
}
|
||||
if err = s.db.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM referrals WHERE inviter_id=? AND status='paid_rewarded'`, userID).Scan(&converted); err != nil {
|
||||
return
|
||||
}
|
||||
// earnedDays: audit_log 里 actor=user:<id> 且 action∈奖励动作,meta.days 累加(简化:reward_claims + referrals 估算)
|
||||
var tg, reg, paid int
|
||||
s.db.QueryRowContext(ctx, `SELECT COALESCE(SUM(granted_days),0) FROM reward_claims WHERE user_id=?`, userID).Scan(&tg)
|
||||
s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM referrals WHERE inviter_id=? AND status IN ('reg_rewarded','paid_rewarded')`, userID).Scan(®)
|
||||
s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM referrals WHERE inviter_id=? AND status='paid_rewarded'`, userID).Scan(&paid)
|
||||
// 邀请人:每 reg_rewarded +3、每 paid_rewarded 再 +7;被邀请人自身得的天数不计入其「邀请战绩」。
|
||||
earnedDays = tg + reg*3 + paid*7
|
||||
return
|
||||
}
|
||||
|
||||
func isDup(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
m := strings.ToLower(err.Error())
|
||||
return strings.Contains(m, "unique") || strings.Contains(m, "duplicate")
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package reward
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/wangjia/pangolin/server/internal/config"
|
||||
"github.com/wangjia/pangolin/server/internal/store"
|
||||
)
|
||||
|
||||
func openDB(t *testing.T) *sql.DB {
|
||||
db, err := store.Open(&config.Config{Driver: "sqlite", DSN: ":memory:"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
if err := store.MigrateUp(db, "sqlite"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.ApplyCodesLibMigrations(context.Background(), db, "sqlite"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func seedU(t *testing.T, db *sql.DB, id int64, uuid string) {
|
||||
_, err := db.Exec(`INSERT INTO users (id,uuid,email,pw_hash,dp_uuid,status,created_at)
|
||||
VALUES (?,?,?, 'x','dp-'||?, 'active', ?)`, id, uuid, uuid+"@x", uuid, time.Now().UTC())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureAndResolveInviteCode(t *testing.T) {
|
||||
db := openDB(t)
|
||||
seedU(t, db, 1, "u1")
|
||||
st := NewStore(db)
|
||||
code, err := st.EnsureInviteCode(context.Background(), 1, func() string { return "ABCD2345" })
|
||||
if err != nil || code != "ABCD2345" {
|
||||
t.Fatalf("ensure: %q %v", code, err)
|
||||
}
|
||||
again, _ := st.EnsureInviteCode(context.Background(), 1, func() string { return "ZZZZ9999" })
|
||||
if again != "ABCD2345" {
|
||||
t.Fatalf("second ensure changed code: %q", again)
|
||||
}
|
||||
inviter, ok, _ := st.ResolveInviteCode(context.Background(), "ABCD2345")
|
||||
if !ok || inviter != 1 {
|
||||
t.Fatalf("resolve: %d %v", inviter, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInsertClaimUniqueGuards(t *testing.T) {
|
||||
db := openDB(t)
|
||||
seedU(t, db, 1, "u1")
|
||||
seedU(t, db, 2, "u2")
|
||||
st := NewStore(db)
|
||||
tx, _ := db.Begin()
|
||||
if err := st.InsertClaimTx(context.Background(), tx, 1, "telegram_join", "tg-100", 3, time.Now().UTC()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tx.Commit()
|
||||
// 同 user 再领 → ErrClaimExists
|
||||
tx2, _ := db.Begin()
|
||||
if err := st.InsertClaimTx(context.Background(), tx2, 1, "telegram_join", "tg-999", 3, time.Now().UTC()); err != ErrClaimExists {
|
||||
t.Fatalf("same user reclaim err = %v, want ErrClaimExists", err)
|
||||
}
|
||||
tx2.Rollback()
|
||||
// 同 telegram_id 换 user → ErrClaimExists
|
||||
tx3, _ := db.Begin()
|
||||
if err := st.InsertClaimTx(context.Background(), tx3, 2, "telegram_join", "tg-100", 3, time.Now().UTC()); err != ErrClaimExists {
|
||||
t.Fatalf("same tgid reclaim err = %v, want ErrClaimExists", err)
|
||||
}
|
||||
tx3.Rollback()
|
||||
}
|
||||
|
||||
func TestInsertReferralUniqueInviteeGuard(t *testing.T) {
|
||||
db := openDB(t)
|
||||
seedU(t, db, 1, "u1")
|
||||
seedU(t, db, 2, "u2")
|
||||
seedU(t, db, 3, "u3")
|
||||
st := NewStore(db)
|
||||
now := time.Now().UTC()
|
||||
|
||||
tx, _ := db.Begin()
|
||||
if err := st.InsertReferralTx(context.Background(), tx, 1, 2, "dev", "reg_rewarded", &now, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tx.Commit()
|
||||
|
||||
// 同一 invitee(2)被另一 inviter(3)再次绑定 → invitee_id UNIQUE 命中,返回 ErrClaimExists
|
||||
tx2, _ := db.Begin()
|
||||
if err := st.InsertReferralTx(context.Background(), tx2, 3, 2, "dev", "reg_rewarded", &now, now); err != ErrClaimExists {
|
||||
t.Fatalf("dup invitee insert err = %v, want ErrClaimExists", err)
|
||||
}
|
||||
tx2.Rollback()
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package reward
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
type tgConfig struct {
|
||||
botUser, channel, botToken, webhookSecret string
|
||||
}
|
||||
|
||||
// ChatMemberChecker 判定某 telegramID 是否是频道/群成员。默认实现打 Telegram Bot API,
|
||||
// 测试注入 fake 以避免真实网络调用。
|
||||
type ChatMemberChecker interface {
|
||||
IsMember(ctx context.Context, channel, telegramID string) (bool, error)
|
||||
}
|
||||
|
||||
// SetMemberChecker 注入自定义成员校验器(测试用);生产走 SetTelegram 设置的默认 apiChecker。
|
||||
func (s *Service) SetMemberChecker(c ChatMemberChecker) { s.checker = c }
|
||||
|
||||
// SetTelegram 注入 TG 配置(经 Service.tg)。若尚未注入 checker(测试未 SetMemberChecker),
|
||||
// 默认设为打 Telegram Bot API 的 apiChecker。
|
||||
func (s *Service) SetTelegram(botUser, channel, botToken, webhookSecret string) {
|
||||
s.tg = tgConfig{botUser: botUser, channel: channel, botToken: botToken, webhookSecret: webhookSecret}
|
||||
if s.checker == nil {
|
||||
s.checker = apiChecker{botToken: botToken, hc: &http.Client{Timeout: 8 * time.Second}}
|
||||
}
|
||||
}
|
||||
|
||||
// WebhookSecret 返回 TG webhook 校验用的 secret token。
|
||||
func (s *Service) WebhookSecret() string { return s.tg.webhookSecret }
|
||||
|
||||
// Channel 返回配置的 TG 频道(@ 形式)。
|
||||
func (s *Service) Channel() string { return s.tg.channel }
|
||||
|
||||
// SetRedis 注入 redis 客户端(经 Service.rdb);为 nil 时 token 走内存兜底(测试/未配 redis)。
|
||||
func (s *Service) SetRedis(rdb *redis.Client) { s.rdb = rdb }
|
||||
|
||||
func randToken() string {
|
||||
b := make([]byte, 16)
|
||||
_, _ = rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
// IssueTelegramToken 生成随机 token,Redis SETEX tg_bind:<token> 600 <userID>;
|
||||
// Redis 为 nil 时用内存 map 兜底(测试/未配 redis)。
|
||||
func (s *Service) IssueTelegramToken(ctx context.Context, userID int64) (string, error) {
|
||||
tok := randToken()
|
||||
if s.rdb != nil {
|
||||
return tok, s.rdb.Set(ctx, "tg_bind:"+tok, userID, 10*time.Minute).Err()
|
||||
}
|
||||
s.memMu.Lock()
|
||||
if s.memTok == nil {
|
||||
s.memTok = map[string]int64{}
|
||||
}
|
||||
s.memTok[tok] = userID
|
||||
s.memMu.Unlock()
|
||||
return tok, nil
|
||||
}
|
||||
|
||||
// ConsumeTelegramToken 一次性消费(GETDEL 语义)。
|
||||
func (s *Service) ConsumeTelegramToken(ctx context.Context, token string) (int64, bool, error) {
|
||||
if s.rdb != nil {
|
||||
v, err := s.rdb.GetDel(ctx, "tg_bind:"+token).Result()
|
||||
if err == redis.Nil {
|
||||
return 0, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return 0, false, err
|
||||
}
|
||||
id, _ := strconv.ParseInt(v, 10, 64)
|
||||
return id, true, nil
|
||||
}
|
||||
s.memMu.Lock()
|
||||
defer s.memMu.Unlock()
|
||||
id, ok := s.memTok[token]
|
||||
if ok {
|
||||
delete(s.memTok, token)
|
||||
}
|
||||
return id, ok, nil
|
||||
}
|
||||
|
||||
// ClaimTelegram: 真是频道成员则一次性发 TgDays 天(source='task'),唯一守卫防重复领取
|
||||
// (同 userID 或同 telegramID 只能成功一次)。
|
||||
func (s *Service) ClaimTelegram(ctx context.Context, userID int64, telegramID string) (bool, error) {
|
||||
member, err := s.checker.IsMember(ctx, s.tg.channel, telegramID)
|
||||
if err != nil || !member {
|
||||
return false, err
|
||||
}
|
||||
tx, err := s.db.BeginTx(ctx, &sql.TxOptions{})
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
now := s.now()
|
||||
if err := s.st.InsertClaimTx(ctx, tx, userID, "telegram_join", telegramID, s.cfg.TgDays, now); err != nil {
|
||||
if err == ErrClaimExists {
|
||||
return false, nil // 已领过(本账户 or 该 tgid)
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
if _, _, err := s.g.GrantRewardTx(ctx, tx, userID, s.cfg.TgDays, "task", "task_telegram_join", "tg:"+telegramID); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// apiChecker 是 ChatMemberChecker 的默认生产实现:打 Telegram getChatMember。
|
||||
type apiChecker struct {
|
||||
botToken string
|
||||
hc *http.Client
|
||||
}
|
||||
|
||||
func (c apiChecker) IsMember(ctx context.Context, channel, telegramID string) (bool, error) {
|
||||
u := "https://api.telegram.org/bot" + c.botToken + "/getChatMember"
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
q := req.URL.Query()
|
||||
q.Set("chat_id", channel)
|
||||
q.Set("user_id", telegramID)
|
||||
req.URL.RawQuery = q.Encode()
|
||||
resp, err := c.hc.Do(req)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var out struct {
|
||||
OK bool `json:"ok"`
|
||||
Result struct {
|
||||
Status string `json:"status"`
|
||||
} `json:"result"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||
return false, err
|
||||
}
|
||||
st := out.Result.Status
|
||||
return out.OK && (st == "member" || st == "administrator" || st == "creator"), nil
|
||||
}
|
||||
|
||||
// SendTelegram 打 Bot API sendMessage 给 chatID 发文案;失败只 log,不影响主流程。
|
||||
func (s *Service) SendTelegram(ctx context.Context, chatID int64, text string) {
|
||||
u := "https://api.telegram.org/bot" + s.tg.botToken + "/sendMessage"
|
||||
body := url.Values{}
|
||||
body.Set("chat_id", strconv.FormatInt(chatID, 10))
|
||||
body.Set("text", text)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, u, strings.NewReader(body.Encode()))
|
||||
if err != nil {
|
||||
slog.Warn("reward: send telegram build request failed", "err", err)
|
||||
return
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
hc := &http.Client{Timeout: 8 * time.Second}
|
||||
resp, err := hc.Do(req)
|
||||
if err != nil {
|
||||
slog.Warn("reward: send telegram failed", "err", err)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 300 {
|
||||
slog.Warn("reward: send telegram non-2xx", "status", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package reward
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/wangjia/pangolin/server/internal/codes"
|
||||
)
|
||||
|
||||
func TestTelegramToken_IssueThenConsumeOnce(t *testing.T) {
|
||||
db := openDB(t)
|
||||
seedU(t, db, 5, "u5")
|
||||
g := codes.NewService(codes.NewStore(db), nil, 5, time.Hour)
|
||||
s := NewService(db, NewStore(db), g, Config{TgDays: 3}, time.Now)
|
||||
s.SetTelegram("pangolin_reward_bot", "@pangolin_app", "bot-token", "wh-secret") // 内存兜底(rdb nil)
|
||||
|
||||
tok, err := s.IssueTelegramToken(context.Background(), 5)
|
||||
if err != nil || tok == "" {
|
||||
t.Fatalf("issue: %q %v", tok, err)
|
||||
}
|
||||
uid, ok, _ := s.ConsumeTelegramToken(context.Background(), tok)
|
||||
if !ok || uid != 5 {
|
||||
t.Fatalf("consume: %d %v", uid, ok)
|
||||
}
|
||||
_, ok2, _ := s.ConsumeTelegramToken(context.Background(), tok)
|
||||
if ok2 {
|
||||
t.Fatalf("token consumable twice")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package reward
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/wangjia/pangolin/server/internal/codes"
|
||||
)
|
||||
|
||||
type fakeChecker struct{ member bool }
|
||||
|
||||
func (f fakeChecker) IsMember(_ context.Context, _, _ string) (bool, error) { return f.member, nil }
|
||||
|
||||
func newTgSvc(t *testing.T, db *sql.DB, member bool) *Service {
|
||||
g := codes.NewService(codes.NewStore(db), nil, 5, time.Hour)
|
||||
s := NewService(db, NewStore(db), g, Config{TgDays: 3}, time.Now)
|
||||
s.SetTelegram("bot", "@ch", "tok", "sec")
|
||||
s.SetMemberChecker(fakeChecker{member: member})
|
||||
return s
|
||||
}
|
||||
|
||||
func TestClaimTelegram_MemberGrantsOnce(t *testing.T) {
|
||||
db := openDB(t)
|
||||
seedU(t, db, 1, "u1")
|
||||
s := newTgSvc(t, db, true)
|
||||
ok, err := s.ClaimTelegram(context.Background(), 1, "tg-777")
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("claim1: %v %v", ok, err)
|
||||
}
|
||||
if d := proDays(t, db, 1); d < 3 {
|
||||
t.Fatalf("pro days=%d", d)
|
||||
}
|
||||
// 再领 → 不再发(唯一守卫)
|
||||
ok2, _ := s.ClaimTelegram(context.Background(), 1, "tg-777")
|
||||
if ok2 {
|
||||
t.Fatalf("claimed twice")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaimTelegram_NonMemberNoGrant(t *testing.T) {
|
||||
db := openDB(t)
|
||||
seedU(t, db, 1, "u1")
|
||||
s := newTgSvc(t, db, false)
|
||||
ok, _ := s.ClaimTelegram(context.Background(), 1, "tg-1")
|
||||
if ok {
|
||||
t.Fatalf("non-member granted")
|
||||
}
|
||||
if d := proDays(t, db, 1); d >= 3 {
|
||||
t.Fatalf("granted days to non-member: %d", d)
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
)
|
||||
|
||||
// TestApplyCodesLibMigrations verifies the shared codes library's tables are
|
||||
// created on top of a fully-migrated pangolin sqlite DB (post-000020, the
|
||||
// created on top of a fully-migrated pangolin sqlite DB (post-000022, the
|
||||
// legacy tables are renamed away so the lib's `codes` table name is free).
|
||||
func TestApplyCodesLibMigrations(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
@@ -49,7 +49,7 @@ func TestApplyCodesLibMigrations(t *testing.T) {
|
||||
// newSQLiteStepper builds a golang-migrate instance identical to the one
|
||||
// internal/store.MigrateUp/MigrateDown use internally (same embedded FS,
|
||||
// same sqlite driver), except it exposes Steps() so the test can land
|
||||
// exactly on the 000020 boundary instead of only being able to invoke a
|
||||
// exactly on the 000022 boundary instead of only being able to invoke a
|
||||
// full up-to-latest / down-to-zero round trip like the public API does.
|
||||
// Mirrors internal/store/migrate.go's newMigrator — kept in the test
|
||||
// because that constructor is unexported.
|
||||
@@ -71,18 +71,18 @@ func newSQLiteStepper(t *testing.T, database *sql.DB) *migrate.Migrate {
|
||||
}
|
||||
|
||||
// TestCodesLibMigrateRoundTrip exercises the real wired path end-to-end:
|
||||
// MigrateUp -> ApplyCodesLibMigrations -> step 000020 back down -> assert
|
||||
// MigrateUp -> ApplyCodesLibMigrations -> step 000022 back down -> assert
|
||||
// legacy restored/lib gone -> step back up -> ApplyCodesLibMigrations again
|
||||
// -> finally the full `store.MigrateDown` (== cmd/migrate's "down" command)
|
||||
// all the way to version 0, which is the exact call the reviewer reported
|
||||
// as failing hard.
|
||||
//
|
||||
// Reproduces the reviewer-found collision: 000020's down does
|
||||
// Reproduces the reviewer-found collision: 000022's down does
|
||||
// legacy_codes -> codes renames, but the codes-lib's own `codes` table
|
||||
// (created by ApplyCodesLibMigrations, untracked by golang-migrate) is
|
||||
// still sitting there, so the rename used to fail with "table already
|
||||
// exists" (sqlite) / "ALTER TABLE ... table already exists" (mysql
|
||||
// RENAME TABLE semantics). 000020's down script must DROP the lib-owned
|
||||
// RENAME TABLE semantics). 000022's down script must DROP the lib-owned
|
||||
// tables before renaming legacy_* back.
|
||||
func TestCodesLibMigrateRoundTrip(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
@@ -100,19 +100,23 @@ func TestCodesLibMigrateRoundTrip(t *testing.T) {
|
||||
t.Fatalf("ApplyCodesLibMigrations: %v", err)
|
||||
}
|
||||
|
||||
// 2. Step exactly 000020 back down (19 <- 20). This is the precise seam
|
||||
// 2. Step exactly 000022 back down (21 <- 22). This is the precise seam
|
||||
// the reviewer's repro hit: the lib's `codes` table collides with the
|
||||
// name 000020's down script renames legacy_codes back to.
|
||||
// name 000022's down script renames legacy_codes back to.
|
||||
m := newSQLiteStepper(t, db)
|
||||
// 000021 (pay_purchases/source-enum) now sits on top of 000020 and is
|
||||
// unrelated to this collision — step it back down first so we land
|
||||
// exactly on the 000020 boundary the test targets.
|
||||
// 000024 (invite_rewards) and 000023 (pay_purchases/source-enum) now sit
|
||||
// on top of 000022 (codes_lib_legacy_rename) and are unrelated to this
|
||||
// collision — step them back down first so we land exactly on the
|
||||
// 000022 boundary the test targets.
|
||||
if err := m.Steps(-1); err != nil {
|
||||
t.Fatalf("step 000021 down: %v", err)
|
||||
t.Fatalf("step 000024 down: %v", err)
|
||||
}
|
||||
if err := m.Steps(-1); err != nil {
|
||||
t.Fatalf("step 000020 down: %v (this is the reviewer-reported collision — "+
|
||||
"000020 down must DROP the codes-lib tables before renaming legacy_* back)", err)
|
||||
t.Fatalf("step 000023 down: %v", err)
|
||||
}
|
||||
if err := m.Steps(-1); err != nil {
|
||||
t.Fatalf("step 000022 down: %v (this is the reviewer-reported collision — "+
|
||||
"000022 down must DROP the codes-lib tables before renaming legacy_* back)", err)
|
||||
}
|
||||
|
||||
// 3. Legacy tables restored to their original pangolin names/shape;
|
||||
@@ -136,16 +140,16 @@ func TestCodesLibMigrateRoundTrip(t *testing.T) {
|
||||
`SELECT name FROM sqlite_master WHERE type='table' AND name=?`, tbl,
|
||||
).Scan(&name)
|
||||
if err == nil {
|
||||
t.Errorf("lib table %q should have been dropped by the 000020 down step, still present", tbl)
|
||||
t.Errorf("lib table %q should have been dropped by the 000022 down step, still present", tbl)
|
||||
} else if err != sql.ErrNoRows {
|
||||
t.Errorf("checking lib table %q gone: %v", tbl, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Step 000020 back up + reapply the lib migrations: idempotent, must
|
||||
// 4. Step 000022 back up + reapply the lib migrations: idempotent, must
|
||||
// stay green.
|
||||
if err := m.Steps(1); err != nil {
|
||||
t.Fatalf("step 000020 up (2nd round-trip): %v", err)
|
||||
t.Fatalf("step 000022 up (2nd round-trip): %v", err)
|
||||
}
|
||||
if err := store.ApplyCodesLibMigrations(ctx, db, "sqlite"); err != nil {
|
||||
t.Fatalf("ApplyCodesLibMigrations (2nd round-trip): %v", err)
|
||||
|
||||
@@ -29,8 +29,8 @@ func TestSQLiteMigrateUpDown(t *testing.T) {
|
||||
if dirty {
|
||||
t.Fatalf("schema dirty after MigrateUp")
|
||||
}
|
||||
if v != 23 {
|
||||
t.Errorf("version = %d, want 23", v)
|
||||
if v != 24 {
|
||||
t.Errorf("version = %d, want 24", v)
|
||||
}
|
||||
|
||||
// 2. Core tables exist.
|
||||
@@ -39,7 +39,7 @@ func TestSQLiteMigrateUpDown(t *testing.T) {
|
||||
"usage_daily", "audit_log", "providers", "nodes", "node_events",
|
||||
"directory_version", "provision_idempotency", "replacements", "admins",
|
||||
"connect_credentials", "usage_device_daily", "sessions", "usage_hourly", "usage_device_hourly",
|
||||
"pay_purchases",
|
||||
"pay_purchases", "referrals", "reward_claims",
|
||||
} {
|
||||
var name string
|
||||
err := db.QueryRow(
|
||||
@@ -50,6 +50,19 @@ func TestSQLiteMigrateUpDown(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 2b. idx_subs_user_exp survives the 000024 subscriptions table rebuild
|
||||
// (SQLite table-rebuild migrations silently drop indexes unless recreated;
|
||||
// this guards against that regression — see 000024_invite_rewards.{up,down}.sql).
|
||||
var idxCount int
|
||||
if err := db.QueryRow(
|
||||
`SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name='idx_subs_user_exp'`,
|
||||
).Scan(&idxCount); err != nil {
|
||||
t.Fatalf("query idx_subs_user_exp: %v", err)
|
||||
}
|
||||
if idxCount != 1 {
|
||||
t.Errorf("idx_subs_user_exp count = %d, want 1 (index lost on subscriptions rebuild?)", idxCount)
|
||||
}
|
||||
|
||||
// 3. Seed: 3 plans + directory_version singleton.
|
||||
var plans int
|
||||
if err := db.QueryRow(`SELECT COUNT(*) FROM plans`).Scan(&plans); err != nil {
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
ALTER TABLE subscriptions MODIFY source ENUM('trial','code','pay') NOT NULL;
|
||||
DROP TABLE reward_claims;
|
||||
DROP TABLE referrals;
|
||||
ALTER TABLE users
|
||||
DROP INDEX ux_users_invite_code,
|
||||
DROP COLUMN first_paid_at,
|
||||
DROP COLUMN invite_code;
|
||||
@@ -0,0 +1,32 @@
|
||||
ALTER TABLE users
|
||||
ADD COLUMN invite_code VARCHAR(16) NULL,
|
||||
ADD COLUMN first_paid_at DATETIME(6) NULL,
|
||||
ADD UNIQUE KEY ux_users_invite_code (invite_code);
|
||||
|
||||
CREATE TABLE referrals (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
inviter_id BIGINT UNSIGNED NOT NULL,
|
||||
invitee_id BIGINT UNSIGNED NOT NULL UNIQUE,
|
||||
device_uuid VARCHAR(64) NULL,
|
||||
status ENUM('bound','reg_rewarded','paid_rewarded','rejected') NOT NULL DEFAULT 'bound',
|
||||
reg_rewarded_at DATETIME(6) NULL,
|
||||
paid_rewarded_at DATETIME(6) NULL,
|
||||
created_at DATETIME(6) NOT NULL,
|
||||
INDEX ix_referrals_inviter (inviter_id, created_at),
|
||||
FOREIGN KEY (inviter_id) REFERENCES users(id),
|
||||
FOREIGN KEY (invitee_id) REFERENCES users(id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE reward_claims (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id BIGINT UNSIGNED NOT NULL,
|
||||
task_key VARCHAR(32) NOT NULL,
|
||||
external_ref VARCHAR(64) NOT NULL DEFAULT '',
|
||||
granted_days INT NOT NULL,
|
||||
granted_at DATETIME(6) NOT NULL,
|
||||
UNIQUE KEY ux_claim_user_task (user_id, task_key),
|
||||
UNIQUE KEY ux_claim_task_ref (task_key, external_ref),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
ALTER TABLE subscriptions MODIFY source ENUM('trial','code','pay','invite','task') NOT NULL;
|
||||
@@ -0,0 +1,22 @@
|
||||
-- 还原 subscriptions.source CHECK
|
||||
CREATE TABLE subscriptions_old (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
plan_id INTEGER NOT NULL,
|
||||
expires_at DATETIME NOT NULL,
|
||||
source TEXT NOT NULL CHECK (source IN ('trial','code','pay')),
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id),
|
||||
FOREIGN KEY (plan_id) REFERENCES plans(id)
|
||||
);
|
||||
INSERT INTO subscriptions_old SELECT id,user_id,plan_id,expires_at,source,created_at
|
||||
FROM subscriptions WHERE source IN ('trial','code','pay');
|
||||
DROP TABLE subscriptions;
|
||||
ALTER TABLE subscriptions_old RENAME TO subscriptions;
|
||||
CREATE INDEX idx_subs_user_exp ON subscriptions (user_id, expires_at);
|
||||
|
||||
DROP TABLE reward_claims;
|
||||
DROP TABLE referrals;
|
||||
DROP INDEX ux_users_invite_code;
|
||||
ALTER TABLE users DROP COLUMN first_paid_at;
|
||||
ALTER TABLE users DROP COLUMN invite_code;
|
||||
@@ -0,0 +1,50 @@
|
||||
-- users 加两列
|
||||
ALTER TABLE users ADD COLUMN invite_code TEXT;
|
||||
ALTER TABLE users ADD COLUMN first_paid_at DATETIME;
|
||||
CREATE UNIQUE INDEX ux_users_invite_code ON users(invite_code);
|
||||
|
||||
-- 邀请关系(一对一绑定)
|
||||
CREATE TABLE referrals (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
inviter_id INTEGER NOT NULL,
|
||||
invitee_id INTEGER NOT NULL UNIQUE,
|
||||
device_uuid TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'bound'
|
||||
CHECK (status IN ('bound','reg_rewarded','paid_rewarded','rejected')),
|
||||
reg_rewarded_at DATETIME,
|
||||
paid_rewarded_at DATETIME,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (inviter_id) REFERENCES users(id),
|
||||
FOREIGN KEY (invitee_id) REFERENCES users(id)
|
||||
);
|
||||
CREATE INDEX ix_referrals_inviter ON referrals(inviter_id, created_at);
|
||||
|
||||
-- 通用一次性任务领取
|
||||
CREATE TABLE reward_claims (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
task_key TEXT NOT NULL,
|
||||
external_ref TEXT NOT NULL DEFAULT '',
|
||||
granted_days INTEGER NOT NULL,
|
||||
granted_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
);
|
||||
CREATE UNIQUE INDEX ux_claim_user_task ON reward_claims(user_id, task_key);
|
||||
CREATE UNIQUE INDEX ux_claim_task_ref ON reward_claims(task_key, external_ref);
|
||||
|
||||
-- subscriptions.source 扩容(SQLite 需重建表)
|
||||
CREATE TABLE subscriptions_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
plan_id INTEGER NOT NULL,
|
||||
expires_at DATETIME NOT NULL,
|
||||
source TEXT NOT NULL CHECK (source IN ('trial','code','pay','invite','task')),
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id),
|
||||
FOREIGN KEY (plan_id) REFERENCES plans(id)
|
||||
);
|
||||
INSERT INTO subscriptions_new (id,user_id,plan_id,expires_at,source,created_at)
|
||||
SELECT id,user_id,plan_id,expires_at,source,created_at FROM subscriptions;
|
||||
DROP TABLE subscriptions;
|
||||
ALTER TABLE subscriptions_new RENAME TO subscriptions;
|
||||
CREATE INDEX idx_subs_user_exp ON subscriptions (user_id, expires_at);
|
||||
Reference in New Issue
Block a user