# 需求:激活码批次发放+自助兑换(推广活动)
状态: accepted
归属: 公司运营(营销线)
来源: boss 直投(会话)
日期: 2026-10-13
建议: 做,boss 要办推广活动:发激活码送3个月会员,小批量验证,后台可见发放/兑换/使用者,web+桌面双入口,已订阅兑换=延长

## 现状

推广活动要发「激活码送 3 个月会员」，链路现状（2026-10-13 实核 account_center 代码）：

- **码的签发/管理已具备**：`license_keys` 表（migration 0001）+ admin `/v1/admin/licenses` CRUD + CLI `ops.mjs license issue/list/revoke`，支持批量签发（1-500/次）、PINN-XXXX 高熵随机码、状态机 reserved→activated/revoked、作废。
- **会员发放不变式已具备**：admin `POST /v1/admin/users/:id/gift`（admin.ts:235-278）manual 订阅「延长不堆叠」（`max(period_end, now)+days`），到期自动回落。
- **断链一处**：用户拿到码**没有自助兑换入口**——`/v1/billing/*` 无 redeem 端点，portal/桌面端均无输码 UI（全仓 grep 无命中）。旧需求票 `2026-09-10-免费授权与买断license.md` 阶段 2 正是此方案，当时设的触发条件（不愿先注册的关键合作者/月均送 ≥10）由 boss 本次直投激活。
- **缺口两处**：① `license_keys` 无权益字段（码兑出去是多久/什么档没处存）；② 无批次维度——boss 口径「每次发放记录给谁、什么活动，类似备注」，现 note 字段可承载，列表按 note 展示即可，不另建批次表。

## 方案

boss 已拍板四点：小批量验证；发放时备注记「给谁/何活动」；兑换入口 web 账户中心+桌面端都做、桌面走公共组件；已付费订阅用户兑换=延长（复用 gift 不变式）。代改范围：

1. **account_center/apps/api**：migration 0011 给 `license_keys` 加 `plan`（monthly/yearly）+`days`（1-3650）两列，签发时写死该码权益；新端点 `POST /v1/billing/redeem {key}`（Bearer 鉴权照抄 `/v1/billing/portal`）：原子 UPDATE reserved→activated 绑 user（并发一码一用），随后按 gift 同款逻辑写 manual 订阅（延长不堆叠；lifetime/Paddle 并存时 entitlements 取 max 现有逻辑天然兼容）。兑换权益参数由码自带，接口不接受客户端传 days。
2. **account_center/apps/admin**：签发 `POST /v1/admin/licenses` 透传 `{plan, days}`+note（备注=「给谁/何活动」，发放人操作时必填本活动备注）；CLI `license issue` 同步加参数。`product` 白名单三处同步新增 `membership`（机检 test/product-lists-sync.test.ts 会盯）。
3. **account_center/apps/portal**：账户中心会员面板加「兑换」输入（内联 fetch+Bearer，lang-zh/lang-en 双 span），成功回显新到期日，失败给人话错误（已兑换/已作废/码不存在统一「兑换失败，请检查码或联系 support@pinnadash.com」，不泄露他码状态）。
4. **ui_kit**：新增 `RedeemDialog` 标准件（与 AccountGroup 同套 i18n/theme 口径），宿主产品接 IPC 打开+回跳刷新会员态；升 patch 版,各产品仓升依赖（本票先落组件+至少 1 产品实证接线，全量铺开归发版波）。
5. **后台可见性**：admin 用户列表既有 gift/终止口径不动；license 列表按 status/user_id 过滤已具备，「谁用了」=activated 行的 user_id join users 显示邮箱（admin GET 返回补 email 字段）。
6. **记账**：每批发放记 `ops/data/events.json`（channel=gift-code，action 含批次备注）；发放操作走 admin 后台，兑换效果由 analyze.py 基线对照自动判定。

不做（小批量验证期砍掉）：码活动截止日（expires_at）、批次聚合报表、CSV 导出、按账号兑换上限——等首批数据回来再议；作废（revoke）能力已有，滥用兜底走它。

## 验收标准

- [x] 真实 SQLite migrations 测试全绿：一码一用（并发第二人兑换失败）/revoked 码拒绝/reserved→activated 绑 user/已有 manual 订阅兑换=延长不堆叠/与 Paddle 订阅并存 entitlements 取更远到期日（2026-10-13：apps/api 223/223，billing-redeem 9 用例）
- [x] admin 后台签发 membership 码带 plan/days+note，列表可见状态与使用者邮箱（2026-10-13：license 22/22 + 后台「激活码」页线上实拍取证 admin-account.pinnadash.com 23 处命中）
- [x] portal 登录态输码兑换成功，会员到期日=预期+90 天（monthly 档），中英双语文案（2026-10-13 prod 真兑：内部号延长 10-29→2027-01-27，页面内联成功文案截图存证；who CLI 口径 bug 同波修复）
- [ ] ui_kit `RedeemDialog` 组件出货，≥1 个桌面产品接上并完成一次真实兑换实证（组件+SDK redeemCode 已落码测试绿；发包与桌面接线待批）
- [x] `pnpm -r test && pnpm -r typecheck` 全绿；ui_kit vitest 绿；首批小批量（≤20 码）实际签发可用（2026-10-13：396/396+两仓 tsc+177/177；prod 实签 1 枚走通，批量签发对话框实测可用）

## 实施状态(2026-10-13 当日落码,待发版)

| 项 | 状态 | 验证口径 |
| --- | --- | --- |
| migration 0011(license_keys +plan/days)+ 兑换端点 `/v1/billing/redeem` | ✅ 已落码 | apps/api 全量 223/223(含 billing-redeem 9 用例:一码一用/作废/延长不堆叠/Paddle 并存/脏码 500 不动库) |
| gift 不变式收归正本 `lib/billing/manual-grant.ts`(admin gift 与 redeem 共用) | ✅ 已落码 | apps/admin 67/67(gift 旧用例全过=重构不回归) |
| admin 签发 membership 码+列表返回 user_email + 后台「激活码」页(筛选/签发对话框/作废) | ✅ 已落码 | license 22/22(+4 条 membership 用例)、page-boot 3/3(首拉三端点) |
| CLI `ops.mjs license issue --plan --days`(白名单加 membership,三处同步机检过) | ✅ 已落码 | product-lists-sync 绿 |
| portal 兑换入口(内联常驻结果+401 续期重试) | ✅ 已落码 | portal check-refs 绿;线上实兑待 deploy |
| 账户 SDK `redeemCode()`(401 自动轮换重试+兑换后强刷 entitlements 缓存) | ✅ 已落码 | sdk 106/106(+5 用例) |
| ui_kit `RedeemDialog` 标准件(五语文案收归正本+样式+7 条 jsdom 行为测试) | ✅ 已落码 | ui_kit 全量 177/177 + tsup 构建绿 |
| 全仓闸 | ✅ | account_center `pnpm test`(396) + `pnpm check`(tsc+生产配置+portal refs) 全绿;ui_kit npm test+build 绿 |
| **待 boss/发版波** | ⏳ | ① 部署 api/admin + prod D1 跑 `wrangler d1 migrations apply`(0011);② portal/admin 静态上线;③ `kit.py release` 发 ui_kit 0.12.0 与 account-sdk 新版(共享 registry,需点头);④ 桌面产品接线+发版(验收第 4 条真兑实证在此闭环);⑤ 首批 ≤20 码签发(建议 `license issue --product membership --plan monthly --days 90 --note "<渠道·活动>"`) |
