Files
pangolin/docs/invite-task-rewards-design.html
T
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

248 lines
18 KiB
HTML

<!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 /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 /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/tg/webhook</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 /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>