85ce5aa639
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
118 lines
10 KiB
HTML
118 lines
10 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="zh-CN">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>pay v2 · P2 收款管线 + Provider 抽象(阅读版)</title>
|
|
<style>
|
|
:root{--bg:#0d1117;--card:#161b22;--card-2:#1c2330;--border:#283041;--fg:#e6edf3;--fg-soft:#aeb9c7;--muted:#7d8896;--accent:#58a6ff;--ok:#3fb950;--warn:#d29922;--radius:14px;--mono:"SF Mono",ui-monospace,Menlo,Consolas,monospace}
|
|
*{box-sizing:border-box}
|
|
body{margin:0;background:radial-gradient(1200px 600px at 80% -10%,rgba(88,166,255,.08),transparent 60%),var(--bg);color:var(--fg);font:15px/1.7 -apple-system,BlinkMacSystemFont,"PingFang SC","Microsoft YaHei",sans-serif}
|
|
.wrap{max-width:960px;margin:0 auto;padding:44px 24px 96px}
|
|
.back{display:inline-block;margin-bottom:18px;font-size:13px}
|
|
a{color:var(--accent);text-decoration:none}
|
|
h1{font-size:26px;margin:6px 0 8px}
|
|
.lead{color:var(--fg-soft);margin:0 0 22px}
|
|
h2{font-size:18px;margin:34px 0 10px;color:var(--accent);border-bottom:1px solid var(--border);padding-bottom:8px}
|
|
code{font-family:var(--mono);font-size:.85em;background:var(--card-2);padding:1px 6px;border-radius:5px;color:#cdd9e5}
|
|
pre{background:#0a0e14;border:1px solid var(--border);border-radius:10px;padding:12px 14px;overflow-x:auto;font-family:var(--mono);font-size:12px;line-height:1.5;color:#cdd9e5}
|
|
pre.code .k{color:#ff7b72}pre.code .ty{color:#79c0ff}pre.code .fn{color:#d2a8ff}pre.code .co{color:#8b949e;font-style:italic}
|
|
b{color:#fff}
|
|
table{width:100%;border-collapse:collapse;margin:14px 0;font-size:13.5px}
|
|
th,td{text-align:left;padding:9px 12px;border-bottom:1px solid var(--border);vertical-align:top}
|
|
th{color:var(--fg-soft);font-weight:600;font-size:13px}
|
|
table.fields td:first-child{font-family:var(--mono);font-size:12px;color:#cdd9e5;white-space:nowrap;width:1%}
|
|
.card{background:var(--card);border:1px solid var(--border);border-radius:var(--radius);padding:14px 18px;margin:12px 0}
|
|
.card h3{margin:0 0 6px;color:var(--accent);font-size:15px}
|
|
.lead-box{background:linear-gradient(180deg,rgba(88,166,255,.10),transparent);border:1px solid var(--border);border-radius:var(--radius);padding:16px 20px;margin:0 0 8px}
|
|
.warn{background:rgba(210,153,34,.08);border:1px solid rgba(210,153,34,.35);border-radius:12px;padding:12px 16px;margin:14px 0;font-size:13.5px}
|
|
.path{color:var(--muted);font-size:12px;font-family:var(--mono);margin-top:6px}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="wrap">
|
|
<a class="back" href="index.html">← 返回文档索引</a>
|
|
<h1>pay v2 · P2 收款管线 + Provider 抽象 + webhook v2<span style="font-size:14px"> (阅读版)</span></h1>
|
|
<p class="lead">执行真相源 <code>docs/superpowers/plans/2026-07-10-pay-v2-p2-pipeline.md</code>(含 checkbox + 每步完整代码/测试)。设计见 <a href="pay-v2-unified-gateway-design.html">pay v2 设计</a>,DAG 见 <a href="pay-v2-roadmap-dag.html">依赖图</a>。</p>
|
|
|
|
<div class="lead-box">
|
|
<b>P2 目标</b>:在 P1 数据地基上搭起<b>一次性收款的完整管线</b> —— Provider 渠道抽象(6 render_type)+ 建单/查单/重试/取消 + 统一入账/开通(webhook 与 query 都归一成 PaidEvent,复用 P1 的幂等标付)+ pay→业务方 webhook v2(带 <code>event_type</code>)。用一个 <b>fake provider</b> 端到端验证管线,真渠道在 P3。全程复用 P1、免 docker。
|
|
</div>
|
|
|
|
<h2>7 个 TDD 任务</h2>
|
|
|
|
<div class="card">
|
|
<h3>Task 1 · Provider 抽象接口 + render_type + PaidEvent + 注册表</h3>
|
|
<p>文件 <code>internal/provider/provider.go</code>。核心契约,P3 所有渠道 adapter 依赖它:</p>
|
|
<pre class="code"><span class="k">type</span> <span class="ty">Provider</span> <span class="k">interface</span> {
|
|
<span class="fn">Method</span>() <span class="ty">string</span>
|
|
<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, 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>; 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>
|
|
|
|
<div class="card">
|
|
<h3>Task 2 · fake provider(测试用)</h3>
|
|
<code>internal/provider/fake/fake.go</code> —— 实现 Provider,<code>Method()=="fake"</code>、render=<code>crypto_address</code>;带 <code>SetQueryResult</code> 测试 seam。P2 用它端到端验管线,不接真渠道。
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h3>Task 3 · OrderStore 扩展(查询)</h3>
|
|
<code>internal/store/order_query.go</code>:<code>GetOrder</code> / <code>AttemptByProviderRef</code> / <code>ListAttemptsByStatus</code> / <code>ExpirePendingAttempts</code> + 哨兵错误。为管线定位订单/尝试用。
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h3>Task 4 · 一次性收款管线 gateway</h3>
|
|
<code>internal/gateway/gateway.go</code>:<code>CreateOrder</code>(选 provider→create→落 Order+Attempt→返回 <code>{order_no, session:{render_type,payload}}</code>)/ <code>GetOrder</code> / <code>RetryOrder</code> / <code>CancelOrder</code>。接口 <code>ProductResolver.Resolve(sku)</code>(取权威价/币种/subject/bizCode)、<code>WebhookEnqueuer.Enqueue(...)</code>。
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h3>Task 5 · 统一入账/开通管线 settle</h3>
|
|
<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>。<b>投递门禁</b>(D4-A1 另一半):发前确认订单已付,防把"未付单"通知出去。
|
|
</div>
|
|
|
|
<div class="card">
|
|
<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>
|
|
<table class="fields">
|
|
<thead><tr><th>取舍</th><th>说明</th></tr></thead>
|
|
<tbody>
|
|
<tr><td>fake provider_ref</td><td>需纳秒唯一化——P1 Attempt 有 <code>uniqueIndex(channel,provider_ref)</code>,retry 同 method 会撞键(真渠道天然不同 ref)</td></tr>
|
|
<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>路由 /api/v2</td><td>D3 已拍:v2 挂 <code>/api/v2</code>(统一 <code>/api</code> 前缀);旧 <code>/api/v1</code> 仅为存量当面付部署保留,P3 渠道迁入后整组删除,最终只剩一套</td></tr>
|
|
</tbody>
|
|
</table>
|
|
|
|
<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>
|
|
</body>
|
|
</html>
|