Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
125 KiB
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 +\njoin + std base64,照抄 payutil/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-33createV2Request);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 .../retrybody{method, metadata?};跨币种 → 409currency_mismatch;非 pending → 409order_not_pending。cancel:POST .../cancel→{"data":{"canceled":bool}}。改状态 POST 有 per-IP 限流(默认 30/min,429rate_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.succeededpayload(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:metadatarender=qr→qr,is_mobile真值→wap,否则 page。 - 产品解析:sku=
products.biz_code(active);crypto 结算 USDT 必须有product_prices(product_id,'USDT',amount_minor)行;alipay CNY 可 fallbackproducts.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()(mysqlFOR 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.dartenum 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登记簿)、卡片=inlineContainer(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.dartApiClient.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(不加);剪贴板用 SDKflutter/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、pangolincodes/store.goCreateSubscription 的'code'硬编码、clientAuthApiException无 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:
-- 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:
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 序列):
-- 订阅 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 前人工清理):
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 ./...全绿;commitfeat(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):
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: 实现
store.go::CreateSubscription签名加尾参source string,INSERT 改VALUES (?, ?, ?, ?, ?)并传source(替换硬编码'code')。grep -rn "CreateSubscription(" server --include='*.go'找全部调用点:service.go::applySubscription一处 + 可能的测试直调(如internal/store/sqlite_stores_test.go)——全部补"code"实参。service.go::applySubscription签名加尾参source string,透传给CreateSubscription;grantSubscription回调内调用处传"code"。(ExtendSubscription 路径不动 source:行的 source 记录「创建者」,付费延长既有卡密行属预期,台账/审计有完整出处。)- 新文件
server/internal/codes/paygrant.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 参数化行为零变化);commitfeat(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(包内测试,可用未导出符号):
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:
// 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:
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 ./...全绿;commitfeat(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 是包内测试):
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:
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 或传空 handlerfunc(http.ResponseWriter,*http.Request){},实现时取后者。
- Step 2: 实现
server/internal/pay/catalog.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()):
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:
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 构造块之后加:
// ── 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", ...) 附近)加:
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 ./...全绿;commitfeat(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:
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:
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 块之后):
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", ...) 旁):
if payWebhook != nil {
v1.Post("/webhook/pay", payWebhook.ServeHTTP)
}
- Step 4: 验证 + commit——
go build ./... && go vet ./... && go test ./...全绿;另跑./run_sqlite_test.sh兜一遍实库;commitfeat(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:
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 解析处):
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:
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):
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:
// 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:
// 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):
// 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全绿;commitfeat(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(前置)
client/pubspec.yamldependencies 加url_launcher: ^6.3.0,flutter pub get。(剪贴板用 SDKClipboard,零依赖;qr 预留不加 qr_flutter。)client/lib/l10n/app_text.dart抽象契约加(红线词自查:无 VPN/翻墙等):
// ── 购买 / 支付 ──
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
strings_zh.dart/strings_en.dart各补实现(照上注释的中英文案)。跑bash ci/scan-redline.sh(或 CI 同款 grep)确认无红线词。
- Step 2: 失败测试
client/test/widget/payment_pages_test.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→ 进支付页):
// 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:
// 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: 导航接线
lib/state/navigation_provider.dart:enum 加purchase, payment;kAccountSubViews加两者。lib/shell/desktop_shell.dart:titles加NavView.purchase: t.purchaseTitle, NavView.payment: t.paymentTitle;content()加:
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)。
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/paymentcase(样式对齐各壳既有子页写法)。flutter analyze过——Dart switch 对 enum 是穷尽检查,漏壳会直接编译错,这是接线完整性的保险。
- Step 6: 验证 + commit——
flutter analyze && flutter test全绿(含 Task 6 单测 + 本任务 widget 测试 + 既有 golden 不回归——新页面不进 golden,见 Self-Review 取舍);commitfeat(client): 购买页三档 + 支付页 render_type 多态(crypto/redirect/qr 预留)+ 导航接线。
Task 8: 终验 — 全量矩阵 + OpenAPI 登记 + 交付说明
Steps
- Step 1: 全量测试矩阵
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与 payutil/sign.go逐字节同构(\njoin + 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 全覆盖签名/幂等语义;本节是真环境验收,逐条打勾。
- pay 侧就绪:pay-server 部署完成,
/api/v2可达;pangolin 的 biz 配置与种子 SQL 已按「部署附录」落库(products×3 + product_prices(USDT)×3 + biz_system=pangolin)。 - 价格一致性:
GET <pay>/api/v2下单三档,断言 pay 返回/扣款金额与 pangolincatalog.go展示价一致(CNY 2999/6888/19999 分;USDT 按附录种子)。 - pangolin server 配置:
PAY_BASE_URL/PAY_BIZ_SYSTEM=pangolin/PAY_BIZ_SECRET(与 pay biz 配置同 secret,存 Bitwarden 不落明文)已入/etc/pangolin*env;重启后日志无「PAY_BASE_URL 未配置」。 - 签名互通: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)。 - webhook 连通:pay 侧 CallbackURL 指向
http://<pangolin-server>:8080/v1/webhook/pay;SupportedEvents=[payment.succeeded];用 pay 的重发工具(或手工 SQL 触发)投递一条 → pangolin 日志无验签错误、pay 侧标记 delivered(收到 200+SUCCESS)。 - USDT 真付一单(小额档):转账精确金额 → pay 确认 → webhook → App 轮询页自动切「已开通」;
subscriptions出现source='pay'行,pay_purchases行paid+ sub_id/amount/currency 回填;audit_log 有pay_grant。 - 重投验证:pay 侧手动重发同一事件 → pangolin 回 SUCCESS,订阅到期不变(查库比对)。
- 支付宝 redirect 一单:
method=alipay+metadata.is_mobile按端型 → 返回 redirect url 可拉起;支付后同 6 号流程走通。 - 换渠道:crypto 下单后
POST .../retry {"method":"alipay"}→ 收 409 CURRENCY_MISMATCH;App 自动取消旧单新建 alipay 单(观察 pay 侧旧单 canceled、新单 pending)。 - 叠加:同一账号再购一档 →
expires_at在原值上顺延(不是从 now 重算,若原订阅未过期)。 - 限流不误伤:连续下单/取消超 30 次/分触发 pay 429 → App 提示「操作过于频繁」而非崩溃。
- 时钟检查:pangolin1 与 pay 所在机
timedatectlNTP 同步(±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 侧,pangolincatalog.go展示价同步改。
-- ① 收款商户(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 实际为准)
# 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)
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。
风险清单(执行者注意)
- SQLite subscriptions 重建(Task 1):worktree 测试全走
:memory:无存量;生产 sqlite 库升级前务必过 Task 8 Step 2 彩排。重建期间的 FK 引用(无表引用 subscriptions,安全)已核。 auth.UserIDFromContext与codes.CtxKeyUserID的耦合:handler 测试直接注codes.CtxKeyUserID——若 auth 侧 helper 实现读的是别的 key,测试会假红;实现 Task 4 时先读internal/auth/middleware.go:71确认(锚点已核:就是这个 key)。- pay 状态词汇:
OrderStatus.Status的取值(pending/succeeded/canceled…)以 pay 实现为准,pangolin 只透传展示、不做分支判断(成功判据是本地 activated)——避免词汇漂移引 bug。 t.Context()(Task 3 测试)需 go1.24+;CI 容器 golang:1.25 满足,本机旧 Go 则替换context.Background()。- Flutter
_FixedFlowController(Task 7 测试)的构造细节以最终编译为准,允许给生产控制器加@visibleForTesting命名构造,不允许放宽生产逻辑。 - 桌面壳 payment 返回键:等待支付中用户点返回(purchase)不取消订单(订单仍在 pay 侧 pending,可从头再进);只有显式「取消订单」才 cancel——注意别在 onBack 里误调 cancel()。