docs(v2): P2 计划折入评审修订 — D3 路由统一 /api/v2;D4 settle 崩溃安全顺序(先入队后翻转+投递门禁)/ QueryRequest 上下文 / PaidEvent.PaidAt

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
This commit is contained in:
wangjia
2026-07-10 10:20:42 +08:00
parent add0d26963
commit 85ce5aa639
4 changed files with 221 additions and 88 deletions
+19 -9
View File
@@ -49,11 +49,12 @@
<span class="fn">Capabilities</span>() Capabilities
<span class="fn">Create</span>(ctx, CreateRequest) (*Session, <span class="ty">error</span>)
<span class="fn">VerifyCallback</span>(ctx, CallbackInput) (*PaidEvent, <span class="ty">error</span>)
<span class="fn">Query</span>(ctx, providerRef <span class="ty">string</span>) (*PaidEvent, <span class="ty">error</span>)
<span class="fn">Query</span>(ctx, QueryRequest) (*PaidEvent, <span class="ty">error</span>) <span class="co">// D4-A2:带尝试上下文快照,非裸 ref</span>
}
<span class="co">// 可选子接口:RefundingProvider(P4)、RecurringProvider(P8)</span>
<span class="k">type</span> <span class="ty">Session</span> <span class="k">struct</span> { ProviderRef <span class="ty">string</span>; RenderType <span class="ty">string</span>; Payload map[<span class="ty">string</span>]any; ExpiresAt *time.Time }
<span class="k">type</span> <span class="ty">PaidEvent</span> <span class="k">struct</span> { ProviderRef <span class="ty">string</span>; Status PaidStatus; PaidAmountMinor <span class="ty">int64</span>; PaidCurrency, Raw <span class="ty">string</span> }</pre>
<span class="k">type</span> <span class="ty">Session</span> <span class="k">struct</span> { ProviderRef <span class="ty">string</span>; RenderType <span class="ty">string</span>; Payload map[<span class="ty">string</span>]any; ExpiresAt *time.Time }
<span class="k">type</span> <span class="ty">PaidEvent</span> <span class="k">struct</span> { ProviderRef <span class="ty">string</span>; Status PaidStatus; PaidAmountMinor <span class="ty">int64</span>; PaidCurrency, Raw <span class="ty">string</span>; PaidAt *time.Time }
<span class="k">type</span> <span class="ty">QueryRequest</span> <span class="k">struct</span> { ProviderRef, OutTradeNo, AccountID <span class="ty">string</span>; AmountMinor <span class="ty">int64</span>; Currency <span class="ty">string</span>; CreatedAt time.Time; ExpiresAt *time.Time }</pre>
<p>6 个 render_type 常量 + PaidPending/Succeeded/Failed;<code>Registry</code>(Register/Get/Methods + ErrUnknownMethod)。</p>
</div>
@@ -74,17 +75,17 @@
<div class="card">
<h3>Task 5 · 统一入账/开通管线 settle</h3>
<code>internal/gateway/settle.go</code>:<code>Settle(ctx, *PaidEvent)</code> = 归一 → 定位(provider_ref→attempt→order)→ 币种/金额核对 → <b>复用 P1 <code>MarkAttemptPaid</code> 幂等</b> → 仅翻转时入队 <code>payment.succeeded</code><code>HandleCallback</code>(webhook 入口)、<code>SyncPendingAttempts</code>(query 兜底)都汇入这条。<code>SettleResult</code>:ignored/not_found/amount_mismatch/duplicate/processed。
<code>internal/gateway/settle.go</code>:<code>Settle(ctx, *PaidEvent)</code> = 归一 → 定位(provider_ref→attempt→order)→ 币种/金额核对 → <b>先幂等入队 <code>payment.succeeded</code>,再复用 P1 <code>MarkAttemptPaid</code> 幂等翻转</b>(D4-A1 崩溃安全顺序:不变量「paid ⇒ outbox 行存在」,入队失败返回 <code>SettleFailed</code> 不翻转、渠道重投自愈)<code>HandleCallback</code>(webhook 入口)、<code>SyncPendingAttempts</code>(query 兜底)都汇入这条。<code>SettleResult</code>:ignored/not_found/amount_mismatch/duplicate/processed/failed。paid_at 优先用渠道报的 <code>PaidAt</code>(D4-A3)
</div>
<div class="card">
<h3>Task 6 · webhook v2 outbox + Notifier</h3>
<code>model.WebhookDelivery</code>(<code>uniqueIndex(out_trade_no,event_type)</code> 幂等)+ <code>store.WebhookStore</code>(Enqueue/ListUndelivered/MarkDelivered/MarkFailed)+ <code>webhook.Notifier</code>(DeliverPending/Start,HMAC 头 <code>X-Pay-System/Event/Timestamp/Nonce/Sign</code>,失败重试)。<b>event_type 从 <code>payment.succeeded</code></b>
<code>model.WebhookDelivery</code>(<code>uniqueIndex(out_trade_no,event_type)</code> 幂等)+ <code>store.WebhookStore</code>(Enqueue/ListUndelivered/MarkDelivered/MarkFailed)+ <code>webhook.Notifier</code>(DeliverPending/Start,HMAC 头 <code>X-Pay-System/Event/Timestamp/Nonce/Sign</code>,失败重试)。<b>event_type 从 <code>payment.succeeded</code></b><b>投递门禁</b>(D4-A1 另一半):发前确认订单已付,防把"未付单"通知出去。
</div>
<div class="card">
<h3>Task 7 · HTTP /v1 接线</h3>
<code>router.SetupV2</code><code>POST /v1/orders</code><code>GET /v1/orders/:no</code><code>.../retry</code><code>.../cancel</code><code>POST /v1/callback/:method</code>;<code>main.go</code> 装配 provider 注册表 + gateway + notifier,AutoMigrate 加 WebhookDelivery。
<h3>Task 7 · HTTP /api/v2 接线</h3>
<code>router.SetupV2</code><code>POST /api/v2/orders</code><code>GET /api/v2/orders/:no</code><code>.../retry</code><code>.../cancel</code><code>POST /api/v2/callback/:method</code>(D3:与旧版统一 <code>/api</code> 前缀;<code>POST /api/v1/orders</code> 被旧契约占用无法共存,P3 渠道迁入后删除整组 <code>/api/v1</code> 收口为一套);<code>main.go</code> 装配 provider 注册表 + gateway + notifier(含投递门禁闭包),AutoMigrate 加 WebhookDelivery。
</div>
<h2>关键设计取舍(计划 Self-Review 已记)</h2>
@@ -95,11 +96,20 @@
<tr><td>webhook 无 biz_code</td><td>P1 OrderV2 无 biz_code 列,不改已落地 schema;业务方暂用 biz_ref 映射,P3 补</td></tr>
<tr><td>路由取首个账户</td><td>P2 取"首个 enabled 账户",完整路由策略在 P5</td></tr>
<tr><td>币种走部署默认</td><td>多币种在 P3+</td></tr>
<tr><td>/v1 与 /api/v1 并存</td><td>v2 新端点与 v1 旧端点并存,收口在 pay 定稿后</td></tr>
<tr><td>路由 /api/v2</td><td>D3 已拍:v2 挂 <code>/api/v2</code>(统一 <code>/api</code> 前缀);旧 <code>/api/v1</code> 仅为存量当面付部署保留,P3 渠道迁入后整组删除,最终只剩一套</td></tr>
</tbody>
</table>
<div class="warn"><b>审阅重点</b>:① Provider 接口是否够覆盖后续渠道(Task1);② settle 的归一/幂等/金额核对链路(Task5,资金命脉);③ webhook outbox 幂等键 <code>(out_trade_no,event_type)</code> 是否合理(Task6);④ 上面 5 条取舍是否认可(尤其"路由取首个账户""币种默认"延后)。</div>
<h2>评审修订(D4 深讲后折入,2026-07-10)</h2>
<table class="fields">
<thead><tr><th>修订</th><th>内容与理由</th></tr></thead>
<tbody>
<tr><td>A1 崩溃安全顺序</td><td>Settle 改为<b>先幂等入队 webhook、再幂等翻转订单</b> + Notifier「订单已付」投递门禁 + <code>SettleFailed</code>。原顺序(先翻转后入队)两步间崩溃 = 已收钱但业务方永不知情且无人重试;新顺序两个方向的窗口都由幂等 + 门禁 + 渠道重投/查单兜底自愈</td></tr>
<tr><td>A2 Query 带上下文</td><td><code>Query(ctx, providerRef)</code><code>Query(ctx, QueryRequest)</code>(尝试完整快照)。crypto 自托管的"查单"是按地址+金额+时间窗扫链核对,裸 ref 会逼 adapter 自建 ref→上下文映射表,重复 pay 已有数据</td></tr>
<tr><td>A3 渠道支付时间</td><td><code>PaidEvent</code><code>PaidAt</code>;settle/webhook payload 优先用渠道报的支付时间,否则收到时间。不加则本地 paid_at 全是"回调到达时间",P6 对账与渠道流水对不上产生假差异</td></tr>
<tr><td>有意不调(记录)</td><td>回调原始报文落表留痕(P3 callback_logs);Notifier 退避/死信上限+告警(P6);多账户回调验签由 P3 装配注入账户注册表(接口不变);crypto 分笔凑单由 adapter 聚合(P3)</td></tr>
</tbody>
</table>
<p class="path">相关:<a href="pay-v2-unified-gateway-design.html">pay v2 设计</a> · 真相源 docs/superpowers/plans/2026-07-10-pay-v2-p2-pipeline.md</p>
</div>