From 390b8b8f8455fa47636ca939719b9a2993c879db Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Sat, 11 Jul 2026 00:34:10 +0800 Subject: [PATCH] =?UTF-8?q?chore(server):=20pay=20v2=20=E6=8E=A5=E5=85=A5?= =?UTF-8?q?=E7=BB=88=E9=AA=8C=20+=20OpenAPI=20=E7=99=BB=E8=AE=B0=20+=20?= =?UTF-8?q?=E4=BA=A4=E4=BB=98=E8=AF=B4=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 全量矩阵本地全绿(build/vet/test/run_sqlite_test.sh + 新增 SQLite 文件库带数据 升级彩排 + MySQL 8 容器验证 000021 MODIFY ENUM;client analyze/test 功能全绿, 27 个既有 golden 像素噪声与本次接入无关)。server/api/openapi.yaml 登记 6 个新 Pay 端点(catalog/orders/orderNo/retry/cancel/webhook)+ schema,通过结构校验。 顺手修复 run_mysql_test.sh 缺 multiStatements=true 的既有 bug(与 pay-v2 无关)。 新增 pay_migration_rehearsal_test.go 验证 000021 升级不丢行/AUTOINCREMENT 续序, 并确认一处安全特性:已有 source='pay' 行后 down 会被 CHECK 约束正确拒绝。 交付说明 docs/pay-v2-integration-delivery.html(含测试矩阵/联调 checklist/部署 附录)已登记 docs/index.html,记录一处既有但未修的部署前置阻断项(go.mod 的 wangjia/codes 本地路径 replace 会阻断异机构建,超出本任务授权范围)。 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u --- docs/index.html | 5 + docs/pay-v2-integration-delivery.html | 230 +++++++++ server/api/openapi.yaml | 473 ++++++++++++++++++ .../store/pay_migration_rehearsal_test.go | 279 +++++++++++ server/run_mysql_test.sh | 2 +- 5 files changed, 988 insertions(+), 1 deletion(-) create mode 100644 docs/pay-v2-integration-delivery.html create mode 100644 server/internal/store/pay_migration_rehearsal_test.go diff --git a/docs/index.html b/docs/index.html index ed10a7d..0ea2aba 100644 --- a/docs/index.html +++ b/docs/index.html @@ -98,6 +98,11 @@

知识库 / 调研

+ +
Pangolin × pay v2 接入 · 终验交付说明 HTML
+
Task 8 终验:server/client 全量测试矩阵结果(含新增 SQLite 文件库带数据升级彩排 + MySQL 8 容器验证 000021 MODIFY ENUM)、OpenAPI 新端点登记、Self-Review 取舍、联调 checklist、部署附录(pay 种子/biz 配置/pangolin env/迁移顺序)。附带发现一处既有的 wangjia/codes 本地路径依赖会阻断异机构建,登记为部署前置阻断项。
+
docs/pay-v2-integration-delivery.html
+
开发规范 · 可测试性五支柱 HTML
「怎么写才好测」——开发规范作为可测试性前置条件。五支柱(接缝即接口/契约单源/纯逻辑分离/错误是值/可观测)+ 支柱↔测试层咬合矩阵图 + 反例→真实bug→对应支柱对照表。与测试框架文档咬合。
diff --git a/docs/pay-v2-integration-delivery.html b/docs/pay-v2-integration-delivery.html new file mode 100644 index 0000000..84ab968 --- /dev/null +++ b/docs/pay-v2-integration-delivery.html @@ -0,0 +1,230 @@ + + + + + +Pangolin × pay v2 接入 — 终验交付说明 + + + +
+ +← 文档索引 + +

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.sh 缺 +multiStatements=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 → 断言:

+
    +
  • 行数不变(仍 2 行)、id 与 source 值原样保留(000021 的表重建未丢行)
  • +
  • 可插入 source='pay' 新行,且 AUTOINCREMENT 序列从原最大 id 之后继续(未被表重建重置为 1)
  • +
  • pay_purchases 表存在且为空(新建)
  • +
+

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. +
  3. 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,要么定期同步脚本化。
  4. +
  5. 支付宝 return_url:App 场景暂空,web 用户中心接入时再传。
  6. +
  7. 本计划 HTML 阅读版docs/superpowers/plans/2026-07-10-pangolin-pay-v2-integration.md 尚无同内容 HTML 阅读版登记进 docs/index.html——按仓规矩属于「定稿后补」,本次交付说明已登记,计划本身的阅读版建议下一刀单独补齐。
  8. +
  9. 手动冒烟(Step 4)未做:brief 标注可选,已用 httptest 覆盖等价路径,详见第 1 节表格备注。
  10. +
  11. 新发现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 凭据),未在本次改动,仅如实记录。
  12. +
+ +

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. +
  3. 价格一致性:pay 下单三档,断言返回/扣款金额与 pangolin catalog.go 展示价一致(CNY 2999/6888/19999 分;USDT 按附录种子)
  4. +
  5. pangolin server 配置:PAY_BASE_URL/PAY_BIZ_SYSTEM=pangolin/PAY_BIZ_SECRET 已入 /etc/pangolin* env;重启后日志无「PAY_BASE_URL 未配置」
  6. +
  7. 签名互通:POST /v1/pay/orders {"sku":"pro_month","method":"crypto"} → 200 返回 crypto_address session;403/401 核对 secret 与时钟(±300s)
  8. +
  9. webhook 连通:pay 侧 CallbackURL 指向 http://<pangolin-server>:8080/v1/webhook/pay;SupportedEvents=[payment.succeeded];重发工具投递一条 → pangolin 无验签错误、pay 侧收到 200+SUCCESS
  10. +
  11. USDT 真付一单(小额档):转账精确金额 → webhook → App 轮询页自动切「已开通」;subscriptions 出现 source='pay' 行,pay_purchasespaid + sub_id/amount/currency 回填;audit_log 有 pay_grant
  12. +
  13. 重投验证:pay 侧手动重发同一事件 → pangolin 回 SUCCESS,订阅到期不变
  14. +
  15. 支付宝 redirect 一单:method=alipay + metadata.is_mobile 按端型 → 返回 redirect url 可拉起
  16. +
  17. 换渠道:crypto 下单后 retry alipay → 收 409 CURRENCY_MISMATCH;App 自动取消旧单新建 alipay 单
  18. +
  19. 叠加:同账号再购一档 → expires_at 在原值上顺延(非从 now 重算)
  20. +
  21. 限流不误伤:连续下单/取消超 30 次/分触发 pay 429 → App 提示「操作过于频繁」而非崩溃
  22. +
  23. 时钟检查:pangolin1 与 pay 所在机 timedatectl NTP 同步(±300s 窗口前提)
  24. +
+ +

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. +
  3. 重启 pangolin-server
  4. +
  5. pay 侧种子 + biz 配置
  6. +
  7. 联调 checklist(第 6 节)
  8. +
+

前置阻断:在 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 节遗留项最后一条。

+ +
+ + diff --git a/server/api/openapi.yaml b/server/api/openapi.yaml index 770c0a8..a133808 100644 --- a/server/api/openapi.yaml +++ b/server/api/openapi.yaml @@ -330,6 +330,323 @@ paths: "500": $ref: "#/components/responses/Internal" + # ── Pay(pay v2 统一支付网关代理;PAY_BASE_URL 未配置时整组不挂载)─── + + /pay/catalog: + get: + operationId: payCatalog + summary: 可购档位目录 + description: | + 三档可购套餐(月/季/年),仅展示用(`price_minor` 为 CNY 展示价,单位分)。 + 实际扣款金额以 pay 侧 `product_prices` / `products.price` 为准,两处人工对齐 + (联调 checklist 有价格一致性核对项)。 + tags: [Pay] + responses: + "200": + description: 目录列表 + content: + application/json: + schema: + type: object + required: [items] + properties: + items: + type: array + items: + $ref: "#/components/schemas/PayCatalogItem" + "401": + $ref: "#/components/responses/Unauthorized" + "500": + $ref: "#/components/responses/Internal" + + /pay/orders: + post: + operationId: payCreateOrder + summary: 下单(代理 pay v2 create) + description: | + 创建支付订单。**客户端只传 sku + method + 端型 metadata,永远不传金额**—— + 金额由服务端按 sku 查 pay 侧目录后代下单,`biz_ref` 由服务端从当前 JWT 用户 + 映射(`users.uuid`),客户端无感知。 + + 下单成功后台账(`pay_purchases`)写入失败不影响本次下单结果(webhook 按 + `biz_ref` 兜底补建),仅记服务端日志。 + tags: [Pay] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [sku, method] + properties: + sku: + type: string + description: 档位代码,取自 `/pay/catalog`(如 `pro_month`) + example: pro_month + method: + type: string + description: 支付方式,取值以 pay 侧支持渠道为准(如 `crypto` / `alipay`) + example: crypto + metadata: + type: object + additionalProperties: + type: string + description: | + 透传给 pay 的端型元数据,白名单仅 `is_mobile` / `render`(其余字段 + 服务端静默丢弃,不透传)。 + example: + is_mobile: "true" + responses: + "200": + description: 下单成功,返回渲染会话 + content: + application/json: + schema: + $ref: "#/components/schemas/PayOrderSessionResult" + "400": + description: 参数非法(未知 sku / method 为空 / 请求体解析失败) + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "401": + $ref: "#/components/responses/Unauthorized" + "404": + description: 当前用户不存在或账户非 active(NOT_FOUND) + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "409": + $ref: "#/components/responses/PayConflict" + "429": + $ref: "#/components/responses/TooManyRequests" + "500": + $ref: "#/components/responses/Internal" + "502": + $ref: "#/components/responses/PayUpstream" + + /pay/orders/{orderNo}: + get: + operationId: payGetOrder + summary: 查单(代理 pay v2 查单 + 本地台账开通状态) + description: | + 每次调用都会回源 pay 查一次单(无本地缓存;量大后可加 server 短缓存,见交付 + 说明「取舍」条目)。`activated` 是客户端轮询判定成功开通的唯一依据——本地台账 + `pay_purchases.status = 'paid'`,与 `pay_status`(pay 侧状态词汇原样透传, + 仅展示、不做分支判断)是两条独立信息。 + tags: [Pay] + parameters: + - name: orderNo + in: path + required: true + schema: + type: string + description: pay 侧订单号(下单响应 `order_no`) + example: "PAY202607100001" + responses: + "200": + description: 订单状态 + content: + application/json: + schema: + $ref: "#/components/schemas/PayOrderStatus" + "401": + $ref: "#/components/responses/Unauthorized" + "404": + description: 订单不属于当前用户,或订单不存在(NOT_FOUND) + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "500": + $ref: "#/components/responses/Internal" + "502": + $ref: "#/components/responses/PayUpstream" + + /pay/orders/{orderNo}/retry: + post: + operationId: payRetryOrder + summary: 换渠道重试(同一订单换 method) + description: | + 同一订单更换支付方式(如 crypto → alipay)。若目标渠道结算币种与订单原币种 + 不符,pay 侧拒绝并返回 409 `CURRENCY_MISMATCH`——客户端应据此自动取消旧单、 + 用新 method 重新走 `/pay/orders` 下单(见联调 checklist「换渠道」条)。 + tags: [Pay] + parameters: + - name: orderNo + in: path + required: true + schema: + type: string + description: pay 侧订单号(下单响应 `order_no`) + example: "PAY202607100001" + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [method] + properties: + method: + type: string + example: alipay + metadata: + type: object + additionalProperties: + type: string + description: 同 `/pay/orders`,仅 `is_mobile` / `render` 白名单透传 + responses: + "200": + description: 重试成功,返回新的渲染会话 + content: + application/json: + schema: + $ref: "#/components/schemas/PayOrderSessionResult" + "400": + description: method 为空或请求体解析失败(BAD_REQUEST) + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "401": + $ref: "#/components/responses/Unauthorized" + "404": + description: 订单不属于当前用户,或订单不存在(NOT_FOUND) + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "409": + $ref: "#/components/responses/PayConflict" + "429": + $ref: "#/components/responses/TooManyRequests" + "500": + $ref: "#/components/responses/Internal" + "502": + $ref: "#/components/responses/PayUpstream" + + /pay/orders/{orderNo}/cancel: + post: + operationId: payCancelOrder + summary: 取消订单 + description: | + 取消一个仍处于待支付状态的订单。仅当用户显式点击「取消订单」时调用—— + 等待支付页面被系统返回键/手势关闭**不**触发取消(订单仍在 pay 侧 pending, + 可从头再进),这是桌面壳的既定行为,见风险清单第 6 条。 + tags: [Pay] + parameters: + - name: orderNo + in: path + required: true + schema: + type: string + description: pay 侧订单号(下单响应 `order_no`) + example: "PAY202607100001" + responses: + "200": + description: 取消结果(`canceled=false` 表示订单已不在待支付状态,未被取消) + content: + application/json: + schema: + type: object + required: [canceled] + properties: + canceled: + type: boolean + "401": + $ref: "#/components/responses/Unauthorized" + "404": + description: 订单不属于当前用户,或订单不存在(NOT_FOUND) + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "500": + $ref: "#/components/responses/Internal" + "502": + $ref: "#/components/responses/PayUpstream" + + /webhook/pay: + post: + operationId: payWebhook + summary: pay v2 出站 webhook 接收(payment.succeeded) + description: | + pay 侧支付成功后的服务端到服务端回调,**不经过 JWT**(本接口不在 + `bearerAuth` 保护范围内),改用 HMAC 请求头验签:与 pay + `util/sign.go` 逐字节同构,`parts = [X-Pay-System, X-Pay-Timestamp, + X-Pay-Nonce, rawBody]`,`\n` join 后 HMAC-SHA256 + 标准 base64, + 时间窗容忍 ±5 分钟。 + + 幂等三层:`X-Pay-Nonce` SETNX(传输层重放,Redis 不可用时跳过此层)→ + `out_trade_no` 行锁 CAS(业务幂等,重投唯一可靠键,已 `paid` 直接 + 返回 SUCCESS)→ `biz_ref` 兜底(台账缺行按用户 uuid 自修复)。 + + **注意**:本接口的成功/失败响应体是**纯文本**,不是 `Error` JSON + schema——ACK 判据是 HTTP 200 且 body 字面包含大写 `SUCCESS`;验签/ + 载荷失败时用 `http.Error`(纯文本)返回 400/401;开通失败返回 500 + 纯文本(pay 侧据此退避重投,事务已回滚、不产生半态)。 + tags: [Pay] + security: [] + parameters: + - name: X-Pay-System + in: header + required: true + schema: + type: string + description: 业务系统标识,须等于服务端配置的 `PAY_BIZ_SYSTEM`(默认 `pangolin`) + - name: X-Pay-Timestamp + in: header + required: true + schema: + type: string + description: Unix 秒级时间戳,±300s 容忍窗口 + - name: X-Pay-Nonce + in: header + required: true + schema: + type: string + description: 一次性随机串,用于传输层防重放(SETNX + TTL) + - name: X-Pay-Sign + in: header + required: true + schema: + type: string + description: HMAC-SHA256(secret, join("\n", system, ts, nonce, rawBody)) 的标准 base64 + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/PayWebhookEvent" + responses: + "200": + description: 已确认处理(幂等;重复投递也回 200 SUCCESS) + content: + text/plain: + schema: + type: string + example: SUCCESS + "400": + description: 请求体读取失败或载荷非法 JSON(纯文本响应,非 Error schema) + content: + text/plain: + schema: + type: string + "401": + description: 验签失败(system 不符 / 缺头 / 时间窗超限 / 签名不匹配,纯文本响应) + content: + text/plain: + schema: + type: string + "500": + description: 开通事务失败,pay 将按退避策略重投(纯文本响应,事务已回滚不产生半态) + content: + text/plain: + schema: + type: string + /plans: get: operationId: listPlans @@ -619,6 +936,25 @@ components: schema: $ref: "#/components/schemas/Error" + PayConflict: + description: | + 订单状态冲突。`code` 可能为 `CURRENCY_MISMATCH`(换渠道结算币种与订单不符, + 客户端应取消旧单重新下单)或 `ORDER_NOT_PENDING`(订单状态已变化,需刷新)。 + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + + PayUpstream: + description: | + pay 网关暂不可用(`code = PAY_UPSTREAM`)——上游 pay-server 不可达、超时, + 或返回了未分类的业务错误(`no_account` / `no_settle_currency` / + `create_failed` / `upstream_error` 等),客户端应提示稍后重试。 + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + # ── Schema ──────────────────────────────────────────────── schemas: @@ -986,6 +1322,139 @@ components: format: date-time description: 发布时间(UTC ISO-8601) + # ── Pay(pay v2 统一支付网关代理)────────────────────── + + PayCatalogItem: + type: object + description: 可购档位(三档单源,见 `internal/pay/catalog.go`) + required: [sku, plan, days, price_minor, currency] + properties: + sku: + type: string + description: 档位代码,与 pay 侧 `products.biz_code` 一一对应 + example: pro_month + plan: + type: string + enum: [free, pro, team] + description: 兑换后生效的套餐代码 + example: pro + days: + type: integer + description: 时长天数(宽松口径:31/92/366,覆盖大月与最长季) + example: 31 + price_minor: + type: integer + format: int64 + description: | + 展示价,CNY 分。**仅展示**,实际扣款以 pay 侧 + `product_prices`(USDT 微单位)/ `products.price`(CNY 元)为准, + 两处人工对齐(见交付说明「取舍」条目 4)。 + example: 2999 + currency: + type: string + example: CNY + + PaySession: + type: object + description: | + pay 下单/retry 返回的渲染会话。`render_type` 多态,`payload` 结构随之变化, + 原样透传给客户端: + - `crypto_address`:`{address, amount, amount_minor, currency, network}`(如 USDT/TRC20) + - `redirect`:`{url}`(如支付宝拉起链接) + - `qr`:`{data}`(当前仅复制兜底,未渲染二维码——见交付说明「取舍」条目 3) + required: [render_type, payload] + properties: + render_type: + type: string + enum: [crypto_address, redirect, qr] + payload: + type: object + description: 结构随 render_type 变化,原样透传,服务端不解析 + additionalProperties: true + expires_at: + type: string + format: date-time + nullable: true + description: 会话/收款地址有效期(UTC ISO-8601),无限期时为 null + + PayOrderSessionResult: + type: object + description: "`/pay/orders`(下单)与 `/pay/orders/{orderNo}/retry`(换渠道)的共同响应体" + required: [order_no, session] + properties: + order_no: + type: string + description: pay 侧订单号 + example: "PAY202607100001" + session: + $ref: "#/components/schemas/PaySession" + + PayOrderStatus: + type: object + required: [order_no, pay_status, activated] + properties: + order_no: + type: string + pay_status: + type: string + description: | + pay 侧状态词汇原样透传(`pending` / `succeeded` / `canceled` 等,具体取值 + 以 pay 实现为准)。**仅展示,不做分支判断**——本地成功判据是 `activated`。 + example: succeeded + activated: + type: boolean + description: | + 本地台账(`pay_purchases.status = 'paid'`)是否已消费权益开通。 + 客户端轮询以此字段为唯一成功判据。 + example: true + expires_at: + type: string + format: date-time + nullable: true + description: 开通/延长后的订阅到期时间(UTC ISO-8601);未开通或查询失败时缺省 + + PayWebhookEvent: + type: object + description: | + pay `settle.go::enqueuePaymentSucceeded` 出站 payload(payment.succeeded 事件, + 无 `refund_id` 字段)。 + required: [event_type, out_trade_no, biz_system, biz_ref, product_biz_code, + amount_minor, currency, channel, paid_at] + properties: + event_type: + type: string + enum: [payment.succeeded] + description: 事件类型;白名单外的事件本接口直接确认(200 SUCCESS)不处理 + out_trade_no: + type: string + description: pay 订单号,业务幂等唯一键 + biz_system: + type: string + example: pangolin + biz_ref: + type: string + format: uuid + description: 下单时传入的业务方用户标识(`users.uuid`) + product_biz_code: + type: string + description: 对应 `/pay/catalog` 的 `sku` + example: pro_month + amount_minor: + type: integer + format: int64 + description: 实付金额(最小单位) + currency: + type: string + example: USDT + channel: + type: string + description: 实际支付渠道 + example: crypto + paid_at: + type: string + format: date-time + description: 支付完成时间(RFC3339);解析失败时服务端回落为处理时刻 + tags: - name: Auth description: 认证相关(发验证码、注册、登录、刷新 Token)——无需 JWT @@ -993,6 +1462,10 @@ tags: description: 账户与设备管理 - name: Commerce description: 商业闭环(激活码兑换、广告解锁、套餐目录) + - name: Pay + description: | + pay v2 统一支付网关代理(下单/查单/换渠道/取消 + webhook 接收)。 + `PAY_BASE_URL` 未配置时整组端点不挂载(服务端启动日志告警,无 404 以外的降级)。 - name: Nodes description: 节点目录与连接凭证下发(数据面入口) - name: Usage diff --git a/server/internal/store/pay_migration_rehearsal_test.go b/server/internal/store/pay_migration_rehearsal_test.go new file mode 100644 index 0000000..8321ddc --- /dev/null +++ b/server/internal/store/pay_migration_rehearsal_test.go @@ -0,0 +1,279 @@ +package store_test + +import ( + "database/sql" + "errors" + "path/filepath" + "testing" + "time" + + "github.com/golang-migrate/migrate/v4" + migratesqlite "github.com/golang-migrate/migrate/v4/database/sqlite" + "github.com/golang-migrate/migrate/v4/source/iofs" + + "github.com/wangjia/pangolin/server/internal/config" + "github.com/wangjia/pangolin/server/internal/store" + "github.com/wangjia/pangolin/server/migrations" +) + +// migratorAt builds a golang-migrate instance against a file-backed SQLite DB +// and steps it to exactly `version` (unlike store.MigrateUp/Down, which always +// target head/0). This is Task 8 Step 2's upgrade rehearsal: a real file DB +// (not :memory:) simulating a production sqlite store carrying pre-000021 rows +// through the 000021 subscriptions-table rebuild. +func migratorAt(t *testing.T, database *sql.DB, version uint) { + t.Helper() + src, err := iofs.New(migrations.SQLiteFS, "sqlite") + if err != nil { + t.Fatalf("iofs source: %v", err) + } + defer src.Close() + + mdriver, err := migratesqlite.WithInstance(database, &migratesqlite.Config{}) + if err != nil { + t.Fatalf("sqlite migrate driver: %v", err) + } + + m, err := migrate.NewWithInstance("iofs", src, "sqlite", mdriver) + if err != nil { + t.Fatalf("new migrator: %v", err) + } + + if err := m.Migrate(version); err != nil && !errors.Is(err, migrate.ErrNoChange) { + t.Fatalf("migrate to version %d: %v", version, err) + } +} + +// insertLegacySubRow inserts one subscriptions row with a pre-000021 source +// value ('trial' or 'code') for a fresh user, returning the assigned user_id +// and subscription id. +func insertLegacySubRow(t *testing.T, database *sql.DB, uuidSuffix, source string) (userID, subID int64) { + t.Helper() + + res, err := database.Exec( + `INSERT INTO users (uuid, email, pw_hash, dp_uuid, status) VALUES (?, ?, 'x', ?, 'active')`, + "u-"+uuidSuffix, uuidSuffix+"@example.com", "dp-"+uuidSuffix, + ) + if err != nil { + t.Fatalf("insert user(%s): %v", source, err) + } + userID, err = res.LastInsertId() + if err != nil { + t.Fatalf("user LastInsertId: %v", err) + } + + var planID int64 + if err := database.QueryRow(`SELECT id FROM plans WHERE code = 'pro'`).Scan(&planID); err != nil { + t.Fatalf("lookup pro plan id: %v", err) + } + + res, err = database.Exec( + `INSERT INTO subscriptions (user_id, plan_id, expires_at, source) VALUES (?, ?, ?, ?)`, + userID, planID, time.Now().Add(30*24*time.Hour).UTC().Format("2006-01-02 15:04:05"), source, + ) + if err != nil { + t.Fatalf("insert subscription(source=%s): %v", source, err) + } + subID, err = res.LastInsertId() + if err != nil { + t.Fatalf("subscription LastInsertId: %v", err) + } + return userID, subID +} + +// TestSQLitePayMigrationRehearsal_UpgradeWithData is Task 8 Step 2: rehearse +// the 000021 upgrade (subscriptions table rebuild for source='pay') against a +// file-backed SQLite DB pre-loaded with real 'trial'/'code' rows, and assert +// no rows are lost and the id/AUTOINCREMENT sequence is preserved. +func TestSQLitePayMigrationRehearsal_UpgradeWithData(t *testing.T) { + dsn := filepath.Join(t.TempDir(), "pay_upgrade_rehearsal.db") + database, err := store.Open(&config.Config{Driver: "sqlite", DSN: dsn}) + if err != nil { + t.Fatalf("store.Open: %v", err) + } + defer database.Close() + + // 1. Up to 000020 (pre-pay baseline; plans already seeded by 000007). + migratorAt(t, database, 20) + + trialUserID, trialSubID := insertLegacySubRow(t, database, "trial1", "trial") + codeUserID, codeSubID := insertLegacySubRow(t, database, "code1", "code") + _ = trialUserID + _ = codeUserID + + // 2. Up to 000021 — subscriptions_new rebuild + pay_purchases creation. + migratorAt(t, database, 21) + + v, dirty, err := store.MigrateVersion(database, "sqlite") + if err != nil { + t.Fatalf("MigrateVersion: %v", err) + } + if dirty || v != 21 { + t.Fatalf("after up to 21: version=%d dirty=%v, want 21/false", v, dirty) + } + + // 3. Row count and ids preserved across the rebuild. + var count int + if err := database.QueryRow(`SELECT COUNT(*) FROM subscriptions`).Scan(&count); err != nil { + t.Fatalf("count subscriptions: %v", err) + } + if count != 2 { + t.Errorf("subscriptions count after 000021 = %d, want 2 (rows lost in rebuild)", count) + } + + for _, want := range []struct { + id int64 + source string + }{{trialSubID, "trial"}, {codeSubID, "code"}} { + var gotSource string + if err := database.QueryRow(`SELECT source FROM subscriptions WHERE id = ?`, want.id).Scan(&gotSource); err != nil { + t.Errorf("subscription id=%d missing after 000021: %v", want.id, err) + continue + } + if gotSource != want.source { + t.Errorf("subscription id=%d source = %q, want %q", want.id, gotSource, want.source) + } + } + + // 4. New source='pay' value now accepted, and AUTOINCREMENT continues + // (not reset to 1 by the table rebuild). + var planID int64 + if err := database.QueryRow(`SELECT id FROM plans WHERE code = 'pro'`).Scan(&planID); err != nil { + t.Fatalf("lookup pro plan id: %v", err) + } + res, err := database.Exec( + `INSERT INTO subscriptions (user_id, plan_id, expires_at, source) VALUES (?, ?, ?, 'pay')`, + trialUserID, planID, time.Now().Add(30*24*time.Hour).UTC().Format("2006-01-02 15:04:05"), + ) + if err != nil { + t.Fatalf("insert source='pay' subscription after 000021: %v", err) + } + paySubID, err := res.LastInsertId() + if err != nil { + t.Fatalf("pay subscription LastInsertId: %v", err) + } + if paySubID <= codeSubID { + t.Errorf("pay subscription id=%d did not continue AUTOINCREMENT sequence (prior max id=%d)", paySubID, codeSubID) + } + + // 5. pay_purchases table exists and is empty (fresh table from 000021). + var payCount int + if err := database.QueryRow(`SELECT COUNT(*) FROM pay_purchases`).Scan(&payCount); err != nil { + t.Fatalf("count pay_purchases (table should exist post-000021): %v", err) + } + if payCount != 0 { + t.Errorf("pay_purchases count = %d, want 0 (fresh table)", payCount) + } +} + +// TestSQLitePayMigrationRehearsal_DownUpIdempotent covers the second half of +// Task 8 Step 2: with only pre-000021 ('trial'/'code') data present, down +// (rollback 000021) then up (re-apply) must be idempotent and lossless. +// +// It also documents an intentional safety property: once a source='pay' row +// exists, 000021's down.sql (which rebuilds subscriptions with the stricter +// CHECK (source IN ('trial','code'))) correctly REFUSES to downgrade rather +// than silently dropping paid-subscription rows — see the trailing assertion. +func TestSQLitePayMigrationRehearsal_DownUpIdempotent(t *testing.T) { + dsn := filepath.Join(t.TempDir(), "pay_downup_rehearsal.db") + database, err := store.Open(&config.Config{Driver: "sqlite", DSN: dsn}) + if err != nil { + t.Fatalf("store.Open: %v", err) + } + defer database.Close() + + migratorAt(t, database, 20) + _, trialSubID := insertLegacySubRow(t, database, "trial2", "trial") + _, codeSubID := insertLegacySubRow(t, database, "code2", "code") + + migratorAt(t, database, 21) + + // down: 000021 -> 000020. No 'pay' rows exist yet, so this must succeed + // and preserve the trial/code rows with their original ids. + migratorAt(t, database, 20) + + v, dirty, err := store.MigrateVersion(database, "sqlite") + if err != nil { + t.Fatalf("MigrateVersion after down: %v", err) + } + if dirty || v != 20 { + t.Fatalf("after down to 20: version=%d dirty=%v, want 20/false", v, dirty) + } + + var count int + if err := database.QueryRow(`SELECT COUNT(*) FROM subscriptions`).Scan(&count); err != nil { + t.Fatalf("count subscriptions after down: %v", err) + } + if count != 2 { + t.Errorf("subscriptions count after down to 000020 = %d, want 2 (rows lost on downgrade)", count) + } + for _, id := range []int64{trialSubID, codeSubID} { + var exists int + if err := database.QueryRow(`SELECT COUNT(*) FROM subscriptions WHERE id = ?`, id).Scan(&exists); err != nil || exists != 1 { + t.Errorf("subscription id=%d missing after down to 000020 (err=%v)", id, err) + } + } + var hasPayTable int + err = database.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='pay_purchases'`).Scan(&hasPayTable) + if err != nil || hasPayTable != 0 { + t.Errorf("pay_purchases table still present after down to 000020 (err=%v)", err) + } + + // up: 000020 -> 000021 again — idempotent re-apply, same rows/ids. + migratorAt(t, database, 21) + v, dirty, err = store.MigrateVersion(database, "sqlite") + if err != nil { + t.Fatalf("MigrateVersion after re-up: %v", err) + } + if dirty || v != 21 { + t.Fatalf("after re-up to 21: version=%d dirty=%v, want 21/false", v, dirty) + } + if err := database.QueryRow(`SELECT COUNT(*) FROM subscriptions`).Scan(&count); err != nil { + t.Fatalf("count subscriptions after re-up: %v", err) + } + if count != 2 { + t.Errorf("subscriptions count after down+up cycle = %d, want 2", count) + } + + // Safety-net documentation: once a source='pay' row exists, down must be + // refused (CHECK (source IN ('trial','code')) on the down-rebuilt table), + // not silently drop it. This is NOT a bug — see 000021.down.sql. + var planID int64 + if err := database.QueryRow(`SELECT id FROM plans WHERE code = 'pro'`).Scan(&planID); err != nil { + t.Fatalf("lookup pro plan id: %v", err) + } + var uid int64 + if err := database.QueryRow(`SELECT user_id FROM subscriptions WHERE id = ?`, trialSubID).Scan(&uid); err != nil { + t.Fatalf("lookup trial subscription user_id: %v", err) + } + if _, err := database.Exec( + `INSERT INTO subscriptions (user_id, plan_id, expires_at, source) VALUES (?, ?, ?, 'pay')`, + uid, planID, time.Now().Add(30*24*time.Hour).UTC().Format("2006-01-02 15:04:05"), + ); err != nil { + t.Fatalf("insert source='pay' subscription: %v", err) + } + + src, err := iofs.New(migrations.SQLiteFS, "sqlite") + if err != nil { + t.Fatalf("iofs source: %v", err) + } + defer src.Close() + mdriver, err := migratesqlite.WithInstance(database, &migratesqlite.Config{}) + if err != nil { + t.Fatalf("sqlite migrate driver: %v", err) + } + m, err := migrate.NewWithInstance("iofs", src, "sqlite", mdriver) + if err != nil { + t.Fatalf("new migrator: %v", err) + } + downErr := m.Migrate(20) + if downErr == nil { + t.Error("expected down to 000020 to FAIL once a source='pay' row exists " + + "(subscriptions_old CHECK (source IN ('trial','code'))); it succeeded instead " + + "— either the safety property regressed or a 'pay' row was silently dropped") + } else { + t.Logf("down to 000020 correctly refused with a source='pay' row present: %v", downErr) + } + // This DB file (and any dirty migration-version bookkeeping from the + // refused down above) is discarded with t.TempDir() at test end. +} diff --git a/server/run_mysql_test.sh b/server/run_mysql_test.sh index 0bad607..f1d28c5 100644 --- a/server/run_mysql_test.sh +++ b/server/run_mysql_test.sh @@ -30,7 +30,7 @@ for i in $(seq 1 30); do sleep 2 done -DSN="root:secret@tcp(127.0.0.1:${PORT})/pangolin_test" +DSN="root:secret@tcp(127.0.0.1:${PORT})/pangolin_test?multiStatements=true" echo "Running lifecycle integration tests with DSN=$DSN" PANGOLIN_TEST_DSN="$DSN" go test -tags integration \