实现计划阅读版 · 2026-07-12 · Spec ② · 14 任务 TDD · 状态 待执行
配套设计文档 邀请奖励 + 奖励任务 — 设计。执行真相源(含 checkbox,驱动 subagent-driven-development/executing-plans):docs/superpowers/plans/2026-07-12-invite-task-rewards.md。
server/internal/reward 包承载发奖 + 防刷;发天数复用 codes.Service(新增 GrantRewardTx)。注册段在 auth.Service.Register 后 best-effort 触发(自有 tx,不拖累注册主流程);首充段挂 pay/webhook.settle 同事务内(幂等、失败即整笔回滚重试);TG 走 POST /tg/webhook(Telegram getChatMember 真校验)。客户端 invite_page 由占位转真实(邀请区 + 任务区),注册页加邀请码输入。codes.Service(Pro plan);迁移须把 subscriptions.source CHECK/ENUM 从 ('trial','code','pay') 扩到 ('trial','code','pay','invite','task')(sqlite 重建表 / mysql MODIFY)。INVITE_REG_MONTHLY_CAP)。audit_log(store.WriteAuditLog),无 sub_events 表。server/migrations/{mysql,sqlite}/ 两套一一对应;裸 SQL + internal/db 方言层;时间一律 Go 端 time.Now().UTC() 传 ?,禁 NOW()/UTC_TIMESTAMP()。0/O/1/I;users.invite_code UNIQUE、惰性生成。referrals.invitee_id UNIQUE + 设备去重(devices.uuid 已属别人则注册奖励不发、记 rejected)+ 注册段月度封顶 + reward_claims 双唯一 (user_id,task_key)/(task_key,external_ref)。getChatMember 返回 member/administrator/creator 视为已加入;bot 未配(TG_REWARD_BOT_TOKEN 空)则 /tg/webhook 404 且 App 任务卡隐藏。webhook 校验 X-Telegram-Bot-Api-Secret-Token。app_text.dart 加抽象 getter + 6 个 strings_{zh,en,es,ja,ko,ru}.dart 各加实现。setWebhook)由用户在实现前于 Telegram 侧配好。cd server && go test ./...;客户端 cd client && flutter analyze && flutter test。# 后端(新建) server/migrations/{mysql,sqlite}/000024_invite_rewards.{up,down}.sql server/internal/reward/{store,service,telegram,handler}.go # 后端(修改) server/internal/codes/paygrant.go # + GrantRewardTx server/internal/auth/{service,handler}.go # Register 加 inviteCode + ReferralHook server/internal/pay/webhook.go # settle 首充钩子 + Rewarder server/cmd/server/main.go # 装配 + 路由 + env # 客户端(新建/修改) client/lib/services/invite_api.dart · state/invite_provider.dart client/lib/screens/invite_page.dart # 占位转真实 client/lib/widgets/auth_screen.dart + services/auth_api.dart # 邀请码输入 client/lib/l10n/app_text.dart + strings_*.dart ×6
建 referrals(邀请关系,一对一绑定)、reward_claims(通用一次性任务领取)两张表;users 加 invite_code/first_paid_at;subscriptions.source CHECK/ENUM 扩容到含 invite/task(SQLite 需重建表,MySQL 直接 MODIFY)。sqlite/mysql 各一对 up/down,四个文件。
server/internal/store/migrate_sqlite_test.go 现有 up→down→up 全量测试(run_sqlite_test.sh)通过。codes.Service.GrantRewardTx — 发奖天数原语在 codes 包加一个薄封装:复用既有 applySubscription(max(到期,now)+days 顺延语义)发 Pro 天数,source∈{invite,task},并写一条 audit_log。这是后续所有发奖调用的唯一入口。
接口:GrantRewardTx(ctx, tx, userID, days, source, auditAction, ref) (subID, expiresAt, err)
source 落对、audit_log 命中 1 条。reward.Store — 数据访问层新建 reward 包的纯数据访问层:邀请码惰性生成/解析、设备复用查询、月度计数、referrals/reward_claims 的 CRUD,唯一冲突统一映射为 ErrClaimExists。
关键方法:EnsureInviteCode · ResolveInviteCode · DeviceUsedByOther · RegRewardCountThisMonth · InsertReferralTx · MarkFirstPaidTx · ReferralByInvitee · MarkPaidRewardedTx · InsertClaimTx · Summary · TelegramClaimed
ErrClaimExists。reward.Service — 邀请码生成、注册段发奖 + 防刷建 Service(依赖 Store + Granter 接口,由 *codes.Service 满足)。核心 OnRegister:best-effort(自有事务,失败只 log、不回滚注册)解析邀请码 → 自邀请/设备复用/月度封顶三道防刷判定 → 建 referrals → 未被拒则双方各发注册段天数。
关键方法:GenInviteCode()(8 位 base32,去 0/O/1/I)· EnsureCode · OnRegister(ctx, inviteeID, inviteCode, deviceUUID)
RegDays;自邀请不建关系;复用他人设备时 status=rejected 且不发奖。auth.Register 加 inviteCodeauth.Service.Register 签名加 inviteCode 参数;新增 ReferralHook 接口 + SetReferralHook setter,注册成功(recordLogin 之后)best-effort 调用钩子。handler.go 的 registerRequest 加 invite_code 字段并透传。
inviteeID/code/deviceID;全库搜 .Register( 更新所有调用点后包内测试全绿。reward.Service 加 OnFirstPaidTx(同事务内调用):先 MarkFirstPaidTx 判是否真首充,是则查 ReferralByInvitee,未发过/未拒则双方各发首充段天数并置 paid_rewarded。pay/webhook.go 加 Rewarder 接口 + SetRewarder,在 GrantPaidSubscriptionTx 之后、tx.Commit() 之前调用 —— 失败则整笔回滚,靠 webhook 重试保证最终发放。
PaidDays、referrals.status→paid_rewarded、users.first_paid_at 落值;二次付费不再重复发(幂等)。GET /v1/invite 端点新建 reward.Handler:惰性拿邀请码 + 邀请战绩汇总(已邀请/已转化/累计获赠天数)+ TG 任务态(enabled/joined/channel)一次性打包返回。
响应:{invite_code, invite_link, invited, converted, earned_days, telegram:{enabled, joined, channel}}
invite_code 非空,telegram.enabled 与构造参数一致。GET /v1/tasks/telegram/start签发一次性 10 分钟 token 绑定当前账户(Redis SETEX,Redis 为 nil 时内存 map 兜底供测试)、消费即失效(GETDEL)。端点返回 bot 深链 t.me/<bot>?start=<token>。
ChatMemberChecker 接口(默认实现打 Telegram getChatMember API,测试可注入 fake)。ClaimTelegram:真是成员 → InsertClaimTx(唯一守卫)+ GrantRewardTx(source='task')。Handler.TelegramWebhook:校验 X-Telegram-Bot-Api-Secret-Token → 解析 /start <token> → 消费 token → 领取 → sendMessage 回执(成功/已领/非成员话术不同)。
TgDays,二次领取不再发;非成员不发;全包 go test ./internal/reward/... 绿。纯装配任务,无独立单测(逻辑单测已在各包)。读 INVITE_REG_MONTHLY_CAP/TG_REWARD_BOT_TOKEN/TG_REWARD_BOT_USER/TG_REWARD_CHANNEL/TG_WEBHOOK_SECRET 构造 rewardSvc;注入 authSvc.SetReferralHook + payWebhook.SetRewarder;挂路由 protected: GET /invite, GET /tasks/telegram/start、public v1: POST /tg/webhook。
go build ./... && go vet ./cmd/server/ 无错;go test ./... 全绿。新建 InviteApi(fetch() 拉 GET /v1/invite、telegramStartLink() 拉深链)+ InviteInfo 数据类;Riverpod InviteNotifier(未登录返回 null、不打网络)。auth_api.dart 的 register 加可选 inviteCode 参并入请求体。
invite_api_test.dart 用 MockClient 断言 /v1/invite 响应正确解析到 InviteInfo 各字段。邀请页占位转真实:邀请区(真实码/链接 + 复制/分享 + 战绩三格)+ 任务区「更多得会员」(TG 任务卡:未完成显「加入频道」+「验证领取」两步,已完成显「已领 +3 天」置灰,bot 未配整卡隐藏)。先加 9 个 l10n getter(app_text.dart + 6 语言实现),再改页面引用。颜色一律走 token,禁硬编码。
flutter test test/widget/invite_page_test.dart 通过(注入 fake InviteInfo,断言邀请码文本 + TG 任务卡文案可见);flutter analyze 对改动文件无 issue。Step 1–5(必做):auth_screen.dart 注册表单密码步下方加一个可选「邀请码(选填)」输入框(key invite-code-field),提交时传 inviteCode 给 auth_api.register。
Step 6(可选后置,MVP 不含):app_links 依赖 + 四端原生配置(Android intent-filter / iOS associated domains / macOS URL scheme),解析邀请链接自动预填并锁定注册页邀请码字段。单列为独立后续任务。
flutter analyze 对该文件无 issue。即本文档:按项目「设计/计划双产物」规范,把 docs/superpowers/plans/2026-07-12-invite-task-rewards.md(执行真相源,含 checkbox)生成同内容 HTML 阅读版,并登记进 docs/index.html「实现计划 / Plans」分类、与设计文档互链。
.md 保持执行真相源不变(供 subagent-driven-development 继续驱动 Task 1–13)。cd server && go test ./... 全绿(含 reward 包 + auth/pay 回归)。cd client && flutter analyze && flutter test 全绿。本页为阅读版,不含逐步 TDD 代码细节;完整测试代码/实现片段见执行真相源 docs/superpowers/plans/2026-07-12-invite-task-rewards.md。