docs(pay): 设计文档 pre 伪表格改真 HTML 表格 + 代码语法高亮

schema/开通管线字段改 table.fields;Provider 接口/REST 端点改 pre.code 高亮
(GitHub-dark 配色,自包含无 CDN)。规矩记入全局记忆 docs-tables-and-code-blocks。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-07-10 07:30:55 +08:00
parent 6cdcec59af
commit 8a72665428
+64 -46
View File
@@ -21,6 +21,9 @@
p{margin:10px 0}
code{font-family:var(--mono);font-size:.86em;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:12.5px;line-height:1.55;color:#cdd3df}
pre.code .k{color:#ff7b72}pre.code .ty{color:#79c0ff}pre.code .fn{color:#d2a8ff}pre.code .st{color:#a5d6ff}pre.code .nu{color:#79c0ff}pre.code .co{color:#8b949e;font-style:italic}
table.fields td:first-child{font-family:var(--mono);font-size:12px;color:#cdd3df;white-space:nowrap;width:1%}
table.fields td code{background:transparent;padding:0}
.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)}
@@ -183,30 +186,40 @@
<p>控制面新增两张表(<code>mysql</code> + <code>sqlite</code> 双套 migration,金额一律 <code>int64</code> 最小单位,禁 float)。</p>
<h3>4.1 <code>pay_orders</code> — 业务订单(购买/权益账本)</h3>
<pre>order_no TEXT UNIQUE -- 幂等主键, 形如 PAY2026...
user_id INTEGER -- FK users.id(内部关联订阅)
user_uuid TEXT -- 对外/跨系统标识(= pay 侧 user_ref)
sku TEXT -- pro-month / pro-quarter / pro-year
plan_code TEXT -- pro(建单时锁定,回调改不了)
duration_days INTEGER -- 30 / 90 / 365(建单时锁定)
status TEXT -- created|pending|paid|expired|canceled
subscription_id INTEGER NULL -- 开通后回填(审计)
created_at / paid_at / expires_at DATETIME</pre>
<table class="fields">
<thead><tr><th>字段</th><th>说明</th></tr></thead>
<tbody>
<tr><td>order_no</td><td>幂等主键(UNIQUE,形如 PAY2026…)</td></tr>
<tr><td>user_id</td><td>FK users.id(内部关联订阅)</td></tr>
<tr><td>user_uuid</td><td>对外/跨系统标识(= pay 侧 user_ref)</td></tr>
<tr><td>sku</td><td>pro-month / pro-quarter / pro-year</td></tr>
<tr><td>plan_code</td><td>pro(建单时锁定,回调改不了)</td></tr>
<tr><td>duration_days</td><td>30 / 90 / 365(建单时锁定)</td></tr>
<tr><td>status</td><td>created | pending | paid | expired | canceled</td></tr>
<tr><td>subscription_id</td><td>开通后回填(审计,可空)</td></tr>
<tr><td>created_at / paid_at / expires_at</td><td>时间戳</td></tr>
</tbody>
</table>
<p class="small">建单时就把 <code>order_no → user / plan / 天数</code> 落库。回调<b>只能推进已存在的订单、开通其中预先锁定的套餐</b>——回调无法指定"给谁开多久"。这是防回调伪造的纵深。</p>
<h3>4.2 <code>pay_attempts</code> — 支付尝试(收款账本)</h3>
<pre>id INTEGER PK
order_no TEXT -- FK pay_orders
method TEXT -- usdt_trc20 / alipay_nazha
provider TEXT -- crypto / nazha
provider_ref TEXT -- pangolin-pay 单号 / 哪吒 trade_no
render_type TEXT -- redirect / display_details
amount_minor INTEGER -- 该尝试的应收(按币种最小单位)
currency TEXT -- USDT / CNY
status TEXT -- created|pending|paid|failed|expired
expires_at DATETIME -- 本次尝试的超时(哪吒 payurl / crypto 15min)
created_at / paid_at DATETIME
UNIQUE(provider, provider_ref) -- 回调幂等的命门</pre>
<table class="fields">
<thead><tr><th>字段</th><th>说明</th></tr></thead>
<tbody>
<tr><td>id</td><td>主键</td></tr>
<tr><td>order_no</td><td>FK pay_orders</td></tr>
<tr><td>method</td><td>usdt_trc20 / alipay_nazha</td></tr>
<tr><td>provider</td><td>crypto / nazha</td></tr>
<tr><td>provider_ref</td><td>pangolin-pay 单号 / 哪吒 trade_no</td></tr>
<tr><td>render_type</td><td>redirect / display_details</td></tr>
<tr><td>amount_minor</td><td>该尝试应收(按币种最小单位)</td></tr>
<tr><td>currency</td><td>USDT / CNY</td></tr>
<tr><td>status</td><td>created | pending | paid | failed | expired</td></tr>
<tr><td>expires_at</td><td>本次尝试超时(哪吒 payurl / crypto 15min)</td></tr>
<tr><td>created_at / paid_at</td><td>时间戳</td></tr>
<tr><td>UNIQUE(provider, provider_ref)</td><td>回调幂等的命门</td></tr>
</tbody>
</table>
<p class="small">⚠️ 超时挂在 <b>attempt</b> 上:某 attempt 超时只把<b>它自己</b>置 expired,<code>order</code><code>pending</code> → 用户可再建 attempt 换渠道。<code>order.expires_at</code><b>整体购买窗口</b>(较长,如 1h),到点才整单 expired。<b>一笔 order 最多一个 attempt 能成功</b> —— <code>MarkOrderPaid</code> 只在 order=<code>pending</code> 时原子成立,paid 后续 attempt 全部失效。</p>
<h3>4.3 两表分工与创建时机(一对多)</h3>
@@ -237,21 +250,21 @@ UNIQUE(provider, provider_ref) -- 回调幂等的命门</pre>
<h2>5. Provider 适配器接口</h2>
<p>每个支付平台实现同一个接口,控制面只依赖接口。三个动作是最小完备集。</p>
<pre>type Provider interface {
Method() MethodInfo // 元信息:{ID, Name, IconURL, RenderType, Currency, Enabled, Min, Max}
Create(ctx, o Order) (Session, error) // ① 下单→返回渲染指令
HandleCallback(ctx, r *http.Request) (CallbackResult, error) // ② 验签+解析+归一化
Query(ctx, providerRef string) (PaymentStatus, *PaidEvent, error) // ③ 主动查单兜底
<pre class="code"><span class="k">type</span> <span class="ty">Provider</span> <span class="k">interface</span> {
<span class="fn">Method</span>() MethodInfo <span class="co">// 元信息:{ID, Name, IconURL, RenderType, Currency, Enabled, Min, Max}</span>
<span class="fn">Create</span>(ctx, o Order) (Session, <span class="ty">error</span>) <span class="co">// ① 下单→返回渲染指令</span>
<span class="fn">HandleCallback</span>(ctx, r *http.Request) (CallbackResult, <span class="ty">error</span>) <span class="co">// ② 验签+解析+归一化</span>
<span class="fn">Query</span>(ctx, providerRef <span class="ty">string</span>) (PaymentStatus, *PaidEvent, <span class="ty">error</span>) <span class="co">// ③ 主动查单兜底</span>
}
type Session struct { // Create 的返回,客户端按 RenderType 分发
ProviderRef string
RenderType string // redirect / display_details / ...
Payload json.RawMessage // 按 RenderType 定 shape
<span class="k">type</span> <span class="ty">Session</span> <span class="k">struct</span> { <span class="co">// Create 的返回,客户端按 RenderType 分发</span>
ProviderRef <span class="ty">string</span>
RenderType <span class="ty">string</span> <span class="co">// redirect / display_details / ...</span>
Payload json.RawMessage <span class="co">// 按 RenderType 定 shape</span>
ExpiresAt time.Time
}
type PaidEvent struct { OrderNo string; ProviderRef string; AmountMinor int64; Currency string; PaidAt time.Time; TxRef string }
type CallbackResult struct { Handled bool; Event *PaidEvent; AckBody []byte } // AckBody: 哪吒要回 "success"</pre>
<span class="k">type</span> <span class="ty">PaidEvent</span> <span class="k">struct</span> { OrderNo <span class="ty">string</span>; ProviderRef <span class="ty">string</span>; AmountMinor <span class="ty">int64</span>; Currency <span class="ty">string</span>; PaidAt time.Time; TxRef <span class="ty">string</span> }
<span class="k">type</span> <span class="ty">CallbackResult</span> <span class="k">struct</span> { Handled <span class="ty">bool</span>; Event *PaidEvent; AckBody []<span class="ty">byte</span> } <span class="co">// AckBody: 哪吒要回 "success"</span></pre>
<h3>5.1 crypto adapter(包裹已部署的 pangolin-pay)</h3>
<p>pay-server 保持<b>独立进程</b>(持 xpub、看链,最小权限),控制面 crypto adapter 只是它的 HTTP 客户端:</p>
@@ -271,15 +284,15 @@ type CallbackResult struct { Handled bool; Event *PaidEvent; AckBody []byte } //
<h2>6. 客户端契约</h2>
<h3>6.1 REST 端点(控制面,全部 Bearer)</h3>
<pre>GET /v1/pay/methods → 200 [{id,name,icon,render_type,currency,enabled,min,max}]
POST /v1/pay/orders {sku, method} → 201 {order_no, status, session:{render_type,payload,expires_at}}
GET /v1/pay/orders/{order_no} → 200 {status, plan, expires_at, session?}
GET /v1/pay/orders?limit&cursor → 200 {orders:[{order_no,sku,plan,amount,currency,status,created_at,paid_at}], next} (历史订单含 canceled,用户中心订单页用)
POST /v1/pay/orders/{order_no}/retry {method} → 201 新 attempt/session(Order 不变)
POST /v1/pay/orders/{order_no}/cancel → 200 {status:"canceled"}(仅 pending 可取消;paid/已取消返 409
--- 平台→控制面(公开,不带 Bearer)---
POST /v1/webhooks/pay/crypto (pangolin-pay, HMAC 验签)
GET /v1/webhooks/pay/nazha (哪吒, RSA 验签, GET)</pre>
<pre class="code"><span class="k">GET</span> /v1/pay/methods → <span class="nu">200</span> [{id,name,icon,render_type,currency,enabled,min,max}]
<span class="k">POST</span> /v1/pay/orders {sku, method} → <span class="nu">201</span> {order_no, status, session:{render_type,payload,expires_at}}
<span class="k">GET</span> /v1/pay/orders/{order_no} → <span class="nu">200</span> {status, plan, expires_at, session?}
<span class="k">GET</span> /v1/pay/orders?limit&amp;cursor → <span class="nu">200</span> {orders:[{order_no,sku,plan,amount,currency,status,created_at,paid_at}], next} <span class="co">(历史订单含 canceled,用户中心订单页用)</span>
<span class="k">POST</span> /v1/pay/orders/{order_no}/retry {method} → <span class="nu">201</span> 新 attempt/session(Order 不变)
<span class="k">POST</span> /v1/pay/orders/{order_no}/cancel → <span class="nu">200</span> {status:"canceled"}<span class="co">(仅 pending 可取消;paid/已取消返 409</span>
<span class="co">--- 平台→控制面(公开,不带 Bearer)---</span>
<span class="k">POST</span> /v1/webhooks/pay/crypto <span class="co">(pangolin-pay, HMAC 验签)</span>
<span class="k">GET</span> /v1/webhooks/pay/nazha <span class="co">(哪吒, RSA 验签, GET)</span></pre>
<h3>6.2 render_type 联合体(写死在协议里)</h3>
<table>
@@ -306,11 +319,16 @@ GET /v1/webhooks/pay/nazha (哪吒, RSA 验签, GET)</pre>
<h2>7. 统一开通管线</h2>
<p>webhook 与 Query 轮询<b>最终都产出 <code>PaidEvent</code></b>,走同一条幂等开通逻辑:</p>
<pre>① 验签 adapter.HandleCallback / Query (各平台不同:HMAC / RSA / 链上确认)
② 定位订单 provider_ref → pay_attempts → pay_orders
③ 幂等 attempt 已 paid? order 已 granted? → 是则直接回 200,不重复开通
④ 金额/币种核对 PaidEvent.amount == attempt.amount_minor && currency 一致
⑤ 事务开通 { order→paid; attempt→paid; 开通订阅(source=pay); 回填 subscription_id; 审计 }</pre>
<table class="fields">
<thead><tr><th>步骤</th><th>动作</th></tr></thead>
<tbody>
<tr><td>① 验签</td><td>adapter.HandleCallback / Query(各平台不同:HMAC / RSA / 链上确认)</td></tr>
<tr><td>② 定位订单</td><td>provider_ref → pay_attempts → pay_orders</td></tr>
<tr><td>③ 幂等</td><td>attempt 已 paid? order 已 granted? → 是则直接回 200,不重复开通</td></tr>
<tr><td>④ 金额/币种核对</td><td>PaidEvent.amount == attempt.amount_minor &amp;&amp; currency 一致</td></tr>
<tr><td>⑤ 事务开通</td><td>{ order→paid; attempt→paid; 开通订阅(source=pay); 回填 subscription_id; 审计 }</td></tr>
</tbody>
</table>
<h3>7.1 复用并重构现有开通逻辑</h3>
<p>开通能力现埋在 <code>codes.Service.applySubscription</code>(<code>server/internal/codes/service.go:235</code>),仅作为 Redeem 事务的一步、且 <code>CreateSubscription</code><code>source</code> 硬编码 <code>'code'</code>。改动:</p>