docs(pay): attempt 带 expires_at(单次超时不关 order)+ 订单取消接口

- 超时挂 attempt(哪吒/crypto 各自),超时只弃本 attempt、order 仍 pending 可换渠道;
  order.expires_at 是整体购买窗口。一单只一个 attempt 成功(MarkOrderPaid 守卫 pending)。
- 加 POST /orders/{no}/cancel(pending→canceled),canceled 单在订单列表可见。
- P1 计划同步:pay_attempts 加 expires_at 列 + CancelOrder 方法 + 取消/列表测试。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-07-09 23:26:39 +08:00
parent bf058077cd
commit 127f09faaa
3 changed files with 77 additions and 21 deletions
+13 -6
View File
@@ -203,8 +203,10 @@ 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>
<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>
<table>
@@ -214,10 +216,11 @@ UNIQUE(provider, provider_ref) -- 回调幂等的命门</pre>
<tr><td>何时创建</td><td>用户选套餐点「购买」确认那刻 —— <code>POST /v1/pay/orders</code>,<b>一次购买一行</b></td><td>每次选定支付方式建 provider 会话 —— 同一 <code>POST /v1/pay/orders</code>(首选渠道),或 <code>/retry</code>(换渠道)</td></tr>
<tr><td>数量</td><td>1 笔购买 = 1 行(<code>order_no</code> 唯一)</td><td>1 笔购买 = <b>N 行</b>(每次换渠道重试各一行)</td></tr>
<tr><td>锁定</td><td>建单即锁 user/plan/天数/金额 → 回调改不了(防伪造)</td><td>绑 provider + provider_ref → <code>UNIQUE</code> 幂等</td></tr>
<tr><td>生命周期</td><td>created→pending→paid/expired/canceled(长)</td><td>created→pending→paid/failed/expired(短,可弃)</td></tr>
<tr><td>生命周期</td><td>created→pending→paid / expired(整体窗口到点) / <b>canceled(用户取消)</b>(长)</td><td>created→pending→paid / failed / expired(单次超时)(短,可弃)</td></tr>
<tr><td>超时</td><td>整体购买窗口(如 1h)到点 → order expired</td><td><b>各自超时</b>(哪吒 payurl / crypto 15min);超时只弃本 attempt,<b>order 不变</b></td></tr>
</tbody>
</table>
<p class="small">典型:用户买 pro-year 选哪吒 → 建 order + attempt#1(nazha);扫码超时 → attempt#1 expired;点「换 crypto」→ order <b>不变</b>,新建 attempt#2(crypto);链上付款回调 → 按 provider_ref 定位 attempt#2 → 幂等 → order 与 attempt#2 一起置 paid、开通订阅。attempt#1 保留 expired 留痕。<b>拆两层就是为了「换渠道重试不污染订单状态机」+「订单是开通谁的唯一真相</b></p>
<p class="small">典型:用户买 pro-year 选哪吒 → 建 order + attempt#1(nazha);扫码超时 → <b>attempt#1 expired,order 仍 pending</b>;点「换 crypto」→ order <b>不变</b>,新建 attempt#2(crypto);链上付款回调 → 按 provider_ref 定位 attempt#2 → 幂等 → order 与 attempt#2 一起置 paid、开通订阅。attempt#1 保留 expired 留痕。<b>一笔 order 最多一个 attempt 成功</b>(<code>MarkOrderPaid</code> 只在 order=pending 原子成立)。用户也可 <code>POST /orders/{no}/cancel</code> 主动取消(pending→canceled),取消单仍在订单列表可见。<b>拆两层:换渠道重试不污染订单状态机 + 订单是开通谁的唯一真相。</b></p>
<h3>4.4 SKU 目录 + 定价 <span class="tag ok">已定</span></h3>
<p>SKU → (plan, 天数, 各币种价格)。哪吒收人民币(支付宝)、crypto 收 USDT,故<b>按币种各定一价</b>(不做实时汇率换算,固定价是产品决策)。首版 SKU 目录作为控制面代码常量(后续可迁 DB)。</p>
@@ -270,8 +273,9 @@ type CallbackResult struct { Handled bool; Event *PaidEvent; AckBody []byte } //
<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} (历史订单,用户中心订单页用)
POST /v1/pay/orders/{order_no}/retry {method} → 201 新 session(Order 不变)
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>
@@ -327,8 +331,11 @@ GET /v1/webhooks/pay/nazha (哪吒, RSA 验签, GET)</pre>
<li><b>webhook 可能丢</b>:crypto 无 webhook(只链上侦测)、哪吒 GET 回调可能被 GFW/CF 吞 → <b>Query 轮询兜底是刚需</b>,不只依赖 webhook。</li>
<li><b>重复回调</b>:<code>UNIQUE(provider, provider_ref)</code> + order granted 标记,已开通再来直接 200。</li>
<li><b>金额不符</b>:crypto 付错金额进 pay 侧 orphan;哪吒金额核对不过则拒绝并告警,不开通。</li>
<li><b>订单过期后到账</b>:订单已 expired 则不自动开通,转人工对账(避免迟到付款误开/漏开)</li>
<li><b>换渠道重试</b>:新建 Attempt,老 Attempt 置 expired/canceled,Order 仍 pending</li>
<li><b>attempt 超时 ≠ order 关闭</b>:单个 attempt 到自己的 expires_at 只置该 attempt expired,order 仍 pending,用户可继续换渠道;只有 order 整体窗口到点才整单 expired</li>
<li><b>只有一个 attempt 能成功</b>:<code>MarkOrderPaid</code> 原子守卫 order=pending,paid 后所有 attempt/retry 失效</li>
<li><b>用户主动取消</b>:<code>POST /orders/{no}/cancel</code> 仅在 pending 生效(→canceled);已 paid/已取消返 409。canceled 单仍在订单列表可见。取消后若链上迟到付款到账 → 进 orphan 人工处理。</li>
<li><b>订单过期/取消后到账</b>:order 非 pending 则不自动开通,转人工对账(避免迟到付款误开/漏开)。</li>
<li><b>换渠道重试</b>:新建 Attempt,老 Attempt 置 expired,Order 仍 pending。</li>
</ul>
<h2>10. 测试策略</h2>