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

125 KiB
Raw Blame History

pangolin · 接入 pay v2 统一支付网关 Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务实现本计划。步骤用 - [ ] checkbox 追踪。每个任务自包含:不读全仓也能按锚点+代码落地。

契约真相源(必读,pay 仓分支 design/pay-v2 @ c0c7ecc,当前 ~/code/pay 工作区即该分支):

  • ~/code/pay/internal/handler/gateway.go + internal/router/router.go——/api/v2 端点(下单/查单/retry/cancel;改状态端点 per-IP 限流 429)。
  • ~/code/pay/internal/handler/order.go::verifyBizSign + internal/util/sign.go::HMACSign——业务方签名范式。
  • ~/code/pay/internal/webhook/notifier.go + internal/gateway/settle.go——webhook 出站契约(头/签名/重试/ACK)。
  • ~/code/pay/docs/pay-v2-unified-gateway-design.html §4.2——render_type 契约。
  • 本仓 CLAUDE.md「数据层:多数据库」节——双方言纪律;server/internal/codes/service.go::applySubscription——订阅叠加语义唯一真相(webhook 开通必须复用,不复制)。

Goal: pangolin 客户端可以用「支付宝 / USDT(TRC20)」购买 pro 月/季/年三档订阅:server 侧以业务方身份(biz_system=pangolin,HMAC 签名)代理 pay v2 下单/查单/换渠道/取消,接收 payment.succeeded webhook 后复用 codes 的订阅叠加语义开通/续期;client 侧新增购买页(三档 PlanCard)与支付页(按 render_type 多态 + 轮询)。全部单测以 httptest 模拟 pay(签名语义照抄 pay 源码),端到端真联调等 pay 部署后按文末 checklist 执行。不 merge、不部署、不发版。

Architecture:

  • server 新包 internal/pay/:sign.go(HMAC-SHA256 + \n join + std base64,照抄 pay util/sign.go)、client.go(出站 PayClient:下单签名,查单/retry/cancel 免签——pay 侧只在 create 且 biz_system 非空时验签)、catalog.go(sku→plan/days/展示价 单源)、store.go(购买台账 pay_purchases)、handler.go(JWT 保护的 App 代理端点 /v1/pay/*)、webhook.go(POST /v1/webhook/pay 接收器)。
  • 叠加复用:internal/codes 内新增导出方法 Service.GrantPaidSubscriptionTx(ctx, tx, userID, plan, days, ref)——在调用方事务里走 GetPlanIDTx → applySubscription(source='pay'),与兑换码同一段代码;applySubscription/Store.CreateSubscription 参数化 source(兑换路径传 'code',行为零变化)。
  • 幂等三层:传输层 nonce SETNX(±5min 窗口,rdb 可空降级)→ 业务层以 out_trade_no 为幂等键(pay 重投每次换新 nonce,唯一可靠键是订单号):台账行 status created→paid 在锁内 CAS,已 paid 直接回 SUCCESS → 兜底:台账缺行(下单后本地写失败)时按 biz_ref(=用户 uuid)定位用户补建台账再开通,webhook 自足可修复。
  • client:services/payment_api.dart(经 ApiClient 走 server 代理,JWT 自动注入)+ state/payment_provider.dart(StateNotifier 支付流控制器,3s 轮询查单直到本地 activated)+ screens/purchase_page.dart/payment_page.dart(组件全部取自 client/lib/widgets/ 真相源:PlanCard/PangolinButton/showPangolinToast/PangolinIcons+既有 inline card 配方,不新造样式)。

关键决策(已定,含依据)

决策点 结论 依据
sku 命名 pro_month / pro_quarter / pro_year(= pay products.biz_code,webhook product_biz_code 原样回带) 计划前提定死;pay 侧 skubiz_code(gateway.goDBProductResolver.Resolvebiz_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,在 mountV1os.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;AuthApiExceptioncode 字段以便 client 分辨
qr 渲染 预留:显示 display_amount + 复制 qr_content 兜底文案,不引入 qr_flutter scope 定义「qr 预留」;当面付非首发渠道

Global Constraints

  • 分支/worktree:全部工作在本 worktree(.claude/worktrees/pay-v2-integration,基于 codes 集成分支)。只加代码不动 codes 集成已有行为;todo/ 不在本计划内动。
  • 双方言铁律(仓根 CLAUDE.md):SQL 一律 ? 占位、时间 Go 端算好传参、禁 NOW()/UTC_TIMESTAMP()/FIELD();upsert 走 dialect.Upsert、行锁走 dialect.LockForUpdate();迁移 migrations/{mysql,sqlite}/000021_* 两套同号。
  • 文案脱敏:所有用户可见文案(apierr 双语、Flutter l10n)禁「VPN/翻墙/科学上网」等红线词(CI ci/scan-redline.sh 扫描)。
  • 金额纪律:金额一律 int64 最小单位(CNY=分,USDT=微);客户端只传 sku+method,绝不传金额;展示价仅 catalog,扣款额以 pay 为准。
  • HMAC 契约逐字节照抄(pay internal/util/sign.go):HMAC-SHA256,message = strings.Join(parts, "\n"),std base64(非 hex 非 url-safe);入站/出站 parts 均为 [system, ts, nonce, rawBody];ts=unix 秒十进制字符串,窗口 ±300s。
  • webhook ACK:HTTP 200 且 body 含 SUCCESS(pay 大写包含判定),否则 pay 以 30s→1h 退避重投最多 12 次——接收器必须重投安全
  • 每个 Task 结束:server 任务跑 cd /Users/wangjia/code/pangolin/.claude/worktrees/pay-v2-integration/server && go build ./... && go vet ./... && go test ./...;client 任务跑 cd .../client && flutter analyze && flutter test。全绿再 commit(每刀一 commit)。
  • 范围外:退款接入、订阅(recurring)接入、旧 pangolin-pay 退役、IAP/广告、部署/发版、pay 侧任何代码改动。

Task 0: Preflight — 现状锚点(全部已于 2026-07-10 逐文件核实,执行前抽查 2-3 条)

pay 侧契约锚点(~/code/pay,分支 design/pay-v2 @ c0c7ecc)

  • 下单:POST /api/v2/orders,body {sku, method, biz_system?, biz_ref?, return_url?, metadata?}(handler/gateway.go:24-33 createV2Request);metadata 白名单is_mobile/render(gateway.go:38-41);biz_system!="" 必验签(order.go:68-93 verifyBizSign:头 X-Pay-System/X-Pay-Timestamp/X-Pay-Nonce/X-Pay-Sign,±300s,HMACVerify(secret, sign, system, ts, nonce, rawBody));body 上限 64KB。
  • 响应包络:成功 {"data": ...},错误 {"code","message"}(util/response.go)。下单/retry 返回 OrderResult{order_no, session{render_type, payload, expires_at?}};查单 GET /api/v2/orders/:order_no 返回 {order_no, status, subject, amount_minor, currency, paid_at?}(无 session——render payload 只在 create/retry 给)。
  • retry:POST .../retry body {method, metadata?};跨币种 → 409 currency_mismatch;非 pending → 409 order_not_pending。cancel:POST .../cancel{"data":{"canceled":bool}}。改状态 POST 有 per-IP 限流(默认 30/min,429 rate_limited)。
  • 签名原语(util/sign.go):HMACSign(secret, parts...) = base64.Std(HMAC-SHA256(secret, strings.Join(parts,"\n")))
  • webhook 出站(webhook/notifier.go:142-191):POST 到 biz CallbackURL,头 X-Pay-System/X-Pay-Event/X-Pay-Timestamp/X-Pay-Nonce/X-Pay-Sign,sign parts=[bizSystem, ts, nonce, payloadJSON];nonce 每次重投都是新 uuid;ACK=200+body 含 "SUCCESS";退避 30s×2 上限 1h,maxAttempts 12。
  • payment.succeeded payload(gateway/settle.go:84-95):{event_type, out_trade_no, biz_system, biz_ref, product_biz_code, amount_minor(int64), currency, channel, paid_at(RFC3339)}——无 refund_id 字段
  • render_type payload:crypto_address{address, amount(string 展示), amount_minor(int64), currency:"USDT", network:"TRC20", contract}(provider/crypto/crypto.go:223-235);redirect{url}(provider/alipay/alipay.go:108-112);qr{qr_content, display_amount(元 string), currency:"CNY"}(alipay.go:138-147)。alipay:metadata render=qr→qr,is_mobile 真值→wap,否则 page。
  • 产品解析:sku=products.biz_code(active);crypto 结算 USDT 必须product_prices(product_id,'USDT',amount_minor) 行;alipay CNY 可 fallback products.price 元字符串。

pangolin server 锚点(本 worktree)

  • 迁移最高号 000020(migrations/{mysql,sqlite}/000020_codes_lib_legacy_rename.*;internal/store/sqlite_migrate_test.go:31-33 断言 version = 20)→ 本计划新增 000021
  • subscriptions 表:source ENUM('trial','code')(mysql 000002)/ TEXT CHECK (source IN ('trial','code'))(sqlite 000002);索引 idx_subs_user_exp/idx_user_exp
  • 叠加语义:internal/codes/service.go:182-221 applySubscription(ctx, tx, userID, planID, durationDays)——同 plan 活跃订阅取最晚一条 max(expires,now)+days 原地延长(store.go:131 ExtendSubscription),否则新建 max(now, latestSamePlan)+days(store.go:155 CreateSubscription,source 硬编码 'code')。store.go:93 GetPlanIDTx / store.go:186 WriteAuditLog / store.go:239 BeginTx;PlanCode 常量 PlanFree/PlanPro/PlanTeam(store.go:13-20)。
  • 路由:chi;cmd/server/main.go:222 mountV1(r chi.Router, sqlDB *sql.DB, rdb *redis.Client, nodeSvc *nodes.Service);webhook 免 JWT 挂 v1.Post("/webhook/store/codes", ...)(main.go:363);受保护组 v1.Group(func(protected chi.Router){ protected.Use(auth.RequireAuth(tm)); ... });getenvDefault 助手已存在。
  • 鉴权取 uid:auth.UserIDFromContext(ctx) (int64, bool)(middleware 注入 codes.CtxKeyUserID);错误统一 apierr.WriteJSON(w, status, *apierr.Error),apierr.New(code, zh, en),预置 ErrUnauthorized/ErrBadRequest/ErrNotFound/ErrInternal/ErrRateLimited
  • dialect:internal/db/dialect.go——dbx.DialectForDB(db),LockForUpdate()(mysql FOR UPDATE/sqlite 空),Upsert(conflictCols, setExprs...)
  • 出站 HTTP 范式:internal/alert/notifier.go(http.Client{Timeout} 存 struct + NewRequestWithContext + drain body + BaseURL 测试钩子)。
  • sqlite 测试底座:internal/codes/sqlite_helper_test.go::openMigratedSQLite(store.Open(&config.Config{Driver:"sqlite",DSN:":memory:"}) + store.MigrateUp(db,"sqlite") + store.ApplyCodesLibMigrations)+ seedUser(t, db, id);plans 三行由迁移种子。httptest 风格:internal/codes/webhook_test.go
  • OpenAPI:CI 只结构校验 design/server/openapi.yaml(不校验与代码同步);server/api/openapi.yaml 为文档,Task 8 顺手补新端点。

pangolin client 锚点

  • 导航:lib/state/navigation_provider.dart enum NavView { connect, servers, stats, account, contact, settings, plans, redeem, devices } + kAccountSubViews;4 壳 lib/shell/{home,desktop,tablet,mobile}_shell.dart 各有 switch(view);移动端下钻用 Navigator.push(MaterialPageRoute)(screens/account_page.dart:41-47 open())。
  • 组件真相源 lib/widgets/:PlanCard{name, price, period, features, ctaLabel, featured, isCurrent, popularLabel, onPressed}PangolinButton{label, onPressed, icon?, variant(primary/secondary/ghost/danger), expand}showPangolinToast(context, msg)ContentTopBarPangolinIcons.{copy?, externalLink, checkCircle, creditCard, arrowLeft}(copy 需 grep 确认,无则用 LucideIcons.copy 补进 pangolin_icons.dart 登记簿)、卡片=inline Container(color: c.surface, radius PangolinRadius.lg, border c.border, shadow PangolinShadow.sm) 配方(account_page.dart::_Card)、子页骨架 _SubScaffold(widgets/account_screens.dart:25,embedded 双态)。
  • 主题:final c = context.pangolin;(PangolinScheme:bg/surface/fg1-3/accent/border/success/danger…);文本 PangolinText.{display,h1-3,body,sm,caption,mono};金额/地址用 PangolinText.mono
  • 网络:lib/services/api_client.dart ApiClient.getJson/postJson(Bearer 自动注入,401 刷新重试一次,非 2xx 抛 AuthApiException{statusCode, messageZh, messageEn}——无 code 字段,Task 6 补);DI 在 lib/state/account_providers.dart(apiClientProvider/accountApiProvider/meProvider(AsyncNotifier)/plansProvider)。
  • l10n:lib/l10n/app_text.dart 抽象 getter 契约 + strings_zh.dart/strings_en.dart 双实现;组件内禁写死字面量
  • 测试:test/helpers/harness.dart::wrapThemed(child, {overrides}) + setUpAll(disableGoogleFontsFetching);网络 mock 用 package:http/testing.dart MockClient 或 provider override;golden 在 test/golden/desktop_pages_golden_test.dart(本计划不加新 golden,取舍见 Self-Review)。
  • pubspec:有 http/flutter_riverpod;无 url_launcher(Task 7 加)、无 qr_flutter(不加);剪贴板用 SDK flutter/services.dart Clipboard(零依赖)。

Steps

  • Step 1: 基线——cd /Users/wangjia/code/pangolin/.claude/worktrees/pay-v2-integration/server && go build ./... && go test ./...;cd ../client && flutter analyze && flutter test。基线不绿先停(带病不开工)。
  • Step 2: 抽查锚点——任选 3 条(建议:pay util/sign.go 的 base64.Std、pangolin codes/store.go CreateSubscription 的 'code' 硬编码、client AuthApiException 无 code 字段)确认没漂移;漂移则回报调整计划。

Task 1: 迁移 000021 — pay_purchases 台账 + subscriptions.source 扩 'pay'(双方言)

产出:server/migrations/{mysql,sqlite}/000021_pay_purchases.{up,down}.sql 四个文件 + sqlite_migrate_test.go 断言更新。

Steps

  • Step 1: 先改测试(失败先行)——server/internal/store/sqlite_migrate_test.go:

    • if v != 20if 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 ./... 全绿;commit feat(server): 迁移 000021 pay_purchases 台账 + subscriptions.source 扩 pay

Task 2: codes 包提炼 — GrantPaidSubscriptionTx(叠加语义复用,source 参数化)

产出:internal/codes/store.go CreateSubscriptionsource 参数;service.go applySubscriptionsource 参数(兑换路径传 "code");新文件 internal/codes/paygrant.go 导出事务内授予方法;sqlite 测试。

Steps

  • Step 1: 失败测试——新文件 server/internal/codes/paygrant_sqlite_test.go(复用 sqlite_helper_test.goopenMigratedSQLite/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: 实现
    1. store.go::CreateSubscription 签名加尾参 source string,INSERT 改 VALUES (?, ?, ?, ?, ?) 并传 source(替换硬编码 'code')。
    2. grep -rn "CreateSubscription(" server --include='*.go' 找全部调用点:service.go::applySubscription 一处 + 可能的测试直调(如 internal/store/sqlite_stores_test.go)——全部补 "code" 实参。
    3. service.go::applySubscription 签名加尾参 source string,透传给 CreateSubscription;grantSubscription 回调内调用处传 "code"。(ExtendSubscription 路径不动 source:行的 source 记录「创建者」,付费延长既有卡密行属预期,台账/审计有完整出处。)
    4. 新文件 server/internal/codes/paygrant.go:
package codes

import (
	"context"
	"database/sql"
	"encoding/json"
	"time"
)

// GrantPaidSubscriptionTx 在调用方事务里执行支付驱动的订阅开通/续期。
// 与兑换码走同一段叠加语义(applySubscription):同 plan 有活跃订阅则原地
// 延长 max(expires,now)+days,否则新建 source='pay' 的一行。ref 为审计
// 追踪串(如 "pay:<out_trade_no>"),审计写失败不影响事务(与兑换一致)。
func (svc *Service) GrantPaidSubscriptionTx(
	ctx context.Context, tx *sql.Tx, userID int64, plan PlanCode, days int, ref string,
) (subID int64, expiresAt time.Time, err error) {
	planID, err := svc.store.GetPlanIDTx(ctx, tx, plan)
	if err != nil {
		return 0, time.Time{}, err
	}
	subID, expiresAt, err = svc.applySubscription(ctx, tx, userID, planID, days, "pay")
	if err != nil {
		return 0, time.Time{}, err
	}
	meta, _ := json.Marshal(map[string]any{
		"plan": string(plan), "duration_days": days, "sub_id": subID,
	})
	_ = svc.store.WriteAuditLog(ctx, tx, formatUserActor(userID), "pay_grant", ref, string(meta))
	return subID, expiresAt, nil
}

formatUserActor:若 service.go 里已有 fmt.Sprintf("user:%d", userID) 的内联写法而无助手,则在 paygrant.go 里直接 fmt.Sprintf("user:%d", userID)(import fmt),不强造助手。

  • Step 3: 验证 + commit——go build ./... && go test ./... 全绿(既有兑换测试必须原样过——source 参数化行为零变化);commit feat(server): codes.GrantPaidSubscriptionTx 提炼订阅叠加语义供 pay 复用

Task 3: internal/pay — 签名原语 + 出站 PayClient(下单/查单/retry/cancel)

产出:server/internal/pay/{sign.go,client.go,client_test.go}。签名逐字节照抄 pay internal/util/sign.go;测试用 httptest 起假 pay,验签语义照抄 verifyBizSign

Steps

  • Step 1: 失败测试 server/internal/pay/client_test.go(包内测试,可用未导出符号):
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 ./... 全绿;commit feat(server): pay v2 出站客户端(HMAC 签名下单/查单/retry/cancel)

Task 4: internal/pay — catalog + 购买台账 Store + App 代理端点 + mountV1 接线

产出:server/internal/pay/{catalog.go,store.go,handler.go,handler_test.go,testutil_test.go} + cmd/server/main.go 接线。端点(全部 JWT 保护):GET /v1/pay/catalogPOST /v1/pay/ordersGET /v1/pay/orders/{orderNo}POST /v1/pay/orders/{orderNo}/retryPOST /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 或传空 handler func(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 ./... 全绿;commit feat(server): /v1/pay 下单代理端点 + 购买台账(JWT 鉴权,user→biz_ref 映射)

Task 5: webhook 接收器 — 验签/时间窗/nonce → 幂等开通(复用叠加语义)→ 回 SUCCESS

产出:server/internal/pay/{webhook.go,webhook_sqlite_test.go} + mountV1 webhook 路由。重投安全是硬指标:pay 以 30s→1h 退避重投 12 次,每次 nonce 都是新的——幂等只能靠 out_trade_no 锁内 CAS。

Steps

  • Step 1: 失败测试 server/internal/pay/webhook_sqlite_test.go:
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 兜一遍实库;commit feat(server): pay webhook 接收器(验签/时间窗/nonce + out_trade_no 幂等开通,回 SUCCESS)

Task 6: Flutter — 模型 + PaymentApi + 支付流控制器(轮询)

产出:client/lib/models/payment.dartclient/lib/services/payment_api.dartclient/lib/state/payment_provider.dartAuthApiExceptioncode 字段、单测两份。全部经 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 全绿;commit feat(client): pay 支付领域模型 + PaymentApi + 支付流控制器(轮询 activated)

Task 7: Flutter — 购买页 + 支付页(render_type 多态)+ 导航/l10n/pubspec + widget 测试

产出:client/lib/screens/purchase_page.dartclient/lib/screens/payment_page.dart、NavView 扩展 + 4 壳接线、l10n 三文件新增文案、pubspec 加 url_launcher、widget 测试。UI 铁律:组件全部取自 client/lib/widgets/ 真相源(PlanCard/PangolinButton/showPangolinToast/PangolinIcons/_SubScaffold 模式/inline card 配方);不新造视觉样式——若实现中发现需要真相源没有的组件,停下先补 design/preview/ 规格再继续(全局记忆铁律)。

Steps

  • Step 1: pubspec + l10n(前置)
    1. client/pubspec.yaml dependencies 加 url_launcher: ^6.3.0,flutter pub get。(剪贴板用 SDK Clipboard,零依赖;qr 预留不加 qr_flutter。)
    2. client/lib/l10n/app_text.dart 抽象契约加(红线词自查:无 VPN/翻墙等):
  // ── 购买 / 支付 ──
  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
  1. 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 不触碰,或把 _refRef? 空安全短路),测试用 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.dartstatic const copy = LucideIcons.copy; 并登记进 name map(登记簿新增图标属允许操作,非新造样式)。

  • Step 5: 导航接线
    1. lib/state/navigation_provider.dart:enum 加 purchase, payment;kAccountSubViews 加两者。
    2. lib/shell/desktop_shell.dart:titlesNavView.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)。
  1. plans 入口改道:desktop_shell 的 case NavView.plans: PlansScreen(..., onChoose: (code) => go(code == 'pro' ? NavView.purchase : NavView.redeem));tablet/mobile 壳与 account_page.dart 的升级入口同法(移动端 Navigator.push(MaterialPageRoute(builder: (_) => PurchaseScreen(t: t, onOrderCreated: () => Navigator.of(context).push(MaterialPageRoute(builder: (_) => PaymentScreen(t: t))))))——沿用 account_page.dart::open() 的双态惯例)。tablet_shell/mobile_shell 的 switch 各补 purchase/payment case(样式对齐各壳既有子页写法)。
  2. flutter analyze 过——Dart switch 对 enum 是穷尽检查,漏壳会直接编译错,这是接线完整性的保险。
  • Step 6: 验证 + commit——flutter analyze && flutter test 全绿(含 Task 6 单测 + 本任务 widget 测试 + 既有 golden 不回归——新页面不进 golden,见 Self-Review 取舍);commit feat(client): 购买页三档 + 支付页 render_type 多态(crypto/redirect/qr 预留)+ 导航接线

Task 8: 终验 — 全量矩阵 + OpenAPI 登记 + 交付说明

Steps

  • Step 1: 全量测试矩阵
cd /Users/wangjia/code/pangolin/.claude/worktrees/pay-v2-integration/server
go build ./... && go vet ./... && go test ./... && ./run_sqlite_test.sh
# docker 可用则加跑:./run_mysql_test.sh(重点:000021 迁移在 mysql 上的 MODIFY ENUM)
cd ../client
flutter analyze && flutter test
  • Step 2: 迁移彩排(sqlite 文件库模拟带数据升级)——重点验 000021 的 subscriptions 重建不丢行、id/AUTOINCREMENT 序列保持:up 到 000020 → 手工插 users/subscriptions(source='trial'/'code' 各一行)→ up 到 021 → 断言行数/id 不变、可插 source='pay' → down → up 幂等。
  • Step 3: OpenAPI 登记——server/api/openapi.yaml/v1/pay/catalog/v1/pay/orders/v1/pay/orders/{orderNo}(GET/retry/cancel)、/v1/webhook/pay 条目(CI 仅结构校验 design/server/openapi.yaml,若两文件同步维护则两处都补);python -m openapi_spec_validator 本地过一遍(或走 CI)。
  • Step 4: 手动冒烟(可选,本地起 server)——PAY_BASE_URL 指向本机假 pay(可用 Task 3 的 httptest 思路写个临时 main 或 python -m http.server 改造),curl 走一遍 create→webhook→get 看 activated 翻转。
  • Step 5: 收尾 commit + 汇总——向用户报告:测试矩阵结果、联调 checklist(下节)与部署附录(pay 侧种子/biz 配置/pangolin env)是上线前提、本计划已知取舍(Self-Review)。到此为止,不 merge、不部署。

Self-Review Checklist(执行完逐条打勾,含取舍说明)

  • git log 每刀一 commit;server/client 全量测试绿;run_sqlite_test.sh 绿。
  • 金额纪律:client 代码 grep amount 确认只有展示/payload 读取,无任何请求体携带金额;server 下单请求体只含 sku/method/biz_system/biz_ref/metadata。
  • HMAC 对称性:internal/pay/sign.go 与 pay util/sign.go 逐字节同构(\n join + std base64);webhook 验签 parts 顺序 [system, ts, nonce, body] 与 notifier 出站一致。
  • 重投安全:TestWebhook_RedeliveryIdempotent 断言订阅行数与到期都不变;500 路径(未知 sku)不产生半态(事务回滚)。
  • 叠加语义零复制:webhook 开通只经 GrantPaidSubscriptionTx → applySubscription,grep -rn "AddDate" server/internal/pay/ 应为 0 行(时长计算只在 codes 包)。
  • codes 既有行为零变化:兑换/试用相关既有测试未改一行且全绿(source 参数化只是穿参)。
  • UI 真相源纪律:新页面 grep 无硬编码 hex(全部 context.pangolin/PangolinText/PangolinRadius);组件仅 PlanCard/PangolinButton/toast/icons + _Card 同款 inline 配方。
  • 取舍 1(golden):purchase/payment 未加 desktop golden——页面状态依赖运行时订单,固化价值低;若用户要求像素闸,后续按 design/preview 补规格 + golden 一并做。
  • 取舍 2(bottom sheet):支付方式选择用 Material showModalBottomSheet + ListTile(SDK 原生组件,主题色仍走 token)——真相源暂无「选择弹层」规格;若视觉不满意,先补 design/preview/ 规格再改,不在本计划内造样式。
  • 取舍 3(qr):qr render_type 只做复制兜底,未渲染二维码(不引 qr_flutter);当面付上线前需补。
  • 取舍 4(catalog 双源):展示价在 pangolin catalog.go、扣款价在 pay 种子——两处人工对齐,漂移风险列入联调 checklist 第 2 条;后续可做 pay 端 price 查询接口消掉双源。
  • 取舍 5(轮询打 pay):GET /v1/pay/orders/{no} 每拍都回源 pay 查单(3s 间隔单用户可接受;pay 侧 GET 无限流)。量大再加 server 短缓存。
  • 遗留项登记:① mysql 集成测试若本机无 docker,交付说明标注「待跑」;② OpenAPI 两份文件的同步策略;③ 支付宝 return_url(App 场景暂空,web 用户中心接入时再传);④ 本计划 HTML 阅读版 + docs/index.html 登记(定稿后按仓规矩补)。

联调 Checklist(端到端,等 pay 部署后执行;不阻塞上面任何开发)

单测已用 httptest 假 pay 全覆盖签名/幂等语义;本节是真环境验收,逐条打勾。

  1. pay 侧就绪:pay-server 部署完成,/api/v2 可达;pangolin 的 biz 配置与种子 SQL 已按「部署附录」落库(products×3 + product_prices(USDT)×3 + biz_system=pangolin)。
  2. 价格一致性:GET <pay>/api/v2 下单三档,断言 pay 返回/扣款金额与 pangolin catalog.go 展示价一致(CNY 2999/6888/19999 分;USDT 按附录种子)。
  3. pangolin server 配置:PAY_BASE_URL/PAY_BIZ_SYSTEM=pangolin/PAY_BIZ_SECRET(与 pay biz 配置同 secret,存 Bitwarden 不落明文)已入 /etc/pangolin* env;重启后日志无「PAY_BASE_URL 未配置」。
  4. 签名互通:App(或 curl 带 JWT)POST /v1/pay/orders {"sku":"pro_month","method":"crypto"} → 200 返回 crypto_address session(地址/amount/amount_minor/USDT/TRC20)。403/401 则核对 secret 与时钟(±300s)。
  5. webhook 连通:pay 侧 CallbackURL 指向 http://<pangolin-server>:8080/v1/webhook/pay;SupportedEvents=[payment.succeeded];用 pay 的重发工具(或手工 SQL 触发)投递一条 → pangolin 日志无验签错误、pay 侧标记 delivered(收到 200+SUCCESS)。
  6. USDT 真付一单(小额档):转账精确金额 → pay 确认 → webhook → App 轮询页自动切「已开通」;subscriptions 出现 source='pay' 行,pay_purchasespaid + sub_id/amount/currency 回填;audit_log 有 pay_grant
  7. 重投验证:pay 侧手动重发同一事件 → pangolin 回 SUCCESS,订阅到期不变(查库比对)。
  8. 支付宝 redirect 一单:method=alipay + metadata.is_mobile 按端型 → 返回 redirect url 可拉起;支付后同 6 号流程走通。
  9. 换渠道:crypto 下单后 POST .../retry {"method":"alipay"} → 收 409 CURRENCY_MISMATCH;App 自动取消旧单新建 alipay 单(观察 pay 侧旧单 canceled、新单 pending)。
  10. 叠加:同一账号再购一档 → expires_at 在原值上顺延(不是从 now 重算,若原订阅未过期)。
  11. 限流不误伤:连续下单/取消超 30 次/分触发 pay 429 → App 提示「操作过于频繁」而非崩溃。
  12. 时钟检查:pangolin1 与 pay 所在机 timedatectl NTP 同步(±300s 窗口的前提)。

部署附录(联调/上线前的 pay 侧与 pangolin 侧配置;本计划不执行部署)

A. pay 侧种子 SQL(pay 库;merchants/products/product_prices 为 GORM 表)

金额:CNY 走 products.price 元字符串(alipay fallback);USDT 走 product_prices.amount_minor 微单位(1 USDT = 1_000_000)。USDT 定价按 ≈7.15 汇率取整,上线前可调——调价只改 pay 侧,pangolin catalog.go 展示价同步改

-- ① 收款商户(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。


风险清单(执行者注意)

  1. SQLite subscriptions 重建(Task 1):worktree 测试全走 :memory: 无存量;生产 sqlite 库升级前务必过 Task 8 Step 2 彩排。重建期间的 FK 引用(无表引用 subscriptions,安全)已核。
  2. auth.UserIDFromContextcodes.CtxKeyUserID 的耦合:handler 测试直接注 codes.CtxKeyUserID——若 auth 侧 helper 实现读的是别的 key,测试会假红;实现 Task 4 时先读 internal/auth/middleware.go:71 确认(锚点已核:就是这个 key)。
  3. pay 状态词汇:OrderStatus.Status 的取值(pending/succeeded/canceled…)以 pay 实现为准,pangolin 只透传展示、不做分支判断(成功判据是本地 activated)——避免词汇漂移引 bug。
  4. t.Context()(Task 3 测试)需 go1.24+;CI 容器 golang:1.25 满足,本机旧 Go 则替换 context.Background()
  5. Flutter _FixedFlowController(Task 7 测试)的构造细节以最终编译为准,允许给生产控制器加 @visibleForTesting 命名构造,不允许放宽生产逻辑。
  6. 桌面壳 payment 返回键:等待支付中用户点返回(purchase)不取消订单(订单仍在 pay 侧 pending,可从头再进);只有显式「取消订单」才 cancel——注意别在 onBack 里误调 cancel()。