Files
pangolin/docs/invite-task-rewards-plan.html
T

256 lines
21 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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>