Task 8 终验:server/client 全量测试矩阵结果(含新增 SQLite 文件库带数据升级彩排 + MySQL 8 容器验证 000021 MODIFY ENUM)、OpenAPI 新端点登记、Self-Review 取舍、联调 checklist、部署附录(pay 种子/biz 配置/pangolin env/迁移顺序)。附带发现一处既有的 wangjia/codes 本地路径依赖会阻断异机构建,登记为部署前置阻断项。
diff --git a/docs/invite-task-rewards-design.html b/docs/invite-task-rewards-design.html
new file mode 100644
index 0000000..4efa02a
--- /dev/null
+++ b/docs/invite-task-rewards-design.html
@@ -0,0 +1,247 @@
+
+
+
+
+
← 文档索引
+
邀请奖励 + 奖励任务
+
设计文档 · 2026-07-12 · Spec ②(推广激励)· 状态 待评审
+
+
+目标:用「送 Pro 会员天数」驱动增长。两条并行的得分途径,共用同一条发天数链路:
+① 邀请好友 —— 两段式,双方都得(注册各 +3 天,被邀请人首充双方再各 +7 天)。
+② 奖励任务 —— 首个任务「加入 Telegram 频道」+3 天,经 Bot getChatMember 真校验。框架可扩展(后续「关注推特 / App 评分」复用)。
+发天数原语:复用现有 subscriptions 延时逻辑(applySubscription(..., source) = max(到期,now)+days),与付费 / 兑换码同一条,不造新轮子。
+
+
+
1 · 邀请奖励模型(两段式,双方都得)
+
+| 阶段 | 触发 | 邀请人 | 被邀请人 | 封顶 |
+| 注册 | 被邀请人用邀请码注册成功 | +3 天 Pro | +3 天 Pro | 邀请人 N 次/月(默认 10) |
+| 首充 | 被邀请人首次成功付费(任何档,含 ¥6 优惠) | +7 天 Pro | +7 天 Pro | 不封顶 |
+
+
每转化一个付费用户:邀请人累计 +10 天、被邀请人累计 +10 天。注册段是唯一「免费面」故设月度封顶;首充段真金白银自带成本,不封顶。
+
+
邀请码 / 链接 / 绑定
+
+- 邀请码:每用户一枚永久短码,从
users.uuid 派生 8 位 base32(去除易混字符 0/O/1/I),存 users.invite_code。
+- 邀请链接:
https://pangolin.yanmeiai.com/i/<code> → 落地页引导下载 App;App 内 deep link 自动预填邀请码。
+- 绑定时机:仅注册时(手动填码 或 点邀请链接注册,两条路都行),first-touch 永久不可改;注册后不可补绑。
+
+
+
2 · 防刷
+
+- 自邀请拦截 —— 不能绑自己的码(
inviter_id ≠ invitee_id)。
+- 新账户唯一绑定 —— 一个被邀请人一生只出现一次(
referrals.invitee_id 唯一);老账户不可事后补绑。
+- 设备去重(复用 dp_uuid) —— 注册携带的
DeviceMeta.dp_uuid 若已注册过别的账户,则关系照记但注册奖励不发(status=rejected)。
+- 注册段月度封顶 —— 每邀请人每自然月最多
INVITE_REG_MONTHLY_CAP(默认 10)次注册奖励;超出仍记关系、不发注册奖。首充奖励不受此限。
+- TG 任务 —— 每账户一次 + telegram_id 全局唯一领取(挡「一个 TG 号刷多账户」)+ 真成员校验(见 §5)。
+
+
+
3 · 数据模型
+
users 加一列(SQLite / MySQL 两套迁移):
+
+| 列 | 类型 | 说明 |
+invite_code | varchar(16) UNIQUE | 本用户的永久邀请码;注册/首次访问邀请页时惰性生成。 |
+first_paid_at | datetime NULL | 首次成功付费时刻;判「首充」+ 幂等首充奖励。 |
+
+
+
referrals —— 邀请关系(关系型,一对一绑定):
+
+| 列 | 类型 | 说明 |
+id | PK | |
+inviter_id | bigint | 邀请人 user id(索引)。 |
+invitee_id | bigint UNIQUE | 被邀请人 user id;唯一 = 一人只被绑一次。 |
+device_uuid | varchar | 被邀请人注册设备 dp_uuid(设备去重取证)。 |
+status | varchar | bound→reg_rewarded→paid_rewarded,或 rejected(防刷命中)。 |
+reg_rewarded_at | datetime NULL | 注册段奖励发放时刻(幂等)。 |
+paid_rewarded_at | datetime NULL | 首充段奖励发放时刻(幂等)。 |
+created_at | datetime | |
+
+
+
reward_claims —— 通用一次性任务领取(TG 及后续任务复用):
+
+| 列 | 类型 | 说明 |
+id | PK | |
+user_id | bigint | 领取人。 |
+task_key | varchar | 任务标识,如 telegram_join。 |
+external_ref | varchar | 外部去重键;TG 用 telegram_id。 |
+granted_days | int | 本次发放天数。 |
+granted_at | datetime | |
+约束:UNIQUE(user_id, task_key)(每账户每任务一次)+ UNIQUE(task_key, external_ref)(同一 telegram_id 只领一次)。 |
+
+
奖励发放本身落现有 subscriptions(source='invite' / 'task')+ sub_events 审计,不新增奖励台账表。
+
+
4 · 后端 API + 钩子
+
+| 端点 / 钩子 | 动作 |
+Register(..., inviteCode) | 注册流程加 inviteCode 参:建号成功后解析码 → 邀请人 → 建 referrals(bound) → 过防刷闸(自邀请 / 设备去重 / 月度封顶)→ 发注册段双方 +3(reg_rewarded)。 |
+pay webhook.settle() | 首充钩子:在 GrantPaidSubscriptionTx 之后,若 users.first_paid_at 本次由空转非空(=首充)且该用户是被邀请人(referrals 命中)→ 发首充段双方 +7(paid_rewarded,幂等)。 |
+GET /v1/invite | 我的邀请码 / 链接 + 战绩:已邀请数、已转化(首充)数、累计获赠天数、明细列表;附奖励任务清单(TG 完成态)。 |
+GET /v1/tasks/telegram/start | 签发绑定 token(10 分钟有效、绑当前账户),返回 bot 深链 t.me/<reward_bot>?start=<token>。 |
+POST /v1/tg/webhook | Telegram Bot 更新回调(secret 校验,与 pay webhook 同款外部回调):收 /start <token> → 校验成员 → 发 TG 任务 +3。详见 §5。 |
+
+
+
5 · TG 任务验证流程(Bot getChatMember)
+
+
+
+
+- token 短时有效(10 分钟)、一次性,绑当前账户 —— 防止链接被转发后他人领取。
+- webhook 安全:设置 Telegram
secret_token,服务端校验 X-Telegram-Bot-Api-Secret-Token 头;路径也可带 secret 段。控制面经 Cloudflare Tunnel 已是公网 HTTPS,Telegram 可达,无需开入站端口。
+- 成员判定:
getChatMember 返回 member / administrator / creator 视为已加入;left / kicked / 查询失败视为未加入。
+- 不追溯退群:一次性发放,发后不因退群回收(回收体验差、收益低)。
+
+
+
6 · 新基建(上线前置,需在 Telegram 侧配一次)
+
+- 建一个面向用户的 Bot(与运维告警 bot 分开),token 存
TG_REWARD_BOT_TOKEN(Bitwarden,不入 git)。
+- 把该 Bot 设为频道管理员 —— 否则
getChatMember 查不到成员。
+- 配
TG_REWARD_CHANNEL(如 @pangolin_app)、TG_WEBHOOK_SECRET;向 Telegram setWebhook 指向 https://api.yanmeiai.com/v1/tg/webhook(路由挂在 /v1 组下,真实路径含 /v1 前缀)。
+- 未配
TG_REWARD_BOT_TOKEN 时,TG 任务卡在 App 里整卡隐藏(邀请功能不受影响,优雅降级)。
+
+
+
7 · 客户端 UI
+
邀请页(占位屏已在)升级为「邀请 + 任务」两区,数据来自 GET /v1/invite:
+
+
邀请区(上)
+
+- 真实邀请码 + 邀请链接,复制 / 系统分享。
+- 规则说明:注册双方各 +3、首充双方各 +7。
+- 战绩台账:已邀请 / 已转化 / 累计获赠天数 + 明细列表。
+
+
任务区(下)「更多得会员」
+
+- 首张卡:加入 Telegram 频道 +3 天。
+- 未完成 → 「加入频道」+「验证领取」两步按钮。
+- 已完成 → 「已领 +3 天」置灰。
+- 未配 bot → 整卡不显示。
+
+
+
注册页:加「邀请码(选填)」输入框;deep link …/i/<code> 打开 App 自动预填并锁定。l10n 六语。
+
+
8 · 边界(YAGNI,本期不做)
+
+- 不做 现金 / 提现奖励 —— 只发会员天数。
+- 不做 通知集成 —— 奖励发放留一个「事件」接缝,等 Spec ③ 通知系统建好再接;现阶段奖励在邀请页台账可见即可。
+- 不做 多级分销(邀请人的邀请人也分成)—— 只做一级。
+- 不做 注册后补填邀请码 —— 绑定仅注册时。
+- 不做 退群回收已发天数。
+
+
+
9 · 测试要点
+
+- 注册段:正常双方各 +3;自邀请拒;已注册设备拒(rejected 不发);月度封顶到点后只记不发;无效码正常注册(不报错、不发)。
+- 首充段:首充双方各 +7;二次付费不再发(幂等,
first_paid_at 已非空);未绑定用户首充不触发。
+- TG 任务:成员校验通过发 +3;非成员不发;同账户重复领拒;同 telegram_id 换账户领拒(UNIQUE);token 过期 / 伪造拒;webhook secret 校验。
+- 发天数原语:
max(到期,now)+days 语义,已是 Pro 的用户正确顺延(不缩短)。
+- SQLite + MySQL 两套迁移与查询一致(遵项目多 DB 方言层,时间 Go 端算好传
?)。
+
+
+
10 · 涉及文件(实现锚点)
+
# 后端
+server/internal/auth/service.go # Register 加 inviteCode + 建 referrals + 注册段发奖
+server/internal/reward/ # 新包:邀请/任务发奖 + 防刷闸 + getChatMember
+server/internal/pay/webhook.go # settle() 首充钩子接首充段发奖
+server/internal/httpapi/ # GET /v1/invite、/v1/tasks/telegram/start、POST /v1/tg/webhook
+server/migrations/{mysql,sqlite}/ # users +2 列、referrals、reward_claims
+# 客户端
+client/lib/screens/invite_page.dart # 占位 → 真实(邀请区 + 任务区)
+client/lib/widgets/auth_screen.dart # 注册页加邀请码输入 + deep link 预填
+client/lib/services/ + state/ # invite api + provider(auth_api.Register 加 inviteCode)
+client/lib/l10n/ # 六语文案
+
+
下一步:定稿后进 writing-plans 出逐任务实现计划(docs/superpowers/plans/2026-07-12-invite-task-rewards.md),Subagent 驱动执行。TG 新基建(bot / 频道管理员 / webhook)在实现前由你在 Telegram 侧配好。
+
+
+
+
diff --git a/docs/invite-task-rewards-plan.html b/docs/invite-task-rewards-plan.html
new file mode 100644
index 0000000..d827f9c
--- /dev/null
+++ b/docs/invite-task-rewards-plan.html
@@ -0,0 +1,255 @@
+
+
+
+
+
← 文档索引
+
邀请奖励 + 奖励任务(加入 TG 频道)
+
实现计划阅读版 · 2026-07-12 · Spec ② · 14 任务 TDD · 状态 待执行
+
配套设计文档 邀请奖励 + 奖励任务 — 设计。执行真相源(含 checkbox,驱动 subagent-driven-development/executing-plans):docs/superpowers/plans/2026-07-12-invite-task-rewards.md。
+
+
+Goal:用「送 Pro 会员天数」驱动增长 —— 邀请两段式(注册双方各 +3、被邀请人首充双方各 +7)+ 奖励任务(加入 TG 频道 +3,Bot 真校验),全部复用现有 subscriptions 发天数链路,不造新轮子。
+Architecture:新增 server/internal/reward 包承载发奖 + 防刷;发天数复用 codes.Service(新增 GrantRewardTx)。注册段在 auth.Service.Register 后 best-effort 触发(自有 tx,不拖累注册主流程);首充段挂 pay/webhook.settle 同事务内(幂等、失败即整笔回滚重试);TG 走 POST /tg/webhook(Telegram getChatMember 真校验)。客户端 invite_page 由占位转真实(邀请区 + 任务区),注册页加邀请码输入。
+Tech Stack:Go(chi + 裸 SQL + golang-migrate 双 DB)、Redis(TG 绑定 token)、Flutter/Riverpod、Telegram Bot API。
+
+
+
Global Constraints
+
+- 发天数一律复用
codes.Service(Pro plan);迁移须把 subscriptions.source CHECK/ENUM 从 ('trial','code','pay') 扩到 ('trial','code','pay','invite','task')(sqlite 重建表 / mysql MODIFY)。
+- 奖励天数:注册段各 3、首充段各 7、TG 任务 3;注册段邀请人月度封顶默认 10(env
INVITE_REG_MONTHLY_CAP)。
+- 审计一律走
audit_log(store.WriteAuditLog),无 sub_events 表。
+- 多 DB:
server/migrations/{mysql,sqlite}/ 两套一一对应;裸 SQL + internal/db 方言层;时间一律 Go 端 time.Now().UTC() 传 ?,禁 NOW()/UTC_TIMESTAMP()。
+- 邀请码 = 8 位 base32 大写、去
0/O/1/I;users.invite_code UNIQUE、惰性生成。
+- 绑定仅注册时、first-touch 永久不可改。防刷四道:自邀请拦截 +
referrals.invitee_id UNIQUE + 设备去重(devices.uuid 已属别人则注册奖励不发、记 rejected)+ 注册段月度封顶 + reward_claims 双唯一 (user_id,task_key)/(task_key,external_ref)。
+- TG:
getChatMember 返回 member/administrator/creator 视为已加入;bot 未配(TG_REWARD_BOT_TOKEN 空)则 /tg/webhook 404 且 App 任务卡隐藏。webhook 校验 X-Telegram-Bot-Api-Secret-Token。
+- 客户端 l10n 无 codegen:新 string 须
app_text.dart 加抽象 getter + 6 个 strings_{zh,en,es,ja,ko,ru}.dart 各加实现。
+- deep-link 自动预填 = 可选后置任务(Task 13 Step 6);MVP 走注册页手填邀请码。
+- TG 新基建(reward bot / 设为频道管理员 /
setWebhook)由用户在实现前于 Telegram 侧配好。
+- 命令:后端
cd server && go test ./...;客户端 cd client && flutter analyze && flutter test。
+
+
+
File Structure(总览)
+
# 后端(新建)
+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
+
+
后端(Task 1–10)
+
+
+
1
迁移 000024 — 新表 + source 扩容
+
建 referrals(邀请关系,一对一绑定)、reward_claims(通用一次性任务领取)两张表;users 加 invite_code/first_paid_at;subscriptions.source CHECK/ENUM 扩容到含 invite/task(SQLite 需重建表,MySQL 直接 MODIFY)。sqlite/mysql 各一对 up/down,四个文件。
+
新建 server/migrations/{sqlite,mysql}/000024_invite_rewards.{up,down}.sql
+
+- 验收:
server/internal/store/migrate_sqlite_test.go 现有 up→down→up 全量测试(run_sqlite_test.sh)通过。
+
+
+
+
+
2
codes.Service.GrantRewardTx — 发奖天数原语
+
在 codes 包加一个薄封装:复用既有 applySubscription(max(到期,now)+days 顺延语义)发 Pro 天数,source∈{invite,task},并写一条 audit_log。这是后续所有发奖调用的唯一入口。
+
改 server/internal/codes/paygrant.go · 新建 reward_grant_sqlite_test.go
+
接口:GrantRewardTx(ctx, tx, userID, days, source, auditAction, ref) (subID, expiresAt, err)
+
+- 验收:新用户发奖建订阅、
source 落对、audit_log 命中 1 条。
+
+
+
+
+
3
reward.Store — 数据访问层
+
新建 reward 包的纯数据访问层:邀请码惰性生成/解析、设备复用查询、月度计数、referrals/reward_claims 的 CRUD,唯一冲突统一映射为 ErrClaimExists。
+
新建 server/internal/reward/{store.go, store_sqlite_test.go}
+
关键方法:EnsureInviteCode · ResolveInviteCode · DeviceUsedByOther · RegRewardCountThisMonth · InsertReferralTx · MarkFirstPaidTx · ReferralByInvitee · MarkPaidRewardedTx · InsertClaimTx · Summary · TelegramClaimed
+
+- 验收:邀请码生成幂等(二次 ensure 不变);同用户/同 telegram_id 重复领取皆命中
ErrClaimExists。
+
+
+
+
+
4
reward.Service — 邀请码生成、注册段发奖 + 防刷
+
建 Service(依赖 Store + Granter 接口,由 *codes.Service 满足)。核心 OnRegister:best-effort(自有事务,失败只 log、不回滚注册)解析邀请码 → 自邀请/设备复用/月度封顶三道防刷判定 → 建 referrals → 未被拒则双方各发注册段天数。
+
新建 server/internal/reward/{service.go, service_sqlite_test.go}
+
关键方法:GenInviteCode()(8 位 base32,去 0/O/1/I)· EnsureCode · OnRegister(ctx, inviteeID, inviteCode, deviceUUID)
+
+- 验收:正常注册双方各得
RegDays;自邀请不建关系;复用他人设备时 status=rejected 且不发奖。
+
+
+
+
+
5
接入注册 — auth.Register 加 inviteCode
+
auth.Service.Register 签名加 inviteCode 参数;新增 ReferralHook 接口 + SetReferralHook setter,注册成功(recordLogin 之后)best-effort 调用钩子。handler.go 的 registerRequest 加 invite_code 字段并透传。
+
改 server/internal/auth/{service.go, handler.go} · 新建 register_invite_test.go
+
+- 验收:注册后 hook 收到正确
inviteeID/code/deviceID;全库搜 .Register( 更新所有调用点后包内测试全绿。
+
+
+
+
+
6
首充钩子 — pay webhook 接首充段发奖
+
reward.Service 加 OnFirstPaidTx(同事务内调用):先 MarkFirstPaidTx 判是否真首充,是则查 ReferralByInvitee,未发过/未拒则双方各发首充段天数并置 paid_rewarded。pay/webhook.go 加 Rewarder 接口 + SetRewarder,在 GrantPaidSubscriptionTx 之后、tx.Commit() 之前调用 —— 失败则整笔回滚,靠 webhook 重试保证最终发放。
+
改 server/internal/reward/service.go(追加)、server/internal/pay/webhook.go · 新建 webhook_referral_sqlite_test.go
+
+- 验收:首充双方各得
PaidDays、referrals.status→paid_rewarded、users.first_paid_at 落值;二次付费不再重复发(幂等)。
+
+
+
+
+
7
GET /v1/invite 端点
+
新建 reward.Handler:惰性拿邀请码 + 邀请战绩汇总(已邀请/已转化/累计获赠天数)+ TG 任务态(enabled/joined/channel)一次性打包返回。
+
新建 server/internal/reward/handler.go(GetInvite 部分)、handler_invite_test.go
+
响应:{invite_code, invite_link, invited, converted, earned_days, telegram:{enabled, joined, channel}}
+
+- 验收:返回 200,
invite_code 非空,telegram.enabled 与构造参数一致。
+
+
+
+
+
8
TG 绑定 token — GET /v1/tasks/telegram/start
+
签发一次性 10 分钟 token 绑定当前账户(Redis SETEX,Redis 为 nil 时内存 map 兜底供测试)、消费即失效(GETDEL)。端点返回 bot 深链 t.me/<bot>?start=<token>。
+
新建 server/internal/reward/telegram.go(token 部分)· 改 handler.go · 新建 telegram_token_test.go
+
+- 验收:token 签发后可消费一次拿回正确 userID,二次消费返回 not-ok。
+
+
+
+
+
9
TG webhook — getChatMember 真校验 + 发奖
+
ChatMemberChecker 接口(默认实现打 Telegram getChatMember API,测试可注入 fake)。ClaimTelegram:真是成员 → InsertClaimTx(唯一守卫)+ GrantRewardTx(source='task')。Handler.TelegramWebhook:校验 X-Telegram-Bot-Api-Secret-Token → 解析 /start <token> → 消费 token → 领取 → sendMessage 回执(成功/已领/非成员话术不同)。
+
改 server/internal/reward/{telegram.go, handler.go} · 新建 telegram_webhook_test.go
+
+- 验收:成员一次性发
TgDays,二次领取不再发;非成员不发;全包 go test ./internal/reward/... 绿。
+
+
+
+
+
10
装配 main.go — 构造 reward svc + 注入 + 路由 + env
+
纯装配任务,无独立单测(逻辑单测已在各包)。读 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。
+
改 server/cmd/server/main.go
+
+- 验收:
go build ./... && go vet ./cmd/server/ 无错;go test ./... 全绿。
+
+
+
+
客户端(Task 11–13)
+
+
+
11
客户端 invite api + provider
+
新建 InviteApi(fetch() 拉 GET /v1/invite、telegramStartLink() 拉深链)+ InviteInfo 数据类;Riverpod InviteNotifier(未登录返回 null、不打网络)。auth_api.dart 的 register 加可选 inviteCode 参并入请求体。
+
新建 client/lib/services/invite_api.dart · state/invite_provider.dart · 改 services/auth_api.dart
+
+- 验收:
invite_api_test.dart 用 MockClient 断言 /v1/invite 响应正确解析到 InviteInfo 各字段。
+
+
+
+
+
12
客户端 invite_page 真实化 + l10n
+
邀请页占位转真实:邀请区(真实码/链接 + 复制/分享 + 战绩三格)+ 任务区「更多得会员」(TG 任务卡:未完成显「加入频道」+「验证领取」两步,已完成显「已领 +3 天」置灰,bot 未配整卡隐藏)。先加 9 个 l10n getter(app_text.dart + 6 语言实现),再改页面引用。颜色一律走 token,禁硬编码。
+
改 client/lib/screens/invite_page.dart · l10n/app_text.dart + strings_{zh,en,es,ja,ko,ru}.dart
+
+- 验收:
flutter test test/widget/invite_page_test.dart 通过(注入 fake InviteInfo,断言邀请码文本 + TG 任务卡文案可见);flutter analyze 对改动文件无 issue。
+
+
+
+
+
13
注册页加邀请码输入 + deep-link 预填(可选后置)
+
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),解析邀请链接自动预填并锁定注册页邀请码字段。单列为独立后续任务。
+
改 client/lib/widgets/auth_screen.dart · 新建 test/widget/auth_invite_field_test.dart
+
+- 验收:widget 测试断言注册表单存在邀请码输入框;
flutter analyze 对该文件无 issue。
+
+
+
+
文档(Task 14)
+
+
+
14
计划 HTML 阅读版 + 索引登记
+
即本文档:按项目「设计/计划双产物」规范,把 docs/superpowers/plans/2026-07-12-invite-task-rewards.md(执行真相源,含 checkbox)生成同内容 HTML 阅读版,并登记进 docs/index.html「实现计划 / Plans」分类、与设计文档互链。
+
新建 docs/invite-task-rewards-plan.html · 改 docs/index.html
+
+- 验收:两文件登记完整、互链可点;
.md 保持执行真相源不变(供 subagent-driven-development 继续驱动 Task 1–13)。
+
+
+
+
验收(端到端)
+
+- 后端:
cd server && go test ./... 全绿(含 reward 包 + auth/pay 回归)。
+- 客户端:
cd client && flutter analyze && flutter test 全绿。
+- 真机联调(需 TG 新基建就绪):A 注册拿邀请码 → B 用 A 的码注册 → A/B 各 +3;B 用人民币下一单付成 → A/B 各再 +7;B 在 App 点「加入频道」+「验证领取」→ bot 校验成员 → +3;重复领被拒。
+- 防刷:自邀请无关系;同设备第二账号注册奖励被拒(status=rejected);同一 telegram_id 换账户领被拒。
+
+
+
不在本轮(YAGNI)
+
+- 不做 现金/提现、多级分销、退群回收、注册后补填邀请码。
+- 不做 deep-link 自动预填(Task 13 Step 6 单列后续)。
+- 不做 通知集成(奖励事件接缝留给 Spec ③)。
+
+
+
本页为阅读版,不含逐步 TDD 代码细节;完整测试代码/实现片段见执行真相源 docs/superpowers/plans/2026-07-12-invite-task-rewards.md。
+
+
+
+
diff --git a/docs/ios-distribution-outside-appstore.html b/docs/ios-distribution-outside-appstore.html
new file mode 100644
index 0000000..fdad55c
--- /dev/null
+++ b/docs/ios-distribution-outside-appstore.html
@@ -0,0 +1,187 @@
+
+
+
+
+
+
iOS 绕过 App Store 分发 VPN · 渠道调研与落地建议
+
场景锁定:个人 Apple Developer 账号 · 含 NetworkExtension/PacketTunnel · 面向中国大陆散户 · 中国区 App Store 无法上架未授权 VPN · 调研时点 2026-07
+
+
+一句话结论:对「个人账号 + 含 NE 的 VPN + 大陆散户」这个组合,官方框架内唯一现实可行的是 TestFlight(上万用户、支持 PacketTunnel、大陆可兑换),代价是 每个构建 90 天滚动重传 + 每次外部测试过一次 Beta 审核。其余官方渠道分别败于设备硬顶、企业门槛与吊销潮、B2B 定位、欧盟地理围栏、技术不可能。国内灰产的超级签 / 企业签 / MDM 签能补规模,但都建立在「滥用苹果通道」之上,掉签与账号吊销是常态、不是意外。行业真实做法 = TF 打门面 + 超级签/独立企业签冗余兜底 + 落地页「掉线重装」引导。
+
+
+
官方 / 半官方渠道(七条)
+
+| 渠道 | 评级 | 人数/设备上限 | NE 支持 | 大陆可用 | 为什么行 / 不行 |
+
+ | TestFlight |
+ ⭐⭐⭐⭐ |
+ 外测 10,000 / 内测 100 | ✅ PacketTunnel 完全支持 | ✅ 兑换链接不依赖中国区 |
+ 唯一「官方框架内、上万级、支持 NE、覆盖大陆」的路。代价:构建 90 天过期须重传、外测每次一次 Beta App Review(比正式审核宽松,1–2 天)。VPN 过审有随机性,长期当分发渠道属灰色但目前可行。 |
+
+
+ | Ad Hoc |
+ ⭐ |
+ 每会员年每类 100 台 UDID(删了仍占额度) | ✅(付费账号有 entitlement) | △ Diawi 等装机页大陆常连不通 |
+ 100 台硬顶 + 逐台收 UDID,只能内测。规模化分发不可行。 |
+
+
+ | 企业计划 ADEP(In-House) |
+ ❌ |
+ 不限设备 | ✅ | △ 灰市共享证书随时吊销 |
+ $299/年,但要求法人 + 100+ 员工 + D-U-N-S + 苹果面谈,个人根本不符门槛;ToS 只准分发给「自己员工」,公开分发违规;吊销后 OCSP 缓存 3–7 天全体停跑。近年苹果大幅收紧。不可行。 |
+
+
+ | ABM 自定义 App |
+ ❌ |
+ 兑换码 2.5 万/周 | ✅ | △ |
+ 仍要过正常 App Review(VPN 审核关没绕过)+ 面向指定企业/机构,不是匿名散户。为 B2B 设计,不解决大陆 C 端。 |
+
+
+ EU DMA 第三方市场 (AltStore PAL / Epic) |
+ ❌ |
+ — | ✅ 政策对 VPN 较宽 | ✗ 硬性欧盟限定 |
+ 装新 app 须人在欧盟 + 欧盟 Apple ID;走 Notarization 而非审核,对 VPN 宽松——但地理围栏使其对大陆用户完全无意义。还有 CTF €0.50/首年安装。 |
+
+
+ | Web Clip / PWA |
+ ❌ |
+ — | ✗ WKWebView 沙箱无 NE/TUN | — |
+ 技术上不可能做 VPN。 |
+
+
+ 个人账号 sideload (AltStore / Sideloadly) |
+ ⭐ |
+ 每设备 3 app | △ 免费号拿不到 VPN entitlement,须付费号(又回 Ad Hoc 100 台) | △ |
+ 免费证书 7 天、付费 1 年,须连电脑/AltServer 定期重签。普通大陆用户门槛过高,规模化不现实。 |
+
+
+
+
国内灰产签名(补规模的现实手段)
+
官方只有 TF 一条路且有上限,大陆 VPN 实务里普遍叠加以下灰产渠道。全部建立在「滥用苹果分发通道」上,掉签/吊销是结构性风险。
+
+
+| 方案 | 原理 | 成本 / 计费 | 稳定性 | 掉签风险 | 用户操作 |
+
+ | 超级签最稳的灰产档 |
+ 借个人号 Ad Hoc 通道,每号 100 台 UDID,自动收 UDID 装机,满即换号 |
+ 16–20 元/台(一次性) |
+ 高 |
+ 低(除非服务商用违规号被清) |
+ 信任描述文件,免装 MDM |
+
+
+ | 企业签(共享证书) |
+ 共享企业证书($299)重签,免 UDID 免连电脑,网页直装 |
+ 300–800 元/月 |
+ 低 |
+ 高(公开分发被苹果批量吊销) |
+ 网页装 + 信任企业开发者 |
+
+
+ | 企业签(独立/稳定签) |
+ 不外租的独享证书 + 多证容灾 |
+ 1500–5000+ 元/月 |
+ 中–较高 |
+ <1% ~ 中 |
+ 网页装 + 信任 |
+
+
+ | MDM 签 |
+ MDM 描述文件把设备标为「企业内部设备」,底层仍企业证书,按下载量计费 |
+ 按下载量 |
+ 中 |
+ 中 |
+ 须装 MDM 描述文件 + 授管理权(信任/隐私成本最高) |
+
+
+ | TF 代上架= 官方 TestFlight |
+ 第三方帮你过审、维护 TF 构建 |
+ 代上架 ~2000/月、5000/季 |
+ 最高(官方,不掉签) |
+ 不掉签,但 90 天 / 审核限 |
+ 装官方 TF app + 链接 |
+
+
+
稳定性梯度:TF ≳ 超级签 > 独立企业签/稳定签 > MDM > 共享企业签。单用户成本:超级签一次性最省,稳定签包月最贵。
+
+
合规与账号风险(务必先读)
+
+- 法律灰色:2017 年工信部要求 VPN 服务商须持经营资质、用国营基础设施;无资质对大陆公开提供翻墙服务处灰色/违法地带。
+- 平台层:中国区 App Store 已全量下架 VPN/代理工具(苹果称应工信部要求);苹果对翻墙类证书吊销执行较严。
+- 开发者账号:用「功能开关(switch)规避审核」等手法一旦被识别,苹果可将账号标记为待移除直接封号(V2EX 有独立开发者被直接封号案例)。企业证书公开分发翻墙 App 尤其高危。
+- 👉 对 Pangolin 而言:拿你个人主账号去硬刚 VPN 分发,最坏结果是主账号被封。若走灰产,务必隔离账号(专用于分发的独立账号),别赌主力开发者身份。
+
+
+
给 Pangolin 的落地建议
+
第 1 步(低风险、马上能做):TestFlight。 你现在 iOS 已是本机直装联调。把 iOS 版提交 TF 外部测试 → 大陆用户装官方 TestFlight app + 你的公开兑换链接即可用,支持 PacketTunnel,上万名额。代价是 90 天重传 + 每次外测过 Beta 审核。审核时别在描述里写"翻墙/VPN 突破防火墙",按通用「安全代理/隐私网络」类表述提交。
+
第 2 步(掉线兜底):超级签。 若 TF 被拒或名额紧,叠加超级签(16–20 元/台、稳定性最高的灰产档)。官网/落地页备一条「iOS 安装」入口,TF 链接失效时切超级签重装页——和你已有的官网下载引导天然契合。
+
不建议:企业签(共享)掉签太频、用户体验差;MDM 签要用户交设备管理权,对隐私敏感的 VPN 用户是劝退项;ADEP/ABM/EU 市场对你的场景直接不成立。
+
现实运营模型(大陆 VPN 通行做法):TF 打门面 + 超级签冗余兜底 + 落地页"无法打开?点此重装"话术。没有单一稳定渠道,靠组合冗余 + 快速重装引导消化掉签。若想彻底避开吊销风险,唯一"正规"路是海外区 App Store 上架 + 大陆用户自行换区,但那把安装门槛推给了用户。
+
+
来源
+
官方渠道
+
+
国内灰产签名
+
+
+
调研时点 2026-07,联网检索汇总。苹果政策(尤其 EU DMA、企业证书吊销、TF 审核尺度)变动频繁,落地前请以官方文档最新状态为准。相关:客户端发版链路(若存在)。
+
+
+
+
diff --git a/docs/notifications-design.html b/docs/notifications-design.html
new file mode 100644
index 0000000..a0d29c7
--- /dev/null
+++ b/docs/notifications-design.html
@@ -0,0 +1,235 @@
+
+
+
+
+
← 文档索引
+
系统通知机制
+
设计文档 · 2026-07-13 · Spec ③(系统通知)· 状态 已评审定稿 · 配套实现计划见 notifications-plan.html
+
+
+目标:App 内统一通知收件箱(顶栏铃铛 + 通知列表),覆盖六类内容:重要通知 / 新特性 / 新闻 / 到账(购买·邀请·任务奖励,个人定向)/ 版本 / 活动。
+送达:App 内为主(打开/回前台拉取),邮件仅兜底「重要通知」且需显式指定;系统推送(APNs/FCM)明确二期。
+决策(已定):单表 + audience(user_id 空=广播)· 已读用服务端 last_read_at 水位(多端同步)· 发版脚本联动插 version 公告 · 发布走 nodectl notice 子命令。
+现状底子:GET /v1/notices 空壳已在(恒空列表);openapi 已有双语 Notice schema(需扩 type/link/unread);客户端占位屏/铃铛/五条 l10n 已备;jiu 无可参考实现(其通知为二期占位)。
+
+
+
1 · 类型口径(单一来源)
+
+| type | 标签(zh/en) | 受众 | 典型内容 | 生产方式 |
+important | 重要 / Important | 广播(可定向) | 服务条款、安全、合规、线路维护 | nodectl(可 --email) |
+feature | 功能 / Feature | 广播 | 新增大阪线路、协议选择上线 | nodectl |
+news | 新闻 / News | 广播 | 产品动态、博客 | nodectl |
+reward | 到账 / Credited | 个人定向 | 购买开通 ×31 天、邀请注册 +3、首充 +7、TG 任务 +3 | 事件钩子自动(同事务) |
+version | 版本 / Update | 广播 | vX.Y.Z 发布 + 要点;点击接更新流程 | 发版脚本联动 |
+promo | 活动 / Promo | 广播 | ¥6 限购上线、邀请双倍周、年付折扣 | nodectl |
+
+
promo 与 feature/news 的边界:让用户「花钱/参与活动」的归 promo;产品功能上新归 feature;动态资讯归 news。类型集与客户端类型 pill、原型登记、l10n 标签三处须同集(L1 闸)。
+
+
2 · 数据流总览
+
+
+
+
+
3 · 数据模型(迁移 000026,sqlite/mysql 两套)
+
notices 新表:
+
+| 列 | 类型 | 说明 |
+id | PK 自增 | |
+type | CHECK/ENUM(六值) | important|feature|news|reward|version|promo |
+user_id | bigint NULL | NULL=全员广播;非空=定向该用户(比字符串 audience 可索引);FK users(id) |
+title_zh / title_en | text | 双语标题(内容仅 zh/en;ja/ko/ru/es 界面回落 en) |
+body_zh / body_en | text NULL | 双语正文(Markdown;reward 类一行文案可空) |
+link | text NULL | 可选动作(version 类点击接更新流程等) |
+published_at | datetime | 排序与未读水位比较基准(Go 端算好传 ?) |
+expires_at | datetime NULL | 到期自动不再下发(promo 常用) |
+revoked_at | datetime NULL | 撤回(nodectl notice revoke) |
+email_sent_at | datetime NULL | 邮件兜底幂等标记(同公告只发一轮) |
+索引:(user_id, published_at);广播查询走 user_id IS NULL 分支 |
+
+
users 加一列:notices_read_at DATETIME NULL —— 已读水位:published_at > notices_read_at 即未读。不做逐条已读(YAGNI,水位方案多端同步且零关联表)。
+
+
4 · API(扩现有空壳 accountAPI.ListNotices)
+
+| 端点 | 行为 |
+GET /v1/notices(protected) | 返回 {notices:[…], unread_count}:合并「user_id IS NULL + user_id=me」,过滤 revoked_at IS NOT NULL 与 expires_at < now,published_at 倒序 limit 50;每条含 id/type/title_zh/title_en/body_zh/body_en/link/published_at/unread(unread 服务端按水位算)。 |
+POST /v1/notices/read(protected) | UPDATE users SET notices_read_at=?(now)。客户端打开通知列表时调,红点全端清零。 |
+
+
+- openapi:
Notice schema 增 type/link/unread,响应对象增 unread_count(现有 id/title/body/published_at 必填保持兼容)。
+- 不做独立未读计数端点——列表响应自带,客户端启动/回前台拉列表即得红点。
+
+
+
5 · 生产者(三路)
+
5.1 nodectl notice 子命令(手工发布,important/feature/news/promo)
+
nodectl notice add --type important --title-zh 线路维护通知 --title-en "Line Maintenance" \
+ [--body-zh …] [--body-en …] [--link …] [--expires 2026-08-01] [--email]
+nodectl notice list [--all] # 含已撤回/过期
+nodectl notice revoke <id>
+
+- 接入方式沿 nodectl 既有模式(直连 DB 或管理通道,以现状为准);校验:type 合法、双语标题必填、--email 仅 important 可用。
+- 发布/撤回写
audit_log(action notice_publish/notice_revoke)。
+
+
5.2 事件自动通知(reward 类,个人定向,同事务)
+
+| 事件 | 接缝(既有事务内追加一条 INSERT) | 文案模板(双语,服务端渲染入库) |
+| 购买开通 | pay webhook.settle(Grant→MarkPaid 之后、Commit 前) | 「已开通 Pro ·31 天 / Pro activated · 31 days」 |
+| 邀请注册奖励(双方) | reward OnRegister 两次 Grant 后 | 「邀请奖励 +3 天已到账 / Invite reward +3 days credited」 |
+| 首充奖励(双方) | reward OnFirstPaidTx 两次 Grant 后 | 「好友首购奖励 +7 天已到账 / …+7 days credited」 |
+| TG 任务 | reward ClaimTelegram Grant 后 Commit 前 | 「任务奖励 +3 天已到账 / Task reward +3 days credited」 |
+
+
同事务不变量:通知与发放同 COMMIT/同回滚——用户看到「到账」通知 ⟺ 权益真到账,零孤儿通知(与钱路既有纪律一致)。实现为 reward/notices 侧一个 InsertNoticeTx(ctx, tx, userID, type, …) 小函数,各接缝一行调用。
+
5.3 发版联动(version 类)
+
+scripts/ci/release-client.sh 在推 version.yaml 到 pangolin1 后,追加一步经 ssh 调 nodectl notice add --type version --title-zh "vX.Y.Z 已发布" …(版本号/要点来自 tag 与 release notes)。
+- 客户端 version 类行点击 → 走现有
update_provider/更新流程(link 承载)。
+
+
+
6 · 邮件兜底
+
+- 范围:仅
important 且发布时显式 --email;广播=遍历 active 用户逐发,定向=单发。复用现有 SMTP mailer(auth/mailer.go 同款配置)。
+- 幂等:
notices.email_sent_at 置位后不重发;发送失败记日志继续(best-effort,不阻塞发布)。用户量增大后再演进为队列(明确非本期)。
+- 模板一套双语合排(邮件不受 App 单显铁律约束):标题 + 正文 + 「在 App 内查看」。
+
+
+
7 · 客户端
+
+- noticesProvider(AsyncNotifier,照 update_provider 惯例):启动延迟数秒首拉 + 回前台重拉;缓存内存;
unread_count>0 驱动铃铛红点(替换现在两处硬编码 hasUnread: true——content_top_bar 与 account_page)。
+- 通知页真实化(notifications_page):删静态示例;行 = 类型 pill + 标题 + 相对时间(复用
relativeTime 单源)+ 未读点;点击行内展开正文(Markdown 降级纯文本渲染即可);进入页面即调 POST /read 清红点;空态用现有 notifEmpty;version 类行尾「去更新」动作接现有更新流程。
+- l10n:类型标签补
notifTypeReward / notifTypeVersion / notifTypePromo(六语);通知内容按 App 语言取 zh 或 en 字段(非中即英)。
+- 原型先行(铁律):动代码前先统一两端原型通知视图——现状桌面用文字 pill、移动用图标,不一致;统一为「类型 pill + 未读点」桌面式(移动同构),并补 reward/version/promo 三类示例行 + 展开态;同一提交回填两份原型。
+
+
+
8 · 边界(YAGNI,本期不做)
+
+- 不做 APNs/FCM 系统推送(二期;大陆 FCM 可达性另议)。
+- 不做 逐条已读 / 通知偏好按类型开关 / 管理后台页。
+- 不做 六语内容(内容 zh/en,其余界面语言回落 en)。
+- 不做 openapi 描述里的「多镜像签名静态 JSON 副本」(意图保留,后续抗封锁专项再做)。
+- 不做 邮件队列/重试体系(现量级直发 + email_sent_at 幂等够用)。
+
+
+
9 · 测试要点
+
+- 查询合并:广播+定向合并、他人定向不可见;revoked/expired 过滤;limit 50;倒序。
+- 未读水位:read 前后 unread/unread_count 变化;新公告晚于水位再变未读;多“设备”(两次会话)读同一水位。
+- 同事务:TG 领奖/首充/注册奖励成功 → reward 通知在;Grant 失败回滚 → 通知不留(孤儿为零);购买开通同款。
+- nodectl:类型校验、--email 限 important、revoke 后列表即消失、audit_log 两 action。
+- 邮件:仅 important+--email 触发;email_sent_at 幂等(重复执行不再发);失败不阻塞发布。
+- 客户端:红点由 unread_count 驱动(0 不显);进入列表调 read 后红点清;空态;version 行动作;golden(通知页多主题)。
+- 双 DB:迁移 up/down round-trip;时间 Go 端传 ?。
+
+
+
10 · 涉及文件(实现锚点)
+
# 后端
+server/migrations/{mysql,sqlite}/000026_notices.{up,down}.sql # notices 表 + users.notices_read_at
+server/internal/notices/ # 新包:store(查询合并/水位/InsertNoticeTx)+ service(邮件兜底)+ handler
+server/internal/httpapi/account.go # ListNotices 空壳替换为真实现(或移交 notices 包)
+server/internal/reward/service.go # OnRegister/OnFirstPaidTx/ClaimTelegram 接 InsertNoticeTx
+server/internal/pay/webhook.go # settle 接购买开通通知
+server/cmd/nodectl/ # notice add/list/revoke 子命令
+scripts/ci/release-client.sh # 发版联动插 version 公告
+server/api/openapi.yaml # Notice schema 扩 type/link/unread + /notices/read
+# 客户端
+design/prototype/screens/{ui-desktop,ui-mobile}.html # 原型先行:两端通知视图统一 + 三类新示例
+client/lib/state/notices_provider.dart # 新:拉取/未读/read
+client/lib/services/notices_api.dart # 新:GET /v1/notices + POST /read
+client/lib/screens/notifications_page.dart # 占位 → 真实
+client/lib/widgets/{notification_bell,content_top_bar}.dart + screens/account_page.dart # 红点接 provider
+client/lib/l10n/ # notifTypeReward/Version/Promo 六语
+
+
下一步:writing-plans 出逐任务实现计划(md 真相源 + HTML 阅读版),Subagent 驱动执行。与 邀请奖励设计(Spec ②)的「奖励事件接缝」在 5.2 节正式接通。
+
+
+
+
diff --git a/docs/notifications-plan.html b/docs/notifications-plan.html
new file mode 100644
index 0000000..40147c6
--- /dev/null
+++ b/docs/notifications-plan.html
@@ -0,0 +1,226 @@
+
+
+
+
+
← 文档索引
+
系统通知机制
+
实现计划阅读版 · 2026-07-13 · Spec ③ · 11 任务 TDD · 状态 待执行
+
配套设计文档 系统通知机制设计。执行真相源(含 checkbox,驱动 subagent-driven-development/executing-plans):docs/superpowers/plans/2026-07-13-notifications.md。
+
+
+Goal:App 内统一通知收件箱(铃铛红点 + 六类通知列表),含个人到账通知(同事务零孤儿)、nodectl 发布、发版联动与 important 邮件兜底。
+Architecture:单 notices 表(user_id NULL=广播/非空=定向)+ users.notices_read_at 已读水位;新 server/internal/notices 包(store/service/handler)替换现有 GET /v1/notices 空壳;生产三路(nodectl 手工、reward/pay 事务内钩子、发版脚本);客户端 noticesProvider 驱动铃铛与列表,原型先行统一两端通知视图。
+Tech Stack:Go(chi+裸 SQL 双方言)、SMTP(复用 auth mailer 配置)、Flutter/Riverpod、shared 设计原型。
+
+
+
Global Constraints
+
+- 类型集六值恒定:
important|feature|news|reward|version|promo(DB CHECK/ENUM、openapi、客户端 pill、原型登记四处同集)。
+- 内容仅 zh/en 双语字段;客户端 ja/ko/ru/es 界面取 en 内容。
+- 已读=水位:
published_at > users.notices_read_at 即未读;不做逐条已读。
+- 事件到账通知必须与发放同事务(传入 tx,不自开;失败随事务回滚)。
+- 邮件仅
important 且显式 --email;notices.email_sent_at 幂等;发送失败只记日志不阻塞发布。
+- 多 DB:迁移
server/migrations/{mysql,sqlite}/ 两套一一对应,下一编号 000026;裸 SQL;时间 Go 端 time.Now().UTC() 传 ?,禁 NOW()/UTC_TIMESTAMP()。
+GET /v1/notices 响应契约(客户端按此解析):{"notices":[{id,type,title_zh,title_en,body_zh,body_en,link,published_at,unread}],"unread_count":N};列表=广播∪我的定向,过滤 revoked_at IS NOT NULL/expires_at<now,published_at 倒序 limit 50。
+- 客户端改 UI 前原型先行(两端通知视图统一为「类型 pill+未读点」桌面式,补 reward/version/promo 示例),原型与代码同批次落地。
+- 颜色走 token(
context.pangolin/var(--token)),文案经 AppText,红线词禁用。
+- 命令:
cd server && go test ./...;cd client && flutter analyze && flutter test;bash server/run_sqlite_test.sh。
+
+
+
File Structure(总览)
+
# 后端(新建)
+server/migrations/{mysql,sqlite}/000026_notices.{up,down}.sql
+server/internal/notices/{store,service,handler}.go
+# 后端(修改)
+server/internal/reward/service.go # OnRegister×2/OnFirstPaidTx×2/ClaimTelegram 接 InsertNoticeTx
+server/internal/pay/webhook.go # settle 插「购买开通」通知
+server/cmd/nodectl/main.go + notice.go # notice add/list/revoke 子命令
+server/cmd/server/main.go # 装配 notices + 注入 reward/pay + 路由替换空壳
+server/internal/httpapi/account.go # 删 ListNotices 空壳
+server/api/openapi.yaml # Notice schema 扩展 + /notices/read
+scripts/ci/release-client.sh # 发版联动插 version 公告
+# 客户端/原型(新建/修改)
+design/prototype/screens/{ui-desktop,ui-mobile}.html # 通知视图统一+新类型示例
+client/lib/services/notices_api.dart · state/notices_provider.dart
+client/lib/screens/notifications_page.dart # 真实化
+client/lib/widgets/content_top_bar.dart + screens/account_page.dart # 红点接 provider
+client/lib/l10n/app_text.dart + strings_*.dart ×6
+
+
后端(Task 1–7)
+
+
+
1
迁移 000026 — notices 表 + users.notices_read_at
+
新建 notices 表(六值 CHECK/ENUM、user_id NULL=广播、双语标题/正文、link/published_at/expires_at/revoked_at/email_sent_at、索引 (user_id,published_at))+ users 加 notices_read_at。sqlite/mysql 各一对 up/down,四个文件。
+
新建 server/migrations/{sqlite,mysql}/000026_notices.{up,down}.sql
+
+- 验收:
run_sqlite_test.sh + 迁移 up→down→up 全量测试通过(断言若因新迁移顶偏,按 000024/000025 既有修法更新计数)。
+
+
+
+
+
2
notices.Store — 数据访问层
+
新建 notices 包的纯数据访问层:广播∪定向合并查询(过滤 revoked/expired,倒序 limit)+ 已读水位判定;事务内插通知(事件钩子用,不自开事务)+ 自管连接的广播插入(nodectl/发版用)。
+
新建 server/internal/notices/{store.go, store_sqlite_test.go}
+
关键方法:ListForUser · MarkRead · InsertNoticeTx(事务内定向)· InsertBroadcast · Revoke · ListAdmin · MarkEmailSent · ListActiveUserEmails
+
+- 验收:广播+我的定向合并可见、他人定向/撤回/过期均排除;水位判定正确(空水位全未读);
InsertNoticeTx 随外部事务回滚则零孤儿。
+
+
+
+
+
3
notices HTTP handler + openapi 扩展 + 替换空壳
+
新建 Handler.List(GET /v1/notices,未登录 401)+ Handler.MarkRead(POST /v1/notices/read);删 httpapi/account.go 的 ListNotices 空壳(若编译顺序需要可延到 Task 7);openapi Notice schema 增 type/link/unread,响应增 unread_count,新增 /notices/read。
+
新建 server/internal/notices/{handler.go, handler_test.go} · 改 server/internal/httpapi/account.go、server/api/openapi.yaml
+
+- 验收:list 返回契约字段齐全(
type/unread/unread_count);read 后 unread_count 清零;未登录 401;openapi 校验通过;go build ./... 绿。
+
+
+
+
+
4
事件钩子 — 四处到账通知(同事务)
+
reward/pay 各自声明小接口 Noticer(照 Granter/Rewarder 惯例避免 import cycle),四接缝在既有事务内、Grant 成功之后追加 InsertNoticeTx:OnRegister(邀请注册双方)、OnFirstPaidTx(首充双方)、ClaimTelegram(TG 任务)、pay webhook.settle(购买开通)。插入失败视为该事务失败(随之回滚),非吞错。
+
改 server/internal/reward/service.go、server/internal/pay/webhook.go · 新建 notices_hook_sqlite_test.go、webhook_notice_sqlite_test.go
+
+- 验收:四事件各自触发一条
type='reward' 定向通知;Grant 失败回滚时通知同回滚(零孤儿);双语文案模板落对天数。
+
+
+
+
+
5
notices.Service — 发布/撤回 + 邮件兜底
+
校验(type∈六值、标题非空、Email=true 仅 important 可用)→ Publish:InsertBroadcast → 写 audit(notice_publish)→ Email 时逐发 active 用户(失败仅 log)→ MarkEmailSent 幂等。RevokeByID:Revoke + audit(notice_revoke)。
+
新建 server/internal/notices/{service.go, service_test.go}
+
+- 验收:非法 type/缺标题/email 配非 important 均报错;成功发布落库+audit;email=true 全员发送、email_sent_at 置位、二次 Publish 是新公告不误判幂等;Revoke 后列表不可见+audit 行。
+
+
+
+
+
6
nodectl notice 子命令
+
notice add --type … --title-zh … --title-en … [--body-zh] [--body-en] [--link] [--expires] [--email] / notice list [--all] [--limit] / notice revoke <id>。add 组 PublishInput 调 Service.Publish(SMTP mailer 复用 auth 配置或包内极简 net/smtp 实现;无配置且 --email 明确报错拒发)。
+
新建 server/cmd/nodectl/{notice.go, notice_test.go} · 改 server/cmd/nodectl/main.go
+
+- 验收:参数解析层单测覆盖 type 非法/缺标题/email 非 important/expires 格式各红;
go build ./... && go test ./cmd/nodectl/ 绿。
+
+
+
+
+
7
main.go 装配 + 发版联动
+
装配 noticesStore/noticesHandler,注入 rewardSvc.SetNoticer/payWebhook.SetNoticer;路由 protected 组替换空壳:GET /notices、新增 POST /notices/read。scripts/ci/release-client.sh 在 version.yaml 推送成功后追加 nodectl notice add --type version(失败不阻断发版)。
+
改 server/cmd/server/main.go、server/internal/httpapi/account.go、scripts/ci/release-client.sh
+
+- 验收:
go build ./... && go vet ./cmd/server/ && go test ./... -count=1 全绿;shellcheck release-client.sh 通过。
+
+
+
+
原型与客户端(Task 8–10)
+
+
+
8
原型先行 — 两端通知视图统一 + 新类型示例
+
统一形态为桌面式「类型 pill + 标题 + 相对时间 + 未读点」:改造 ui-mobile 通知子屏对齐桌面 .notif-row 结构(放弃 icon 区分);两端各补三类新示例行(reward/version/promo),i18n 字典补对应示例文案(zh/en),两端占位内容统一为同一组;点击行展开正文的简单交互示意。
+
改 design/prototype/screens/{ui-desktop,ui-mobile}.html
+
+- 验收:
node design/prototype/serve.mjs 起两文件均 200;新增行无裸 hex,全走 token。
+
+
+
+
+
9
客户端 notices api + provider
+
新建 NoticesApi(fetch() 拉 GET /v1/notices、markRead() 打 POST /v1/notices/read)+ NoticeItem/NoticesData 数据类(fromJson 安全默认,缺字段不 crash);Riverpod noticesProvider(AsyncNotifierProvider):未登录 null、已登录延迟 2s 首拉(照 update_provider 模式),refresh()/markAllRead() 本地态同步。
+
新建 client/lib/services/notices_api.dart · state/notices_provider.dart · test/unit/notices_api_test.dart
+
+- 验收:
notices_api_test.dart 用 MockClient 断言 fetch 解析(2 条/unread_count/类型/双语字段)+ markRead 打对路径;flutter analyze 新文件 0 issue。
+
+
+
+
+
10
通知页真实化 + 铃铛红点接 provider + l10n
+
l10n 加 notifTypeReward/Version/Promo(六语实现)。notifications_page 删静态数据,ref.watch(noticesProvider) 三态(loading/空/error);行=类型 pill + 双语标题 + 相对时间 + 未读点,点击展开正文,version 行「去更新」接现有更新流程,进页调 markAllRead()。content_top_bar/account_page 的 NotificationBell(hasUnread:true) 硬编码 → 接 unreadCount>0 真值;回前台刷新接壳层 lifecycle。
+
改 client/lib/screens/notifications_page.dart、client/lib/widgets/content_top_bar.dart、client/lib/screens/account_page.dart、client/lib/l10n/app_text.dart + strings_{zh,en,ja,ko,ru,es}.dart · 新建 test/widget/notifications_page_test.dart
+
+- 验收:widget 测试断言类型 pill 文案在/未读点在/进页触发 markAllRead;
flutter analyze 0 新增;flutter test 全绿(通知页 golden 若有则重录)。
+
+
+
+
文档(Task 11)
+
+
+
11
计划 HTML 阅读版 + 索引登记 + 设计文档互链
+
即本文档:按项目「设计/计划双产物」规范,把 docs/superpowers/plans/2026-07-13-notifications.md(执行真相源,含 checkbox)生成同内容 HTML 阅读版,登记进 docs/index.html「实现计划 / Plans」分类,并把 notifications-design.html 顶部「配套实现计划」链接改为直链本文档。
+
新建 docs/notifications-plan.html · 改 docs/index.html、docs/notifications-design.html
+
+- 验收:三文件登记完整、互链可点;
.md 保持执行真相源不变(供 subagent-driven-development 继续驱动 Task 1–10)。
+
+
+
+
验收(端到端)
+
+- 后端:
cd server && go test ./... 全绿;nodectl notice add --type news … 后 GET /v1/notices(带 JWT)可见、红点计数对;revoke 后消失。
+- 事件:TG 领奖/邀请注册/首充/购买各触发一次 → 对应用户列表出现 reward 通知,与权益同事务。
+- 邮件:important +
--email → active 用户收信一轮,email_sent_at 置位,重复 add 是新公告不误判。
+- 客户端:铃铛红点=unread_count 驱动;进通知页红点清零(另一设备登录同账号也清);version 行可跳更新;六语标签;空态正常。
+- 部署:迁移 000026 + server + nodectl 上 pangolin1;发一条真实 news 公告真机验证。
+
+
+
不在本轮(YAGNI)
+
+- 不做 APNs/FCM 推送、逐条已读、通知偏好开关、管理后台页、六语内容、静态镜像 JSON 副本、邮件队列。
+
+
+
本页为阅读版,不含逐步 TDD 代码细节;完整测试代码/实现片段见执行真相源 docs/superpowers/plans/2026-07-13-notifications.md。
+
+
+
+
diff --git a/docs/pay-v2-dev-log.md b/docs/pay-v2-dev-log.md
new file mode 100644
index 0000000..f3341db
--- /dev/null
+++ b/docs/pay-v2-dev-log.md
@@ -0,0 +1,54 @@
+# pay v2 支付+订单 — 自动开发决策日志
+
+> 2026-07-11 起,自动模式落地。用户明早 double check。每个决断记这里。
+
+## 背景
+用户指令:把今天设计的支付/订单功能全部落成真实代码(Flutter + Go),前后端测试,增量提交,自行决断,留记录。
+
+## 最终 UX 模型(prototype 已定稿)
+- 购买页一个**支付渠道 segswitch**:默认 **USDT→加密货币**,可切 **人民币→支付宝**。渠道=币种=方式,一步定,无弹层。
+- 套餐价随渠道切换(一口价不换算):月 ¥29.99/$4.99 · 季 ¥68.88/$12.99 · 年 ¥199.99/$34.99。
+- 买完直达支付页:crypto=USDT 地址+精确金额;alipay=桌面二维码/移动拉起 App。
+- 订单:列表→详情,状态 pending/paid/settled/expired。
+- nezha(哪吒)已从购买流程移除。
+
+## 决策日志
+- **[step0]** prototype 支付渠道 switch 收口(3 屏 + `.segswitch` 原子入真相源+登记)。→ 提交后进开发。
+
+## [step1] prototype 支付渠道 switch 收口(2026-07-11)
+- `.segswitch` 原子入 atoms.css + 登记 index.html;purchase.html/ui-mobile/ui-desktop 三屏改成购买页渠道 switch(默认 USDT→crypto / 人民币→alipay),删两步弹层 + nezha。
+- 闸全绿:check-ds 5 项 / check-l1-sync(58 图标 + 190 l10n×6)。
+
+## [step2] 服务端 pay orders + 分币种 catalog(2026-07-11)
+- catalog.go:`CatalogItem.PriceUsdtMicro`(USDT 展示价,一口价)+ `SettlementCurrency`/`DisplayAmountMinor` helper(crypto→USDT / 法币→CNY)。
+- store.go:`PurchaseRow.CreatedAt`;`Insert` 增 amount/currency 参(下单落展示金额,webhook 覆盖实际);新增 `ListByUser`。
+- handler.go:新增 `List`(GET /v1/pay/orders,纯本地台账倒序)+ `orderView` 富字段;`GetOrder` 详情回带 sku/plan/method/amount/currency/created_at/paid_at;下单按方式落展示金额。
+- main.go:注册 `GET /v1/pay/orders`。
+- 测试:新增 List 越权/倒序/富字段、下单展示金额分币种、详情富字段;**修 3 个 pre-existing 迁移测试**(renumber 21→23 遗留)。`go test ./...` 全绿。
+- 决策:USDT 展示价放服务端 catalog($4.99/$12.99/$34.99),客户端 switch 直接读,不硬编码;实际扣款仍以 pay 侧为准。
+
+## [step3] 客户端 Flutter 落地(2026-07-11)
+- 探查结论:购买/支付主干已存在(Riverpod + payment_api + payment_page 多态渲染);缺订单列表/详情整链 + 购买页 segswitch/分币种。
+- **锁定共享契约(主 agent 手写,防并行冲突)**:
+ - models/payment.dart:`PayChannel{usdt,cny}`(.currency/.method);`PayCatalogItem.priceUsdtMicro` + `priceLabel(ch)`/`perMonthLabel(ch)`/`months`(**priceLabel 改带参**);新 `PayOrderSummary`;`PayOrderStatus.summary` 富字段。
+ - payment_api.dart:`listOrders()`。
+ - l10n:+19 getter(支付渠道 switch 4 + 订单 15)×6 语言,l10n 闸绿(209×6)。
+ - StatusPill 复用(created→connecting/等待付款、paid→connected/已开通、canceled→neutral/已取消)。
+- **两 agent 并行建屏**:A=SegSwitch widget + purchase_page 重构(渠道 switch 置顶、分币种价、删底部弹窗) + payment_page 订单卡;B=orders_provider + orders_page(列表+详情) + 导航(NavView.orders + desktop_shell + account 入口)。
+- 决策:USDT 展示价由服务端 catalog 下发(不客户端硬编码);「继续支付」MVP 走购买页重下单(retry-resume 列为后续)。
+
+## [step4] 集成 + 测试 + 提交(2026-07-12)
+- 整体 `flutter analyze` 0 error(3 个 pre-existing info:withOpacity×2 + unused import)。
+- `flutter test` 226 全绿:修 priceLabel 带参、假 API 补 listOrders、购买页测改成默认 USDT+切 CNY 断言、新增订单列表/空态/详情测试、golden 重录(icon 迁移+账户订单入口+连接键 loader)。
+- 服务端 `go test ./...` 全绿。
+- l10n 19 getter ×6 语言补 @override,check-l1-sync 绿(209×6)。check-ds 绿。
+- **提交**:a5adbf3 server / f6ce526 design / 272eca0 client。→ push + draft PR。
+- 遗留(非阻塞,明日可议):订单「继续支付」走重新下单(非 retry-resume);订单无 expired 本地态;`local-l13-start.sh` 是本地联调脚本(含 throwaway 密钥)不入 git。
+
+## [step5] 账户 IA 重构落地 + 订单过期态(2026-07-12,SDD 执行)
+- 计划 `docs/superpowers/plans/2026-07-12-account-ia-reorg.md`,9 任务 SDD(每任务实现+审查+修)全过。
+- 后端(Task 0):订单展示层过期态(created 超 30min TTL → orderView.Status=expired,零迁移),go test 绿。
+- 前端(Task 1-7.5):NavView 加 invite/notifications;l10n +21 getter×6;联系渠道单源+Redeem 删去哪买;账户页重排+邀请入口+删偏好卡+formFactor 门控+InviteScreen 占位;桌面侧栏加 Account 一级+顶栏通知铃铛+NotificationBell;通知占位屏+移动铃铛;设置页归连接/外观/关于三组+Web 下沉;订单页 expired pill。
+- 验收:flutter analyze 0 error;flutter test 232 全绿(7 desktop golden 因 IA 改壳重录);check-ds/check-l1-sync(59图标+230×6 l10n)绿;server go test 绿。
+- 待:server 重编部署 pangolin1(Task0 生效)+ 终审 + Windows 编译测试。
+- 遗留(终审triage):ContactChannel 死 class / chEmail·buyTitle·buySub 孤立 l10n / _SectionLabel 双份 —— 均 Minor。
diff --git a/docs/superpowers/plans/2026-07-12-account-ia-reorg.md b/docs/superpowers/plans/2026-07-12-account-ia-reorg.md
new file mode 100644
index 0000000..c1f9780
--- /dev/null
+++ b/docs/superpowers/plans/2026-07-12-account-ia-reorg.md
@@ -0,0 +1,528 @@
+# 账户/设置/联系 信息架构重构 — Flutter 实现计划
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** 把已评审的 IA(`docs/account-ia-reorg-design.html`)落成 Flutter:账户/设置/联系去重归位,Account 提为桌面一级项,顶栏加通知铃铛,账户页加邀请入口,并为 Spec ②推广激励 / ③系统通知 留占位屏与接口。
+
+**Architecture:** 复用现有 Riverpod + shell(desktop/mobile/tablet) + `open(NavView, mobilePage)` 下钻模式。多数是改造现有页面(account_page/settings_page/account_screens)+ 导航(navigation_provider/desktop_shell);新建仅 2 个占位屏(InviteScreen/NotificationsScreen)+ 铃铛 widget。**唯一后端改动 = 订单过期态(Task 0,display-only 零迁移)**——付款/订单核心后端已完成并部署生产。邀请奖励引擎、通知下发机制**不在本计划**(各自 Spec ②③,前后端一起)。
+
+**Tech Stack:** Flutter / Dart · flutter_riverpod 2.x · 现有 `PangolinIcons`(生成,勿手改) · `AppText` 抽象基类 + 6 语言 strings。
+
+## Global Constraints
+
+- **l10n 完整性闸**:`app_text.dart` 每加一个抽象 getter,必须在 6 份 `strings_{zh,en,ja,ko,ru,es}.dart` 全部实现,否则 `node tools/check-l1-sync.mjs` red。默认语言英文。
+- **图标单源**:`client/lib/widgets/pangolin_icons.dart` 是生成文件**勿手改**;真源 `design/prototype/icons.js`。本计划所需图标(bell/gift/crown/ticket/monitorSmartphone/users/settings/messageCircle)均已在。
+- **颜色单源**:禁裸 `Color(0x)`/`Colors.x`,走 `context.pangolin` 语义色;例外加 `// ds-ignore: 理由`。
+- **验收**:每任务后 `cd client && flutter analyze`(0 error) + 相关 `flutter test` 绿;改到有 golden 的屏随功能 `--update-goldens` 重录。
+- **占位屏边界**:InviteScreen/NotificationsScreen 本计划只做**静态占位 UI + 导航接线**;数据/奖励/下发逻辑属 Spec ②③,用 `// TODO(spec-2)` / `// TODO(spec-3)` 标注接口点。
+
+---
+
+### Task 0: 后端 — 订单过期态(display-only,零迁移)
+
+**Files:**
+- Modify: `server/internal/pay/handler.go`(`orderViewFromRow` + `orderPendingTTL` 常量)
+- Test: `server/internal/pay/handler_test.go`
+
+**Interfaces:**
+- Produces: 订单视图 `status` 新增可能值 `"expired"`(供前端映射)。判定:本地台账 `status=="created"` 且 `created_at + orderPendingTTL < now` → 视图显 `expired`(DB 不改,与金额兜底同为展示层)。
+
+- [ ] **Step 1: 写过期判定测试(失败)**
+
+在 `handler_test.go` 加:
+```go
+// 老 created 单超 TTL → 列表视图显 expired;近期 created → 仍 created。
+func TestList_ExpiredWhenStale(t *testing.T) {
+ router, st := newHandlerRig(t, nil)
+ ctx := context.Background()
+ // 直接构造两条 created 单,改 created_at 到很久以前 / 刚刚
+ if err := st.Insert(ctx, 1, "uuid-1", "pro_month", "old", "alipay", 2999, "CNY"); err != nil { t.Fatal(err) }
+ if err := st.Insert(ctx, 1, "uuid-1", "pro_month", "fresh", "alipay", 2999, "CNY"); err != nil { t.Fatal(err) }
+ // 把 old 的 created_at 回拨到 TTL 之前
+ if _, err := st.db.ExecContext(ctx,
+ `UPDATE pay_purchases SET created_at = ? WHERE out_trade_no = 'old'`,
+ time.Now().Add(-2*orderPendingTTL).UTC()); err != nil { t.Fatal(err) }
+ w := httptest.NewRecorder()
+ router.ServeHTTP(w, authed(httptest.NewRequest(http.MethodGet, "/v1/pay/orders", nil), 1))
+ var resp struct{ Orders []orderView `json:"orders"` }
+ _ = json.Unmarshal(w.Body.Bytes(), &resp)
+ got := map[string]string{}
+ for _, o := range resp.Orders { got[o.OrderNo] = o.Status }
+ if got["old"] != "expired" { t.Fatalf("old 应过期, got %q", got["old"]) }
+ if got["fresh"] != "created" { t.Fatalf("fresh 应 created, got %q", got["fresh"]) }
+}
+```
+> 注:`st.db` 是 `Store` 的私有字段,测试同包(`package pay`)可访问。
+
+- [ ] **Step 2: 跑测试确认失败**
+
+Run: `cd server && go test ./internal/pay/ -run TestList_ExpiredWhenStale`
+Expected: FAIL(old 仍为 created)。
+
+- [ ] **Step 3: 实现过期判定**
+
+`handler.go` 加常量 + 在 `orderViewFromRow` 里判定:
+```go
+// 未付订单展示层过期窗口(近似 pay 会话时效;仅影响列表/详情显示,不改台账)。
+const orderPendingTTL = 30 * time.Minute
+```
+在 `orderViewFromRow` 组装 `v` 后、`return v` 前加:
+```go
+ if row.Status == "created" && row.CreatedAt.Add(orderPendingTTL).Before(time.Now()) {
+ v.Status = "expired" // 展示层:台账仍 created,可继续支付/取消
+ }
+```
+
+- [ ] **Step 4: 跑测试确认通过**
+
+Run: `cd server && go test ./internal/pay/`
+Expected: ok(含新测试 + 原有全绿)。
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add server/internal/pay/handler.go server/internal/pay/handler_test.go
+git commit -m "feat(server/pay): 订单展示层过期态(created 超 TTL 显 expired,零迁移)"
+```
+
+> **部署**:本任务改 server,需重编 linux 二进制 + 部署 pangolin1(Task 8 统一部署,或单独发)。
+
+---
+
+### Task 1: 导航枚举加 invite / notifications
+
+**Files:**
+- Modify: `client/lib/state/navigation_provider.dart:7,10`
+
+**Interfaces:**
+- Produces: `NavView.invite`、`NavView.notifications` 枚举值;`kAccountSubViews` 含 `invite`。
+
+- [ ] **Step 1: 加枚举值 + 子页登记**
+
+`navigation_provider.dart` 第 7 行 `enum NavView` 末尾加 `, invite, notifications`:
+```dart
+enum NavView { connect, servers, stats, account, contact, settings, plans, redeem, devices, purchase, payment, orders, invite, notifications }
+```
+第 10 行 `kAccountSubViews` 加 `invite`(notifications 走全屏,不入子页返回集):
+```dart
+const Set