← 文档索引

Pangolin × pay v2 接入 — 终验交付说明

Task 8(终验)· 全量矩阵 + OpenAPI 登记 + 交付说明 · 2026-07-10/11 · 配套计划:2026-07-10-pangolin-pay-v2-integration.md(执行真相源,Task 1–8 checkbox)

结论:server/client 全量验证矩阵本地全绿(含 SQLite 实库 + MySQL 8 容器集成测试 + 新增迁移彩排); client 27 个失败均为既有 golden 像素噪声,与本次接入无关。OpenAPI 已登记 6 个新端点并通过结构校验。 本计划不执行部署——联调 checklist 与部署附录是上线前提,逐条见下。 过程中发现并顺手修复一处与 pay-v2 无关的既有脚本 bug(run_mysql_test.shmultiStatements=true),并发现一处与 pay-v2 无关但会阻断 CI/异机构建的既有 问题(go.modwangjia/codes 本地文件系统 replace)——详见「遗留项」。

1. 测试矩阵结果

范围命令结果备注
servergo build ./...PASS
servergo vet ./...PASS
servergo test ./... -count=1PASS26 个含测试的包全部 ok,0 FAILinternal/pay 15 个测试全绿(含 client 签名/webhook 幂等/handler 鉴权与所有权校验)
serverbash run_sqlite_test.shPASSinternal/store + internal/db 全绿,含 TestSQLiteMigrateUpDown(全量 up→21→down→0)
serverbash run_mysql_test.sh(docker 可用,加跑)PASS(修复后)初跑在 000001 迁移即失败——脚本自建的 DSN 缺 ?multiStatements=true,与本次 pay 改动无关的既有 bug(internal/nodes/testmain_test.go 自带的 TestMain 一直是正确写法,只有此脚本手搭的 DSN 漏了)。补上后重跑:TestLifecycle_* 全绿,全链路迁移(含 000021 ALTER TABLE subscriptions MODIFY source ENUM('trial','code','pay'))在真实 MySQL 8 容器上干净应用
server(新增,Step 2)go test ./internal/store/... -run TestSQLitePayMigrationRehearsal -vPASS文件库(非 :memory:)带数据升级彩排,见第 3 节
clientflutter analyzePASS(4 项既有 info/warning,0 新增)4 项均在 usage_line_chart.dart / flow_connect_test.dart / stats_device_filter_test.dart,与购买/支付页无关
clientflutter test功能测试全绿 · 27 golden 噪声(既有)192 个测试,27 个失败**全部**在 test/golden/*.dartcomponents_golden_test / tablet_pages_golden_test / desktop_pages_golden_test),与像素基线漂移相关、与本次接入无关;test/unit/payment_api_test.darttest/unit/payment_flow_test.dart 等 pay 相关测试全部通过
OpenAPIpython -m openapi_spec_validator server/api/openapi.yaml(docker python:3.12-alpine,与 CI 同法)OK结构校验通过
手动冒烟Step 4(本地假 pay + curl 走 create→webhook→get)跳过(brief 标注可选)等价路径已被 internal/pay 的 httptest 单测充分覆盖(TestWebhook_HappyPath/TestCreateOrder_ProxiesAndRecords/TestWebhook_RedeliveryIdempotent 等),性价比判断为不必再手搭一遍

2. OpenAPI 登记

server/api/openapi.yaml(codegen 唯一实际消费源;design/server/openapi.yaml 是 CI 仅做结构校验的旧文档,二者早已不同步,见「遗留项」②)新增 Pay tag 与以下端点:

方法路径说明
GET/pay/catalog三档目录(展示价,实际扣款以 pay 侧为准)
POST/pay/orders下单代理(sku+method+metadata,服务端映射 biz_ref,永不传金额)
GET/pay/orders/{orderNo}查单(回源 pay + 本地 activated 开通状态)
POST/pay/orders/{orderNo}/retry换渠道重试(409 CURRENCY_MISMATCH 语义)
POST/pay/orders/{orderNo}/cancel取消待支付订单
POST/webhook/paypay 出站 webhook 接收(security: []——HMAC 头验签而非 JWT;响应体纯文本 SUCCESS,非 Error JSON schema,已在文档中特别注明)

新增 schema:PayCatalogItem / PaySessionrender_type 多态: crypto_address / redirect / qr)/ PayOrderSessionResult / PayOrderStatus / PayWebhookEvent;新增 PayConflict / PayUpstream 错误响应组件。未运行 go generate ./... 重新生成 server/api/gen/*.gen.go——main.go 的 /v1/pay/* 路由是直接手写 chi 挂载(cmd/server/main.go:408-413),不经过 gen.ServerInterface,OpenAPI 文档是纯规格登记,不影响运行时;internal/httpapi/unimplemented.go 的编译期断言 var _ gen.ServerInterface = ... 因未重新生成而不受影响,go build 已验证无恙。

3. 迁移彩排(Step 2,文件库带数据升级)

新增 server/internal/store/pay_migration_rehearsal_test.go(纳入常规 go test ./...,非一次性脚本),两个测试:

TestSQLitePayMigrationRehearsal_UpgradeWithData

文件库(t.TempDir() 下真实 .db 文件,非 :memory:)up 到 000020 → 手工插入 users/subscriptionssource='trial'/'code' 各一行)→ up 到 000021 → 断言:

PASS

TestSQLitePayMigrationRehearsal_DownUpIdempotent

同样起点,up 到 021 后:down 到 020(无 pay 行时)→ 断言行/id 保留、pay_purchases 表消失 → 再 up 回 021 → 幂等,行数不变。

PASS

顺带发现的安全特性(非 bug,记录以防未来误判):一旦已存在 source='pay' 的行,再执行 down 到 000020 会被 CHECK (source IN ('trial','code'))(000021.down.sql 里 subscriptions_old 的约束)拒绝, 而不是静默丢弃付费订阅行——测试显式断言了这一拒绝行为。运维含义:000021 一旦有真实 pay 订阅落库,就不再是可安全一键回滚的迁移; 如需回滚,须先人工处理(删除/迁走 source='pay' 行)。

4. 已知取舍(Self-Review,逐条见 task-8-brief.md)

金额纪律已核client grep amount 仅展示/payload 读取;server 下单请求体只含 sku/method/metadata,无 biz_ref/金额
HMAC 对称性已核internal/pay/sign.go 与 pay util/sign.go 同构(\n join + 标准 base64);webhook 验签 parts 顺序 [system, ts, nonce, body]
重投安全已核TestWebhook_RedeliveryIdempotent 断言订阅行数/到期不变;未知 sku 500 路径事务回滚(settle() defer tx.Rollback()
叠加语义零复制已核grep -rn "AddDate" internal/pay/ 0 行——时长计算只在 codes 包,webhook 只经 GrantPaidSubscriptionTx
codes 既有行为零变化已核兑换/试用测试未改动,全绿
UI 真相源纪律已核购买/支付页无硬编码 hex,走 context.pangolin/PangolinText/PangolinRadius
取舍 1(golden)保留purchase/payment 页未加 desktop golden——状态依赖运行时订单,固化价值低;后续如需像素闸再补 design/preview 规格
取舍 2(bottom sheet)保留支付方式选择用 Material showModalBottomSheet(SDK 原生组件,主题色仍走 token)——真相源暂无「选择弹层」规格
取舍 3(qr)保留render_type=qr 仅复制兜底,未渲染二维码;当面付上线前需补
取舍 4(catalog 双源)风险已登记展示价(catalog.go)与扣款价(pay 种子)人工对齐,漂移风险列入联调 checklist 第 2 条
取舍 5(轮询打 pay)保留GET /pay/orders/{no} 每次回源查单,3s 间隔单用户可接受;量大再加 server 短缓存

5. 遗留项

  1. MySQL 集成测试:本次已在本机 docker 验证通过(含 000021 MODIFY ENUM),run_mysql_test.shmultiStatements=true 脚本 bug 已顺带修复入 commit。若 CI runner 本身无 docker,仍会跳过该 job(非本计划新增依赖)。
  2. OpenAPI 两份文件的同步策略server/api/openapi.yaml(codegen 实际消费源)与 design/server/openapi.yaml(CI 仅结构校验,标题都不同、缺 /notices 等多个既有端点)早已不同步——这是 Task 8 之前就存在的既有漂移,本次未强行同步(会引入与既有漂移无关的大改动),只在 server/api/openapi.yaml 登记新端点。建议后续单独排期决定:要么把 design/server/openapi.yaml 废弃改指向 server/api/openapi.yaml,要么定期同步脚本化。
  3. 支付宝 return_url:App 场景暂空,web 用户中心接入时再传。
  4. 本计划 HTML 阅读版docs/superpowers/plans/2026-07-10-pangolin-pay-v2-integration.md 尚无同内容 HTML 阅读版登记进 docs/index.html——按仓规矩属于「定稿后补」,本次交付说明已登记,计划本身的阅读版建议下一刀单独补齐。
  5. 手动冒烟(Step 4)未做:brief 标注可选,已用 httptest 覆盖等价路径,详见第 1 节表格备注。
  6. 新发现go.modgithub.com/wangjia/codes 本地文件系统 replace 会阻断异机 go build——
    replace github.com/wangjia/codes => /Users/wangjia/code/codes
    这行是 codes-lib 重构分支遗留(早于本次 pay-v2 接入,go.mod 里已有注释自述为 「Validation-branch local replace... Coordination item for merge」),与本次 pay-v2 改动无关, 但因为 internal/pay 通过 internal/codes.Service 间接依赖它,实测会影响到本任务。 本次用 docker run --rm -v "$PWD/server:/app:ro" -w /app golang:1.25 go build ./...(不挂载 /Users/wangjia/code/codes,模拟 CI 环境)复现:replacement directory /Users/wangjia/code/codes does not exist——说明 .gitea/workflows/ci.yml 的「Go — build + test」job(同样用干净 golang:1.25 容器)在本分支上当前会构建失败,且如果直接在 pangolin1 上用 deploy/single-node/deploy.sh「就地构建」(脚本里 command -v go 分支)也会同样失败。 不是本计划引入的新问题,但是合并/部署前必须解决的前置阻断项——按 go.mod 注释里已给的方向: 把 wangjia/codes 换成真实发布版本(git config --global url."ssh://git@..." .insteadOf + CI/dev 机 GOPRIVATE=github.com/wangjia/codes),不用本地路径 replace。此项超出 Task 8 授权范围 (涉及私有仓库发布/CI 凭据),未在本次改动,仅如实记录。

6. 联调 Checklist(端到端,等 pay 部署后执行;不阻塞本次交付)

单测已用 httptest 假 pay 全覆盖签名/幂等语义;本节是真环境验收,来自 task-8-brief.md 原文,逐条打勾。

  1. pay 侧就绪:/api/v2 可达;pangolin 的 biz 配置与种子 SQL 已按下节部署附录落库(products×3 + product_prices(USDT)×3 + biz_system=pangolin)
  2. 价格一致性:pay 下单三档,断言返回/扣款金额与 pangolin catalog.go 展示价一致(CNY 2999/6888/19999 分;USDT 按附录种子)
  3. pangolin server 配置:PAY_BASE_URL/PAY_BIZ_SYSTEM=pangolin/PAY_BIZ_SECRET 已入 /etc/pangolin* env;重启后日志无「PAY_BASE_URL 未配置」
  4. 签名互通:POST /v1/pay/orders {"sku":"pro_month","method":"crypto"} → 200 返回 crypto_address session;403/401 核对 secret 与时钟(±300s)
  5. webhook 连通:pay 侧 CallbackURL 指向 http://<pangolin-server>:8080/v1/webhook/pay;SupportedEvents=[payment.succeeded];重发工具投递一条 → pangolin 无验签错误、pay 侧收到 200+SUCCESS
  6. USDT 真付一单(小额档):转账精确金额 → 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 可拉起
  9. 换渠道:crypto 下单后 retry alipay → 收 409 CURRENCY_MISMATCH;App 自动取消旧单新建 alipay 单
  10. 叠加:同账号再购一档 → expires_at 在原值上顺延(非从 now 重算)
  11. 限流不误伤:连续下单/取消超 30 次/分触发 pay 429 → App 提示「操作过于频繁」而非崩溃
  12. 时钟检查:pangolin1 与 pay 所在机 timedatectl NTP 同步(±300s 窗口前提)

7. 部署附录

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

A. pay 侧种子 SQL

-- ① 收款商户(alipay 渠道;crypto 走 pay 的 crypto provider 配置,不在此表)
INSERT INTO merchants (code, name, channel, production, enabled, created_at, updated_at)
VALUES ('pangolin', 'Pangolin', 'alipay', 1, 1, NOW(), NOW());
-- 记下自增 id,下面记作 <MID>

-- ② 三档产品(biz_code 即 v2 sku,与 pangolin catalog.go 严格一致)
INSERT INTO products (merchant_id, name, description, price, active, sort, biz_code, created_at, updated_at) VALUES
  (<MID>, 'Pangolin 专业版·月付', '31 天', '29.99',  1, 1, 'pro_month',   NOW(), NOW()),
  (<MID>, 'Pangolin 专业版·季付', '92 天', '68.88',  1, 2, 'pro_quarter', NOW(), NOW()),
  (<MID>, 'Pangolin 专业版·年付', '366 天','199.99', 1, 3, 'pro_year',    NOW(), NOW());

-- ③ USDT 结算价(crypto 渠道必需;微单位,1 USDT = 1_000_000)
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());

执行前用 SELECT * FROM products WHERE biz_code LIKE 'pro_%' 确认无残留旧行(幂等按 biz_code 判)。NOW() 在 pay 库合法(pangolin「禁 NOW()」纪律只约束本仓 server SQL)。

B. pay 侧 biz 配置

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 取>

D. 部署顺序

  1. pangolin cmd/migrate up(000021,subscriptions 重建 + pay_purchases 建表——不可逆,一旦产生 source='pay' 行后无法一键 down,见第 3 节安全特性)
  2. 重启 pangolin-server
  3. pay 侧种子 + biz 配置
  4. 联调 checklist(第 6 节)

前置阻断:在 D①/D② 之前,若走「就地构建」(deploy/single-node/deploy.sh 机上有 go 时会自动 go build),须先解决第 5 节遗留项⑥的 wangjia/codes replace 路径问题,否则构建会在 pangolin1 上直接失败(本地开发机因为恰好存在 /Users/wangjia/code/codes 才能编译,不代表其它机器可用)。

E. codes 依赖 pin

server/go.mod 当前锁定 github.com/wangjia/codes @ c772d525679441d0579df6e309deba2d60254ab5(commit 见 go.mod 注释),经由本地文件系统 replace 而非可复现的模块下载——部署/CI 前必须换成可在异机复现的方式(git insteadOf + GOPRIVATE,或发布真实版本 tag),否则 pangolin1 / CI runner 上 go build 会直接因「replacement directory 不存在」失败。详情与复现方法见第 5 节遗留项最后一条。