From 3df5867c02ac71ed653a11cfcff7198d9a2c1fab Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Fri, 10 Jul 2026 20:39:09 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20pangolin=C3=97pay-v2=20=E6=8E=A5?= =?UTF-8?q?=E5=85=A5=E8=AE=A1=E5=88=92(9=E4=BB=BB=E5=8A=A1:=E8=BF=81?= =?UTF-8?q?=E7=A7=BB/codes=E6=8F=90=E7=82=BC/PayClient/=E4=BB=A3=E7=90=86/?= =?UTF-8?q?webhook/Flutter=E8=B4=AD=E4=B9=B0=E6=94=AF=E4=BB=98=E9=A1=B5/?= =?UTF-8?q?=E7=BB=88=E9=AA=8C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u --- .../2026-07-10-pangolin-pay-v2-integration.md | 2988 +++++++++++++++++ 1 file changed, 2988 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-10-pangolin-pay-v2-integration.md diff --git a/docs/superpowers/plans/2026-07-10-pangolin-pay-v2-integration.md b/docs/superpowers/plans/2026-07-10-pangolin-pay-v2-integration.md new file mode 100644 index 0000000..169a924 --- /dev/null +++ b/docs/superpowers/plans/2026-07-10-pangolin-pay-v2-integration.md @@ -0,0 +1,2988 @@ +# pangolin · 接入 pay v2 统一支付网关 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务实现本计划。步骤用 `- [ ]` checkbox 追踪。每个任务自包含:不读全仓也能按锚点+代码落地。 + +> **契约真相源(必读,pay 仓分支 `design/pay-v2` @ `c0c7ecc`,当前 `~/code/pay` 工作区即该分支):** +> - `~/code/pay/internal/handler/gateway.go` + `internal/router/router.go`——/api/v2 端点(下单/查单/retry/cancel;改状态端点 per-IP 限流 429)。 +> - `~/code/pay/internal/handler/order.go::verifyBizSign` + `internal/util/sign.go::HMACSign`——业务方签名范式。 +> - `~/code/pay/internal/webhook/notifier.go` + `internal/gateway/settle.go`——webhook 出站契约(头/签名/重试/ACK)。 +> - `~/code/pay/docs/pay-v2-unified-gateway-design.html` §4.2——render_type 契约。 +> - 本仓 `CLAUDE.md`「数据层:多数据库」节——双方言纪律;`server/internal/codes/service.go::applySubscription`——订阅叠加语义唯一真相(webhook 开通必须复用,不复制)。 + +**Goal:** pangolin 客户端可以用「支付宝 / USDT(TRC20)」购买 pro 月/季/年三档订阅:server 侧以业务方身份(`biz_system=pangolin`,HMAC 签名)代理 pay v2 下单/查单/换渠道/取消,接收 `payment.succeeded` webhook 后**复用 codes 的订阅叠加语义**开通/续期;client 侧新增购买页(三档 PlanCard)与支付页(按 render_type 多态 + 轮询)。全部单测以 httptest 模拟 pay(签名语义照抄 pay 源码),端到端真联调等 pay 部署后按文末 checklist 执行。**不 merge、不部署、不发版。** + +**Architecture:** + +- **server 新包 `internal/pay/`**:`sign.go`(HMAC-SHA256 + `\n` join + std base64,照抄 pay `util/sign.go`)、`client.go`(出站 PayClient:下单签名,查单/retry/cancel 免签——pay 侧只在 create 且 biz_system 非空时验签)、`catalog.go`(sku→plan/days/展示价 单源)、`store.go`(购买台账 `pay_purchases`)、`handler.go`(JWT 保护的 App 代理端点 `/v1/pay/*`)、`webhook.go`(`POST /v1/webhook/pay` 接收器)。 +- **叠加复用**:`internal/codes` 内新增导出方法 `Service.GrantPaidSubscriptionTx(ctx, tx, userID, plan, days, ref)`——在**调用方事务**里走 `GetPlanIDTx → applySubscription(source='pay')`,与兑换码同一段代码;`applySubscription`/`Store.CreateSubscription` 参数化 `source`(兑换路径传 `'code'`,行为零变化)。 +- **幂等三层**:传输层 nonce SETNX(±5min 窗口,rdb 可空降级)→ **业务层以 `out_trade_no` 为幂等键**(pay 重投每次换新 nonce,唯一可靠键是订单号):台账行 `status created→paid` 在锁内 CAS,已 paid 直接回 SUCCESS → 兜底:台账缺行(下单后本地写失败)时按 `biz_ref`(=用户 uuid)定位用户补建台账再开通,webhook 自足可修复。 +- **client**:`services/payment_api.dart`(经 ApiClient 走 server 代理,JWT 自动注入)+ `state/payment_provider.dart`(StateNotifier 支付流控制器,3s 轮询查单直到本地 `activated`)+ `screens/purchase_page.dart`/`payment_page.dart`(组件全部取自 `client/lib/widgets/` 真相源:PlanCard/PangolinButton/showPangolinToast/PangolinIcons+既有 inline card 配方,不新造样式)。 + +--- + +## 关键决策(已定,含依据) + +| 决策点 | 结论 | 依据 | +|---|---|---| +| **sku 命名** | `pro_month` / `pro_quarter` / `pro_year`(= pay `products.biz_code`,webhook `product_biz_code` 原样回带) | 计划前提定死;pay 侧 `sku` 即 `biz_code`(`gateway.go` → `DBProductResolver.Resolve` 按 `biz_code+active` 查) | +| **定价** | CNY 29.99/68.88/199.99(pay `products.price` 元字符串,alipay CNY 走 fallback);USDT 微单位价配 `product_prices`(见部署附录,汇率可调) | 既有决策;pay 结算币种由 method `SettleCurrencies[0]` 决定,客户端永不传金额 | +| **时长映射** | month=31 / quarter=92 / year=366 天(覆盖大月/最长季,对用户宽松),单源在 `internal/pay/catalog.go` | subscriptions 以 `duration_days` 叠加(codes 既有语义),pay 侧无时长概念 | +| **薄表设计** | 单表 `pay_purchases`:`out_trade_no UNIQUE` 既是 biz_ref↔订单映射又是 webhook 幂等台账(status `created/paid/canceled` + 锁内 CAS),不另建 events 表 | pay 重投同事件 nonce 每次不同(`notifier.go` 每 attempt `uuid.NewString()`),nonce 表防不了重投;订单号才是稳定幂等键 | +| **webhook 幂等键** | `out_trade_no` + 行级锁(`dialect.LockForUpdate()`)内 status CAS;nonce SETNX 仅作 ±5min 传输重放防御(rdb=nil 时跳过) | 同上;开通与台账翻转同一 `*sql.Tx`,崩溃安全(未 commit 则 pay 重投再来一次) | +| **subscriptions.source 扩 `'pay'`** | 迁移 000021:MySQL `MODIFY ENUM`,SQLite 重建表(CHECK 不可 ALTER) | 数据诚实:运营查询要能区分付费/卡密/试用;台账 join 只能兜底不便审计 | +| **biz_ref = users.uuid** | 下单时带用户 uuid;webhook 主定位走 out_trade_no,uuid 作缺行兜底与交叉校验 | uuid 不透出自增 id、稳定、已有 UNIQUE 索引 | +| **叠加复用方式** | codes 包内导出 `GrantPaidSubscriptionTx`(吃调用方 tx),`applySubscription` 原封不动只加 `source` 参数 | 「复用同一段叠加语义,可提炼共用,不复制」;pay 包 import codes 单向无环 | +| **配置** | `PAY_BASE_URL` / `PAY_BIZ_SYSTEM`(默认 `pangolin`)/ `PAY_BIZ_SECRET`,在 `mountV1` 里 `os.Getenv`;`PAY_BASE_URL` 空则不挂载 /v1/pay(webhook 同) | 与 SMTP_HOST/WEBHOOK_SECRET 等既有可选特性同风格(`cmd/server/main.go` mountV1) | +| **价格展示** | 新端点 `GET /v1/pay/catalog` 下发三档(server catalog 单源);不复用 `/v1/plans`(plans 表一 code 一行,装不下月/季/年三档) | `migrations/000014` plans 只有单 price_cents/period;catalog 展示价与 pay 种子价一致性列入联调 checklist | +| **409 currency_mismatch** | server 原样映射为 409 `CURRENCY_MISMATCH`;client 收到后自动 `cancel 旧单 → 新 method 重新下单`(pay 契约:换结算币种必须新单) | pay `gateway.go` Retry 明确 409;`AuthApiException` 补 `code` 字段以便 client 分辨 | +| **qr 渲染** | 预留:显示 `display_amount` + 复制 `qr_content` 兜底文案,不引入 qr_flutter | scope 定义「qr 预留」;当面付非首发渠道 | + +## Global Constraints + +- **分支/worktree**:全部工作在本 worktree(`.claude/worktrees/pay-v2-integration`,基于 codes 集成分支)。**只加代码不动 codes 集成已有行为**;`todo/` 不在本计划内动。 +- **双方言铁律**(仓根 CLAUDE.md):SQL 一律 `?` 占位、时间 Go 端算好传参、禁 `NOW()`/`UTC_TIMESTAMP()`/`FIELD()`;upsert 走 `dialect.Upsert`、行锁走 `dialect.LockForUpdate()`;迁移 `migrations/{mysql,sqlite}/000021_*` 两套同号。 +- **文案脱敏**:所有用户可见文案(apierr 双语、Flutter l10n)禁「VPN/翻墙/科学上网」等红线词(CI `ci/scan-redline.sh` 扫描)。 +- **金额纪律**:金额一律 int64 最小单位(CNY=分,USDT=微);**客户端只传 sku+method,绝不传金额**;展示价仅 catalog,扣款额以 pay 为准。 +- **HMAC 契约逐字节照抄**(pay `internal/util/sign.go`):HMAC-SHA256,message = `strings.Join(parts, "\n")`,**std base64**(非 hex 非 url-safe);入站/出站 parts 均为 `[system, ts, nonce, rawBody]`;ts=unix 秒十进制字符串,窗口 ±300s。 +- **webhook ACK**:HTTP 200 且 body 含 `SUCCESS`(pay 大写包含判定),否则 pay 以 30s→1h 退避重投最多 12 次——**接收器必须重投安全**。 +- 每个 Task 结束:server 任务跑 `cd /Users/wangjia/code/pangolin/.claude/worktrees/pay-v2-integration/server && go build ./... && go vet ./... && go test ./...`;client 任务跑 `cd .../client && flutter analyze && flutter test`。全绿再 commit(每刀一 commit)。 +- **范围外**:退款接入、订阅(recurring)接入、旧 pangolin-pay 退役、IAP/广告、部署/发版、pay 侧任何代码改动。 + +--- + +## Task 0: Preflight — 现状锚点(全部已于 2026-07-10 逐文件核实,执行前抽查 2-3 条) + +**pay 侧契约锚点(`~/code/pay`,分支 design/pay-v2 @ c0c7ecc)** +- 下单:`POST /api/v2/orders`,body `{sku, method, biz_system?, biz_ref?, return_url?, metadata?}`(`handler/gateway.go:24-33` `createV2Request`);metadata 白名单**仅 `is_mobile`/`render`**(`gateway.go:38-41`);`biz_system!=""` 必验签(`order.go:68-93 verifyBizSign`:头 `X-Pay-System/X-Pay-Timestamp/X-Pay-Nonce/X-Pay-Sign`,±300s,`HMACVerify(secret, sign, system, ts, nonce, rawBody)`);body 上限 64KB。 +- 响应包络:成功 `{"data": ...}`,错误 `{"code","message"}`(`util/response.go`)。下单/retry 返回 `OrderResult{order_no, session{render_type, payload, expires_at?}}`;查单 `GET /api/v2/orders/:order_no` 返回 `{order_no, status, subject, amount_minor, currency, paid_at?}`(**无 session**——render payload 只在 create/retry 给)。 +- retry:`POST .../retry` body `{method, metadata?}`;跨币种 → **409 `currency_mismatch`**;非 pending → 409 `order_not_pending`。cancel:`POST .../cancel` → `{"data":{"canceled":bool}}`。改状态 POST 有 per-IP 限流(默认 30/min,429 `rate_limited`)。 +- 签名原语(`util/sign.go`):`HMACSign(secret, parts...) = base64.Std(HMAC-SHA256(secret, strings.Join(parts,"\n")))`。 +- webhook 出站(`webhook/notifier.go:142-191`):POST 到 biz CallbackURL,头 `X-Pay-System/X-Pay-Event/X-Pay-Timestamp/X-Pay-Nonce/X-Pay-Sign`,sign parts=`[bizSystem, ts, nonce, payloadJSON]`;**nonce 每次重投都是新 uuid**;ACK=200+body 含 "SUCCESS";退避 30s×2 上限 1h,maxAttempts 12。 +- `payment.succeeded` payload(`gateway/settle.go:84-95`):`{event_type, out_trade_no, biz_system, biz_ref, product_biz_code, amount_minor(int64), currency, channel, paid_at(RFC3339)}`——**无 refund_id 字段**。 +- render_type payload:`crypto_address` → `{address, amount(string 展示), amount_minor(int64), currency:"USDT", network:"TRC20", contract}`(`provider/crypto/crypto.go:223-235`);`redirect` → `{url}`(`provider/alipay/alipay.go:108-112`);`qr` → `{qr_content, display_amount(元 string), currency:"CNY"}`(`alipay.go:138-147`)。alipay:metadata `render=qr`→qr,`is_mobile` 真值→wap,否则 page。 +- 产品解析:sku=`products.biz_code`(active);crypto 结算 USDT **必须**有 `product_prices(product_id,'USDT',amount_minor)` 行;alipay CNY 可 fallback `products.price` 元字符串。 + +**pangolin server 锚点(本 worktree)** +- 迁移最高号 **000020**(`migrations/{mysql,sqlite}/000020_codes_lib_legacy_rename.*`;`internal/store/sqlite_migrate_test.go:31-33` 断言 `version = 20`)→ 本计划新增 **000021**。 +- `subscriptions` 表:`source ENUM('trial','code')`(mysql 000002)/ `TEXT CHECK (source IN ('trial','code'))`(sqlite 000002);索引 `idx_subs_user_exp/idx_user_exp`。 +- 叠加语义:`internal/codes/service.go:182-221 applySubscription(ctx, tx, userID, planID, durationDays)`——同 plan 活跃订阅取最晚一条 `max(expires,now)+days` 原地延长(`store.go:131 ExtendSubscription`),否则新建 `max(now, latestSamePlan)+days`(`store.go:155 CreateSubscription`,**source 硬编码 `'code'`**)。`store.go:93 GetPlanIDTx` / `store.go:186 WriteAuditLog` / `store.go:239 BeginTx`;`PlanCode` 常量 `PlanFree/PlanPro/PlanTeam`(store.go:13-20)。 +- 路由:chi;`cmd/server/main.go:222 mountV1(r chi.Router, sqlDB *sql.DB, rdb *redis.Client, nodeSvc *nodes.Service)`;webhook 免 JWT 挂 `v1.Post("/webhook/store/codes", ...)`(main.go:363);受保护组 `v1.Group(func(protected chi.Router){ protected.Use(auth.RequireAuth(tm)); ... })`;`getenvDefault` 助手已存在。 +- 鉴权取 uid:`auth.UserIDFromContext(ctx) (int64, bool)`(middleware 注入 `codes.CtxKeyUserID`);错误统一 `apierr.WriteJSON(w, status, *apierr.Error)`,`apierr.New(code, zh, en)`,预置 `ErrUnauthorized/ErrBadRequest/ErrNotFound/ErrInternal/ErrRateLimited`。 +- dialect:`internal/db/dialect.go`——`dbx.DialectForDB(db)`,`LockForUpdate()`(mysql `FOR UPDATE`/sqlite 空),`Upsert(conflictCols, setExprs...)`。 +- 出站 HTTP 范式:`internal/alert/notifier.go`(`http.Client{Timeout}` 存 struct + `NewRequestWithContext` + drain body + BaseURL 测试钩子)。 +- sqlite 测试底座:`internal/codes/sqlite_helper_test.go::openMigratedSQLite`(`store.Open(&config.Config{Driver:"sqlite",DSN:":memory:"})` + `store.MigrateUp(db,"sqlite")` + `store.ApplyCodesLibMigrations`)+ `seedUser(t, db, id)`;plans 三行由迁移种子。httptest 风格:`internal/codes/webhook_test.go`。 +- OpenAPI:CI 只结构校验 `design/server/openapi.yaml`(不校验与代码同步);`server/api/openapi.yaml` 为文档,Task 8 顺手补新端点。 + +**pangolin client 锚点** +- 导航:`lib/state/navigation_provider.dart` `enum NavView { connect, servers, stats, account, contact, settings, plans, redeem, devices }` + `kAccountSubViews`;4 壳 `lib/shell/{home,desktop,tablet,mobile}_shell.dart` 各有 `switch(view)`;移动端下钻用 `Navigator.push(MaterialPageRoute)`(`screens/account_page.dart:41-47 open()`)。 +- 组件真相源 `lib/widgets/`:`PlanCard{name, price, period, features, ctaLabel, featured, isCurrent, popularLabel, onPressed}`、`PangolinButton{label, onPressed, icon?, variant(primary/secondary/ghost/danger), expand}`、`showPangolinToast(context, msg)`、`ContentTopBar`、`PangolinIcons.{copy?, externalLink, checkCircle, creditCard, arrowLeft}`(copy 需 grep 确认,无则用 `LucideIcons.copy` 补进 `pangolin_icons.dart` 登记簿)、卡片=inline `Container(color: c.surface, radius PangolinRadius.lg, border c.border, shadow PangolinShadow.sm)` 配方(`account_page.dart::_Card`)、子页骨架 `_SubScaffold`(`widgets/account_screens.dart:25`,embedded 双态)。 +- 主题:`final c = context.pangolin;`(PangolinScheme:bg/surface/fg1-3/accent/border/success/danger…);文本 `PangolinText.{display,h1-3,body,sm,caption,mono}`;**金额/地址用 `PangolinText.mono`**。 +- 网络:`lib/services/api_client.dart` `ApiClient.getJson/postJson`(Bearer 自动注入,401 刷新重试一次,非 2xx 抛 `AuthApiException{statusCode, messageZh, messageEn}`——**无 code 字段,Task 6 补**);DI 在 `lib/state/account_providers.dart`(`apiClientProvider`/`accountApiProvider`/`meProvider(AsyncNotifier)`/`plansProvider`)。 +- l10n:`lib/l10n/app_text.dart` 抽象 getter 契约 + `strings_zh.dart`/`strings_en.dart` 双实现;**组件内禁写死字面量**。 +- 测试:`test/helpers/harness.dart::wrapThemed(child, {overrides})` + `setUpAll(disableGoogleFontsFetching)`;网络 mock 用 `package:http/testing.dart MockClient` 或 provider override;golden 在 `test/golden/desktop_pages_golden_test.dart`(本计划**不加新 golden**,取舍见 Self-Review)。 +- pubspec:有 `http/flutter_riverpod`;**无 url_launcher(Task 7 加)、无 qr_flutter(不加)**;剪贴板用 SDK `flutter/services.dart Clipboard`(零依赖)。 + +### Steps + +- [ ] **Step 1: 基线**——`cd /Users/wangjia/code/pangolin/.claude/worktrees/pay-v2-integration/server && go build ./... && go test ./...`;`cd ../client && flutter analyze && flutter test`。基线不绿先停(带病不开工)。 +- [ ] **Step 2: 抽查锚点**——任选 3 条(建议:pay `util/sign.go` 的 base64.Std、pangolin `codes/store.go` CreateSubscription 的 `'code'` 硬编码、client `AuthApiException` 无 code 字段)确认没漂移;漂移则回报调整计划。 + +--- + +## Task 1: 迁移 000021 — pay_purchases 台账 + subscriptions.source 扩 'pay'(双方言) + +**产出**:`server/migrations/{mysql,sqlite}/000021_pay_purchases.{up,down}.sql` 四个文件 + `sqlite_migrate_test.go` 断言更新。 + +### Steps + +- [ ] **Step 1: 先改测试(失败先行)**——`server/internal/store/sqlite_migrate_test.go`: + - `if v != 20` → `if v != 21`(两处字符串 `want 20` 同步)。 + - 表清单断言数组加入 `"pay_purchases"`。 + - 运行 `go test ./internal/store/ -run TestSQLiteMigrate -count=1` 确认红。 + +- [ ] **Step 2: 写迁移** + +`server/migrations/mysql/000021_pay_purchases.up.sql`: + +```sql +-- pay v2 接入:订阅来源扩 'pay' + 购买台账(biz_ref↔out_trade_no 映射 + webhook 幂等消费) +ALTER TABLE subscriptions MODIFY source ENUM('trial','code','pay') NOT NULL; + +CREATE TABLE pay_purchases ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT UNSIGNED NOT NULL, + biz_ref CHAR(36) NOT NULL, -- users.uuid 冗余(webhook 兜底定位) + sku VARCHAR(64) NOT NULL, -- pay products.biz_code + out_trade_no VARCHAR(64) NOT NULL UNIQUE, -- pay 订单号 = 幂等键 + method VARCHAR(32) NOT NULL, -- alipay | crypto(retry 换渠道时更新) + status ENUM('created','paid','canceled') NOT NULL DEFAULT 'created', + amount_minor BIGINT NOT NULL DEFAULT 0, -- webhook 回填,最小单位 + currency VARCHAR(16) NOT NULL DEFAULT '', + channel VARCHAR(32) NOT NULL DEFAULT '', + sub_id BIGINT UNSIGNED NULL, -- 开通/延长的 subscriptions.id + paid_at DATETIME(6) NULL, + created_at DATETIME(6) NOT NULL, + updated_at DATETIME(6) NOT NULL, + INDEX idx_pay_user (user_id, created_at), + FOREIGN KEY (user_id) REFERENCES users(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +``` + +`server/migrations/mysql/000021_pay_purchases.down.sql`: + +```sql +DROP TABLE IF EXISTS pay_purchases; +-- 注意:若已存在 source='pay' 的行,该 MODIFY 会失败——down 前需人工清理(标准 enum 收窄语义)。 +ALTER TABLE subscriptions MODIFY source ENUM('trial','code') NOT NULL; +``` + +`server/migrations/sqlite/000021_pay_purchases.up.sql`(SQLite CHECK 不可 ALTER → 重建表;显式拷 id 保 AUTOINCREMENT 序列): + +```sql +-- 订阅 source 扩 'pay':重建表(CHECK 约束不可 ALTER) +CREATE TABLE subscriptions_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + plan_id INTEGER NOT NULL, + expires_at DATETIME NOT NULL, + source TEXT NOT NULL CHECK (source IN ('trial','code','pay')), + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id), + FOREIGN KEY (plan_id) REFERENCES plans(id) +); +INSERT INTO subscriptions_new (id, user_id, plan_id, expires_at, source, created_at) + SELECT id, user_id, plan_id, expires_at, source, created_at FROM subscriptions; +DROP TABLE subscriptions; +ALTER TABLE subscriptions_new RENAME TO subscriptions; +CREATE INDEX idx_subs_user_exp ON subscriptions (user_id, expires_at); + +CREATE TABLE pay_purchases ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + biz_ref TEXT NOT NULL, + sku TEXT NOT NULL, + out_trade_no TEXT NOT NULL UNIQUE, + method TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'created' CHECK (status IN ('created','paid','canceled')), + amount_minor INTEGER NOT NULL DEFAULT 0, + currency TEXT NOT NULL DEFAULT '', + channel TEXT NOT NULL DEFAULT '', + sub_id INTEGER NULL, + paid_at DATETIME NULL, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) +); +CREATE INDEX idx_pay_user ON pay_purchases (user_id, created_at); +``` + +`server/migrations/sqlite/000021_pay_purchases.down.sql`(反向重建,'pay' 行会因 CHECK 失败——同 mysql 语义,down 前人工清理): + +```sql +DROP TABLE IF EXISTS pay_purchases; +CREATE TABLE subscriptions_old ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + plan_id INTEGER NOT NULL, + expires_at DATETIME NOT NULL, + source TEXT NOT NULL CHECK (source IN ('trial','code')), + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id), + FOREIGN KEY (plan_id) REFERENCES plans(id) +); +INSERT INTO subscriptions_old (id, user_id, plan_id, expires_at, source, created_at) + SELECT id, user_id, plan_id, expires_at, source, created_at FROM subscriptions; +DROP TABLE subscriptions; +ALTER TABLE subscriptions_old RENAME TO subscriptions; +CREATE INDEX idx_subs_user_exp ON subscriptions (user_id, expires_at); +``` + +- [ ] **Step 3: 验证 + commit**——`go test ./internal/store/ -count=1` 绿(up→down→up 幂等由既有测试覆盖);`go build ./... && go test ./...` 全绿;commit `feat(server): 迁移 000021 pay_purchases 台账 + subscriptions.source 扩 pay`。 + +--- + +## Task 2: codes 包提炼 — GrantPaidSubscriptionTx(叠加语义复用,source 参数化) + +**产出**:`internal/codes/store.go` `CreateSubscription` 加 `source` 参数;`service.go` `applySubscription` 加 `source` 参数(兑换路径传 `"code"`);新文件 `internal/codes/paygrant.go` 导出事务内授予方法;sqlite 测试。 + +### Steps + +- [ ] **Step 1: 失败测试**——新文件 `server/internal/codes/paygrant_sqlite_test.go`(复用 `sqlite_helper_test.go` 的 `openMigratedSQLite`/`seedUser`): + +```go +package codes_test + +import ( + "context" + "testing" + "time" + + "github.com/wangjia/pangolin/server/internal/codes" +) + +// 新用户:GrantPaidSubscriptionTx 新建一行 source='pay',到期 = now+days。 +func TestGrantPaidSubscription_NewRow(t *testing.T) { + db := openMigratedSQLite(t) + seedUser(t, db, 1) + store := codes.NewStore(db) + svc := codes.NewService(store, nil, 5, time.Hour) + ctx := context.Background() + + tx, err := store.BeginTx(ctx) + if err != nil { + t.Fatalf("BeginTx: %v", err) + } + subID, expiresAt, err := svc.GrantPaidSubscriptionTx(ctx, tx, 1, codes.PlanPro, 31, "order:test001") + if err != nil { + t.Fatalf("GrantPaidSubscriptionTx: %v", err) + } + if err := tx.Commit(); err != nil { + t.Fatalf("commit: %v", err) + } + if subID == 0 { + t.Fatal("subID = 0") + } + want := time.Now().UTC().AddDate(0, 0, 31) + if d := expiresAt.Sub(want); d > time.Minute || d < -time.Minute { + t.Errorf("expiresAt = %v, want ≈ %v", expiresAt, want) + } + var source string + if err := db.QueryRow(`SELECT source FROM subscriptions WHERE id = ?`, subID).Scan(&source); err != nil { + t.Fatalf("query source: %v", err) + } + if source != "pay" { + t.Errorf("source = %q, want pay", source) + } +} + +// 已有同 plan 活跃订阅:原地延长(行数不变,expires 累加),复用兑换码同一段叠加语义。 +func TestGrantPaidSubscription_StacksOnActive(t *testing.T) { + db := openMigratedSQLite(t) + seedUser(t, db, 1) + store := codes.NewStore(db) + svc := codes.NewService(store, nil, 5, time.Hour) + ctx := context.Background() + + grant := func(days int) time.Time { + tx, err := store.BeginTx(ctx) + if err != nil { + t.Fatalf("BeginTx: %v", err) + } + _, exp, err := svc.GrantPaidSubscriptionTx(ctx, tx, 1, codes.PlanPro, days, "order:test002") + if err != nil { + t.Fatalf("grant: %v", err) + } + if err := tx.Commit(); err != nil { + t.Fatalf("commit: %v", err) + } + return exp + } + first := grant(31) + second := grant(92) + + want := first.AddDate(0, 0, 92) + if d := second.Sub(want); d > time.Minute || d < -time.Minute { + t.Errorf("stacked expiresAt = %v, want ≈ %v", second, want) + } + var n int + if err := db.QueryRow(`SELECT COUNT(*) FROM subscriptions WHERE user_id = 1`).Scan(&n); err != nil { + t.Fatalf("count: %v", err) + } + if n != 1 { + t.Errorf("subscription rows = %d, want 1(原地延长)", n) + } +} +``` + +运行 `go test ./internal/codes/ -run TestGrantPaid -count=1` → 编译失败(方法不存在)即红。 + +- [ ] **Step 2: 实现** + 1. `store.go::CreateSubscription` 签名加尾参 `source string`,INSERT 改 `VALUES (?, ?, ?, ?, ?)` 并传 `source`(替换硬编码 `'code'`)。 + 2. `grep -rn "CreateSubscription(" server --include='*.go'` 找全部调用点:`service.go::applySubscription` 一处 + 可能的测试直调(如 `internal/store/sqlite_stores_test.go`)——全部补 `"code"` 实参。 + 3. `service.go::applySubscription` 签名加尾参 `source string`,透传给 `CreateSubscription`;`grantSubscription` 回调内调用处传 `"code"`。(**ExtendSubscription 路径不动 source**:行的 source 记录「创建者」,付费延长既有卡密行属预期,台账/审计有完整出处。) + 4. 新文件 `server/internal/codes/paygrant.go`: + +```go +package codes + +import ( + "context" + "database/sql" + "encoding/json" + "time" +) + +// GrantPaidSubscriptionTx 在调用方事务里执行支付驱动的订阅开通/续期。 +// 与兑换码走同一段叠加语义(applySubscription):同 plan 有活跃订阅则原地 +// 延长 max(expires,now)+days,否则新建 source='pay' 的一行。ref 为审计 +// 追踪串(如 "pay:"),审计写失败不影响事务(与兑换一致)。 +func (svc *Service) GrantPaidSubscriptionTx( + ctx context.Context, tx *sql.Tx, userID int64, plan PlanCode, days int, ref string, +) (subID int64, expiresAt time.Time, err error) { + planID, err := svc.store.GetPlanIDTx(ctx, tx, plan) + if err != nil { + return 0, time.Time{}, err + } + subID, expiresAt, err = svc.applySubscription(ctx, tx, userID, planID, days, "pay") + if err != nil { + return 0, time.Time{}, err + } + meta, _ := json.Marshal(map[string]any{ + "plan": string(plan), "duration_days": days, "sub_id": subID, + }) + _ = svc.store.WriteAuditLog(ctx, tx, formatUserActor(userID), "pay_grant", ref, string(meta)) + return subID, expiresAt, nil +} +``` + + > `formatUserActor`:若 `service.go` 里已有 `fmt.Sprintf("user:%d", userID)` 的内联写法而无助手,则在 paygrant.go 里直接 `fmt.Sprintf("user:%d", userID)`(import fmt),不强造助手。 + +- [ ] **Step 3: 验证 + commit**——`go build ./... && go test ./...` 全绿(既有兑换测试必须原样过——source 参数化行为零变化);commit `feat(server): codes.GrantPaidSubscriptionTx 提炼订阅叠加语义供 pay 复用`。 + +--- + +## Task 3: internal/pay — 签名原语 + 出站 PayClient(下单/查单/retry/cancel) + +**产出**:`server/internal/pay/{sign.go,client.go,client_test.go}`。签名逐字节照抄 pay `internal/util/sign.go`;测试用 httptest 起假 pay,验签语义照抄 `verifyBizSign`。 + +### Steps + +- [ ] **Step 1: 失败测试** `server/internal/pay/client_test.go`(包内测试,可用未导出符号): + +```go +package pay + +import ( + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "strconv" + "testing" + "time" +) + +const testSecret = "test-biz-secret" + +// fakePay 模拟 pay v2:create 按 pay verifyBizSign 语义验签(±300s+HMAC), +// 其余端点免签(与 pay 一致:仅 create 且 biz_system 非空时验签)。 +func fakePay(t *testing.T, handler http.HandlerFunc) *httptest.Server { + t.Helper() + srv := httptest.NewServer(handler) + t.Cleanup(srv.Close) + return srv +} + +func verifyCreateSign(t *testing.T, r *http.Request, body []byte) { + t.Helper() + system := r.Header.Get("X-Pay-System") + ts := r.Header.Get("X-Pay-Timestamp") + nonce := r.Header.Get("X-Pay-Nonce") + sign := r.Header.Get("X-Pay-Sign") + if system != "pangolin" || ts == "" || nonce == "" || sign == "" { + t.Fatalf("签名头缺失: system=%q ts=%q nonce=%q sign=%q", system, ts, nonce, sign) + } + tsi, err := strconv.ParseInt(ts, 10, 64) + if err != nil { + t.Fatalf("ts 非 unix 秒: %v", err) + } + if d := time.Now().Unix() - tsi; d > 300 || d < -300 { + t.Fatalf("ts 超 ±300s 窗口: %d", d) + } + if !hmacVerify(testSecret, sign, system, ts, nonce, string(body)) { + t.Fatal("HMAC 校验失败(须为 std base64 + \\n join)") + } +} + +func TestCreateOrder_SignsAndParses(t *testing.T) { + srv := fakePay(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != "/api/v2/orders" { + t.Fatalf("意外请求: %s %s", r.Method, r.URL.Path) + } + body, _ := io.ReadAll(r.Body) + verifyCreateSign(t, r, body) + var req map[string]any + _ = json.Unmarshal(body, &req) + if req["sku"] != "pro_month" || req["method"] != "crypto" || + req["biz_system"] != "pangolin" || req["biz_ref"] != "uuid-1" { + t.Fatalf("下单 body 不符: %v", req) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":{"order_no":"pay123","session":{ + "render_type":"crypto_address", + "payload":{"address":"Txx","amount":"4.201234","amount_minor":4201234, + "currency":"USDT","network":"TRC20","contract":"Tcc"}, + "expires_at":"2026-07-10T12:00:00Z"}}}`)) + }) + c := NewClient(srv.URL, "pangolin", testSecret) + res, err := c.CreateOrder(t.Context(), "pro_month", "crypto", "uuid-1", nil) + if err != nil { + t.Fatalf("CreateOrder: %v", err) + } + if res.OrderNo != "pay123" || res.Session.RenderType != "crypto_address" { + t.Fatalf("解析不符: %+v", res) + } + if got := res.Session.Payload["address"]; got != "Txx" { + t.Errorf("payload.address = %v", got) + } + if res.Session.ExpiresAt == nil { + t.Error("expires_at 未解析") + } +} + +func TestGetOrder_ParsesStatus(t *testing.T) { + srv := fakePay(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v2/orders/pay123" { + t.Fatalf("path = %s", r.URL.Path) + } + _, _ = w.Write([]byte(`{"data":{"order_no":"pay123","status":"succeeded", + "subject":"Pangolin 专业版·月付","amount_minor":4201234,"currency":"USDT", + "paid_at":"2026-07-10T11:00:00Z"}}`)) + }) + c := NewClient(srv.URL, "pangolin", testSecret) + st, err := c.GetOrder(t.Context(), "pay123") + if err != nil { + t.Fatalf("GetOrder: %v", err) + } + if st.Status != "succeeded" || st.AmountMinor != 4201234 || st.PaidAt == nil { + t.Fatalf("解析不符: %+v", st) + } +} + +func TestRetry_CurrencyMismatchMapsToError(t *testing.T) { + srv := fakePay(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v2/orders/pay123/retry" { + t.Fatalf("path = %s", r.URL.Path) + } + w.WriteHeader(http.StatusConflict) + _, _ = w.Write([]byte(`{"code":"currency_mismatch","message":"换渠道需新单"}`)) + }) + c := NewClient(srv.URL, "pangolin", testSecret) + _, err := c.Retry(t.Context(), "pay123", "alipay", nil) + var pe *Error + if !errors.As(err, &pe) || pe.Code != "currency_mismatch" || pe.HTTPStatus != http.StatusConflict { + t.Fatalf("err = %v, want *pay.Error{409 currency_mismatch}", err) + } +} + +func TestCancel(t *testing.T) { + srv := fakePay(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v2/orders/pay123/cancel" { + t.Fatalf("path = %s", r.URL.Path) + } + _, _ = w.Write([]byte(`{"data":{"canceled":true}}`)) + }) + c := NewClient(srv.URL, "pangolin", testSecret) + ok, err := c.Cancel(t.Context(), "pay123") + if err != nil || !ok { + t.Fatalf("Cancel = %v, %v", ok, err) + } +} +``` + +> 若 Go 版本 `t.Context()` 不可用(需 go1.24+;本仓 golang:1.25 应可用),退回 `context.Background()`。 + +- [ ] **Step 2: 实现** `server/internal/pay/sign.go`: + +```go +// Package pay 实现 pangolin 作为业务方接入 pay v2 统一支付网关:出站客户端 +// (签名下单/查单/换渠道/取消)、购买台账、App 代理端点与 payment.succeeded +// webhook 接收器。契约真相源:pay 仓 design/pay-v2 分支(见计划文档头)。 +package pay + +import ( + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "strings" +) + +// hmacSign 逐字节照抄 pay internal/util/sign.go::HMACSign: +// HMAC-SHA256(secret, strings.Join(parts, "\n")) → 标准 base64(非 hex/url-safe)。 +func hmacSign(secret string, parts ...string) string { + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write([]byte(strings.Join(parts, "\n"))) + return base64.StdEncoding.EncodeToString(mac.Sum(nil)) +} + +func hmacVerify(secret, sig string, parts ...string) bool { + expected := hmacSign(secret, parts...) + return hmac.Equal([]byte(expected), []byte(sig)) +} + +// newNonce 返回 32 字符随机 hex(不新增 uuid 依赖)。 +func newNonce() string { + b := make([]byte, 16) + _, _ = rand.Read(b) + return hex.EncodeToString(b) +} +``` + +`server/internal/pay/client.go`: + +```go +package pay + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" +) + +// Session 是 pay 下单/retry 返回的渲染会话(render_type 多态,payload 原样透传给客户端)。 +type Session struct { + RenderType string `json:"render_type"` + Payload map[string]any `json:"payload"` + ExpiresAt *time.Time `json:"expires_at,omitempty"` +} + +// OrderResult 对应 pay gateway.OrderResult。 +type OrderResult struct { + OrderNo string `json:"order_no"` + Session Session `json:"session"` +} + +// OrderStatus 对应 pay gateway.OrderStatusView(查单无 session)。 +type OrderStatus struct { + OrderNo string `json:"order_no"` + Status string `json:"status"` + Subject string `json:"subject"` + AmountMinor int64 `json:"amount_minor"` + Currency string `json:"currency"` + PaidAt *time.Time `json:"paid_at,omitempty"` +} + +// Error 是 pay 网关的业务错误({code,message} 包络 + HTTP 状态)。 +type Error struct { + HTTPStatus int `json:"-"` + Code string `json:"code"` + Message string `json:"message"` +} + +func (e *Error) Error() string { + return fmt.Sprintf("pay: HTTP %d %s: %s", e.HTTPStatus, e.Code, e.Message) +} + +// Client 是 pay v2 出站客户端。仅 CreateOrder 需业务方 HMAC 签名 +// (pay 只在 create 且 biz_system 非空时验签;查单/retry/cancel 以 +// order_no 为持有凭据,pay 侧免签 + 改状态端点 per-IP 限流)。 +type Client struct { + baseURL string + system string + secret string + hc *http.Client + now func() time.Time // 测试注入 +} + +func NewClient(baseURL, system, secret string) *Client { + return &Client{ + baseURL: strings.TrimRight(baseURL, "/"), + system: system, + secret: secret, + hc: &http.Client{Timeout: 15 * time.Second}, + now: time.Now, + } +} + +type createOrderReq struct { + SKU string `json:"sku"` + Method string `json:"method"` + BizSystem string `json:"biz_system"` + BizRef string `json:"biz_ref"` + ReturnURL string `json:"return_url,omitempty"` + Metadata map[string]string `json:"metadata,omitempty"` +} + +// CreateOrder 签名下单。metadata 只应含 pay 白名单键(is_mobile/render), +// 由 handler 层过滤;金额永远不出现在请求里(pay 按 sku+结算币种定价)。 +func (c *Client) CreateOrder(ctx context.Context, sku, method, bizRef string, metadata map[string]string) (*OrderResult, error) { + body, err := json.Marshal(createOrderReq{ + SKU: sku, Method: method, BizSystem: c.system, BizRef: bizRef, Metadata: metadata, + }) + if err != nil { + return nil, err + } + var out OrderResult + if err := c.do(ctx, http.MethodPost, "/api/v2/orders", body, true, &out); err != nil { + return nil, err + } + return &out, nil +} + +func (c *Client) GetOrder(ctx context.Context, orderNo string) (*OrderStatus, error) { + var out OrderStatus + if err := c.do(ctx, http.MethodGet, "/api/v2/orders/"+url.PathEscape(orderNo), nil, false, &out); err != nil { + return nil, err + } + return &out, nil +} + +type retryReq struct { + Method string `json:"method"` + Metadata map[string]string `json:"metadata,omitempty"` +} + +// Retry 换支付方式重试。跨结算币种 → pay 回 409 currency_mismatch +// (*Error),调用方按「取消旧单 + 新单」处理。 +func (c *Client) Retry(ctx context.Context, orderNo, method string, metadata map[string]string) (*OrderResult, error) { + body, err := json.Marshal(retryReq{Method: method, Metadata: metadata}) + if err != nil { + return nil, err + } + var out OrderResult + if err := c.do(ctx, http.MethodPost, "/api/v2/orders/"+url.PathEscape(orderNo)+"/retry", body, false, &out); err != nil { + return nil, err + } + return &out, nil +} + +func (c *Client) Cancel(ctx context.Context, orderNo string) (bool, error) { + var out struct { + Canceled bool `json:"canceled"` + } + if err := c.do(ctx, http.MethodPost, "/api/v2/orders/"+url.PathEscape(orderNo)+"/cancel", nil, false, &out); err != nil { + return false, err + } + return out.Canceled, nil +} + +// do 发送请求并解 {"data":...} 成功包络 / {code,message} 错误包络。 +func (c *Client) do(ctx context.Context, httpMethod, path string, body []byte, signed bool, out any) error { + req, err := http.NewRequestWithContext(ctx, httpMethod, c.baseURL+path, bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + if signed { + ts := strconv.FormatInt(c.now().Unix(), 10) + nonce := newNonce() + req.Header.Set("X-Pay-System", c.system) + req.Header.Set("X-Pay-Timestamp", ts) + req.Header.Set("X-Pay-Nonce", nonce) + req.Header.Set("X-Pay-Sign", hmacSign(c.secret, c.system, ts, nonce, string(body))) + } + resp, err := c.hc.Do(req) + if err != nil { + return err + } + rb, readErr := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + _ = resp.Body.Close() + if readErr != nil { + return readErr + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + pe := &Error{HTTPStatus: resp.StatusCode} + if json.Unmarshal(rb, pe) != nil || pe.Code == "" { + pe.Code = "upstream_error" + pe.Message = strings.TrimSpace(string(rb)) + } + return pe + } + if out == nil { + return nil + } + var env struct { + Data json.RawMessage `json:"data"` + } + if err := json.Unmarshal(rb, &env); err != nil { + return fmt.Errorf("pay: 解析响应包络: %w", err) + } + return json.Unmarshal(env.Data, out) +} +``` + +- [ ] **Step 3: 验证 + commit**——`go test ./internal/pay/ -count=1` 绿;`go build ./... && go vet ./... && go test ./...` 全绿;commit `feat(server): pay v2 出站客户端(HMAC 签名下单/查单/retry/cancel)`。 + +--- + +## Task 4: internal/pay — catalog + 购买台账 Store + App 代理端点 + mountV1 接线 + +**产出**:`server/internal/pay/{catalog.go,store.go,handler.go,handler_test.go,testutil_test.go}` + `cmd/server/main.go` 接线。端点(全部 JWT 保护):`GET /v1/pay/catalog`、`POST /v1/pay/orders`、`GET /v1/pay/orders/{orderNo}`、`POST /v1/pay/orders/{orderNo}/retry`、`POST /v1/pay/orders/{orderNo}/cancel`;webhook 路由留 Task 5。 + +### Steps + +- [ ] **Step 1: 失败测试**——先建 `server/internal/pay/testutil_test.go`(sqlite 底座,仿 `internal/codes/sqlite_helper_test.go`,但 pay 是包内测试): + +```go +package pay + +import ( + "context" + "database/sql" + "testing" + "time" + + "github.com/wangjia/pangolin/server/internal/config" + "github.com/wangjia/pangolin/server/internal/store" +) + +func openMigratedSQLite(t *testing.T) *sql.DB { + t.Helper() + db, err := store.Open(&config.Config{Driver: "sqlite", DSN: ":memory:"}) + if err != nil { + t.Fatalf("open: %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + if err := store.MigrateUp(db, "sqlite"); err != nil { + t.Fatalf("migrate: %v", err) + } + if err := store.ApplyCodesLibMigrations(context.Background(), db, "sqlite"); err != nil { + t.Fatalf("lib migrate: %v", err) + } + return db +} + +// seedUser 造最小 users 行(列以 codes/sqlite_helper_test.go::seedUser 为准, +// 实现时照抄那份 INSERT——含 uuid/email/pw_hash/dp_uuid 等 NOT NULL 列)。 +func seedUser(t *testing.T, db *sql.DB, id int64, uuid string) { + t.Helper() + _, err := db.Exec( + `INSERT INTO users (id, uuid, email, pw_hash, dp_uuid, status, created_at) + VALUES (?, ?, ?, 'x', ?, 'active', ?)`, + id, uuid, uuid+"@t.local", uuid, time.Now().UTC()) + if err != nil { + t.Fatalf("seedUser: %v", err) + } +} +``` + +再写 `server/internal/pay/handler_test.go`: + +```go +package pay + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/go-chi/chi/v5" + + "github.com/wangjia/pangolin/server/internal/codes" +) + +// newHandlerRig: 假 pay + sqlite 台账 + chi 路由(URL 参数解析需要真实路由)。 +func newHandlerRig(t *testing.T, payFn http.HandlerFunc) (*chi.Mux, *Store) { + t.Helper() + db := openMigratedSQLite(t) + seedUser(t, db, 1, "uuid-1") + seedUser(t, db, 2, "uuid-2") + srv := fakePay(t, payFn) + st := NewStore(db) + h := NewHandler(NewClient(srv.URL, "pangolin", testSecret), st, db) + r := chi.NewRouter() + r.Get("/v1/pay/catalog", h.Catalog) + r.Post("/v1/pay/orders", h.CreateOrder) + r.Get("/v1/pay/orders/{orderNo}", h.GetOrder) + r.Post("/v1/pay/orders/{orderNo}/retry", h.Retry) + r.Post("/v1/pay/orders/{orderNo}/cancel", h.Cancel) + return r, st +} + +// authed 注入 uid(auth.RequireAuth 注入的就是 codes.CtxKeyUserID)。 +func authed(r *http.Request, uid int64) *http.Request { + return r.WithContext(context.WithValue(r.Context(), codes.CtxKeyUserID, uid)) +} + +func TestCreateOrder_ProxiesAndRecords(t *testing.T) { + router, st := newHandlerRig(t, func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"data":{"order_no":"pay001","session":{ + "render_type":"redirect","payload":{"url":"https://alipay.example/x"}}}}`)) + }) + body := []byte(`{"sku":"pro_month","method":"alipay","metadata":{"is_mobile":"1","evil":"x"}}`) + req := authed(httptest.NewRequest(http.MethodPost, "/v1/pay/orders", bytes.NewReader(body)), 1) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("code = %d, body = %s", w.Code, w.Body) + } + var resp struct { + OrderNo string `json:"order_no"` + Session Session `json:"session"` + } + _ = json.Unmarshal(w.Body.Bytes(), &resp) + if resp.OrderNo != "pay001" || resp.Session.RenderType != "redirect" { + t.Fatalf("resp = %+v", resp) + } + row, err := st.GetForUser(context.Background(), 1, "pay001") + if err != nil { + t.Fatalf("台账未落: %v", err) + } + if row.SKU != "pro_month" || row.BizRef != "uuid-1" || row.Status != "created" { + t.Fatalf("台账行不符: %+v", row) + } +} + +func TestCreateOrder_UnknownSKU400(t *testing.T) { + router, _ := newHandlerRig(t, func(w http.ResponseWriter, r *http.Request) { + t.Fatal("不该打到 pay") + }) + body := []byte(`{"sku":"pro_lifetime","method":"alipay"}`) + req := authed(httptest.NewRequest(http.MethodPost, "/v1/pay/orders", bytes.NewReader(body)), 1) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != http.StatusBadRequest { + t.Fatalf("code = %d", w.Code) + } +} + +func TestGetOrder_OwnershipEnforced(t *testing.T) { + router, st := newHandlerRig(t, func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"data":{"order_no":"pay001","status":"pending", + "subject":"s","amount_minor":2999,"currency":"CNY"}}`)) + }) + if err := st.Insert(context.Background(), 1, "uuid-1", "pro_month", "pay001", "alipay"); err != nil { + t.Fatal(err) + } + // 属主可查 + w := httptest.NewRecorder() + router.ServeHTTP(w, authed(httptest.NewRequest(http.MethodGet, "/v1/pay/orders/pay001", nil), 1)) + if w.Code != http.StatusOK { + t.Fatalf("owner code = %d", w.Code) + } + var resp struct { + PayStatus string `json:"pay_status"` + Activated bool `json:"activated"` + } + _ = json.Unmarshal(w.Body.Bytes(), &resp) + if resp.PayStatus != "pending" || resp.Activated { + t.Fatalf("resp = %+v", resp) + } + // 他人 404 + w2 := httptest.NewRecorder() + router.ServeHTTP(w2, authed(httptest.NewRequest(http.MethodGet, "/v1/pay/orders/pay001", nil), 2)) + if w2.Code != http.StatusNotFound { + t.Fatalf("other code = %d", w2.Code) + } +} + +func TestRetry_CurrencyMismatchMapped409(t *testing.T) { + router, st := newHandlerRig(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusConflict) + _, _ = w.Write([]byte(`{"code":"currency_mismatch","message":"换渠道需新单"}`)) + }) + _ = st.Insert(context.Background(), 1, "uuid-1", "pro_month", "pay001", "crypto") + body := []byte(`{"method":"alipay"}`) + w := httptest.NewRecorder() + router.ServeHTTP(w, authed(httptest.NewRequest(http.MethodPost, "/v1/pay/orders/pay001/retry", bytes.NewReader(body)), 1)) + if w.Code != http.StatusConflict { + t.Fatalf("code = %d", w.Code) + } + var e struct { + Code string `json:"code"` + } + _ = json.Unmarshal(w.Body.Bytes(), &e) + if e.Code != "CURRENCY_MISMATCH" { + t.Fatalf("code = %q, want CURRENCY_MISMATCH", e.Code) + } +} + +func TestCatalog(t *testing.T) { + router, _ := newHandlerRig(t, nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, authed(httptest.NewRequest(http.MethodGet, "/v1/pay/catalog", nil), 1)) + var resp struct { + Items []CatalogItem `json:"items"` + } + _ = json.Unmarshal(w.Body.Bytes(), &resp) + if len(resp.Items) != 3 || resp.Items[0].SKU != "pro_month" { + t.Fatalf("catalog = %+v", resp.Items) + } +} +``` + +> 注:`newHandlerRig(t, nil)` 会在 `fakePay` 里传 nil handler——给 `TestCatalog` 单独 new 一个不发请求的 rig 或传空 handler `func(http.ResponseWriter,*http.Request){}`,实现时取后者。 + +- [ ] **Step 2: 实现** `server/internal/pay/catalog.go`: + +```go +package pay + +import "github.com/wangjia/pangolin/server/internal/codes" + +// CatalogItem 是可购档位。SKU 与 pay 侧 products.biz_code 一一对应 +// (webhook product_biz_code 原样回带);PriceMinor 仅展示(CNY 分), +// 实际扣款以 pay 侧 ProductPrice/price 为准——一致性列入联调 checklist。 +type CatalogItem struct { + SKU string `json:"sku"` + Plan string `json:"plan"` + Days int `json:"days"` + PriceMinor int64 `json:"price_minor"` + Currency string `json:"currency"` +} + +// Catalog 三档单源。时长取宽松口径(31/92/366 覆盖大月与最长季)。 +var Catalog = []CatalogItem{ + {SKU: "pro_month", Plan: string(codes.PlanPro), Days: 31, PriceMinor: 2999, Currency: "CNY"}, + {SKU: "pro_quarter", Plan: string(codes.PlanPro), Days: 92, PriceMinor: 6888, Currency: "CNY"}, + {SKU: "pro_year", Plan: string(codes.PlanPro), Days: 366, PriceMinor: 19999, Currency: "CNY"}, +} + +func CatalogBySKU(sku string) (CatalogItem, bool) { + for _, it := range Catalog { + if it.SKU == sku { + return it, true + } + } + return CatalogItem{}, false +} +``` + +`server/internal/pay/store.go`(台账;时间全部 Go 端算好传 `?`,行锁走 `dialect.LockForUpdate()`): + +```go +package pay + +import ( + "context" + "database/sql" + "fmt" + "time" + + dbx "github.com/wangjia/pangolin/server/internal/db" +) + +// PurchaseRow 是 pay_purchases 一行:biz_ref↔out_trade_no 映射 + webhook 幂等台账。 +type PurchaseRow struct { + ID int64 + UserID int64 + BizRef string + SKU string + OutTradeNo string + Method string + Status string // created | paid | canceled + AmountMinor int64 + Currency string + Channel string + SubID sql.NullInt64 + PaidAt sql.NullTime +} + +type Store struct { + db *sql.DB + dialect dbx.Dialect +} + +func NewStore(db *sql.DB) *Store { + return &Store{db: db, dialect: dbx.DialectForDB(db)} +} + +func (s *Store) BeginTx(ctx context.Context) (*sql.Tx, error) { + return s.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted}) +} + +// Insert 下单成功后落台账(status=created)。 +func (s *Store) Insert(ctx context.Context, userID int64, bizRef, sku, outTradeNo, method string) error { + now := time.Now().UTC() + _, err := s.db.ExecContext(ctx, + `INSERT INTO pay_purchases (user_id, biz_ref, sku, out_trade_no, method, status, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, 'created', ?, ?)`, + userID, bizRef, sku, outTradeNo, method, now, now) + if err != nil { + return fmt.Errorf("pay.Store.Insert: %w", err) + } + return nil +} + +const purchaseCols = `id, user_id, biz_ref, sku, out_trade_no, method, status, + amount_minor, currency, channel, sub_id, paid_at` + +func scanPurchase(row *sql.Row) (*PurchaseRow, error) { + var p PurchaseRow + if err := row.Scan(&p.ID, &p.UserID, &p.BizRef, &p.SKU, &p.OutTradeNo, &p.Method, + &p.Status, &p.AmountMinor, &p.Currency, &p.Channel, &p.SubID, &p.PaidAt); err != nil { + return nil, err + } + return &p, nil +} + +// GetForUser 按 (userID, outTradeNo) 取行——所有权校验由查询本身完成。 +func (s *Store) GetForUser(ctx context.Context, userID int64, outTradeNo string) (*PurchaseRow, error) { + return scanPurchase(s.db.QueryRowContext(ctx, + `SELECT `+purchaseCols+` FROM pay_purchases WHERE user_id = ? AND out_trade_no = ?`, + userID, outTradeNo)) +} + +// LockByOutTradeNoTx 事务内锁行(mysql FOR UPDATE;sqlite 空后缀,靠 +// _txlock=immediate 串行化——与 codes 兑换同一套悲观语义)。 +func (s *Store) LockByOutTradeNoTx(ctx context.Context, tx *sql.Tx, outTradeNo string) (*PurchaseRow, error) { + q := `SELECT ` + purchaseCols + ` FROM pay_purchases WHERE out_trade_no = ? ` + s.dialect.LockForUpdate() + return scanPurchase(tx.QueryRowContext(ctx, q, outTradeNo)) +} + +// InsertFromWebhookTx 兜底补台账(下单后本地写失败的孤儿单,webhook 按 biz_ref 修复)。 +func (s *Store) InsertFromWebhookTx(ctx context.Context, tx *sql.Tx, userID int64, bizRef, sku, outTradeNo, channel string) (int64, error) { + now := time.Now().UTC() + res, err := tx.ExecContext(ctx, + `INSERT INTO pay_purchases (user_id, biz_ref, sku, out_trade_no, method, status, channel, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, 'created', ?, ?, ?)`, + userID, bizRef, sku, outTradeNo, channel, channel, now, now) + if err != nil { + return 0, fmt.Errorf("pay.Store.InsertFromWebhookTx: %w", err) + } + id, _ := res.LastInsertId() + return id, nil +} + +// MarkPaidTx 台账翻转 →paid 并回填结算信息(幂等判定已在锁内完成,直写)。 +func (s *Store) MarkPaidTx(ctx context.Context, tx *sql.Tx, id int64, amountMinor int64, currency, channel string, subID int64, paidAt time.Time) error { + _, err := tx.ExecContext(ctx, + `UPDATE pay_purchases SET status = 'paid', amount_minor = ?, currency = ?, + channel = ?, sub_id = ?, paid_at = ?, updated_at = ? + WHERE id = ?`, + amountMinor, currency, channel, subID, paidAt, time.Now().UTC(), id) + if err != nil { + return fmt.Errorf("pay.Store.MarkPaidTx: %w", err) + } + return nil +} + +// UpdateMethod retry 换渠道成功后同步台账(仅未支付单)。 +func (s *Store) UpdateMethod(ctx context.Context, userID int64, outTradeNo, method string) error { + _, err := s.db.ExecContext(ctx, + `UPDATE pay_purchases SET method = ?, updated_at = ? + WHERE user_id = ? AND out_trade_no = ? AND status = 'created'`, + method, time.Now().UTC(), userID, outTradeNo) + if err != nil { + return fmt.Errorf("pay.Store.UpdateMethod: %w", err) + } + return nil +} + +// MarkCanceled 仅未支付单可取消(paid 行不动——钱已收,开通不回退)。 +func (s *Store) MarkCanceled(ctx context.Context, userID int64, outTradeNo string) error { + _, err := s.db.ExecContext(ctx, + `UPDATE pay_purchases SET status = 'canceled', updated_at = ? + WHERE user_id = ? AND out_trade_no = ? AND status = 'created'`, + time.Now().UTC(), userID, outTradeNo) + if err != nil { + return fmt.Errorf("pay.Store.MarkCanceled: %w", err) + } + return nil +} + +// SubscriptionExpiry 查开通行的到期时间(查单响应回带给客户端)。 +func (s *Store) SubscriptionExpiry(ctx context.Context, subID int64) (time.Time, error) { + var exp time.Time + err := s.db.QueryRowContext(ctx, + `SELECT expires_at FROM subscriptions WHERE id = ?`, subID).Scan(&exp) + return exp, err +} +``` + +`server/internal/pay/handler.go`: + +```go +package pay + +import ( + "database/sql" + "encoding/json" + "errors" + "io" + "log/slog" + "net/http" + "time" + + "github.com/go-chi/chi/v5" + + "github.com/wangjia/pangolin/server/internal/apierr" + "github.com/wangjia/pangolin/server/internal/auth" +) + +// Handler 是面向 App 的下单代理(JWT 保护;user→biz_ref 映射在 server 侧, +// 客户端只传 sku+method+端型 metadata,永远不传金额)。 +type Handler struct { + client *Client + store *Store + db *sql.DB +} + +func NewHandler(client *Client, store *Store, db *sql.DB) *Handler { + return &Handler{client: client, store: store, db: db} +} + +// allowedMetadataKeys 与 pay gateway.go 白名单一致(is_mobile/render)。 +var allowedMetadataKeys = map[string]bool{"is_mobile": true, "render": true} + +func filterMetadata(in map[string]string) map[string]string { + if len(in) == 0 { + return nil + } + out := make(map[string]string, len(in)) + for k, v := range in { + if allowedMetadataKeys[k] { + out[k] = v + } + } + if len(out) == 0 { + return nil + } + return out +} + +func writeJSON(w http.ResponseWriter, v any) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(v) +} + +// writePayErr 把 pay 网关错误映射为 apierr(脱敏双语;code 透传供客户端分辨)。 +func writePayErr(w http.ResponseWriter, err error) { + var pe *Error + if !errors.As(err, &pe) { + apierr.WriteJSON(w, http.StatusBadGateway, + apierr.New("PAY_UPSTREAM", "支付服务暂不可用,请稍后重试", "Payment service unavailable, please retry later")) + return + } + switch pe.Code { + case "currency_mismatch": + apierr.WriteJSON(w, http.StatusConflict, + apierr.New("CURRENCY_MISMATCH", "该支付方式结算币种与订单不符,请重新下单", "Settlement currency mismatch, please create a new order")) + case "order_not_pending": + apierr.WriteJSON(w, http.StatusConflict, + apierr.New("ORDER_NOT_PENDING", "订单状态已变化,请刷新后重试", "Order is no longer pending")) + case "order_not_found", "product_not_found": + apierr.WriteJSON(w, http.StatusNotFound, apierr.ErrNotFound) + case "rate_limited": + apierr.WriteJSON(w, http.StatusTooManyRequests, apierr.ErrRateLimited) + case "unknown_method", "bad_request", "method_not_recurring": + apierr.WriteJSON(w, http.StatusBadRequest, apierr.ErrBadRequest) + default: // no_account / no_settle_currency / create_failed / upstream_error… + apierr.WriteJSON(w, http.StatusBadGateway, + apierr.New("PAY_UPSTREAM", "支付服务暂不可用,请稍后重试", "Payment service unavailable, please retry later")) + } +} + +// ─── GET /v1/pay/catalog ──────────────────────────────────────────────────── + +func (h *Handler) Catalog(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, map[string]any{"items": Catalog}) +} + +// ─── POST /v1/pay/orders ──────────────────────────────────────────────────── + +type createOrderRequest struct { + SKU string `json:"sku"` + Method string `json:"method"` + Metadata map[string]string `json:"metadata,omitempty"` +} + +type sessionResponse struct { + OrderNo string `json:"order_no"` + Session Session `json:"session"` +} + +func (h *Handler) CreateOrder(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + uid, ok := auth.UserIDFromContext(ctx) + if !ok { + apierr.WriteJSON(w, http.StatusUnauthorized, apierr.ErrUnauthorized) + return + } + var req createOrderRequest + if err := json.NewDecoder(io.LimitReader(r.Body, 16<<10)).Decode(&req); err != nil { + apierr.WriteJSON(w, http.StatusBadRequest, apierr.ErrBadRequest) + return + } + if _, ok := CatalogBySKU(req.SKU); !ok || req.Method == "" { + apierr.WriteJSON(w, http.StatusBadRequest, apierr.ErrBadRequest) + return + } + var bizRef string + err := h.db.QueryRowContext(ctx, + `SELECT uuid FROM users WHERE id = ? AND status = 'active'`, uid).Scan(&bizRef) + if err == sql.ErrNoRows { + apierr.WriteJSON(w, http.StatusNotFound, apierr.ErrNotFound) + return + } else if err != nil { + apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal) + return + } + + res, err := h.client.CreateOrder(ctx, req.SKU, req.Method, bizRef, filterMetadata(req.Metadata)) + if err != nil { + writePayErr(w, err) + return + } + if err := h.store.Insert(ctx, uid, bizRef, req.SKU, res.OrderNo, req.Method); err != nil { + // 不吞单:webhook 会按 biz_ref 兜底补台账,这里记日志便于追查。 + slog.Error("pay: 台账写入失败(webhook 将按 biz_ref 兜底)", "order_no", res.OrderNo, "err", err) + } + writeJSON(w, sessionResponse{OrderNo: res.OrderNo, Session: res.Session}) +} + +// ─── GET /v1/pay/orders/{orderNo} ─────────────────────────────────────────── + +type orderStatusResponse struct { + OrderNo string `json:"order_no"` + PayStatus string `json:"pay_status"` // pay 侧状态词汇原样透传 + Activated bool `json:"activated"` // 本地台账已消费(权益已开通)——客户端轮询以此为成功判据 + ExpiresAt *string `json:"expires_at,omitempty"` +} + +func (h *Handler) GetOrder(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + uid, ok := auth.UserIDFromContext(ctx) + if !ok { + apierr.WriteJSON(w, http.StatusUnauthorized, apierr.ErrUnauthorized) + return + } + orderNo := chi.URLParam(r, "orderNo") + row, err := h.store.GetForUser(ctx, uid, orderNo) + if err == sql.ErrNoRows { + apierr.WriteJSON(w, http.StatusNotFound, apierr.ErrNotFound) + return + } else if err != nil { + apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal) + return + } + st, err := h.client.GetOrder(ctx, orderNo) + if err != nil { + writePayErr(w, err) + return + } + resp := orderStatusResponse{OrderNo: orderNo, PayStatus: st.Status, Activated: row.Status == "paid"} + if row.SubID.Valid { + if exp, err := h.store.SubscriptionExpiry(ctx, row.SubID.Int64); err == nil { + s := exp.UTC().Format(time.RFC3339) + resp.ExpiresAt = &s + } + } + writeJSON(w, resp) +} + +// ─── POST /v1/pay/orders/{orderNo}/retry ──────────────────────────────────── + +type retryOrderRequest struct { + Method string `json:"method"` + Metadata map[string]string `json:"metadata,omitempty"` +} + +func (h *Handler) Retry(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + uid, ok := auth.UserIDFromContext(ctx) + if !ok { + apierr.WriteJSON(w, http.StatusUnauthorized, apierr.ErrUnauthorized) + return + } + orderNo := chi.URLParam(r, "orderNo") + var req retryOrderRequest + if err := json.NewDecoder(io.LimitReader(r.Body, 16<<10)).Decode(&req); err != nil || req.Method == "" { + apierr.WriteJSON(w, http.StatusBadRequest, apierr.ErrBadRequest) + return + } + if _, err := h.store.GetForUser(ctx, uid, orderNo); err == sql.ErrNoRows { + apierr.WriteJSON(w, http.StatusNotFound, apierr.ErrNotFound) + return + } else if err != nil { + apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal) + return + } + res, err := h.client.Retry(ctx, orderNo, req.Method, filterMetadata(req.Metadata)) + if err != nil { + writePayErr(w, err) + return + } + _ = h.store.UpdateMethod(ctx, uid, orderNo, req.Method) + writeJSON(w, sessionResponse{OrderNo: res.OrderNo, Session: res.Session}) +} + +// ─── POST /v1/pay/orders/{orderNo}/cancel ─────────────────────────────────── + +func (h *Handler) Cancel(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + uid, ok := auth.UserIDFromContext(ctx) + if !ok { + apierr.WriteJSON(w, http.StatusUnauthorized, apierr.ErrUnauthorized) + return + } + orderNo := chi.URLParam(r, "orderNo") + if _, err := h.store.GetForUser(ctx, uid, orderNo); err == sql.ErrNoRows { + apierr.WriteJSON(w, http.StatusNotFound, apierr.ErrNotFound) + return + } else if err != nil { + apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal) + return + } + canceled, err := h.client.Cancel(ctx, orderNo) + if err != nil { + writePayErr(w, err) + return + } + if canceled { + _ = h.store.MarkCanceled(ctx, uid, orderNo) + } + writeJSON(w, map[string]bool{"canceled": canceled}) +} +``` + +- [ ] **Step 3: mountV1 接线**——`cmd/server/main.go`(import 加 `"github.com/wangjia/pangolin/server/internal/pay"`): + +在 Codes 构造块之后加: + +```go + // ── Pay(pay v2 统一支付网关;PAY_BASE_URL 未配则整组不挂载)────────────── + var payHandler *pay.Handler + if payBase := os.Getenv("PAY_BASE_URL"); payBase != "" { + paySystem := getenvDefault("PAY_BIZ_SYSTEM", "pangolin") + paySecret := os.Getenv("PAY_BIZ_SECRET") + payClient := pay.NewClient(payBase, paySystem, paySecret) + payStore := pay.NewStore(sqlDB) + payHandler = pay.NewHandler(payClient, payStore, sqlDB) + } else { + log.Printf("PAY_BASE_URL 未配置 — /v1/pay 支付端点不挂载") + } +``` + +受保护组内(`protected.Get("/plans", ...)` 附近)加: + +```go + if payHandler != nil { + protected.Get("/pay/catalog", payHandler.Catalog) + protected.Post("/pay/orders", payHandler.CreateOrder) + protected.Get("/pay/orders/{orderNo}", payHandler.GetOrder) + protected.Post("/pay/orders/{orderNo}/retry", payHandler.Retry) + protected.Post("/pay/orders/{orderNo}/cancel", payHandler.Cancel) + } +``` + +- [ ] **Step 4: 验证 + commit**——`go build ./... && go vet ./... && go test ./...` 全绿;commit `feat(server): /v1/pay 下单代理端点 + 购买台账(JWT 鉴权,user→biz_ref 映射)`。 + +--- + +## Task 5: webhook 接收器 — 验签/时间窗/nonce → 幂等开通(复用叠加语义)→ 回 SUCCESS + +**产出**:`server/internal/pay/{webhook.go,webhook_sqlite_test.go}` + mountV1 webhook 路由。**重投安全是硬指标**:pay 以 30s→1h 退避重投 12 次,每次 nonce 都是新的——幂等只能靠 `out_trade_no` 锁内 CAS。 + +### Steps + +- [ ] **Step 1: 失败测试** `server/internal/pay/webhook_sqlite_test.go`: + +```go +package pay + +import ( + "bytes" + "context" + "database/sql" + "encoding/json" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "testing" + "time" + + "github.com/wangjia/pangolin/server/internal/codes" +) + +func newWebhookRig(t *testing.T) (*WebhookHandler, *sql.DB, *Store) { + t.Helper() + db := openMigratedSQLite(t) + seedUser(t, db, 1, "uuid-1") + st := NewStore(db) + codesSvc := codes.NewService(codes.NewStore(db), nil, 5, time.Hour) + h := NewWebhookHandler(st, codesSvc, db, nil, "pangolin", testSecret, 5*time.Minute, 15*time.Minute) + return h, db, st +} + +// deliver 按 pay notifier.go 出站语义构造签名请求(每次新 nonce,模拟重投)。 +func deliver(t *testing.T, h *WebhookHandler, payload map[string]any) *httptest.ResponseRecorder { + t.Helper() + body, _ := json.Marshal(payload) + ts := strconv.FormatInt(time.Now().Unix(), 10) + nonce := newNonce() + r := httptest.NewRequest(http.MethodPost, "/v1/webhook/pay", bytes.NewReader(body)) + r.Header.Set("Content-Type", "application/json") + r.Header.Set("X-Pay-System", "pangolin") + r.Header.Set("X-Pay-Event", "payment.succeeded") + r.Header.Set("X-Pay-Timestamp", ts) + r.Header.Set("X-Pay-Nonce", nonce) + r.Header.Set("X-Pay-Sign", hmacSign(testSecret, "pangolin", ts, nonce, string(body))) + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + return w +} + +func succeededPayload(orderNo, sku string) map[string]any { + return map[string]any{ + "event_type": "payment.succeeded", + "out_trade_no": orderNo, + "biz_system": "pangolin", + "biz_ref": "uuid-1", + "product_biz_code": sku, + "amount_minor": int64(4201234), + "currency": "USDT", + "channel": "crypto", + "paid_at": time.Now().UTC().Format(time.RFC3339), + } +} + +func TestWebhook_HappyPath(t *testing.T) { + h, db, st := newWebhookRig(t) + ctx := context.Background() + if err := st.Insert(ctx, 1, "uuid-1", "pro_month", "pay001", "crypto"); err != nil { + t.Fatal(err) + } + w := deliver(t, h, succeededPayload("pay001", "pro_month")) + if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "SUCCESS") { + t.Fatalf("ACK 不符: %d %q(pay 要求 200+body 含 SUCCESS)", w.Code, w.Body.String()) + } + row, err := st.GetForUser(ctx, 1, "pay001") + if err != nil { + t.Fatal(err) + } + if row.Status != "paid" || !row.SubID.Valid || row.AmountMinor != 4201234 || row.Channel != "crypto" { + t.Fatalf("台账未正确消费: %+v", row) + } + var source string + var expires time.Time + if err := db.QueryRow(`SELECT source, expires_at FROM subscriptions WHERE id = ?`, row.SubID.Int64). + Scan(&source, &expires); err != nil { + t.Fatalf("订阅未开通: %v", err) + } + if source != "pay" { + t.Errorf("source = %q, want pay", source) + } + want := time.Now().UTC().AddDate(0, 0, 31) + if d := expires.Sub(want); d > time.Minute || d < -time.Minute { + t.Errorf("expires = %v, want ≈ %v(pro_month=31 天)", expires, want) + } +} + +func TestWebhook_RedeliveryIdempotent(t *testing.T) { + h, db, st := newWebhookRig(t) + ctx := context.Background() + _ = st.Insert(ctx, 1, "uuid-1", "pro_month", "pay001", "crypto") + deliver(t, h, succeededPayload("pay001", "pro_month")) + var exp1 time.Time + _ = db.QueryRow(`SELECT expires_at FROM subscriptions WHERE user_id = 1`).Scan(&exp1) + + // 重投(新 nonce)必须:200+SUCCESS、订阅行数不变、到期不变。 + w := deliver(t, h, succeededPayload("pay001", "pro_month")) + if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "SUCCESS") { + t.Fatalf("重投未确认: %d %q", w.Code, w.Body.String()) + } + var n int + _ = db.QueryRow(`SELECT COUNT(*) FROM subscriptions WHERE user_id = 1`).Scan(&n) + if n != 1 { + t.Fatalf("重投多开了订阅: rows = %d", n) + } + var exp2 time.Time + _ = db.QueryRow(`SELECT expires_at FROM subscriptions WHERE user_id = 1`).Scan(&exp2) + if !exp1.Equal(exp2) { + t.Errorf("重投改了到期: %v → %v", exp1, exp2) + } +} + +func TestWebhook_TwoOrdersStack(t *testing.T) { + h, db, st := newWebhookRig(t) + ctx := context.Background() + _ = st.Insert(ctx, 1, "uuid-1", "pro_month", "pay001", "crypto") + _ = st.Insert(ctx, 1, "uuid-1", "pro_quarter", "pay002", "crypto") + deliver(t, h, succeededPayload("pay001", "pro_month")) + deliver(t, h, succeededPayload("pay002", "pro_quarter")) + var n int + _ = db.QueryRow(`SELECT COUNT(*) FROM subscriptions WHERE user_id = 1`).Scan(&n) + if n != 1 { + t.Fatalf("同 plan 应原地叠加: rows = %d", n) + } + var expires time.Time + _ = db.QueryRow(`SELECT expires_at FROM subscriptions WHERE user_id = 1`).Scan(&expires) + want := time.Now().UTC().AddDate(0, 0, 31+92) + if d := expires.Sub(want); d > time.Minute || d < -time.Minute { + t.Errorf("expires = %v, want ≈ %v(31+92 天叠加)", expires, want) + } +} + +// 台账缺行(下单后本地写失败):按 biz_ref 兜底定位用户、补台账、照常开通。 +func TestWebhook_MissingLedgerFallsBackToBizRef(t *testing.T) { + h, db, st := newWebhookRig(t) + w := deliver(t, h, succeededPayload("pay-orphan", "pro_year")) + if w.Code != http.StatusOK { + t.Fatalf("code = %d body = %s", w.Code, w.Body.String()) + } + row, err := st.GetForUser(context.Background(), 1, "pay-orphan") + if err != nil { + t.Fatalf("兜底台账未建: %v", err) + } + if row.Status != "paid" || row.SKU != "pro_year" { + t.Fatalf("兜底行不符: %+v", row) + } + var n int + _ = db.QueryRow(`SELECT COUNT(*) FROM subscriptions WHERE user_id = 1 AND source = 'pay'`).Scan(&n) + if n != 1 { + t.Errorf("订阅未开通: %d", n) + } +} + +func TestWebhook_RejectsBadSignatureAndStaleTimestamp(t *testing.T) { + h, db, _ := newWebhookRig(t) + body, _ := json.Marshal(succeededPayload("pay001", "pro_month")) + mk := func(mutate func(r *http.Request)) int { + ts := strconv.FormatInt(time.Now().Unix(), 10) + nonce := newNonce() + r := httptest.NewRequest(http.MethodPost, "/v1/webhook/pay", bytes.NewReader(body)) + r.Header.Set("X-Pay-System", "pangolin") + r.Header.Set("X-Pay-Timestamp", ts) + r.Header.Set("X-Pay-Nonce", nonce) + r.Header.Set("X-Pay-Sign", hmacSign(testSecret, "pangolin", ts, nonce, string(body))) + mutate(r) + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + return w.Code + } + if c := mk(func(r *http.Request) { r.Header.Set("X-Pay-Sign", "AAAA") }); c != http.StatusUnauthorized { + t.Errorf("坏签名 code = %d, want 401", c) + } + if c := mk(func(r *http.Request) { r.Header.Set("X-Pay-System", "jiu") }); c != http.StatusUnauthorized { + t.Errorf("错 system code = %d, want 401", c) + } + if c := mk(func(r *http.Request) { + stale := strconv.FormatInt(time.Now().Add(-10*time.Minute).Unix(), 10) + r.Header.Set("X-Pay-Timestamp", stale) + // 注意:重签,否则先挂在签名而非时间窗上 + nonce := r.Header.Get("X-Pay-Nonce") + r.Header.Set("X-Pay-Sign", hmacSign(testSecret, "pangolin", stale, nonce, string(body))) + }); c != http.StatusUnauthorized { + t.Errorf("过期 ts code = %d, want 401", c) + } + var n int + _ = db.QueryRow(`SELECT COUNT(*) FROM subscriptions`).Scan(&n) + if n != 0 { + t.Errorf("拒绝路径不得开通: %d", n) + } +} + +// 未知 sku(catalog 漂移)→ 500,pay 会重投,期间可修 catalog 后自愈。 +func TestWebhook_UnknownSKU500(t *testing.T) { + h, _, _ := newWebhookRig(t) + w := deliver(t, h, succeededPayload("pay001", "pro_lifetime")) + if w.Code != http.StatusInternalServerError { + t.Fatalf("code = %d, want 500", w.Code) + } +} + +// 白名单外事件(如未来误配 refund.succeeded):确认不处理,避免无谓重投 12 次。 +func TestWebhook_IgnoredEventAcked(t *testing.T) { + h, db, _ := newWebhookRig(t) + p := succeededPayload("pay001", "pro_month") + p["event_type"] = "refund.succeeded" + w := deliver(t, h, p) + if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "SUCCESS") { + t.Fatalf("白名单外事件应直接确认: %d", w.Code) + } + var n int + _ = db.QueryRow(`SELECT COUNT(*) FROM subscriptions`).Scan(&n) + if n != 0 { + t.Errorf("不得开通: %d", n) + } +} +``` + +- [ ] **Step 2: 实现** `server/internal/pay/webhook.go`: + +```go +package pay + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "strconv" + "time" + + "github.com/redis/go-redis/v9" + + "github.com/wangjia/pangolin/server/internal/codes" +) + +// Granter 抽象 codes.Service 的支付授予入口(测试可替身;生产传 *codes.Service)。 +type Granter interface { + GrantPaidSubscriptionTx(ctx context.Context, tx *sql.Tx, userID int64, plan codes.PlanCode, days int, ref string) (int64, time.Time, error) +} + +// WebhookHandler 接收 pay 的 payment.succeeded 出站 webhook。 +// 验签与 pay verifyBizSign 对称:同 secret,parts=[system, ts, nonce, rawBody], +// ±tolerance 时间窗。幂等三层:nonce SETNX(传输重放)→ out_trade_no 锁内 +// CAS(业务幂等,重投唯一可靠键)→ biz_ref 兜底(台账缺行自修复)。 +type WebhookHandler struct { + store *Store + granter Granter + db *sql.DB + rdb *redis.Client // 可为 nil:跳过 nonce 层,业务幂等仍成立 + system string + secret string + tolerance time.Duration + nonceTTL time.Duration + now func() time.Time // 测试注入 +} + +func NewWebhookHandler(store *Store, granter Granter, db *sql.DB, rdb *redis.Client, + system, secret string, tolerance, nonceTTL time.Duration) *WebhookHandler { + return &WebhookHandler{store: store, granter: granter, db: db, rdb: rdb, + system: system, secret: secret, tolerance: tolerance, nonceTTL: nonceTTL, now: time.Now} +} + +// webhookEvent 对应 pay settle.go::enqueuePaymentSucceeded 的 payload +// (注意:payment.succeeded 无 refund_id 字段)。 +type webhookEvent struct { + EventType string `json:"event_type"` + OutTradeNo string `json:"out_trade_no"` + BizSystem string `json:"biz_system"` + BizRef string `json:"biz_ref"` + ProductBizCode string `json:"product_biz_code"` + AmountMinor int64 `json:"amount_minor"` + Currency string `json:"currency"` + Channel string `json:"channel"` + PaidAt string `json:"paid_at"` // RFC3339 +} + +func (h *WebhookHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(io.LimitReader(r.Body, 64<<10)) + if err != nil { + http.Error(w, "read body", http.StatusBadRequest) + return + } + if !h.verify(r, body) { + http.Error(w, "signature verification failed", http.StatusUnauthorized) + return + } + // nonce 防重放(仅传输层;pay 每次重投换新 nonce,业务幂等靠 out_trade_no)。 + if h.rdb != nil { + if nonce := r.Header.Get("X-Pay-Nonce"); nonce != "" { + ok, err := h.rdb.SetNX(r.Context(), "pay:webhook:nonce:"+nonce, 1, h.nonceTTL).Result() + if err == nil && !ok { + writeSuccess(w) // 同 nonce 重放:已处理过,直接确认 + return + } + } + } + var ev webhookEvent + if err := json.Unmarshal(body, &ev); err != nil { + http.Error(w, "bad payload", http.StatusBadRequest) + return + } + if ev.EventType != "payment.succeeded" { + // 事件白名单外(pay 侧只应配 payment.succeeded):确认不处理,免重投。 + writeSuccess(w) + return + } + if err := h.settle(r.Context(), &ev); err != nil { + slog.Error("pay webhook 开通失败(pay 将退避重投)", "order_no", ev.OutTradeNo, "err", err) + http.Error(w, "settle failed", http.StatusInternalServerError) + return + } + writeSuccess(w) +} + +// writeSuccess:pay 的 ACK 判据是 HTTP 200 且 body 含 "SUCCESS"(大写包含)。 +func writeSuccess(w http.ResponseWriter) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("SUCCESS")) +} + +func (h *WebhookHandler) verify(r *http.Request, body []byte) bool { + if r.Header.Get("X-Pay-System") != h.system { + return false + } + ts := r.Header.Get("X-Pay-Timestamp") + nonce := r.Header.Get("X-Pay-Nonce") + sign := r.Header.Get("X-Pay-Sign") + if ts == "" || nonce == "" || sign == "" { + return false + } + tsi, err := strconv.ParseInt(ts, 10, 64) + if err != nil { + return false + } + tol := int64(h.tolerance.Seconds()) + if d := h.now().Unix() - tsi; d > tol || d < -tol { + return false + } + return hmacVerify(h.secret, sign, h.system, ts, nonce, string(body)) +} + +// settle 幂等开通:锁台账行 → created→paid 翻转 + 同事务 grant(叠加语义 +// 复用 codes.applySubscription)。已 paid 直接返回 nil(重投/并发输家)。 +// canceled 行也照常开通——钱已实收,本地 cancel 只是未支付单的整理。 +func (h *WebhookHandler) settle(ctx context.Context, ev *webhookEvent) error { + item, ok := CatalogBySKU(ev.ProductBizCode) + if !ok { + return fmt.Errorf("未知 product_biz_code %q(与 pay 种子漂移?)", ev.ProductBizCode) + } + tx, err := h.store.BeginTx(ctx) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + + var purchaseID, userID int64 + row, err := h.store.LockByOutTradeNoTx(ctx, tx, ev.OutTradeNo) + switch { + case err == sql.ErrNoRows: + // 台账缺行(下单后本地写失败)→ 按 biz_ref=用户 uuid 兜底定位补建。 + if err := h.db.QueryRowContext(ctx, + `SELECT id FROM users WHERE uuid = ? AND status = 'active'`, ev.BizRef).Scan(&userID); err != nil { + return fmt.Errorf("biz_ref %q 定位用户失败: %w", ev.BizRef, err) + } + purchaseID, err = h.store.InsertFromWebhookTx(ctx, tx, userID, ev.BizRef, ev.ProductBizCode, ev.OutTradeNo, ev.Channel) + if err != nil { + return err + } + case err != nil: + return err + case row.Status == "paid": + return nil // 幂等:已消费,直接 SUCCESS + default: + purchaseID, userID = row.ID, row.UserID + } + + subID, _, err := h.granter.GrantPaidSubscriptionTx(ctx, tx, userID, + codes.PlanCode(item.Plan), item.Days, "pay:"+ev.OutTradeNo) + if err != nil { + return err + } + paidAt, perr := time.Parse(time.RFC3339, ev.PaidAt) + if perr != nil { + paidAt = h.now().UTC() + } + if err := h.store.MarkPaidTx(ctx, tx, purchaseID, ev.AmountMinor, ev.Currency, ev.Channel, subID, paidAt); err != nil { + return err + } + return tx.Commit() +} +``` + +> ⚠️ 一个必须注意的点:兜底路径里 `users` 查询走 `h.db`(事务外只读),`pay_purchases`/`subscriptions` 写全部在 `tx` 内——崩溃在 commit 前则整体回滚,pay 重投后重来,不会出现「开通了但台账没翻转」的半态。 + +- [ ] **Step 3: mountV1 接线**——Task 4 的 Pay 构造块里补 webhook(需要 codesSvc,故整块放 Codes 块之后): + +```go + var payWebhook *pay.WebhookHandler + // (放进 Task 4 的 if payBase != "" 块内) + payWebhook = pay.NewWebhookHandler(payStore, codesSvc, sqlDB, rdb, + paySystem, paySecret, 5*time.Minute, 15*time.Minute) +``` + +路由(webhook 免 JWT,挂在 `v1.Post("/webhook/store/codes", ...)` 旁): + +```go + if payWebhook != nil { + v1.Post("/webhook/pay", payWebhook.ServeHTTP) + } +``` + +- [ ] **Step 4: 验证 + commit**——`go build ./... && go vet ./... && go test ./...` 全绿;另跑 `./run_sqlite_test.sh` 兜一遍实库;commit `feat(server): pay webhook 接收器(验签/时间窗/nonce + out_trade_no 幂等开通,回 SUCCESS)`。 + +--- + +## Task 6: Flutter — 模型 + PaymentApi + 支付流控制器(轮询) + +**产出**:`client/lib/models/payment.dart`、`client/lib/services/payment_api.dart`、`client/lib/state/payment_provider.dart`、`AuthApiException` 补 `code` 字段、单测两份。全部经 server 代理(JWT 由 ApiClient 自动注入),客户端只传 sku+method。 + +### Steps + +- [ ] **Step 1: AuthApiException 补 code(小前置)**——`client/lib/services/auth_api.dart`: + +```dart +class AuthApiException implements Exception { + const AuthApiException({ + required this.statusCode, + required this.messageZh, + required this.messageEn, + this.code, + }); + + final int statusCode; + final String messageZh; + final String messageEn; + + /// 服务端 apierr 的机器码(如 CURRENCY_MISMATCH),旧调用点可空。 + final String? code; + + @override + String toString() => 'AuthApiException($statusCode): $messageZh'; +} +``` + +`client/lib/services/api_client.dart::_throwFromResponse` 补解析(在既有 zh/en 解析处): + +```dart + Never _throwFromResponse(http.Response resp) { + String zh = '操作失败 (HTTP ${resp.statusCode})'; + String en = 'Request failed (HTTP ${resp.statusCode})'; + String? code; + try { + final b = jsonDecode(resp.body) as Map; + zh = b['message_zh'] as String? ?? zh; + en = b['message_en'] as String? ?? en; + code = b['code'] as String?; + } catch (_) {} + throw AuthApiException(statusCode: resp.statusCode, messageZh: zh, messageEn: en, code: code); + } +``` + +跑 `flutter analyze`(命名参数可选,既有构造点零改动)。 + +- [ ] **Step 2: 失败测试** `client/test/unit/payment_api_test.dart`: + +```dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:pangolin_vpn/services/api_client.dart'; +import 'package:pangolin_vpn/services/auth_api.dart'; +import 'package:pangolin_vpn/services/payment_api.dart'; + +ApiClient _client(MockClient mock) => ApiClient( + baseUrl: 'http://x', + getToken: () => 'tok1', + refresh: () async => false, + client: mock, + ); + +void main() { + test('catalog 解析三档', () async { + final api = PaymentApi(_client(MockClient((req) async { + expect(req.url.path, '/v1/pay/catalog'); + return http.Response( + '{"items":[{"sku":"pro_month","plan":"pro","days":31,"price_minor":2999,"currency":"CNY"},' + '{"sku":"pro_quarter","plan":"pro","days":92,"price_minor":6888,"currency":"CNY"},' + '{"sku":"pro_year","plan":"pro","days":366,"price_minor":19999,"currency":"CNY"}]}', + 200); + }))); + final items = await api.catalog(); + expect(items.length, 3); + expect(items.first.sku, 'pro_month'); + expect(items.first.priceLabel(), '¥29.99'); + }); + + test('createOrder 只传 sku+method+metadata,解析 session', () async { + final api = PaymentApi(_client(MockClient((req) async { + expect(req.url.path, '/v1/pay/orders'); + expect(req.body.contains('"amount'), isFalse, reason: '客户端绝不传金额'); + return http.Response( + '{"order_no":"pay1","session":{"render_type":"crypto_address",' + '"payload":{"address":"Txx","amount":"4.201234","amount_minor":4201234,' + '"currency":"USDT","network":"TRC20"},"expires_at":"2026-07-10T12:00:00Z"}}', + 200); + }))); + final order = await api.createOrder(sku: 'pro_month', method: 'crypto'); + expect(order.orderNo, 'pay1'); + expect(order.session.renderType, 'crypto_address'); + expect(order.session.payload['address'], 'Txx'); + expect(order.session.expiresAt, isNotNull); + }); + + test('orderStatus 解析 activated/expires_at', () async { + final api = PaymentApi(_client(MockClient((req) async => http.Response( + '{"order_no":"pay1","pay_status":"succeeded","activated":true,' + '"expires_at":"2026-08-10T12:00:00Z"}', + 200)))); + final st = await api.orderStatus('pay1'); + expect(st.activated, isTrue); + expect(st.payStatus, 'succeeded'); + expect(st.expiresAt, isNotNull); + }); + + test('retry 409 CURRENCY_MISMATCH 抛带 code 的异常', () async { + final api = PaymentApi(_client(MockClient((req) async => http.Response( + '{"code":"CURRENCY_MISMATCH","message_zh":"该支付方式结算币种与订单不符,请重新下单",' + '"message_en":"mismatch"}', + 409)))); + try { + await api.retry('pay1', method: 'alipay'); + fail('应抛异常'); + } on AuthApiException catch (e) { + expect(e.statusCode, 409); + expect(e.code, 'CURRENCY_MISMATCH'); + } + }); +} +``` + +`client/test/unit/payment_flow_test.dart`(控制器逻辑,轮询用手动 `pollOnce` 驱动,不依赖真实 Timer): + +```dart +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pangolin_vpn/models/payment.dart'; +import 'package:pangolin_vpn/services/auth_api.dart'; +import 'package:pangolin_vpn/services/payment_api.dart'; +import 'package:pangolin_vpn/state/payment_provider.dart'; + +class _FakePaymentApi implements PaymentApi { + _FakePaymentApi(); + int createCalls = 0, cancelCalls = 0, statusCalls = 0; + bool activated = false; + Object? retryError; + String lastMethod = ''; + + @override + Future> catalog() async => const []; + + @override + Future createOrder({required String sku, required String method, Map? metadata}) async { + createCalls++; + lastMethod = method; + return PayOrder( + orderNo: 'pay$createCalls', + session: PaySession(renderType: method == 'crypto' ? 'crypto_address' : 'redirect', payload: const {'url': 'https://x'}, expiresAt: null)); + } + + @override + Future orderStatus(String orderNo) async { + statusCalls++; + return PayOrderStatus(orderNo: orderNo, payStatus: activated ? 'succeeded' : 'pending', activated: activated, expiresAt: null); + } + + @override + Future retry(String orderNo, {required String method, Map? metadata}) async { + if (retryError != null) throw retryError!; + return PayOrder(orderNo: orderNo, session: PaySession(renderType: 'redirect', payload: const {'url': 'https://y'}, expiresAt: null)); + } + + @override + Future cancel(String orderNo) async { + cancelCalls++; + } +} + +ProviderContainer _container(_FakePaymentApi api) { + final c = ProviderContainer(overrides: [paymentApiProvider.overrideWithValue(api)]); + addTearDown(c.dispose); + return c; +} + +void main() { + const item = PayCatalogItem(sku: 'pro_month', plan: 'pro', days: 31, priceMinor: 2999, currency: 'CNY'); + + test('start → awaitingPayment,pollOnce 到 activated 变 succeeded', () async { + final api = _FakePaymentApi(); + final c = _container(api); + final ctl = c.read(paymentFlowProvider.notifier); + await ctl.start(item, 'crypto'); + expect(c.read(paymentFlowProvider).phase, PaymentPhase.awaitingPayment); + await ctl.pollOnce(); + expect(c.read(paymentFlowProvider).phase, PaymentPhase.awaitingPayment); + api.activated = true; + await ctl.pollOnce(); + expect(c.read(paymentFlowProvider).phase, PaymentPhase.succeeded); + }); + + test('switchMethod 遇 CURRENCY_MISMATCH → cancel 旧单 + 新 method 重新下单', () async { + final api = _FakePaymentApi(); + final c = _container(api); + final ctl = c.read(paymentFlowProvider.notifier); + await ctl.start(item, 'crypto'); + api.retryError = const AuthApiException( + statusCode: 409, messageZh: 'x', messageEn: 'x', code: 'CURRENCY_MISMATCH'); + await ctl.switchMethod('alipay'); + expect(api.cancelCalls, 1); + expect(api.createCalls, 2, reason: '换币种必须新单(pay 契约)'); + expect(api.lastMethod, 'alipay'); + expect(c.read(paymentFlowProvider).order?.orderNo, 'pay2'); + }); +} +``` + +> 若 `paymentFlowProvider` 的 succeeded 路径里调用 `meProvider.refresh()`,fake 容器里没配 auth 会打真网络——实现时把「刷新 me」做成 best-effort(`try { ... } catch (_) {}`)或经注入回调,保证本测试不需要覆盖 meProvider。 + +- [ ] **Step 3: 实现** + +`client/lib/models/payment.dart`: + +```dart +// payment.dart — pay v2 支付领域模型(server 代理端点的响应形状)。 +// 金额只读展示:price_minor 为 CNY 分;crypto 精确金额在 session.payload 里。 + +class PayCatalogItem { + const PayCatalogItem({ + required this.sku, + required this.plan, + required this.days, + required this.priceMinor, + required this.currency, + }); + + final String sku; + final String plan; + final int days; + final int priceMinor; + final String currency; + + factory PayCatalogItem.fromJson(Map j) => PayCatalogItem( + sku: j['sku'] as String, + plan: j['plan'] as String? ?? 'pro', + days: (j['days'] as num?)?.toInt() ?? 0, + priceMinor: (j['price_minor'] as num?)?.toInt() ?? 0, + currency: j['currency'] as String? ?? 'CNY', + ); + + String priceLabel() => '¥${(priceMinor / 100).toStringAsFixed(2)}'; +} + +class PaySession { + const PaySession({required this.renderType, required this.payload, this.expiresAt}); + + final String renderType; // crypto_address | redirect | qr + final Map payload; + final DateTime? expiresAt; + + factory PaySession.fromJson(Map j) => PaySession( + renderType: j['render_type'] as String? ?? '', + payload: (j['payload'] as Map?) ?? const {}, + expiresAt: j['expires_at'] == null ? null : DateTime.tryParse(j['expires_at'] as String), + ); +} + +class PayOrder { + const PayOrder({required this.orderNo, required this.session}); + + final String orderNo; + final PaySession session; + + factory PayOrder.fromJson(Map j) => PayOrder( + orderNo: j['order_no'] as String, + session: PaySession.fromJson((j['session'] as Map?) ?? const {}), + ); +} + +class PayOrderStatus { + const PayOrderStatus({ + required this.orderNo, + required this.payStatus, + required this.activated, + this.expiresAt, + }); + + final String orderNo; + final String payStatus; + final bool activated; // server 台账已消费 = 权益已开通(轮询成功判据) + final DateTime? expiresAt; + + factory PayOrderStatus.fromJson(Map j) => PayOrderStatus( + orderNo: j['order_no'] as String? ?? '', + payStatus: j['pay_status'] as String? ?? '', + activated: j['activated'] as bool? ?? false, + expiresAt: j['expires_at'] == null ? null : DateTime.tryParse(j['expires_at'] as String), + ); +} +``` + +`client/lib/services/payment_api.dart`: + +```dart +// payment_api.dart — /v1/pay 代理端点封装(JWT 经 ApiClient 自动注入)。 +import '../models/payment.dart'; +import 'api_client.dart'; + +class PaymentApi { + PaymentApi(this._c); + final ApiClient _c; + + Future> catalog() async { + final body = await _c.getJson('/v1/pay/catalog'); + final items = (body['items'] as List?) ?? const []; + return [for (final it in items) PayCatalogItem.fromJson(it as Map)]; + } + + Future createOrder({ + required String sku, + required String method, + Map? metadata, + }) async => + PayOrder.fromJson(await _c.postJson('/v1/pay/orders', { + 'sku': sku, + 'method': method, + if (metadata != null && metadata.isNotEmpty) 'metadata': metadata, + })); + + Future orderStatus(String orderNo) async => + PayOrderStatus.fromJson(await _c.getJson('/v1/pay/orders/$orderNo')); + + Future retry(String orderNo, {required String method, Map? metadata}) async => + PayOrder.fromJson(await _c.postJson('/v1/pay/orders/$orderNo/retry', { + 'method': method, + if (metadata != null && metadata.isNotEmpty) 'metadata': metadata, + })); + + Future cancel(String orderNo) async { + await _c.postJson('/v1/pay/orders/$orderNo/cancel'); + } +} +``` + +`client/lib/state/payment_provider.dart`(轮询模板 = `_DevicesAutoRefresh`/`ConnectionController`:`Timer.periodic` + 再入保护 + dispose 取消;测试接缝 = provider override + `pollOnce`): + +```dart +// payment_provider.dart — 支付流状态机:选档下单 → 等待支付(轮询查单)→ 成功/失败。 +// 轮询成功判据是 server 的 activated(webhook 已开通),不是 pay 的 succeeded—— +// 保证用户看到「成功」时权益一定已到账。 +import 'dart:async'; +import 'dart:io' show Platform; + +import 'package:flutter/foundation.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../models/payment.dart'; +import '../services/auth_api.dart'; +import '../services/payment_api.dart'; +import 'account_providers.dart'; + +final paymentApiProvider = Provider( + (ref) => PaymentApi(ref.watch(apiClientProvider)), +); + +final payCatalogProvider = FutureProvider>( + (ref) => ref.watch(paymentApiProvider).catalog(), +); + +enum PaymentPhase { idle, creating, awaitingPayment, succeeded, failed } + +@immutable +class PaymentFlowState { + const PaymentFlowState({ + this.phase = PaymentPhase.idle, + this.item, + this.method = '', + this.order, + this.status, + this.errorZh, + this.errorEn, + }); + + final PaymentPhase phase; + final PayCatalogItem? item; + final String method; // alipay | crypto + final PayOrder? order; + final PayOrderStatus? status; + final String? errorZh; + final String? errorEn; + + PaymentFlowState copyWith({ + PaymentPhase? phase, + PayCatalogItem? item, + String? method, + PayOrder? order, + PayOrderStatus? status, + String? errorZh, + String? errorEn, + }) => + PaymentFlowState( + phase: phase ?? this.phase, + item: item ?? this.item, + method: method ?? this.method, + order: order ?? this.order, + status: status ?? this.status, + errorZh: errorZh, + errorEn: errorEn, + ); +} + +class PaymentFlowController extends StateNotifier { + PaymentFlowController(this._ref) : super(const PaymentFlowState()); + + final Ref _ref; + Timer? _poll; + bool _polling = false; // 再入保护(轮询慢于间隔时跳过本拍) + + static const _pollInterval = Duration(seconds: 3); + + bool get _isMobile { + if (kIsWeb) return false; + return Platform.isAndroid || Platform.isIOS; + } + + Map _metadata(String method) => + method == 'alipay' ? {'is_mobile': _isMobile ? '1' : '0'} : const {}; + + Future start(PayCatalogItem item, String method) async { + _stopPolling(); + state = PaymentFlowState(phase: PaymentPhase.creating, item: item, method: method); + try { + final order = await _ref + .read(paymentApiProvider) + .createOrder(sku: item.sku, method: method, metadata: _metadata(method)); + if (!mounted) return; + state = state.copyWith(phase: PaymentPhase.awaitingPayment, order: order); + _startPolling(); + } on AuthApiException catch (e) { + if (!mounted) return; + state = state.copyWith(phase: PaymentPhase.failed, errorZh: e.messageZh, errorEn: e.messageEn); + } + } + + /// 换支付方式:先 retry;pay 回 409 CURRENCY_MISMATCH(跨结算币种)时, + /// 按契约「换币种必须新单」→ cancel 旧单 + 新 method 重新下单。 + Future switchMethod(String method) async { + final item = state.item; + final order = state.order; + if (item == null || order == null) return; + try { + final next = await _ref + .read(paymentApiProvider) + .retry(order.orderNo, method: method, metadata: _metadata(method)); + if (!mounted) return; + state = state.copyWith(phase: PaymentPhase.awaitingPayment, method: method, order: next); + } on AuthApiException catch (e) { + if (!mounted) return; + if (e.code == 'CURRENCY_MISMATCH') { + try { + await _ref.read(paymentApiProvider).cancel(order.orderNo); + } catch (_) {} // 旧单取消失败不阻断新单 + await start(item, method); + return; + } + state = state.copyWith(phase: PaymentPhase.failed, errorZh: e.messageZh, errorEn: e.messageEn); + } + } + + Future cancel() async { + final order = state.order; + _stopPolling(); + if (order != null) { + try { + await _ref.read(paymentApiProvider).cancel(order.orderNo); + } catch (_) {} + } + if (mounted) state = const PaymentFlowState(); + } + + /// 单拍轮询(Timer 驱动;测试直接调用,不依赖真实时钟)。 + Future pollOnce() async { + final order = state.order; + if (order == null || _polling) return; + _polling = true; + try { + final st = await _ref.read(paymentApiProvider).orderStatus(order.orderNo); + if (!mounted) return; + if (st.activated) { + _stopPolling(); + state = state.copyWith(phase: PaymentPhase.succeeded, status: st); + // 权益已变,best-effort 刷新「我的」(失败不影响成功态)。 + try { + await _ref.read(meProvider.notifier).refresh(); + } catch (_) {} + } else { + state = state.copyWith(status: st); + } + } on AuthApiException { + // 单拍失败静默,下一拍重试(网络抖动不打断等待页)。 + } finally { + _polling = false; + } + } + + void _startPolling() { + _poll?.cancel(); + _poll = Timer.periodic(_pollInterval, (_) => pollOnce()); + } + + void _stopPolling() { + _poll?.cancel(); + _poll = null; + } + + @override + void dispose() { + _stopPolling(); + super.dispose(); + } +} + +final paymentFlowProvider = + StateNotifierProvider( + (ref) => PaymentFlowController(ref), +); +``` + +> `meProvider.refresh()` 在测试容器缺 auth 配置时可能抛——已包 `try/catch`,与 Step 2 的测试假设一致。`dart:io Platform` 在 web 不可用,已用 `kIsWeb` 短路(本项目桌面/移动为主)。 + +- [ ] **Step 4: 验证 + commit**——`flutter analyze && flutter test test/unit/payment_api_test.dart test/unit/payment_flow_test.dart && flutter test` 全绿;commit `feat(client): pay 支付领域模型 + PaymentApi + 支付流控制器(轮询 activated)`。 + +--- + +## Task 7: Flutter — 购买页 + 支付页(render_type 多态)+ 导航/l10n/pubspec + widget 测试 + +**产出**:`client/lib/screens/purchase_page.dart`、`client/lib/screens/payment_page.dart`、NavView 扩展 + 4 壳接线、l10n 三文件新增文案、pubspec 加 `url_launcher`、widget 测试。**UI 铁律**:组件全部取自 `client/lib/widgets/` 真相源(PlanCard/PangolinButton/showPangolinToast/PangolinIcons/`_SubScaffold` 模式/inline card 配方);**不新造视觉样式**——若实现中发现需要真相源没有的组件,停下先补 `design/preview/` 规格再继续(全局记忆铁律)。 + +### Steps + +- [ ] **Step 1: pubspec + l10n(前置)** + 1. `client/pubspec.yaml` dependencies 加 `url_launcher: ^6.3.0`,`flutter pub get`。(剪贴板用 SDK `Clipboard`,零依赖;qr 预留不加 qr_flutter。) + 2. `client/lib/l10n/app_text.dart` 抽象契约加(红线词自查:无 VPN/翻墙等): + +```dart + // ── 购买 / 支付 ── + String get purchaseTitle; // 购买套餐 / Purchase + String get paymentTitle; // 支付 / Payment + String get buyNow; // 立即购买 / Buy now + String get payMethodAlipay; // 支付宝 / Alipay + String get payMethodCrypto; // USDT (TRC20) + String get choosePayMethod; // 选择支付方式 / Choose payment method + String get proMonthly; // 专业版·月付 / Pro · Monthly + String get proQuarterly; // 专业版·季付 / Pro · Quarterly + String get proYearly; // 专业版·年付 / Pro · Yearly + String get perQuarter; // /季 / /quarter + String get perYear; // /年 / /year + String get payAmountLabel; // 转账金额 / Amount + String get payAddressLabel; // 收款地址 / Address + String get payNetworkLabel; // 网络 / Network + String get payExactAmountHint; // 金额须精确一致,到账后自动开通 / Send the exact amount; activates automatically + String get copied; // 已复制 / Copied + String get openAlipay; // 打开支付宝支付 / Pay with Alipay + String get openAlipayHint; // 完成支付后返回,本页会自动刷新 / Return after paying; this page refreshes automatically + String get awaitingPayment; // 等待付款 / Awaiting payment + String get paySucceeded; // 已开通 / Activated + String get payExpiresAt; // 有效期至 / Valid until + String get payDone; // 完成 / Done + String get payFailed; // 支付失败 / Payment failed + String get payRetry; // 重试 / Retry + String get switchPayMethod; // 换一种支付方式 / Switch payment method + String get cancelOrder; // 取消订单 / Cancel order + String get qrNotSupported; // 请复制内容后在支付宝内打开 / Copy and open in Alipay +``` + + 3. `strings_zh.dart` / `strings_en.dart` 各补实现(照上注释的中英文案)。跑 `bash ci/scan-redline.sh`(或 CI 同款 grep)确认无红线词。 + +- [ ] **Step 2: 失败测试** `client/test/widget/payment_pages_test.dart`: + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pangolin_vpn/l10n/strings_zh.dart'; +import 'package:pangolin_vpn/models/payment.dart'; +import 'package:pangolin_vpn/screens/payment_page.dart'; +import 'package:pangolin_vpn/screens/purchase_page.dart'; +import 'package:pangolin_vpn/state/payment_provider.dart'; +import 'package:pangolin_vpn/widgets/plan_card.dart'; + +import '../helpers/harness.dart'; + +const _items = [ + PayCatalogItem(sku: 'pro_month', plan: 'pro', days: 31, priceMinor: 2999, currency: 'CNY'), + PayCatalogItem(sku: 'pro_quarter', plan: 'pro', days: 92, priceMinor: 6888, currency: 'CNY'), + PayCatalogItem(sku: 'pro_year', plan: 'pro', days: 366, priceMinor: 19999, currency: 'CNY'), +]; + +void main() { + setUpAll(disableGoogleFontsFetching); + const t = StringsZh(); + + testWidgets('购买页:三档 PlanCard + 价格', (tester) async { + await tester.pumpWidget(wrapThemed( + PurchaseScreen(t: t, embedded: true), + overrides: [payCatalogProvider.overrideWith((ref) async => _items)], + )); + await tester.pumpAndSettle(); + expect(find.byType(PlanCard), findsNWidgets(3)); + expect(find.text('¥29.99'), findsOneWidget); + expect(find.text('¥199.99'), findsOneWidget); + expect(find.text(t.proQuarterly), findsOneWidget); + }); + + testWidgets('支付页 crypto_address:地址/金额/复制,金额用 mono', (tester) async { + final flow = PaymentFlowState( + phase: PaymentPhase.awaitingPayment, + item: _items.first, + method: 'crypto', + order: const PayOrder( + orderNo: 'pay1', + session: PaySession(renderType: 'crypto_address', payload: { + 'address': 'TXYZabc123', + 'amount': '4.201234', + 'amount_minor': 4201234, + 'currency': 'USDT', + 'network': 'TRC20', + }), + ), + ); + await tester.pumpWidget(wrapThemed( + PaymentScreen(t: t, embedded: true), + overrides: [ + paymentFlowProvider.overrideWith((ref) => _FixedFlowController(flow)), + ], + )); + await tester.pump(); + expect(find.text('TXYZabc123'), findsOneWidget); + expect(find.textContaining('4.201234'), findsOneWidget); + expect(find.text(t.payExactAmountHint), findsOneWidget); + expect(find.text(t.awaitingPayment), findsOneWidget); + }); + + testWidgets('支付页 redirect:外链按钮 + 引导文案', (tester) async { + final flow = PaymentFlowState( + phase: PaymentPhase.awaitingPayment, + item: _items.first, + method: 'alipay', + order: const PayOrder( + orderNo: 'pay1', + session: PaySession(renderType: 'redirect', payload: {'url': 'https://alipay.example/x'}), + ), + ); + await tester.pumpWidget(wrapThemed( + PaymentScreen(t: t, embedded: true), + overrides: [paymentFlowProvider.overrideWith((ref) => _FixedFlowController(flow))], + )); + await tester.pump(); + expect(find.text(t.openAlipay), findsOneWidget); + expect(find.text(t.openAlipayHint), findsOneWidget); + }); + + testWidgets('支付页成功态:已开通 + 完成', (tester) async { + final flow = PaymentFlowState( + phase: PaymentPhase.succeeded, + item: _items.first, + method: 'crypto', + status: PayOrderStatus( + orderNo: 'pay1', payStatus: 'succeeded', activated: true, + expiresAt: DateTime.utc(2026, 8, 10)), + ); + await tester.pumpWidget(wrapThemed( + PaymentScreen(t: t, embedded: true), + overrides: [paymentFlowProvider.overrideWith((ref) => _FixedFlowController(flow))], + )); + await tester.pump(); + expect(find.text(t.paySucceeded), findsOneWidget); + expect(find.text(t.payDone), findsOneWidget); + }); +} + +/// 固定状态的控制器替身(不发网络、不起 Timer)。 +class _FixedFlowController extends PaymentFlowController { + _FixedFlowController(PaymentFlowState fixed) : super(_dummyRef()) { + state = fixed; + } + static Ref _dummyRef() => throw UnimplementedError(); // 见下注 +} +``` + +> `_FixedFlowController` 直接继承会被 Ref 卡住——实现时改为给 `PaymentFlowController` 加 `@visibleForTesting PaymentFlowController.fixed(PaymentFlowState s)` 命名构造(`_ref` 置 late 不触碰,或把 `_ref` 改 `Ref?` 空安全短路),测试用 `paymentFlowProvider.overrideWith((ref) => PaymentFlowController.fixed(flow))`。以最终编译通过的最小改法为准,**不得为测试放宽生产逻辑**。 + +- [ ] **Step 3: 实现购买页** `client/lib/screens/purchase_page.dart`(骨架仿 `widgets/account_screens.dart::PlansScreen`,选档 → 底部弹层选方式 → `paymentFlowProvider.start` → 进支付页): + +```dart +// purchase_page.dart — 购买套餐(三档 pro:月/季/年,pay v2 通道)。 +// 档位数据来自 GET /v1/pay/catalog(server 单源);金额仅展示,扣款以 pay 为准。 +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../l10n/app_text.dart'; +import '../models/payment.dart'; +import '../pangolin_theme.dart'; +import '../state/payment_provider.dart'; +import '../widgets/pangolin_icons.dart'; +import '../widgets/plan_card.dart'; + +class PurchaseScreen extends ConsumerWidget { + const PurchaseScreen({super.key, required this.t, this.onBack, this.onOrderCreated, this.embedded = false}); + + final AppText t; + final VoidCallback? onBack; + /// 下单成功(进入 awaitingPayment)后由壳导航到支付页。 + final VoidCallback? onOrderCreated; + final bool embedded; + + String _name(String sku) => switch (sku) { + 'pro_month' => t.proMonthly, + 'pro_quarter' => t.proQuarterly, + 'pro_year' => t.proYearly, + _ => sku, + }; + + String _period(String sku) => switch (sku) { + 'pro_month' => t.perMonth, + 'pro_quarter' => t.perQuarter, + 'pro_year' => t.perYear, + _ => '', + }; + + Future _choose(BuildContext context, WidgetRef ref, PayCatalogItem item) async { + final c = context.pangolin; + final method = await showModalBottomSheet( + context: context, + backgroundColor: c.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(PangolinRadius.xl)), + ), + builder: (sheetCtx) => SafeArea( + child: Column(mainAxisSize: MainAxisSize.min, children: [ + Padding( + padding: const EdgeInsets.fromLTRB(20, 18, 20, 8), + child: Align( + alignment: Alignment.centerLeft, + child: Text(t.choosePayMethod, + style: PangolinText.h3.copyWith(color: c.fg1, fontWeight: FontWeight.w700)), + ), + ), + ListTile( + leading: Icon(PangolinIcons.creditCard, color: c.fg2), + title: Text(t.payMethodAlipay, style: PangolinText.body.copyWith(color: c.fg1)), + trailing: Icon(PangolinIcons.chevronRight, size: 18, color: c.fg3), + onTap: () => Navigator.of(sheetCtx).pop('alipay'), + ), + ListTile( + leading: Icon(PangolinIcons.globe, color: c.fg2), + title: Text(t.payMethodCrypto, style: PangolinText.body.copyWith(color: c.fg1)), + trailing: Icon(PangolinIcons.chevronRight, size: 18, color: c.fg3), + onTap: () => Navigator.of(sheetCtx).pop('crypto'), + ), + const SizedBox(height: 12), + ]), + ), + ); + if (method == null || !context.mounted) return; + await ref.read(paymentFlowProvider.notifier).start(item, method); + if (!context.mounted) return; + if (ref.read(paymentFlowProvider).phase != PaymentPhase.idle) { + onOrderCreated?.call(); + } + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final c = context.pangolin; + final catalog = ref.watch(payCatalogProvider); + + final body = catalog.when( + loading: () => const Center( + child: Padding(padding: EdgeInsets.all(40), child: CircularProgressIndicator())), + error: (_, __) => Center( + child: Padding( + padding: const EdgeInsets.all(40), + child: Text(t.lang == AppLang.zh ? '加载失败,请重试' : 'Failed to load, retry', + style: PangolinText.body.copyWith(color: c.fg3)), + ), + ), + data: (items) => ListView( + padding: const EdgeInsets.fromLTRB(20, 14, 20, 24), + children: [ + for (final it in items) + Padding( + padding: EdgeInsets.only(bottom: 14, top: it.sku == 'pro_year' ? 12 : 0), + child: PlanCard( + name: _name(it.sku), + price: it.priceLabel(), + period: _period(it.sku), + features: t.featsPro, + ctaLabel: t.buyNow, + featured: it.sku == 'pro_year', + popularLabel: it.sku == 'pro_year' ? t.mostPopular : null, + onPressed: () => _choose(context, ref, it), + ), + ), + ], + ), + ); + + if (embedded) return body; + return Scaffold( + backgroundColor: c.bg, + body: SafeArea( + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Padding( + padding: const EdgeInsets.fromLTRB(8, 6, 16, 10), + child: Row(children: [ + IconButton( + onPressed: onBack ?? () => Navigator.of(context).maybePop(), + icon: Icon(PangolinIcons.arrowLeft, size: 22, color: c.fg1), + ), + Text(t.purchaseTitle, + style: PangolinText.h3.copyWith(color: c.fg1, fontWeight: FontWeight.w700)), + ]), + ), + Expanded(child: body), + ]), + ), + ); + } +} +``` + +> `PangolinIcons.globe/chevronRight/creditCard/arrowLeft` 已在登记簿;`t.featsPro/t.mostPopular/t.perMonth` 为既有文案。移动/桌面双态骨架与 `_SubScaffold` 同构(它是 account_screens 的私有类,不跨文件引用,这里内联同配方)。 + +- [ ] **Step 4: 实现支付页** `client/lib/screens/payment_page.dart`: + +```dart +// payment_page.dart — 支付页:按 session.render_type 多态渲染,轮询到 +// activated 切成功态。crypto_address=地址+精确金额+复制;redirect=外链拉起; +// qr=预留(复制内容兜底)。 +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import '../l10n/app_text.dart'; +import '../pangolin_theme.dart'; +import '../state/payment_provider.dart'; +import '../widgets/pangolin_button.dart'; +import '../widgets/pangolin_icons.dart'; +import '../widgets/pangolin_toast.dart'; + +class PaymentScreen extends ConsumerWidget { + const PaymentScreen({super.key, required this.t, this.onBack, this.onDone, this.embedded = false}); + + final AppText t; + final VoidCallback? onBack; + /// 成功态「完成」/取消订单后的退出导航(壳层决定去向)。 + final VoidCallback? onDone; + final bool embedded; + + bool get _zh => t.lang == AppLang.zh; + + Future _copy(BuildContext context, String text) async { + await Clipboard.setData(ClipboardData(text: text)); + if (context.mounted) showPangolinToast(context, t.copied); + } + + // 卡片配方 = account_page.dart::_Card(真相源既有样式,非新造)。 + Widget _card(PangolinScheme c, {required Widget child}) => Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: c.surface, + borderRadius: BorderRadius.circular(PangolinRadius.lg), + border: Border.all(color: c.border), + boxShadow: PangolinShadow.sm, + ), + child: child, + ); + + Widget _kvRow(BuildContext context, String label, String value, {bool mono = true}) { + final c = context.pangolin; + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ + SizedBox( + width: 76, + child: Text(label, style: PangolinText.sm.copyWith(color: c.fg3)), + ), + Expanded( + child: SelectableText(value, + style: (mono ? PangolinText.mono : PangolinText.body) + .copyWith(color: c.fg1, fontSize: 14)), + ), + IconButton( + visualDensity: VisualDensity.compact, + icon: Icon(PangolinIcons.copy, size: 16, color: c.fg3), + onPressed: () => _copy(context, value), + ), + ]), + ); + } + + Widget _awaiting(BuildContext context, WidgetRef ref, PaymentFlowState s) { + final c = context.pangolin; + final session = s.order!.session; + final payload = session.payload; + + Widget renderBody; + switch (session.renderType) { + case 'crypto_address': + renderBody = _card(c, child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + _kvRow(context, t.payNetworkLabel, '${payload['currency'] ?? 'USDT'} · ${payload['network'] ?? 'TRC20'}'), + _kvRow(context, t.payAddressLabel, '${payload['address'] ?? ''}'), + _kvRow(context, t.payAmountLabel, '${payload['amount'] ?? ''}'), + const SizedBox(height: 6), + Text(t.payExactAmountHint, style: PangolinText.caption.copyWith(color: c.warning)), + ])); + case 'redirect': + renderBody = _card(c, child: Column(children: [ + PangolinButton( + label: t.openAlipay, + icon: PangolinIcons.externalLink, + expand: true, + onPressed: () => launchUrl(Uri.parse('${payload['url'] ?? ''}'), + mode: LaunchMode.externalApplication), + ), + const SizedBox(height: 10), + Text(t.openAlipayHint, style: PangolinText.caption.copyWith(color: c.fg3)), + ])); + case 'qr': // 预留:复制内容兜底,不引入二维码渲染依赖 + renderBody = _card(c, child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + _kvRow(context, t.payAmountLabel, '¥${payload['display_amount'] ?? ''}', mono: true), + _kvRow(context, t.payAddressLabel, '${payload['qr_content'] ?? ''}'), + const SizedBox(height: 6), + Text(t.qrNotSupported, style: PangolinText.caption.copyWith(color: c.fg3)), + ])); + default: + renderBody = _card(c, child: Text('${session.renderType}: ${_zh ? "暂不支持,请换支付方式" : "Unsupported, switch method"}', + style: PangolinText.body.copyWith(color: c.fg2))); + } + + return ListView(padding: const EdgeInsets.fromLTRB(20, 14, 20, 24), children: [ + renderBody, + const SizedBox(height: 16), + Row(children: [ + SizedBox(width: 14, height: 14, child: CircularProgressIndicator(strokeWidth: 2, color: c.accent)), + const SizedBox(width: 10), + Text(t.awaitingPayment, style: PangolinText.sm.copyWith(color: c.fg2)), + ]), + const SizedBox(height: 22), + PangolinButton( + label: t.switchPayMethod, + variant: PangolinButtonVariant.secondary, + expand: true, + onPressed: () => _pickAndSwitch(context, ref, s), + ), + const SizedBox(height: 10), + PangolinButton( + label: t.cancelOrder, + variant: PangolinButtonVariant.ghost, + expand: true, + onPressed: () async { + await ref.read(paymentFlowProvider.notifier).cancel(); + onDone?.call(); + }, + ), + ]); + } + + Future _pickAndSwitch(BuildContext context, WidgetRef ref, PaymentFlowState s) async { + final other = s.method == 'crypto' ? 'alipay' : 'crypto'; + await ref.read(paymentFlowProvider.notifier).switchMethod(other); + } + + Widget _succeeded(BuildContext context, PaymentFlowState s) { + final c = context.pangolin; + final exp = s.status?.expiresAt; + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column(mainAxisSize: MainAxisSize.min, children: [ + Icon(PangolinIcons.checkCircle, size: 56, color: c.success), + const SizedBox(height: 16), + Text(t.paySucceeded, style: PangolinText.h2.copyWith(color: c.fg1, fontWeight: FontWeight.w700)), + if (exp != null) ...[ + const SizedBox(height: 8), + Text('${t.payExpiresAt} ${exp.toLocal().toString().split(' ').first}', + style: PangolinText.sm.copyWith(color: c.fg2)), + ], + const SizedBox(height: 24), + PangolinButton(label: t.payDone, expand: true, onPressed: () => onDone?.call()), + ]), + ), + ); + } + + Widget _failed(BuildContext context, WidgetRef ref, PaymentFlowState s) { + final c = context.pangolin; + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column(mainAxisSize: MainAxisSize.min, children: [ + Text(t.payFailed, style: PangolinText.h3.copyWith(color: c.danger)), + const SizedBox(height: 8), + Text((_zh ? s.errorZh : s.errorEn) ?? '', style: PangolinText.sm.copyWith(color: c.fg3)), + const SizedBox(height: 20), + PangolinButton( + label: t.payRetry, + expand: true, + onPressed: () { + final item = s.item; + if (item != null) ref.read(paymentFlowProvider.notifier).start(item, s.method); + }, + ), + ]), + ), + ); + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final c = context.pangolin; + final s = ref.watch(paymentFlowProvider); + + final body = switch (s.phase) { + PaymentPhase.creating => const Center(child: CircularProgressIndicator()), + PaymentPhase.awaitingPayment => _awaiting(context, ref, s), + PaymentPhase.succeeded => _succeeded(context, s), + PaymentPhase.failed => _failed(context, ref, s), + PaymentPhase.idle => Center( + child: Text(_zh ? '暂无进行中的订单' : 'No active order', + style: PangolinText.body.copyWith(color: c.fg3))), + }; + + if (embedded) return body; + return Scaffold( + backgroundColor: c.bg, + body: SafeArea( + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Padding( + padding: const EdgeInsets.fromLTRB(8, 6, 16, 10), + child: Row(children: [ + IconButton( + onPressed: onBack ?? () => Navigator.of(context).maybePop(), + icon: Icon(PangolinIcons.arrowLeft, size: 22, color: c.fg1), + ), + Text(t.paymentTitle, + style: PangolinText.h3.copyWith(color: c.fg1, fontWeight: FontWeight.w700)), + ]), + ), + Expanded(child: body), + ]), + ), + ); + } +} +``` + +> `PangolinIcons.copy` 若登记簿没有:在 `pangolin_icons.dart` 加 `static const copy = LucideIcons.copy;` 并登记进 name map(登记簿新增图标属允许操作,非新造样式)。 + +- [ ] **Step 5: 导航接线** + 1. `lib/state/navigation_provider.dart`:enum 加 `purchase, payment`;`kAccountSubViews` 加两者。 + 2. `lib/shell/desktop_shell.dart`:`titles` 加 `NavView.purchase: t.purchaseTitle, NavView.payment: t.paymentTitle`;`content()` 加: + +```dart + case NavView.purchase: + return PurchaseScreen(t: t, embedded: true, onOrderCreated: () => go(NavView.payment)); + case NavView.payment: + return PaymentScreen(t: t, embedded: true, onDone: () => go(NavView.account)); +``` + + `ContentTopBar` 返回逻辑:`payment → purchase → account`(把现有 `onBack: isSub ? () => go(NavView.account) : null` 细化为 switch,payment 回 purchase)。 + 3. `plans` 入口改道:desktop_shell 的 `case NavView.plans: PlansScreen(..., onChoose: (code) => go(code == 'pro' ? NavView.purchase : NavView.redeem))`;tablet/mobile 壳与 `account_page.dart` 的升级入口同法(移动端 `Navigator.push(MaterialPageRoute(builder: (_) => PurchaseScreen(t: t, onOrderCreated: () => Navigator.of(context).push(MaterialPageRoute(builder: (_) => PaymentScreen(t: t))))))`——沿用 `account_page.dart::open()` 的双态惯例)。tablet_shell/mobile_shell 的 switch 各补 `purchase/payment` case(样式对齐各壳既有子页写法)。 + 4. `flutter analyze` 过——**Dart switch 对 enum 是穷尽检查,漏壳会直接编译错**,这是接线完整性的保险。 + +- [ ] **Step 6: 验证 + commit**——`flutter analyze && flutter test` 全绿(含 Task 6 单测 + 本任务 widget 测试 + 既有 golden 不回归——新页面不进 golden,见 Self-Review 取舍);commit `feat(client): 购买页三档 + 支付页 render_type 多态(crypto/redirect/qr 预留)+ 导航接线`。 + +--- + +## Task 8: 终验 — 全量矩阵 + OpenAPI 登记 + 交付说明 + +### Steps + +- [ ] **Step 1: 全量测试矩阵** + +```bash +cd /Users/wangjia/code/pangolin/.claude/worktrees/pay-v2-integration/server +go build ./... && go vet ./... && go test ./... && ./run_sqlite_test.sh +# docker 可用则加跑:./run_mysql_test.sh(重点:000021 迁移在 mysql 上的 MODIFY ENUM) +cd ../client +flutter analyze && flutter test +``` + +- [ ] **Step 2: 迁移彩排(sqlite 文件库模拟带数据升级)**——重点验 000021 的 subscriptions 重建不丢行、id/AUTOINCREMENT 序列保持:up 到 000020 → 手工插 users/subscriptions(source='trial'/'code' 各一行)→ up 到 021 → 断言行数/id 不变、可插 source='pay' → down → up 幂等。 +- [ ] **Step 3: OpenAPI 登记**——`server/api/openapi.yaml` 补 `/v1/pay/catalog`、`/v1/pay/orders`、`/v1/pay/orders/{orderNo}`(GET/retry/cancel)、`/v1/webhook/pay` 条目(CI 仅结构校验 `design/server/openapi.yaml`,若两文件同步维护则两处都补);`python -m openapi_spec_validator` 本地过一遍(或走 CI)。 +- [ ] **Step 4: 手动冒烟(可选,本地起 server)**——`PAY_BASE_URL` 指向本机假 pay(可用 Task 3 的 httptest 思路写个临时 main 或 `python -m http.server` 改造),curl 走一遍 create→webhook→get 看 activated 翻转。 +- [ ] **Step 5: 收尾 commit + 汇总**——向用户报告:测试矩阵结果、联调 checklist(下节)与部署附录(pay 侧种子/biz 配置/pangolin env)是上线前提、本计划已知取舍(Self-Review)。**到此为止,不 merge、不部署。** + +--- + +## Self-Review Checklist(执行完逐条打勾,含取舍说明) + +- [ ] `git log` 每刀一 commit;server/client 全量测试绿;`run_sqlite_test.sh` 绿。 +- [ ] **金额纪律**:client 代码 grep `amount` 确认只有展示/payload 读取,无任何请求体携带金额;server 下单请求体只含 sku/method/biz_system/biz_ref/metadata。 +- [ ] **HMAC 对称性**:`internal/pay/sign.go` 与 pay `util/sign.go` 逐字节同构(`\n` join + std base64);webhook 验签 parts 顺序 `[system, ts, nonce, body]` 与 notifier 出站一致。 +- [ ] **重投安全**:`TestWebhook_RedeliveryIdempotent` 断言订阅行数与到期都不变;500 路径(未知 sku)不产生半态(事务回滚)。 +- [ ] **叠加语义零复制**:webhook 开通只经 `GrantPaidSubscriptionTx → applySubscription`,`grep -rn "AddDate" server/internal/pay/` 应为 0 行(时长计算只在 codes 包)。 +- [ ] **codes 既有行为零变化**:兑换/试用相关既有测试未改一行且全绿(source 参数化只是穿参)。 +- [ ] **UI 真相源纪律**:新页面 grep 无硬编码 hex(全部 `context.pangolin`/`PangolinText`/`PangolinRadius`);组件仅 PlanCard/PangolinButton/toast/icons + `_Card` 同款 inline 配方。 +- [ ] **取舍 1(golden)**:purchase/payment 未加 desktop golden——页面状态依赖运行时订单,固化价值低;若用户要求像素闸,后续按 design/preview 补规格 + golden 一并做。 +- [ ] **取舍 2(bottom sheet)**:支付方式选择用 Material `showModalBottomSheet` + `ListTile`(SDK 原生组件,主题色仍走 token)——真相源暂无「选择弹层」规格;若视觉不满意,先补 `design/preview/` 规格再改,不在本计划内造样式。 +- [ ] **取舍 3(qr)**:qr render_type 只做复制兜底,未渲染二维码(不引 qr_flutter);当面付上线前需补。 +- [ ] **取舍 4(catalog 双源)**:展示价在 pangolin catalog.go、扣款价在 pay 种子——两处人工对齐,漂移风险列入联调 checklist 第 2 条;后续可做 pay 端 price 查询接口消掉双源。 +- [ ] **取舍 5(轮询打 pay)**:GET /v1/pay/orders/{no} 每拍都回源 pay 查单(3s 间隔单用户可接受;pay 侧 GET 无限流)。量大再加 server 短缓存。 +- [ ] 遗留项登记:① mysql 集成测试若本机无 docker,交付说明标注「待跑」;② OpenAPI 两份文件的同步策略;③ 支付宝 `return_url`(App 场景暂空,web 用户中心接入时再传);④ 本计划 HTML 阅读版 + `docs/index.html` 登记(定稿后按仓规矩补)。 + +--- + +## 联调 Checklist(端到端,等 pay 部署后执行;不阻塞上面任何开发) + +> 单测已用 httptest 假 pay 全覆盖签名/幂等语义;本节是真环境验收,逐条打勾。 + +1. [ ] **pay 侧就绪**:pay-server 部署完成,`/api/v2` 可达;pangolin 的 biz 配置与种子 SQL 已按「部署附录」落库(products×3 + product_prices(USDT)×3 + biz_system=pangolin)。 +2. [ ] **价格一致性**:`GET /api/v2` 下单三档,断言 pay 返回/扣款金额与 pangolin `catalog.go` 展示价一致(CNY 2999/6888/19999 分;USDT 按附录种子)。 +3. [ ] **pangolin server 配置**:`PAY_BASE_URL`/`PAY_BIZ_SYSTEM=pangolin`/`PAY_BIZ_SECRET`(与 pay biz 配置同 secret,存 Bitwarden 不落明文)已入 `/etc/pangolin*` env;重启后日志无「PAY_BASE_URL 未配置」。 +4. [ ] **签名互通**:App(或 curl 带 JWT)`POST /v1/pay/orders {"sku":"pro_month","method":"crypto"}` → 200 返回 crypto_address session(地址/amount/amount_minor/USDT/TRC20)。403/401 则核对 secret 与时钟(±300s)。 +5. [ ] **webhook 连通**:pay 侧 CallbackURL 指向 `http://:8080/v1/webhook/pay`;SupportedEvents=[payment.succeeded];用 pay 的重发工具(或手工 SQL 触发)投递一条 → pangolin 日志无验签错误、pay 侧标记 delivered(收到 200+SUCCESS)。 +6. [ ] **USDT 真付一单**(小额档):转账精确金额 → pay 确认 → webhook → App 轮询页自动切「已开通」;`subscriptions` 出现 `source='pay'` 行,`pay_purchases` 行 `paid` + sub_id/amount/currency 回填;audit_log 有 `pay_grant`。 +7. [ ] **重投验证**:pay 侧手动重发同一事件 → pangolin 回 SUCCESS,订阅到期不变(查库比对)。 +8. [ ] **支付宝 redirect 一单**:`method=alipay` + `metadata.is_mobile` 按端型 → 返回 redirect url 可拉起;支付后同 6 号流程走通。 +9. [ ] **换渠道**:crypto 下单后 `POST .../retry {"method":"alipay"}` → 收 409 CURRENCY_MISMATCH;App 自动取消旧单新建 alipay 单(观察 pay 侧旧单 canceled、新单 pending)。 +10. [ ] **叠加**:同一账号再购一档 → `expires_at` 在原值上顺延(不是从 now 重算,若原订阅未过期)。 +11. [ ] **限流不误伤**:连续下单/取消超 30 次/分触发 pay 429 → App 提示「操作过于频繁」而非崩溃。 +12. [ ] **时钟检查**:pangolin1 与 pay 所在机 `timedatectl` NTP 同步(±300s 窗口的前提)。 + +--- + +## 部署附录(联调/上线前的 pay 侧与 pangolin 侧配置;**本计划不执行部署**) + +### A. pay 侧种子 SQL(pay 库;merchants/products/product_prices 为 GORM 表) + +> 金额:CNY 走 `products.price` 元字符串(alipay fallback);USDT 走 `product_prices.amount_minor` **微单位(1 USDT = 1_000_000)**。USDT 定价按 ≈7.15 汇率取整,上线前可调——**调价只改 pay 侧,pangolin `catalog.go` 展示价同步改**。 + +```sql +-- ① 收款商户(alipay 渠道;crypto 渠道账户按 pay 的 crypto provider 配置走,不在此表) +INSERT INTO merchants (code, name, channel, production, enabled, created_at, updated_at) +VALUES ('pangolin', 'Pangolin', 'alipay', 1, 1, NOW(), NOW()); +-- 记下自增 id,下面记作 + +-- ② 三档产品(biz_code 即 v2 sku,与 pangolin catalog.go 严格一致) +INSERT INTO products (merchant_id, name, description, price, active, sort, biz_code, created_at, updated_at) VALUES + (, 'Pangolin 专业版·月付', '31 天', '29.99', 1, 1, 'pro_month', NOW(), NOW()), + (, 'Pangolin 专业版·季付', '92 天', '68.88', 1, 2, 'pro_quarter', NOW(), NOW()), + (, 'Pangolin 专业版·年付', '366 天','199.99', 1, 3, 'pro_year', NOW(), NOW()); + +-- ③ USDT 结算价(crypto 渠道必需;微单位) +-- 29.99/7.15≈4.19 → 4.20;68.88/7.15≈9.63 → 9.70;199.99/7.15≈27.97 → 27.99 +INSERT INTO product_prices (product_id, currency, amount_minor, created_at, updated_at) VALUES + ((SELECT id FROM products WHERE biz_code='pro_month'), 'USDT', 4200000, NOW(), NOW()), + ((SELECT id FROM products WHERE biz_code='pro_quarter'), 'USDT', 9700000, NOW(), NOW()), + ((SELECT id FROM products WHERE biz_code='pro_year'), 'USDT', 27990000, NOW(), NOW()); +``` + +> 注:pay 是 GORM/MySQL 环境,`NOW()` 在 pay 库合法(pangolin 的「禁 NOW()」纪律只约束本仓 server SQL)。执行前用 `SELECT * FROM products WHERE biz_code LIKE 'pro_%'` 确认无残留旧行(幂等按 biz_code 判)。 + +### B. pay 侧 biz 配置(pay config,字段名以 pay `internal/config` 实际为准) + +```yaml +# pay 配置新增业务系统条目(与 config.C.BizByName 对应): +biz_systems: + - name: pangolin + secret: <与 pangolin PAY_BIZ_SECRET 相同,Bitwarden 生成 32+ 字节随机串> + callback_url: http://:8080/v1/webhook/pay + supported_events: [payment.succeeded] # 白名单只开这一个 +``` + +### C. pangolin 侧 env(`/etc/pangolin-server.env` 或等价,不入 git) + +```bash +PAY_BASE_URL=http:// # 如 https://pay.51yanmei.com +PAY_BIZ_SYSTEM=pangolin # 默认值即 pangolin,可省 +PAY_BIZ_SECRET=<同 B 节 secret,Bitwarden 取> +``` + +部署顺序:pangolin `cmd/migrate up`(000021)→ 重启 pangolin-server → pay 侧种子+biz 配置 → 联调 checklist。 + +--- + +## 风险清单(执行者注意) + +1. **SQLite subscriptions 重建**(Task 1):worktree 测试全走 `:memory:` 无存量;生产 sqlite 库升级前务必过 Task 8 Step 2 彩排。重建期间的 FK 引用(无表引用 subscriptions,安全)已核。 +2. **`auth.UserIDFromContext` 与 `codes.CtxKeyUserID` 的耦合**:handler 测试直接注 `codes.CtxKeyUserID`——若 auth 侧 helper 实现读的是别的 key,测试会假红;实现 Task 4 时先读 `internal/auth/middleware.go:71` 确认(锚点已核:就是这个 key)。 +3. **pay 状态词汇**:`OrderStatus.Status` 的取值(pending/succeeded/canceled…)以 pay 实现为准,pangolin 只透传展示、不做分支判断(成功判据是本地 activated)——避免词汇漂移引 bug。 +4. **`t.Context()`**(Task 3 测试)需 go1.24+;CI 容器 golang:1.25 满足,本机旧 Go 则替换 `context.Background()`。 +5. **Flutter `_FixedFlowController`**(Task 7 测试)的构造细节以最终编译为准,允许给生产控制器加 `@visibleForTesting` 命名构造,不允许放宽生产逻辑。 +6. **桌面壳 payment 返回键**:等待支付中用户点返回(purchase)不取消订单(订单仍在 pay 侧 pending,可从头再进);只有显式「取消订单」才 cancel——注意别在 onBack 里误调 cancel()。 +