chore(server): pay v2 接入终验 + OpenAPI 登记 + 交付说明
全量矩阵本地全绿(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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
This commit is contained in:
@@ -98,6 +98,11 @@
|
||||
</a>
|
||||
|
||||
<h2>知识库 / 调研</h2>
|
||||
<a class="doc" href="pay-v2-integration-delivery.html">
|
||||
<div class="t">Pangolin × pay v2 接入 · 终验交付说明 <span class="tag html">HTML</span></div>
|
||||
<div class="d">Task 8 终验:server/client 全量测试矩阵结果(含新增 SQLite 文件库带数据升级彩排 + MySQL 8 容器验证 000021 MODIFY ENUM)、OpenAPI 新端点登记、Self-Review 取舍、联调 checklist、部署附录(pay 种子/biz 配置/pangolin env/迁移顺序)。附带发现一处既有的 <code>wangjia/codes</code> 本地路径依赖会阻断异机构建,登记为部署前置阻断项。</div>
|
||||
<div class="path">docs/pay-v2-integration-delivery.html</div>
|
||||
</a>
|
||||
<a class="doc" href="dev-conventions.html">
|
||||
<div class="t">开发规范 · 可测试性五支柱 <span class="tag html">HTML</span></div>
|
||||
<div class="d">「怎么写才好测」——开发规范作为可测试性前置条件。五支柱(接缝即接口/契约单源/纯逻辑分离/错误是值/可观测)+ 支柱↔测试层咬合矩阵图 + 反例→真实bug→对应支柱对照表。与测试框架文档咬合。</div>
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Pangolin × pay v2 接入 — 终验交付说明</title>
|
||||
<style>
|
||||
:root{
|
||||
--bg:#0f1117; --panel:#171a22; --panel2:#1d2129; --fg:#e6e8ee; --fg2:#a8afbd;
|
||||
--accent:#e0884f; --accent2:#5fb0c9; --ok:#5ec27a; --bad:#e06a6a; --warn:#e0b84f;
|
||||
--border:#272c36; --mono:"SF Mono",ui-monospace,Menlo,Consolas,monospace;
|
||||
--sans:-apple-system,"PingFang SC","Helvetica Neue",Arial,sans-serif;
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;background:var(--bg);color:var(--fg);font-family:var(--sans);line-height:1.7;font-size:15px}
|
||||
.wrap{max-width:960px;margin:0 auto;padding:48px 24px 96px}
|
||||
h1{font-size:30px;line-height:1.3;margin:0 0 8px;letter-spacing:-.01em}
|
||||
.sub{color:var(--fg2);font-size:15px;margin:0 0 32px}
|
||||
h2{font-size:21px;margin:44px 0 14px;padding-bottom:8px;border-bottom:1px solid var(--border)}
|
||||
h3{font-size:16px;margin:26px 0 8px;color:var(--accent2)}
|
||||
p{margin:10px 0}
|
||||
code{font-family:var(--mono);font-size:.88em;background:var(--panel2);padding:1px 6px;border-radius:5px;color:#f0d9c4}
|
||||
pre{background:#0a0c11;border:1px solid var(--border);border-radius:10px;padding:14px 16px;overflow-x:auto;font-family:var(--mono);font-size:13px;line-height:1.55;color:#cdd3df}
|
||||
.tag{display:inline-block;font-size:12px;font-weight:600;padding:2px 9px;border-radius:999px;vertical-align:middle}
|
||||
.tag.ok{background:rgba(94,194,122,.16);color:var(--ok)}
|
||||
.tag.warn{background:rgba(224,184,79,.16);color:var(--warn)}
|
||||
.tag.info{background:rgba(95,176,201,.16);color:var(--accent2)}
|
||||
.tag.bad{background:rgba(224,106,106,.16);color:var(--bad)}
|
||||
.card{background:var(--panel);border:1px solid var(--border);border-radius:12px;padding:18px 20px;margin:16px 0}
|
||||
.card.root{border-left:3px solid var(--accent)}
|
||||
.card h3{margin-top:0}
|
||||
table{width:100%;border-collapse:collapse;margin:16px 0;font-size:14px}
|
||||
th,td{text-align:left;padding:9px 12px;border-bottom:1px solid var(--border);vertical-align:top}
|
||||
th{color:var(--fg2);font-weight:600;font-size:13px}
|
||||
td code{font-size:.85em}
|
||||
ul,ol{padding-left:22px;margin:10px 0}
|
||||
li{margin:5px 0}
|
||||
.lead{background:linear-gradient(180deg,rgba(224,136,79,.10),transparent);border:1px solid var(--border);border-radius:12px;padding:18px 20px;margin:0 0 8px}
|
||||
.small{color:var(--fg2);font-size:13px}
|
||||
a{color:var(--accent2)}
|
||||
.back{display:inline-block;margin-bottom:24px;font-size:13px}
|
||||
b{color:#fff}
|
||||
.checklist li::marker{color:var(--accent2)}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
|
||||
<a class="back" href="index.html">← 文档索引</a>
|
||||
|
||||
<h1>Pangolin × pay v2 接入 — 终验交付说明</h1>
|
||||
<p class="sub">Task 8(终验)· 全量矩阵 + OpenAPI 登记 + 交付说明 · 2026-07-10/11 ·
|
||||
配套计划:<a href="superpowers/plans/2026-07-10-pangolin-pay-v2-integration.md">2026-07-10-pangolin-pay-v2-integration.md</a>(执行真相源,Task 1–8 checkbox)</p>
|
||||
|
||||
<div class="lead">
|
||||
<b>结论:</b>server/client 全量验证矩阵本地全绿(含 SQLite 实库 + MySQL 8 容器集成测试 + 新增迁移彩排);
|
||||
client 27 个失败均为既有 golden 像素噪声,与本次接入无关。OpenAPI 已登记 6 个新端点并通过结构校验。
|
||||
<b>本计划不执行部署</b>——联调 checklist 与部署附录是上线前提,逐条见下。
|
||||
过程中发现并顺手修复一处与 pay-v2 无关的既有脚本 bug(<code>run_mysql_test.sh</code> 缺
|
||||
<code>multiStatements=true</code>),并发现一处<b>与 pay-v2 无关但会阻断 CI/异机构建</b>的既有
|
||||
问题(<code>go.mod</code> 的 <code>wangjia/codes</code> 本地文件系统 replace)——详见「遗留项」。
|
||||
</div>
|
||||
|
||||
<h2>1. 测试矩阵结果</h2>
|
||||
|
||||
<table>
|
||||
<thead><tr><th>范围</th><th>命令</th><th>结果</th><th>备注</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>server</td><td><code>go build ./...</code></td><td><span class="tag ok">PASS</span></td><td>—</td></tr>
|
||||
<tr><td>server</td><td><code>go vet ./...</code></td><td><span class="tag ok">PASS</span></td><td>—</td></tr>
|
||||
<tr><td>server</td><td><code>go test ./... -count=1</code></td><td><span class="tag ok">PASS</span></td><td>26 个含测试的包全部 <code>ok</code>,0 <code>FAIL</code>;<code>internal/pay</code> 15 个测试全绿(含 client 签名/webhook 幂等/handler 鉴权与所有权校验)</td></tr>
|
||||
<tr><td>server</td><td><code>bash run_sqlite_test.sh</code></td><td><span class="tag ok">PASS</span></td><td><code>internal/store</code> + <code>internal/db</code> 全绿,含 <code>TestSQLiteMigrateUpDown</code>(全量 up→21→down→0)</td></tr>
|
||||
<tr><td>server</td><td><code>bash run_mysql_test.sh</code>(docker 可用,加跑)</td><td><span class="tag ok">PASS(修复后)</span></td><td>初跑在 000001 迁移即失败——脚本自建的 DSN 缺 <code>?multiStatements=true</code>,与本次 pay 改动无关的既有 bug(<code>internal/nodes/testmain_test.go</code> 自带的 <code>TestMain</code> 一直是正确写法,只有此脚本手搭的 DSN 漏了)。补上后重跑:<code>TestLifecycle_*</code> 全绿,全链路迁移(含 000021 <code>ALTER TABLE subscriptions MODIFY source ENUM('trial','code','pay')</code>)在真实 MySQL 8 容器上干净应用</td></tr>
|
||||
<tr><td>server(新增,Step 2)</td><td><code>go test ./internal/store/... -run TestSQLitePayMigrationRehearsal -v</code></td><td><span class="tag ok">PASS</span></td><td>文件库(非 <code>:memory:</code>)带数据升级彩排,见第 3 节</td></tr>
|
||||
<tr><td>client</td><td><code>flutter analyze</code></td><td><span class="tag ok">PASS(4 项既有 info/warning,0 新增)</span></td><td>4 项均在 <code>usage_line_chart.dart</code> / <code>flow_connect_test.dart</code> / <code>stats_device_filter_test.dart</code>,与购买/支付页无关</td></tr>
|
||||
<tr><td>client</td><td><code>flutter test</code></td><td><span class="tag ok">功能测试全绿</span> · <span class="tag warn">27 golden 噪声(既有)</span></td><td>192 个测试,27 个失败**全部**在 <code>test/golden/*.dart</code>(<code>components_golden_test</code> / <code>tablet_pages_golden_test</code> / <code>desktop_pages_golden_test</code>),与像素基线漂移相关、与本次接入无关;<code>test/unit/payment_api_test.dart</code>、<code>test/unit/payment_flow_test.dart</code> 等 pay 相关测试全部通过</td></tr>
|
||||
<tr><td>OpenAPI</td><td><code>python -m openapi_spec_validator server/api/openapi.yaml</code>(docker <code>python:3.12-alpine</code>,与 CI 同法)</td><td><span class="tag ok">OK</span></td><td>结构校验通过</td></tr>
|
||||
<tr><td>手动冒烟</td><td>Step 4(本地假 pay + curl 走 create→webhook→get)</td><td><span class="tag info">跳过(brief 标注可选)</span></td><td>等价路径已被 <code>internal/pay</code> 的 httptest 单测充分覆盖(<code>TestWebhook_HappyPath</code>/<code>TestCreateOrder_ProxiesAndRecords</code>/<code>TestWebhook_RedeliveryIdempotent</code> 等),性价比判断为不必再手搭一遍</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h2>2. OpenAPI 登记</h2>
|
||||
<p>在 <code>server/api/openapi.yaml</code>(codegen 唯一实际消费源;<code>design/server/openapi.yaml</code> 是 CI 仅做结构校验的旧文档,二者早已不同步,见「遗留项」②)新增 <b>Pay</b> tag 与以下端点:</p>
|
||||
<table>
|
||||
<thead><tr><th>方法</th><th>路径</th><th>说明</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><code>GET</code></td><td><code>/pay/catalog</code></td><td>三档目录(展示价,实际扣款以 pay 侧为准)</td></tr>
|
||||
<tr><td><code>POST</code></td><td><code>/pay/orders</code></td><td>下单代理(sku+method+metadata,服务端映射 biz_ref,永不传金额)</td></tr>
|
||||
<tr><td><code>GET</code></td><td><code>/pay/orders/{orderNo}</code></td><td>查单(回源 pay + 本地 <code>activated</code> 开通状态)</td></tr>
|
||||
<tr><td><code>POST</code></td><td><code>/pay/orders/{orderNo}/retry</code></td><td>换渠道重试(409 <code>CURRENCY_MISMATCH</code> 语义)</td></tr>
|
||||
<tr><td><code>POST</code></td><td><code>/pay/orders/{orderNo}/cancel</code></td><td>取消待支付订单</td></tr>
|
||||
<tr><td><code>POST</code></td><td><code>/webhook/pay</code></td><td>pay 出站 webhook 接收(<code>security: []</code>——HMAC 头验签而非 JWT;响应体纯文本 <code>SUCCESS</code>,非 <code>Error</code> JSON schema,已在文档中特别注明)</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="small">新增 schema:<code>PayCatalogItem</code> / <code>PaySession</code>(<code>render_type</code> 多态:
|
||||
<code>crypto_address</code> / <code>redirect</code> / <code>qr</code>)/ <code>PayOrderSessionResult</code> /
|
||||
<code>PayOrderStatus</code> / <code>PayWebhookEvent</code>;新增 <code>PayConflict</code> / <code>PayUpstream</code>
|
||||
错误响应组件。未运行 <code>go generate ./...</code> 重新生成 <code>server/api/gen/*.gen.go</code>——main.go 的
|
||||
<code>/v1/pay/*</code> 路由是直接手写 chi 挂载(<code>cmd/server/main.go:408-413</code>),不经过
|
||||
<code>gen.ServerInterface</code>,OpenAPI 文档是纯规格登记,不影响运行时;<code>internal/httpapi/unimplemented.go</code>
|
||||
的编译期断言 <code>var _ gen.ServerInterface = ...</code> 因未重新生成而不受影响,<code>go build</code> 已验证无恙。</p>
|
||||
|
||||
<h2>3. 迁移彩排(Step 2,文件库带数据升级)</h2>
|
||||
<p>新增 <code>server/internal/store/pay_migration_rehearsal_test.go</code>(纳入常规 <code>go test ./...</code>,非一次性脚本),两个测试:</p>
|
||||
<div class="card">
|
||||
<h3><code>TestSQLitePayMigrationRehearsal_UpgradeWithData</code></h3>
|
||||
<p>文件库(<code>t.TempDir()</code> 下真实 <code>.db</code> 文件,非 <code>:memory:</code>)up 到 000020 → 手工插入 <code>users</code>/<code>subscriptions</code>(<code>source='trial'</code>/<code>'code'</code> 各一行)→ up 到 000021 → 断言:</p>
|
||||
<ul>
|
||||
<li>行数不变(仍 2 行)、id 与 <code>source</code> 值原样保留(000021 的表重建未丢行)</li>
|
||||
<li>可插入 <code>source='pay'</code> 新行,且 <code>AUTOINCREMENT</code> 序列从原最大 id 之后继续(未被表重建重置为 1)</li>
|
||||
<li><code>pay_purchases</code> 表存在且为空(新建)</li>
|
||||
</ul>
|
||||
<p><span class="tag ok">PASS</span></p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3><code>TestSQLitePayMigrationRehearsal_DownUpIdempotent</code></h3>
|
||||
<p>同样起点,up 到 021 后:down 到 020(无 <code>pay</code> 行时)→ 断言行/id 保留、<code>pay_purchases</code> 表消失 → 再 up 回 021 → 幂等,行数不变。</p>
|
||||
<p><span class="tag ok">PASS</span></p>
|
||||
<p><b>顺带发现的安全特性</b>(非 bug,记录以防未来误判):一旦已存在 <code>source='pay'</code> 的行,再执行 down 到 000020 会被
|
||||
<code>CHECK (source IN ('trial','code'))</code>(000021.down.sql 里 <code>subscriptions_old</code> 的约束)<b>拒绝</b>,
|
||||
而不是静默丢弃付费订阅行——测试显式断言了这一拒绝行为。<b>运维含义</b>:000021 一旦有真实 <code>pay</code> 订阅落库,就不再是可安全一键回滚的迁移;
|
||||
如需回滚,须先人工处理(删除/迁走 <code>source='pay'</code> 行)。</p>
|
||||
</div>
|
||||
|
||||
<h2>4. 已知取舍(Self-Review,逐条见 task-8-brief.md)</h2>
|
||||
<table>
|
||||
<tbody>
|
||||
<tr><td>金额纪律</td><td><span class="tag ok">已核</span></td><td>client grep <code>amount</code> 仅展示/payload 读取;server 下单请求体只含 sku/method/metadata,无 biz_ref/金额</td></tr>
|
||||
<tr><td>HMAC 对称性</td><td><span class="tag ok">已核</span></td><td><code>internal/pay/sign.go</code> 与 pay <code>util/sign.go</code> 同构(<code>\n</code> join + 标准 base64);webhook 验签 parts 顺序 <code>[system, ts, nonce, body]</code></td></tr>
|
||||
<tr><td>重投安全</td><td><span class="tag ok">已核</span></td><td><code>TestWebhook_RedeliveryIdempotent</code> 断言订阅行数/到期不变;未知 sku 500 路径事务回滚(<code>settle()</code> defer <code>tx.Rollback()</code>)</td></tr>
|
||||
<tr><td>叠加语义零复制</td><td><span class="tag ok">已核</span></td><td><code>grep -rn "AddDate" internal/pay/</code> 0 行——时长计算只在 <code>codes</code> 包,webhook 只经 <code>GrantPaidSubscriptionTx</code></td></tr>
|
||||
<tr><td>codes 既有行为零变化</td><td><span class="tag ok">已核</span></td><td>兑换/试用测试未改动,全绿</td></tr>
|
||||
<tr><td>UI 真相源纪律</td><td><span class="tag ok">已核</span></td><td>购买/支付页无硬编码 hex,走 <code>context.pangolin</code>/<code>PangolinText</code>/<code>PangolinRadius</code></td></tr>
|
||||
<tr><td>取舍 1(golden)</td><td><span class="tag info">保留</span></td><td>purchase/payment 页未加 desktop golden——状态依赖运行时订单,固化价值低;后续如需像素闸再补 <code>design/preview</code> 规格</td></tr>
|
||||
<tr><td>取舍 2(bottom sheet)</td><td><span class="tag info">保留</span></td><td>支付方式选择用 Material <code>showModalBottomSheet</code>(SDK 原生组件,主题色仍走 token)——真相源暂无「选择弹层」规格</td></tr>
|
||||
<tr><td>取舍 3(qr)</td><td><span class="tag info">保留</span></td><td><code>render_type=qr</code> 仅复制兜底,未渲染二维码;当面付上线前需补</td></tr>
|
||||
<tr><td>取舍 4(catalog 双源)</td><td><span class="tag warn">风险已登记</span></td><td>展示价(<code>catalog.go</code>)与扣款价(pay 种子)人工对齐,漂移风险列入联调 checklist 第 2 条</td></tr>
|
||||
<tr><td>取舍 5(轮询打 pay)</td><td><span class="tag info">保留</span></td><td><code>GET /pay/orders/{no}</code> 每次回源查单,3s 间隔单用户可接受;量大再加 server 短缓存</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h2>5. 遗留项</h2>
|
||||
<ol>
|
||||
<li><b>MySQL 集成测试</b>:本次已在本机 docker 验证通过(含 000021 <code>MODIFY ENUM</code>),<code>run_mysql_test.sh</code> 的 <code>multiStatements=true</code> 脚本 bug 已顺带修复入 commit。若 CI runner 本身无 docker,仍会跳过该 job(非本计划新增依赖)。</li>
|
||||
<li><b>OpenAPI 两份文件的同步策略</b>:<code>server/api/openapi.yaml</code>(codegen 实际消费源)与 <code>design/server/openapi.yaml</code>(CI 仅结构校验,标题都不同、缺 <code>/notices</code> 等多个既有端点)早已不同步——这是 Task 8 之前就存在的既有漂移,本次未强行同步(会引入与既有漂移无关的大改动),只在 <code>server/api/openapi.yaml</code> 登记新端点。建议后续单独排期决定:要么把 <code>design/server/openapi.yaml</code> 废弃改指向 <code>server/api/openapi.yaml</code>,要么定期同步脚本化。</li>
|
||||
<li><b>支付宝 <code>return_url</code></b>:App 场景暂空,web 用户中心接入时再传。</li>
|
||||
<li><b>本计划 HTML 阅读版</b>:<code>docs/superpowers/plans/2026-07-10-pangolin-pay-v2-integration.md</code> 尚无同内容 HTML 阅读版登记进 <code>docs/index.html</code>——按仓规矩属于「定稿后补」,本次交付说明已登记,计划本身的阅读版建议下一刀单独补齐。</li>
|
||||
<li><b>手动冒烟(Step 4)未做</b>:brief 标注可选,已用 httptest 覆盖等价路径,详见第 1 节表格备注。</li>
|
||||
<li><b><span class="tag bad">新发现</span>:<code>go.mod</code> 的 <code>github.com/wangjia/codes</code> 本地文件系统 replace 会阻断异机 <code>go build</code></b>——
|
||||
<pre>replace github.com/wangjia/codes => /Users/wangjia/code/codes</pre>
|
||||
这行是 codes-lib 重构分支遗留(早于本次 pay-v2 接入,<code>go.mod</code> 里已有注释自述为
|
||||
「Validation-branch local replace... Coordination item for merge」),<b>与本次 pay-v2 改动无关</b>,
|
||||
但因为 <code>internal/pay</code> 通过 <code>internal/codes.Service</code> 间接依赖它,实测会影响到本任务。
|
||||
本次用 <code>docker run --rm -v "$PWD/server:/app:ro" -w /app golang:1.25 go build ./...</code>(不挂载
|
||||
<code>/Users/wangjia/code/codes</code>,模拟 CI 环境)复现:<code>replacement directory /Users/wangjia/code/codes
|
||||
does not exist</code>——说明 <code>.gitea/workflows/ci.yml</code> 的「Go — build + test」job(同样用干净
|
||||
<code>golang:1.25</code> 容器)在本分支上<b>当前会构建失败</b>,且如果直接在 <code>pangolin1</code> 上用
|
||||
<code>deploy/single-node/deploy.sh</code>「就地构建」(脚本里 <code>command -v go</code> 分支)也会同样失败。
|
||||
<b>不是本计划引入的新问题,但是合并/部署前必须解决的前置阻断项</b>——按 <code>go.mod</code> 注释里已给的方向:
|
||||
把 <code>wangjia/codes</code> 换成真实发布版本(<code>git config --global url."ssh://git@..." .insteadOf</code> +
|
||||
CI/dev 机 <code>GOPRIVATE=github.com/wangjia/codes</code>),不用本地路径 replace。此项超出 Task 8 授权范围
|
||||
(涉及私有仓库发布/CI 凭据),未在本次改动,仅如实记录。</li>
|
||||
</ol>
|
||||
|
||||
<h2>6. 联调 Checklist(端到端,等 pay 部署后执行;不阻塞本次交付)</h2>
|
||||
<p class="small">单测已用 httptest 假 pay 全覆盖签名/幂等语义;本节是真环境验收,来自 task-8-brief.md 原文,逐条打勾。</p>
|
||||
<ol class="checklist">
|
||||
<li>pay 侧就绪:<code>/api/v2</code> 可达;pangolin 的 biz 配置与种子 SQL 已按下节部署附录落库(products×3 + product_prices(USDT)×3 + biz_system=pangolin)</li>
|
||||
<li>价格一致性:pay 下单三档,断言返回/扣款金额与 pangolin <code>catalog.go</code> 展示价一致(CNY 2999/6888/19999 分;USDT 按附录种子)</li>
|
||||
<li>pangolin server 配置:<code>PAY_BASE_URL</code>/<code>PAY_BIZ_SYSTEM=pangolin</code>/<code>PAY_BIZ_SECRET</code> 已入 <code>/etc/pangolin*</code> env;重启后日志无「PAY_BASE_URL 未配置」</li>
|
||||
<li>签名互通:<code>POST /v1/pay/orders {"sku":"pro_month","method":"crypto"}</code> → 200 返回 crypto_address session;403/401 核对 secret 与时钟(±300s)</li>
|
||||
<li>webhook 连通:pay 侧 CallbackURL 指向 <code>http://<pangolin-server>:8080/v1/webhook/pay</code>;SupportedEvents=[payment.succeeded];重发工具投递一条 → pangolin 无验签错误、pay 侧收到 200+SUCCESS</li>
|
||||
<li>USDT 真付一单(小额档):转账精确金额 → webhook → App 轮询页自动切「已开通」;<code>subscriptions</code> 出现 <code>source='pay'</code> 行,<code>pay_purchases</code> 行 <code>paid</code> + sub_id/amount/currency 回填;audit_log 有 <code>pay_grant</code></li>
|
||||
<li>重投验证:pay 侧手动重发同一事件 → pangolin 回 SUCCESS,订阅到期不变</li>
|
||||
<li>支付宝 redirect 一单:<code>method=alipay</code> + <code>metadata.is_mobile</code> 按端型 → 返回 redirect url 可拉起</li>
|
||||
<li>换渠道:crypto 下单后 retry alipay → 收 409 CURRENCY_MISMATCH;App 自动取消旧单新建 alipay 单</li>
|
||||
<li>叠加:同账号再购一档 → <code>expires_at</code> 在原值上顺延(非从 now 重算)</li>
|
||||
<li>限流不误伤:连续下单/取消超 30 次/分触发 pay 429 → App 提示「操作过于频繁」而非崩溃</li>
|
||||
<li>时钟检查:pangolin1 与 pay 所在机 <code>timedatectl</code> NTP 同步(±300s 窗口前提)</li>
|
||||
</ol>
|
||||
|
||||
<h2>7. 部署附录</h2>
|
||||
<p class="small">联调/上线前的 pay 侧与 pangolin 侧配置;<b>本计划不执行部署</b>。</p>
|
||||
|
||||
<h3>A. pay 侧种子 SQL</h3>
|
||||
<pre>-- ① 收款商户(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());</pre>
|
||||
<p class="small">执行前用 <code>SELECT * FROM products WHERE biz_code LIKE 'pro_%'</code> 确认无残留旧行(幂等按 biz_code 判)。<code>NOW()</code> 在 pay 库合法(pangolin「禁 NOW()」纪律只约束本仓 server SQL)。</p>
|
||||
|
||||
<h3>B. pay 侧 biz 配置</h3>
|
||||
<pre>biz_systems:
|
||||
- name: pangolin
|
||||
secret: <与 pangolin PAY_BIZ_SECRET 相同,Bitwarden 生成 32+ 字节随机串>
|
||||
callback_url: http://<pangolin-server>:8080/v1/webhook/pay
|
||||
supported_events: [payment.succeeded] # 白名单只开这一个</pre>
|
||||
|
||||
<h3>C. pangolin 侧 env(<code>/etc/pangolin-server.env</code> 或等价,不入 git)</h3>
|
||||
<pre>PAY_BASE_URL=http://<pay-server 地址> # 如 https://pay.51yanmei.com
|
||||
PAY_BIZ_SYSTEM=pangolin # 默认值即 pangolin,可省
|
||||
PAY_BIZ_SECRET=<同 B 节 secret,Bitwarden 取></pre>
|
||||
|
||||
<h3>D. 部署顺序</h3>
|
||||
<ol>
|
||||
<li>pangolin <code>cmd/migrate up</code>(000021,subscriptions 重建 + pay_purchases 建表——<b>不可逆</b>,一旦产生 <code>source='pay'</code> 行后无法一键 down,见第 3 节安全特性)</li>
|
||||
<li>重启 pangolin-server</li>
|
||||
<li>pay 侧种子 + biz 配置</li>
|
||||
<li>联调 checklist(第 6 节)</li>
|
||||
</ol>
|
||||
<p><span class="tag bad">前置阻断</span>:在 D①/D② 之前,若走「就地构建」(<code>deploy/single-node/deploy.sh</code> 机上有 go 时会自动 <code>go build</code>),须先解决第 5 节遗留项⑥的 <code>wangjia/codes</code> replace 路径问题,否则构建会在 pangolin1 上直接失败(本地开发机因为恰好存在 <code>/Users/wangjia/code/codes</code> 才能编译,不代表其它机器可用)。</p>
|
||||
|
||||
<h3>E. codes 依赖 pin</h3>
|
||||
<p><code>server/go.mod</code> 当前锁定 <code>github.com/wangjia/codes @ c772d525679441d0579df6e309deba2d60254ab5</code>(commit 见 <code>go.mod</code> 注释),经由本地文件系统 <code>replace</code> 而非可复现的模块下载——部署/CI 前必须换成可在异机复现的方式(git insteadOf + GOPRIVATE,或发布真实版本 tag),否则 pangolin1 / CI runner 上 <code>go build</code> 会直接因「replacement directory 不存在」失败。详情与复现方法见第 5 节遗留项最后一条。</p>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
@@ -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 \
|
||||
|
||||
Reference in New Issue
Block a user