Compare commits

...

21 Commits

Author SHA1 Message Date
wangjia 559fd38a52 docs(reward): 设计文档 webhook 路径 /tg/webhook→/v1/tg/webhook + app_text 首充注释 15→7
ci-pangolin / Lint — shellcheck (pull_request) Successful in 10s
ci-pangolin / Cleartext Scan — Android 禁明文 (pull_request) Successful in 25s
ci-pangolin / Portable SQL — 可移植性 (mysql/sqlite) (pull_request) Successful in 19s
ci-pangolin / Flutter — analyze + test (pull_request) Successful in 36s
ci-pangolin / Codegen Drift — token 生成物未漂移 (pull_request) Successful in 3s
ci-pangolin / DS-flow — 原型/跨端同源/代码色单源闸 (pull_request) Successful in 3s
ci-pangolin / Go — build + test (pull_request) Failing after 13s
ci-pangolin / E2E Smoke — L4 进程级端到端 (pull_request) Failing after 10s
ci-pangolin / Go — integration (mysql/redis testcontainers) (pull_request) Successful in 4m28s
ci-pangolin / Golden — 视觉回归 (全量:components/auth/desktop/tablet) (pull_request) Failing after 20s
ci-pangolin / OpenAPI Sync Check (pull_request) Failing after 14m23s
ci-pangolin / Redline Scan — 脱敏 (UI 文案) (pull_request) Failing after 14m35s
终审指出:实际路由挂在 /v1 组下,setWebhook 须指向 https://api.yanmeiai.com/v1/tg/webhook;
app_text.dart:603 抽象 getter 上的过时「15 天」注释一并订正为 7。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 08:18:40 +08:00
wangjia 488688aeac fix(reward): TG webhook 空 secret fail-fast + 首充奖励文案六语 15→7(终审必修)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 08:17:11 +08:00
wangjia 98ba578169 docs: 邀请奖励实现计划 HTML 阅读版 + 索引登记 2026-07-13 08:10:55 +08:00
wangjia d04183b066 merge(invite): 客户端邀请奖励 UI 轨(Task 11-13)并入
invite api/provider + invite页真实化(邀请区+战绩+TG任务卡,六语) + 注册页邀请码输入。
客户端并行轨,文件与后端不相交,SDD 逐任务评审全过。
2026-07-13 08:05:18 +08:00
wangjia c29aaf9a2a feat(server): 装配 reward 服务 — 注入 auth/webhook + 挂 /v1/invite、/tasks/telegram/start、/tg/webhook 2026-07-13 08:01:01 +08:00
wangjia 0b24e26fb6 feat(client/auth): 注册页加邀请码(选填)输入,提交时传 invite_code 2026-07-13 08:00:25 +08:00
wangjia e26284d072 feat(server/reward): TG getChatMember 真校验 + ClaimTelegram + POST /tg/webhook 2026-07-13 07:54:46 +08:00
wangjia e061237496 feat(client/invite): 邀请页真实化(邀请区+战绩+TG 任务卡)+ 六语 l10n 2026-07-13 07:51:19 +08:00
wangjia e23cd5e1bd feat(server/reward): TG 绑定 token 签发/消费 + GET /v1/tasks/telegram/start 2026-07-13 07:49:31 +08:00
wangjia d23f7bca8f feat(server/reward): GET /v1/invite — 邀请码/链接/战绩/TG 任务态 2026-07-13 07:46:07 +08:00
wangjia de1117b0e6 feat(server/pay): 首充钩子 — settle 内接邀请首充段发奖(first_paid_at 幂等) 2026-07-13 07:41:15 +08:00
wangjia cdf67a200c feat(client): invite api + provider + auth_api.register 加 inviteCode 2026-07-13 07:39:01 +08:00
wangjia 54d106ca8b feat(server/auth): Register 加 inviteCode + ReferralHook(注册后 best-effort 归因)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 07:33:42 +08:00
wangjia 2fb7391a17 feat(server/reward): OnRegister 绑定 + 注册段双方发奖 + 自邀请/设备/月封顶防刷
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-13 07:27:10 +08:00
wangjia 2bd31bbd55 fix(server/reward): isDup 收窄到 unique/duplicate 防误判 FK/NOTNULL + 补 referrals 唯一路径测试
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 07:19:41 +08:00
wangjia 75636c8ea3 feat(server/reward): Store — referrals/reward_claims/invite_code 数据访问 + 唯一守卫 2026-07-13 07:15:28 +08:00
wangjia 0695c7958b feat(server/codes): GrantRewardTx 复用 applySubscription 发奖励会员天数 2026-07-13 07:11:17 +08:00
wangjia 52b5e90d92 fix(server/migrate): 000024 sqlite 重建 subscriptions 后补回 idx_subs_user_exp 索引 + 回归断言
000024 迁移重建 subscriptions 表(source CHECK 扩容)时未重新创建
idx_subs_user_exp(user_id, expires_at)索引,导致 SQLite(生产 pangolin1
实际驱动)上订阅按 user_id/expires_at 查询退化为全表扫描。up/down 均补回
CREATE INDEX,并在 TestSQLiteMigrateUpDown 里加断言防止再次静默丢失。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-13 07:08:26 +08:00
wangjia 9b858c5224 feat(server/migrate): 000024 邀请奖励表 + subscriptions.source 扩 invite/task 2026-07-13 06:59:52 +08:00
wangjia b70857cc0e docs(plan): 邀请奖励 + 奖励任务实现计划(14 任务 TDD)
依据 docs/invite-task-rewards-design.html 拆成逐任务 TDD 计划:
后端 迁移000024 → GrantRewardTx → reward.Store → reward.Service(注册段防刷发奖)
→ auth.Register 接 inviteCode → pay webhook 首充钩子 → GET /v1/invite
→ TG token → TG webhook getChatMember 真校验 → main 装配;
客户端 invite api/provider → invite 页真实化+六语 l10n → 注册页邀请码输入。
含真实签名/建表 SQL/防刷用例;deep-link 预填列为可选后置。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 23:58:30 +08:00
wangjia b223cc5f81 docs: 邀请奖励 + 奖励任务(加入 TG 频道)设计文档(Spec ②)
两条并行得分途径共用发天数原语(applySubscription source=invite/task):
- 邀请两段式双方都得:注册各 +3、被邀请人首充双方再各 +7(每转化 +10/+10)
- 奖励任务首个「加入 TG 频道」+3,Bot getChatMember 真校验
  (token 绑号 → /tg/webhook → 查频道成员 → 发)
防刷:自邀请拦截 + invitee 唯一 + dp_uuid 设备去重 + 注册段月度封顶(首充不封)
      + telegram_id 全局唯一领
数据:users +invite_code/first_paid_at、referrals、通用 reward_claims
含 TG 验证时序 SVG 图;登记 docs/index.html。边界:不做现金/多级/退群回收,
通知集成留接缝待 Spec③。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 23:10:20 +08:00
45 changed files with 4193 additions and 87 deletions
+10 -1
View File
@@ -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
+19 -1
View File
@@ -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';
+19 -1
View File
@@ -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';
+19 -1
View File
@@ -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 => '通知はありません';
+19 -1
View File
@@ -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 => '알림이 없습니다';
+19 -1
View File
@@ -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 => 'Пока нет уведомлений';
+19 -1
View File
@@ -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 => '暂无通知';
+195 -32
View File
@@ -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,
+2
View File
@@ -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);
+35
View File
@@ -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? ?? '';
}
+25
View File
@@ -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);
+14 -2
View File
@@ -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),
+24
View File
@@ -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);
});
}
+31
View File
@@ -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;
}
+10
View File
@@ -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>
+247
View File
@@ -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/&lt;code&gt;</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/&lt;reward_bot&gt;?start=&lt;token&gt;</code></td></tr>
<tr><td><code>POST /v1/tg/webhook</code></td><td>Telegram Bot 更新回调(secret 校验,与 pay webhook 同款外部回调):收 <code>/start &lt;token&gt;</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/&lt;bot&gt;?start=&lt;token&gt;</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/&lt;code&gt;</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>
+255
View File
@@ -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 &amp;&amp; go test ./...</code>;客户端 <code>cd client &amp;&amp; flutter analyze &amp;&amp; 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 110)</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/&lt;bot&gt;?start=&lt;token&gt;</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 &lt;token&gt;</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 ./... &amp;&amp; go vet ./cmd/server/</code> 无错;<code>go test ./...</code> 全绿。</li>
</ul>
</div>
<h2>客户端(Task 1113)</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 15(必做):<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 113)。</li>
</ul>
</div>
<h2>验收(端到端)</h2>
<ul>
<li>后端:<code>cd server &amp;&amp; go test ./...</code> 全绿(含 reward 包 + auth/pay 回归)。</li>
<li>客户端:<code>cd client &amp;&amp; flutter analyze &amp;&amp; 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
+36 -1
View File
@@ -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)
+4 -4
View File
@@ -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 {
+6 -5
View File
@@ -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
+2 -2
View File
@@ -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)
}
}
+16 -3
View File
@@ -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
}
+12 -12
View File
@@ -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)
}
+18
View File
@@ -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)
}
}
+14
View File
@@ -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")
}
}
+118
View File
@@ -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"])
}
}
+150
View File
@@ -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)
}
}
+156
View File
@@ -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(&reg)
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()
}
+179
View File
@@ -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)
}
}
+20 -16
View File
@@ -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)
+16 -3
View File
@@ -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);