Compare commits

...

4 Commits

Author SHA1 Message Date
wangjia 8a72665428 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>
2026-07-10 07:30:55 +08:00
wangjia 6cdcec59af docs(pay): 设计文档与 P1 计划互相加链接
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 23:30:51 +08:00
wangjia 127f09faaa 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>
2026-07-09 23:26:39 +08:00
wangjia bf058077cd docs(pay): 补两表分工与创建时机(4.3),SKU 定价改 4.4
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 23:18:53 +08:00
3 changed files with 153 additions and 63 deletions
+87 -48
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)}
@@ -55,6 +58,7 @@
<div class="lead">
<b>一句话</b>:控制面新增一个<b>支付编排层</b>,把不同支付平台(哪吒聚合支付、加密货币、未来 Stripe/信用卡/epay)统一到「<code>Order</code> 业务订单 + <code>PaymentAttempt</code> 支付尝试 + <code>Provider</code> 适配器」模型;客户端只认服务端下发的少数几种<b>渲染形态(render_type)</b>,不认具体平台。<b>同形态的新网关 = 零改客户端;只有全新交互形态才动客户端。</b>
</div>
<p class="small">📋 <b>实现计划</b>:<a href="pay-orchestration-p1-plan.html">P1 数据层 + 开通重构</a>(7 阶段之一;P2–P7 落地前逐一细化)。本文档是全景蓝图,计划是逐步施工图。</p>
<h2>1. 背景与目标</h2>
<p>Pangolin 当前<b>没有 App 内支付</b>:用户变 pro 的唯一路径是兑换激活码(<code>POST /v1/redeem</code>),客户端"购买"卡片是 <code>onTap: () {}</code> 空实现。控制面<b>没有订单系统</b>(migration 000001000021 无 orders/payment 表),只有 codes 激活码履约。这是一块绿地。</p>
@@ -182,31 +186,57 @@
<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
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 SKU 目录 + 定价 <span class="tag ok">已定</span></h3>
<h3>4.3 两表分工与创建时机(一对多)</h3>
<table>
<thead><tr><th></th><th><code>pay_orders</code> 业务订单</th><th><code>pay_attempts</code> 支付尝试</th></tr></thead>
<tbody>
<tr><td>回答</td><td><b>谁买了什么、开通谁</b>(购买/权益)</td><td><b>用哪个平台怎么付、平台单号</b>(收款)</td></tr>
<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(整体窗口到点) / <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);扫码超时 → <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>
<table>
<thead><tr><th>SKU</th><th>plan</th><th>天数</th><th>USDT(crypto)</th><th>CNY(哪吒/支付宝)</th></tr></thead>
@@ -220,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>
@@ -254,14 +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} (历史订单,用户中心订单页用)
POST /v1/pay/orders/{order_no}/retry {method} → 201 新 session(Order 不变)
--- 平台→控制面(公开,不带 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>
@@ -288,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>
@@ -314,8 +350,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>
@@ -351,7 +390,7 @@ GET /v1/webhooks/pay/nazha (哪吒, RSA 验签, GET)</pre>
</tbody>
</table>
<p class="small" style="margin-top:32px">相关文档:<a href="payment-channels-overview.html">支付渠道选型总览</a> · <a href="payment-clean-usdt-plan.html">干净 USDT 方案</a> · <a href="pay-single-address-plan.html">单地址收款模型</a> · <a href="crypto-tx-engine-plan.html">加密交易引擎</a></p>
<p class="small" style="margin-top:32px"><b>实现计划</b>:<a href="pay-orchestration-p1-plan.html">P1 数据层 + 开通重构</a>(真相源 docs/superpowers/plans/2026-07-09-pay-orchestration-p1-schema-grant.md)。<br>相关文档:<a href="payment-channels-overview.html">支付渠道选型总览</a> · <a href="payment-clean-usdt-plan.html">干净 USDT 方案</a> · <a href="pay-single-address-plan.html">单地址收款模型</a> · <a href="crypto-tx-engine-plan.html">加密交易引擎</a></p>
</div>
</body>
</html>
+3 -2
View File
@@ -84,7 +84,7 @@
<div class="card">
<h3>Task 4 · <code>payorders</code> 数据访问</h3>
<p>新包 <code>server/internal/payorders</code><code>Order</code>/<code>Attempt</code> 结构 + <code>CreateOrder/GetOrder/ListOrders/CreateAttempt/AttemptByProviderRef</code> + 幂等 <code>MarkOrderPaid</code>(仅 <code>status='pending'</code> 才置 paid + 回填 subscription_id + attempt 置 paid paid 返回 false)。SQLite 内存实库测「建单→查单→标付→再标付幂等」。</p>
<p>新包 <code>server/internal/payorders</code><code>Order</code>/<code>Attempt</code> 结构 + <code>CreateOrder/GetOrder/ListOrders(全状态)/CreateAttempt/AttemptByProviderRef</code> + 幂等 <code>MarkOrderPaid</code>(仅 <code>status='pending'</code> 才置 paid + 回填 subscription_id + attempt 置 paid pending 返回 false —— 「一单只一个 attempt 成功」的守卫)+ <code>CancelOrder</code>pending→canceledcanceled 单仍在列表可见)。attempt 带自己的 <code>expires_at</code>(单次超时不关 order)。SQLite 内存实库测「建单→查单→标付→再标付幂等」「取消→再取消 false→列表可见」</p>
</div>
<h2>两张新表</h2>
@@ -94,7 +94,8 @@
· created_at/paid_at/expires_at
pay_attempts 支付尝试/收款账本:order_no · method · provider · provider_ref
· render_type · amount_minor · currency · status
(created|pending|paid|failed|expired) · UNIQUE(provider,provider_ref) 幂等命门</pre>
(created|pending|paid|failed|expired) · expires_at(单次超时,不关 order)
· UNIQUE(provider,provider_ref) 幂等命门</pre>
<p style="margin-top:28px" class="path">相关:<a href="pay-orchestration-design.html">编排层设计</a> · 真相源 docs/superpowers/plans/2026-07-09-pay-orchestration-p1-schema-grant.md</p>
</div>
@@ -2,6 +2,8 @@
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
> **设计文档(全景蓝图):** `docs/pay-orchestration-design.html`(架构/契约/取舍)。本计划是其 7 阶段中的 **P1** 落地;阅读版 `docs/pay-orchestration-p1-plan.html`。
**Goal:** 给控制面加支付订单数据层(`pay_orders` + `pay_attempts`)并把「开通订阅」逻辑从 codes 里抽成可复用、可传 `source``subscription` 包,为后续支付编排(P2+)打地基。
**Architecture:** 两张新表:`pay_orders`(业务订单,order_no 幂等,建单时锁定 user/plan/天数)+ `pay_attempts`(支付尝试,`UNIQUE(provider,provider_ref)` 幂等)。开通逻辑从 `codes.Service.applySubscription` 抽到新包 `server/internal/subscription`,`Grant(...)``source` 参数;`subscriptions.source` 枚举加 `'pay'`。codes 改为委托新包,行为不变。
@@ -68,6 +70,7 @@ CREATE TABLE pay_attempts (
amount_minor INTEGER NOT NULL,
currency TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('created','pending','paid','failed','expired')),
expires_at DATETIME NOT NULL, -- 本次尝试超时(哪吒 payurl / crypto 15min);超时只弃本 attempt
created_at DATETIME NOT NULL,
paid_at DATETIME NULL,
UNIQUE (provider, provider_ref),
@@ -151,6 +154,7 @@ CREATE TABLE pay_attempts (
amount_minor BIGINT NOT NULL,
currency VARCHAR(16) NOT NULL,
status ENUM('created','pending','paid','failed','expired') NOT NULL,
expires_at DATETIME(6) NOT NULL,
created_at DATETIME(6) NOT NULL,
paid_at DATETIME(6) NULL,
UNIQUE KEY uq_provider_ref (provider, provider_ref),
@@ -476,11 +480,12 @@ git commit -m "refactor(pay): codes 开通委托 subscription.Grant(行为不变
- Consumes: 表 `pay_orders`/`pay_attempts`(Task 1)。
- Produces:
- `type Order struct{ OrderNo string; UserID int64; UserUUID, SKU, PlanCode string; DurationDays int; AmountMinor int64; Currency, Status string; CreatedAt, ExpiresAt time.Time; PaidAt sql.NullTime; SubscriptionID sql.NullInt64 }`
- `type Attempt struct{ ID int64; OrderNo, Method, Provider, ProviderRef, RenderType string; AmountMinor int64; Currency, Status string; CreatedAt time.Time; PaidAt sql.NullTime }`
- `type Attempt struct{ ID int64; OrderNo, Method, Provider, ProviderRef, RenderType string; AmountMinor int64; Currency, Status string; ExpiresAt, CreatedAt time.Time; PaidAt sql.NullTime }`
- `type Store struct{ db *sql.DB }` · `NewStore(db) *Store` · `func (s *Store) DB() *sql.DB`
- `CreateOrder(ctx, Order) error` · `GetOrder(ctx, orderNo) (*Order, error)`(`ErrNotFound``ListOrders(ctx, userID int64, limit int, beforeID int64) ([]Order, error)`
- `CreateOrder(ctx, Order) error` · `GetOrder(ctx, orderNo) (*Order, error)`(`ErrNotFound``ListOrders(ctx, userID int64, limit int, beforeID int64) ([]Order, error)` —— 返回**全部状态**(含 canceled),供订单历史页。
- `CreateAttempt(ctx, Attempt) (int64, error)` · `AttemptByProviderRef(ctx, provider, ref string) (*Attempt, error)`
- `MarkOrderPaid(ctx, tx *sql.Tx, orderNo, provider, ref string, subID int64, at time.Time) (bool, error)` —— 事务内幂等:仅当 order.status='pending' 时置 paid + 回填 subscription_id + 对应 attempt 置 paid;已 paid 返回 `false,nil`
- `MarkOrderPaid(ctx, tx *sql.Tx, orderNo, provider, ref string, subID int64, at time.Time) (bool, error)` —— 事务内幂等:仅当 order.status='pending' 时置 paid + 回填 subscription_id + 对应 attempt 置 paid;非 pending(已 paid/取消/过期)返回 `false,nil`。**这是「一单只一个 attempt 成功」的守卫**
- `CancelOrder(ctx, orderNo string) (bool, error)` —— 仅当 order.status='pending' 时置 canceled,返回 true;非 pending 返回 `false,nil`
- `var ErrNotFound = errors.New("payorders: not found")`
- [ ] **Step 1: 写失败测试(sqlite 实库,建最小 schema)**
@@ -510,7 +515,7 @@ func openPay(t *testing.T) *sql.DB {
status TEXT, subscription_id INTEGER NULL, created_at DATETIME, paid_at DATETIME NULL, expires_at DATETIME)`,
`CREATE TABLE pay_attempts (id INTEGER PRIMARY KEY AUTOINCREMENT, order_no TEXT, method TEXT, provider TEXT,
provider_ref TEXT, render_type TEXT, amount_minor INTEGER, currency TEXT, status TEXT,
created_at DATETIME, paid_at DATETIME NULL, UNIQUE(provider, provider_ref))`,
expires_at DATETIME, created_at DATETIME, paid_at DATETIME NULL, UNIQUE(provider, provider_ref))`,
}
for _, s := range ddl {
if _, err := db.Exec(s); err != nil { t.Fatalf("ddl: %v", err) }
@@ -539,7 +544,7 @@ func TestCreateGetMarkPaidIdempotent(t *testing.T) {
if _, err := st.CreateAttempt(ctx, payorders.Attempt{
OrderNo: "PAY-1", Method: "usdt_trc20", Provider: "crypto", ProviderRef: "P-9",
RenderType: "display_details", AmountMinor: 29990000, Currency: "USDT",
Status: "pending", CreatedAt: now,
Status: "pending", ExpiresAt: now.Add(15 * time.Minute), CreatedAt: now,
}); err != nil { t.Fatalf("attempt: %v", err) }
tx, _ := db.Begin()
@@ -558,6 +563,38 @@ func TestCreateGetMarkPaidIdempotent(t *testing.T) {
t.Fatalf("final: %+v", final)
}
}
func TestCancelOrder(t *testing.T) {
db := openPay(t)
st := payorders.NewStore(db)
ctx := context.Background()
now := time.Now().UTC()
must := payorders.Order{
OrderNo: "PAY-C", UserID: 1, UserUUID: "u-1", SKU: "pro-month", PlanCode: "pro",
DurationDays: 30, AmountMinor: 3990000, Currency: "USDT", Status: "pending",
CreatedAt: now, ExpiresAt: now.Add(time.Hour),
}
if err := st.CreateOrder(ctx, must); err != nil { t.Fatalf("create: %v", err) }
ok, err := st.CancelOrder(ctx, "PAY-C")
if err != nil || !ok { t.Fatalf("cancel#1 ok=%v err=%v", ok, err) }
// 再取消 → false(非 pending)。
ok2, _ := st.CancelOrder(ctx, "PAY-C")
if ok2 { t.Fatalf("第二次 CancelOrder 应 false") }
got, _ := st.GetOrder(ctx, "PAY-C")
if got.Status != "canceled" { t.Fatalf("status = %s, 应 canceled", got.Status) }
// canceled 单仍在 ListOrders 出现。
list, _ := st.ListOrders(ctx, 1, 20, 0)
var seen bool
for _, o := range list {
if o.OrderNo == "PAY-C" { seen = true }
}
if !seen { t.Fatalf("canceled 单应出现在订单列表") }
}
```
- [ ] **Step 2: 跑测试确认失败**
@@ -609,6 +646,7 @@ type Attempt struct {
AmountMinor int64
Currency string
Status string
ExpiresAt time.Time
CreatedAt time.Time
PaidAt sql.NullTime
}
@@ -687,10 +725,10 @@ func (s *Store) ListOrders(ctx context.Context, userID int64, limit int, beforeI
func (s *Store) CreateAttempt(ctx context.Context, a Attempt) (int64, error) {
res, err := s.db.ExecContext(ctx,
`INSERT INTO pay_attempts (order_no, method, provider, provider_ref, render_type,
amount_minor, currency, status, created_at)
VALUES (?,?,?,?,?,?,?,?,?)`,
amount_minor, currency, status, expires_at, created_at)
VALUES (?,?,?,?,?,?,?,?,?,?)`,
a.OrderNo, a.Method, a.Provider, a.ProviderRef, a.RenderType,
a.AmountMinor, a.Currency, a.Status, a.CreatedAt.UTC())
a.AmountMinor, a.Currency, a.Status, a.ExpiresAt.UTC(), a.CreatedAt.UTC())
if err != nil {
return 0, fmt.Errorf("payorders.CreateAttempt: %w", err)
}
@@ -702,10 +740,10 @@ func (s *Store) AttemptByProviderRef(ctx context.Context, provider, ref string)
var a Attempt
err := s.db.QueryRowContext(ctx,
`SELECT id, order_no, method, provider, provider_ref, render_type, amount_minor,
currency, status, created_at, paid_at
currency, status, expires_at, created_at, paid_at
FROM pay_attempts WHERE provider=? AND provider_ref=?`, provider, ref).
Scan(&a.ID, &a.OrderNo, &a.Method, &a.Provider, &a.ProviderRef, &a.RenderType,
&a.AmountMinor, &a.Currency, &a.Status, &a.CreatedAt, &a.PaidAt)
&a.AmountMinor, &a.Currency, &a.Status, &a.ExpiresAt, &a.CreatedAt, &a.PaidAt)
if err == sql.ErrNoRows {
return nil, ErrNotFound
}
@@ -715,6 +753,18 @@ func (s *Store) AttemptByProviderRef(ctx context.Context, provider, ref string)
return &a, nil
}
// CancelOrder flips a pending order to canceled. Returns false if the order was
// not in 'pending' (already paid / canceled / expired).
func (s *Store) CancelOrder(ctx context.Context, orderNo string) (bool, error) {
res, err := s.db.ExecContext(ctx,
`UPDATE pay_orders SET status='canceled' WHERE order_no=? AND status='pending'`, orderNo)
if err != nil {
return false, fmt.Errorf("payorders.CancelOrder: %w", err)
}
n, _ := res.RowsAffected()
return n > 0, nil
}
// MarkOrderPaid flips a pending order to paid inside tx (idempotent). Returns
// false if the order was not in 'pending' (already handled / expired).
func (s *Store) MarkOrderPaid(ctx context.Context, tx *sql.Tx, orderNo, provider, ref string, subID int64, at time.Time) (bool, error) {
@@ -737,10 +787,10 @@ func (s *Store) MarkOrderPaid(ctx context.Context, tx *sql.Tx, orderNo, provider
}
```
- [ ] **Step 4: 跑测试确认通过**
- [ ] **Step 4: 跑测试确认通过(含取消/列表)**
Run: `cd server && go test ./internal/payorders/ -run TestCreateGetMarkPaidIdempotent -v`
Expected: PASS。
Run: `cd server && go test ./internal/payorders/ -v`
Expected: `TestCreateGetMarkPaidIdempotent``TestCancelOrder` PASS。
- [ ] **Step 5: 全量编译 + 测试**