Files
pangolin/docs/pay-v2-integration-delivery.html
T
wangjia 390b8b8f84 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
2026-07-11 00:34:10 +08:00

231 lines
23 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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 18 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">PASS4 项既有 info/warning0 新增)</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>取舍 1golden</td><td><span class="tag info">保留</span></td><td>purchase/payment 页未加 desktop golden——状态依赖运行时订单,固化价值低;后续如需像素闸再补 <code>design/preview</code> 规格</td></tr>
<tr><td>取舍 2bottom sheet</td><td><span class="tag info">保留</span></td><td>支付方式选择用 Material <code>showModalBottomSheet</code>(SDK 原生组件,主题色仍走 token)——真相源暂无「选择弹层」规格</td></tr>
<tr><td>取舍 3qr</td><td><span class="tag info">保留</span></td><td><code>render_type=qr</code> 仅复制兜底,未渲染二维码;当面付上线前需补</td></tr>
<tr><td>取舍 4catalog 双源)</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 session403/401 核对 secret 与时钟(±300s</li>
<li>webhook 连通:pay 侧 CallbackURL 指向 <code>http://&lt;pangolin-server&gt;: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_MISMATCHApp 自动取消旧单新建 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,下面记作 &lt;MID&gt;
-- ② 三档产品(biz_code 即 v2 sku,与 pangolin catalog.go 严格一致)
INSERT INTO products (merchant_id, name, description, price, active, sort, biz_code, created_at, updated_at) VALUES
(&lt;MID&gt;, 'Pangolin 专业版·月付', '31 天', '29.99', 1, 1, 'pro_month', NOW(), NOW()),
(&lt;MID&gt;, 'Pangolin 专业版·季付', '92 天', '68.88', 1, 2, 'pro_quarter', NOW(), NOW()),
(&lt;MID&gt;, '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: &lt;与 pangolin PAY_BIZ_SECRET 相同,Bitwarden 生成 32+ 字节随机串&gt;
callback_url: http://&lt;pangolin-server&gt;: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://&lt;pay-server 地址&gt; # 如 https://pay.51yanmei.com
PAY_BIZ_SYSTEM=pangolin # 默认值即 pangolin,可省
PAY_BIZ_SECRET=&lt;同 B 节 secretBitwarden 取&gt;</pre>
<h3>D. 部署顺序</h3>
<ol>
<li>pangolin <code>cmd/migrate up</code>000021subscriptions 重建 + 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>