Files
pangolin/docs/superpowers/plans/2026-07-10-pangolin-pay-v2-integration.md
T

2989 lines
125 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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:<out_trade_no>"),审计写失败不影响事务(与兑换一致)。
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<String, dynamic>;
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<List<PayCatalogItem>> catalog() async => const [];
@override
Future<PayOrder> createOrder({required String sku, required String method, Map<String, String>? 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<PayOrderStatus> orderStatus(String orderNo) async {
statusCalls++;
return PayOrderStatus(orderNo: orderNo, payStatus: activated ? 'succeeded' : 'pending', activated: activated, expiresAt: null);
}
@override
Future<PayOrder> retry(String orderNo, {required String method, Map<String, String>? metadata}) async {
if (retryError != null) throw retryError!;
return PayOrder(orderNo: orderNo, session: PaySession(renderType: 'redirect', payload: const {'url': 'https://y'}, expiresAt: null));
}
@override
Future<void> 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<String, dynamic> 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<String, dynamic> payload;
final DateTime? expiresAt;
factory PaySession.fromJson(Map<String, dynamic> j) => PaySession(
renderType: j['render_type'] as String? ?? '',
payload: (j['payload'] as Map<String, dynamic>?) ?? 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<String, dynamic> j) => PayOrder(
orderNo: j['order_no'] as String,
session: PaySession.fromJson((j['session'] as Map<String, dynamic>?) ?? 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<String, dynamic> 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<List<PayCatalogItem>> catalog() async {
final body = await _c.getJson('/v1/pay/catalog');
final items = (body['items'] as List<dynamic>?) ?? const [];
return [for (final it in items) PayCatalogItem.fromJson(it as Map<String, dynamic>)];
}
Future<PayOrder> createOrder({
required String sku,
required String method,
Map<String, String>? metadata,
}) async =>
PayOrder.fromJson(await _c.postJson('/v1/pay/orders', {
'sku': sku,
'method': method,
if (metadata != null && metadata.isNotEmpty) 'metadata': metadata,
}));
Future<PayOrderStatus> orderStatus(String orderNo) async =>
PayOrderStatus.fromJson(await _c.getJson('/v1/pay/orders/$orderNo'));
Future<PayOrder> retry(String orderNo, {required String method, Map<String, String>? metadata}) async =>
PayOrder.fromJson(await _c.postJson('/v1/pay/orders/$orderNo/retry', {
'method': method,
if (metadata != null && metadata.isNotEmpty) 'metadata': metadata,
}));
Future<void> 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<PaymentApi>(
(ref) => PaymentApi(ref.watch(apiClientProvider)),
);
final payCatalogProvider = FutureProvider<List<PayCatalogItem>>(
(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<PaymentFlowState> {
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<String, String> _metadata(String method) =>
method == 'alipay' ? {'is_mobile': _isMobile ? '1' : '0'} : const {};
Future<void> 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<void> 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<void> 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<void> 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<PaymentFlowController, PaymentFlowState>(
(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<void> _choose(BuildContext context, WidgetRef ref, PayCatalogItem item) async {
final c = context.pangolin;
final method = await showModalBottomSheet<String>(
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<void> _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<void> _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 <pay>/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://<pangolin-server>: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,下面记作 <MID>
-- ② 三档产品(biz_code 即 v2 sku,与 pangolin catalog.go 严格一致)
INSERT INTO products (merchant_id, name, description, price, active, sort, biz_code, created_at, updated_at) VALUES
(<MID>, 'Pangolin 专业版·月付', '31 天', '29.99', 1, 1, 'pro_month', NOW(), NOW()),
(<MID>, 'Pangolin 专业版·季付', '92 天', '68.88', 1, 2, 'pro_quarter', NOW(), NOW()),
(<MID>, '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://<pangolin-server>:8080/v1/webhook/pay
supported_events: [payment.succeeded] # 白名单只开这一个
```
### C. pangolin 侧 env(`/etc/pangolin-server.env` 或等价,不入 git)
```bash
PAY_BASE_URL=http://<pay-server 地址> # 如 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()。