# 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()。