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:
+2
-2
@@ -64,8 +64,8 @@
|
||||
</a>
|
||||
<a class="doc" href="./pay-v2-p2-plan.html">
|
||||
<div class="title">pay v2 · P2 收款管线 + Provider 抽象 + webhook v2(计划)</div>
|
||||
<div class="desc">7 个 TDD 任务:Provider 接口(create/verify_callback/query)+6 render_type+PaidEvent+注册表 → fake provider → OrderStore 扩展 → 一次性收款管线 gateway → 统一入账/开通(复用 P1 MarkAttemptPaid 幂等)→ webhook v2 outbox+Notifier(event_type+HMAC)→ HTTP /v1 接线。复用 P1 全部类型、免 docker。/v1 与 v1 /api/v1 并存。</div>
|
||||
<div class="meta">P2 · 2026-07-10 · 待执行 · 真相源 .md</div>
|
||||
<div class="desc">7 个 TDD 任务:Provider 接口(create/verify_callback/query 带 QueryRequest 上下文)+6 render_type+PaidEvent(含 PaidAt)+注册表 → fake provider → OrderStore 扩展 → 一次性收款管线 gateway → 统一入账/开通(先幂等入队再幂等翻转,崩溃安全 + Notifier 已付门禁)→ webhook v2 outbox+Notifier(event_type+HMAC)→ HTTP /api/v2 接线(与旧版统一 /api 前缀,P3 后删 /api/v1 收口)。复用 P1 全部类型、免 docker。含 2026-07-10 评审修订(D3/D4)。</div>
|
||||
<div class="meta">P2 · 2026-07-10 · 评审修订已折入 · 真相源 .md</div>
|
||||
</a>
|
||||
<a class="doc" href="./pay-v2-roadmap-dag.html">
|
||||
<div class="title">pay v2 · P2-P8 依赖 DAG 与并行执行</div>
|
||||
|
||||
@@ -97,9 +97,9 @@
|
||||
<li>fake provider(测试用)</li>
|
||||
<li>OrderStore 扩展(GetOrder/AttemptByProviderRef…)</li>
|
||||
<li>一次性收款管线 gateway(建单/查单/重试/取消)</li>
|
||||
<li>统一入账/开通 settle(复用 P1 幂等标付)</li>
|
||||
<li>webhook v2 outbox + Notifier(event_type + HMAC)</li>
|
||||
<li>HTTP /v1 接线</li>
|
||||
<li>统一入账/开通 settle(先幂等入队再幂等翻转,崩溃安全)</li>
|
||||
<li>webhook v2 outbox + Notifier(event_type + HMAC + 已付门禁)</li>
|
||||
<li>HTTP /api/v2 接线(D3:与旧版统一 /api 前缀)</li>
|
||||
</ol>
|
||||
<p style="font-size:12.5px;color:var(--muted)">→ <a href="pay-v2-p2-plan.html">P2 详细阅读版</a></p>
|
||||
</div>
|
||||
@@ -119,14 +119,14 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>4. 你要拍的决策点</h2>
|
||||
<div class="decision"><b>D1 · 是否开跑大执行波</b> —— P2(7)+P7(8)≈15 任务、~40 次 SDD agent 调用,多小时工程;P7 要<b>新建仓 <code>~/code/codes</code></b>。开跑 / 先只跑 P7 / 暂缓?</div>
|
||||
<div class="decision"><b>D2 · P7 独立新仓</b> —— codes 做成 <code>github.com/wangjia/codes</code> 独立仓(不放 pay 内、不留 pangolin),核心零第三方依赖。认可?</div>
|
||||
<div class="decision"><b>D3 · P2 延后取舍</b> —— webhook 暂无 biz_code(用 biz_ref)、路由取"首个 enabled 账户"(策略延 P5)、币种走部署默认(多币种延 P3+)、/v1 与 /api/v1 并存。都认可?</div>
|
||||
<div class="decision"><b>D4 · settle 资金链路 & Provider 接口</b> —— 入账归一/幂等/金额核对(P2 Task5)与 Provider 抽象(P2 Task1)是资金命脉与扩展地基,是否需要我讲深或调整?</div>
|
||||
<h2>4. 决策点(2026-07-10 评审:D2/D3/D4 已拍,余 D1)</h2>
|
||||
<div class="decision"><b>D1 · 是否开跑大执行波</b> —— P2(7)+P7(8)≈15 任务、~40 次 SDD agent 调用,多小时工程;P7 要<b>新建仓 <code>~/code/codes</code></b>。开跑 / 先只跑 P7 / 暂缓?<b>【待拍】</b></div>
|
||||
<div class="decision" style="border-left-color:var(--ok)"><b>D2 · P7 独立新仓</b> —— <span class="tag ok">✅ 已拍:认可</span> <code>github.com/wangjia/codes</code> 独立仓,核心零第三方依赖。</div>
|
||||
<div class="decision" style="border-left-color:var(--ok)"><b>D3 · 路由统一</b> —— <span class="tag ok">✅ 已拍:统一 /api 前缀</span> v2 挂 <code>/api/v2</code>(<code>POST /api/v1/orders</code> 被旧契约占用,同前缀无法并存);旧 <code>/api/v1</code> 仅为存量当面付部署保留,P3 渠道迁入后整组删除,最终只剩一套。其余延后取舍(webhook 暂用 biz_ref、路由首账户延 P5、币种部署默认延 P3+)维持。</div>
|
||||
<div class="decision" style="border-left-color:var(--ok)"><b>D4 · settle 资金链路 & Provider 接口</b> —— <span class="tag ok">✅ 已深讲并折入 3 处修订</span>:A1 先入队后翻转+投递门禁(消灭"已付但永不通知"窗口)/ A2 <code>Query(QueryRequest)</code> 带尝试上下文 / A3 <code>PaidEvent.PaidAt</code> 渠道支付时间。详见 <a href="pay-v2-p2-plan.html">P2 阅读版「评审修订」</a>。</div>
|
||||
|
||||
<h2>5. 建议下一步</h2>
|
||||
<p>我建议:<b>D2/D3 认可后开跑</b> —— P7 在新仓/worktree 与 P2 关键路径并行,按 DAG 波次推进,每波边界向你汇报。若想稳,先只跑 P7(独立、低风险、不动 pay 主线)试水。你定。</p>
|
||||
<p>只剩 <b>D1</b>:说"开跑"即按 DAG 波次启动(P7 新仓 ∥ P2 关键路径,SDD 逐任务实现+复审,每波边界汇报);想稳则"先只跑 P7"。</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -39,8 +39,9 @@
|
||||
- `type CreateRequest struct{ OutTradeNo, Subject string; AmountMinor int64; Currency string; Account config.AccountConfig; ReturnURL string; Metadata map[string]string }`。
|
||||
- `type Session struct{ ProviderRef string; RenderType RenderType; Payload map[string]any; ExpiresAt *time.Time }`。
|
||||
- `type CallbackInput struct{ Raw []byte; Headers, Query map[string]string }`。
|
||||
- `type PaidEvent struct{ ProviderRef string; Status PaidStatus; PaidAmountMinor int64; PaidCurrency string; Raw string }`。
|
||||
- `type Provider interface{ Method() string; Capabilities() Capabilities; Create(ctx,CreateRequest)(*Session,error); VerifyCallback(ctx,CallbackInput)(*PaidEvent,error); Query(ctx,providerRef string)(*PaidEvent,error) }`。
|
||||
- `type PaidEvent struct{ ProviderRef string; Status PaidStatus; PaidAmountMinor int64; PaidCurrency string; Raw string; PaidAt *time.Time }`(`PaidAt`=渠道报的支付时间,对账要用;渠道不报则 nil,settle 落 `time.Now()`)。
|
||||
- `type QueryRequest struct{ ProviderRef, OutTradeNo, AccountID string; AmountMinor int64; Currency string; CreatedAt time.Time; ExpiresAt *time.Time }`(查单带尝试完整上下文快照,不是裸 ref——crypto 的"查单"是按地址+金额+时间窗扫链核对)。
|
||||
- `type Provider interface{ Method() string; Capabilities() Capabilities; Create(ctx,CreateRequest)(*Session,error); VerifyCallback(ctx,CallbackInput)(*PaidEvent,error); Query(ctx,QueryRequest)(*PaidEvent,error) }`。
|
||||
- 可选扩展接口 `RefundingProvider`(P4)、`RecurringProvider`(P8),供类型断言探测。
|
||||
- `type Registry struct{...}` · `NewRegistry()` · `Register(Provider)` · `Get(method)(Provider,error)` · `Methods()[]string`;`ErrUnknownMethod`。
|
||||
|
||||
@@ -69,7 +70,7 @@ func (s stubProvider) Create(context.Context, provider.CreateRequest) (*provider
|
||||
func (s stubProvider) VerifyCallback(context.Context, provider.CallbackInput) (*provider.PaidEvent, error) {
|
||||
return &provider.PaidEvent{ProviderRef: "R-1", Status: provider.PaidSucceeded}, nil
|
||||
}
|
||||
func (s stubProvider) Query(context.Context, string) (*provider.PaidEvent, error) {
|
||||
func (s stubProvider) Query(context.Context, provider.QueryRequest) (*provider.PaidEvent, error) {
|
||||
return &provider.PaidEvent{ProviderRef: "R-1", Status: provider.PaidPending}, nil
|
||||
}
|
||||
|
||||
@@ -188,6 +189,21 @@ type PaidEvent struct {
|
||||
PaidAmountMinor int64
|
||||
PaidCurrency string
|
||||
Raw string
|
||||
PaidAt *time.Time // 渠道报的支付时间;nil 则 settle 用收到时间,对账时两边时间才对得上
|
||||
}
|
||||
|
||||
// QueryRequest — Provider.Query 入参:尝试的完整上下文快照,不是裸 provider_ref。
|
||||
// crypto 自托管的"查单"= 按地址+期望金额+时间窗扫链核对;裸 ref 会逼渠道 adapter
|
||||
// 自建 ref→(地址/金额/窗口) 映射表,重复 pay 已持有的数据。管线侧(SyncPendingAttempts)
|
||||
// 本就拿着整个 attempt,填这个结构零成本。
|
||||
type QueryRequest struct {
|
||||
ProviderRef string
|
||||
OutTradeNo string
|
||||
AccountID string
|
||||
AmountMinor int64
|
||||
Currency string
|
||||
CreatedAt time.Time
|
||||
ExpiresAt *time.Time
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -201,7 +217,7 @@ type Provider interface {
|
||||
Capabilities() Capabilities
|
||||
Create(ctx context.Context, req CreateRequest) (*Session, error)
|
||||
VerifyCallback(ctx context.Context, in CallbackInput) (*PaidEvent, error)
|
||||
Query(ctx context.Context, providerRef string) (*PaidEvent, error)
|
||||
Query(ctx context.Context, req QueryRequest) (*PaidEvent, error)
|
||||
}
|
||||
|
||||
// RefundingProvider — 可选:支持渠道退款的 Provider 额外实现(P4;不支持则 capabilities=false)。
|
||||
@@ -271,7 +287,7 @@ git commit -m "feat(v2): Provider 渠道抽象接口 + 6 render_type + PaidEvent
|
||||
- `type Provider struct{...}` · `func New() *Provider`(实现 `provider.Provider`,`Method()=="fake"`,render_type=`crypto_address`)。
|
||||
- `Create` 确定性产出 `provider_ref = "FAKE-"+OutTradeNo`,payload 含 address/amount_minor/currency。
|
||||
- `VerifyCallback` 解析 JSON `{provider_ref,status,amount_minor,currency}` → `PaidEvent`。
|
||||
- `Query` 返回预置结果(默认 pending);`SetQueryResult(providerRef, PaidEvent)` 测试 seam(模拟查单命中)。
|
||||
- `Query(ctx, provider.QueryRequest)` 按 `req.ProviderRef` 返回预置结果(默认 pending);`SetQueryResult(providerRef, PaidEvent)` 测试 seam(模拟查单命中)。
|
||||
|
||||
- [ ] **Step 1: 写失败测试**
|
||||
|
||||
@@ -316,7 +332,7 @@ func TestFakeQueryPreset(t *testing.T) {
|
||||
p := fake.New()
|
||||
ctx := context.Background()
|
||||
|
||||
ev, _ := p.Query(ctx, "FAKE-PAY-2")
|
||||
ev, _ := p.Query(ctx, provider.QueryRequest{ProviderRef: "FAKE-PAY-2"})
|
||||
if ev.Status != provider.PaidPending {
|
||||
t.Fatalf("默认应 pending, got %v", ev.Status)
|
||||
}
|
||||
@@ -324,7 +340,7 @@ func TestFakeQueryPreset(t *testing.T) {
|
||||
p.SetQueryResult("FAKE-PAY-2", provider.PaidEvent{
|
||||
ProviderRef: "FAKE-PAY-2", Status: provider.PaidSucceeded, PaidAmountMinor: 100, PaidCurrency: "USDT",
|
||||
})
|
||||
ev2, _ := p.Query(ctx, "FAKE-PAY-2")
|
||||
ev2, _ := p.Query(ctx, provider.QueryRequest{ProviderRef: "FAKE-PAY-2"})
|
||||
if ev2.Status != provider.PaidSucceeded || ev2.PaidAmountMinor != 100 {
|
||||
t.Fatalf("预置后应 succeeded, got %+v", ev2)
|
||||
}
|
||||
@@ -410,14 +426,14 @@ func (p *Provider) VerifyCallback(_ context.Context, in provider.CallbackInput)
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *Provider) Query(_ context.Context, providerRef string) (*provider.PaidEvent, error) {
|
||||
func (p *Provider) Query(_ context.Context, req provider.QueryRequest) (*provider.PaidEvent, error) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if ev, ok := p.queryResults[providerRef]; ok {
|
||||
if ev, ok := p.queryResults[req.ProviderRef]; ok {
|
||||
e := ev
|
||||
return &e, nil
|
||||
}
|
||||
return &provider.PaidEvent{ProviderRef: providerRef, Status: provider.PaidPending}, nil
|
||||
return &provider.PaidEvent{ProviderRef: req.ProviderRef, Status: provider.PaidPending}, nil
|
||||
}
|
||||
|
||||
// SetQueryResult primes Query to report a specific event (test seam).
|
||||
@@ -638,6 +654,7 @@ package gateway_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/wangjia/pay/config"
|
||||
@@ -661,10 +678,15 @@ func (stubResolver) Resolve(sku string) (int64, string, string, string, error) {
|
||||
}
|
||||
|
||||
type spyEnqueuer struct {
|
||||
calls []map[string]any
|
||||
calls []map[string]any
|
||||
failNext bool // 置 true 模拟 outbox 入队失败(settle 崩溃窗口测试用)
|
||||
}
|
||||
|
||||
func (s *spyEnqueuer) Enqueue(outTradeNo, bizSystem, eventType string, data map[string]any) error {
|
||||
if s.failNext {
|
||||
s.failNext = false
|
||||
return errors.New("outbox down")
|
||||
}
|
||||
s.calls = append(s.calls, data)
|
||||
return nil
|
||||
}
|
||||
@@ -1101,11 +1123,11 @@ git commit -m "feat(v2): 一次性收款管线 CreateOrder/GetOrder/Retry/Cancel
|
||||
**Interfaces:**
|
||||
- Consumes: `provider.PaidEvent`、`store.OrderStore.AttemptByProviderRef/MarkAttemptPaid/GetOrder`(P1+T3)、`WebhookEnqueuer`。
|
||||
- Produces(挂 `*Gateway`):
|
||||
- `type SettleResult string` + `SettleIgnored/SettleNotFound/SettleAmountMismatch/SettleDuplicate/SettleProcessed`。
|
||||
- `type SettleResult string` + `SettleIgnored/SettleNotFound/SettleAmountMismatch/SettleDuplicate/SettleProcessed/SettleFailed`。
|
||||
- `var ErrAmountMismatch = errors.New(...)`
|
||||
- `Settle(ctx, ev *provider.PaidEvent) (SettleResult, error)` — 定位 attempt→order,核对币种/金额,幂等 `MarkAttemptPaid`,仅真正翻转时入队 `payment.succeeded`。
|
||||
- `Settle(ctx, ev *provider.PaidEvent) (SettleResult, error)` — 定位 attempt→order,核对币种/金额,**先幂等入队 `payment.succeeded`、再幂等 `MarkAttemptPaid` 翻转**(崩溃安全顺序,见实现注释;入队失败返回 `SettleFailed` 且不翻转,渠道重投可恢复)。
|
||||
- `HandleCallback(ctx, method string, in provider.CallbackInput) (SettleResult, error)` — provider.VerifyCallback → Settle。
|
||||
- `SyncPendingAttempts(ctx, limit int) (int, error)` — 扫 pending 尝试 → 各自 provider.Query → Settle(查单兜底,设计 §8「对账优先于实时」)。
|
||||
- `SyncPendingAttempts(ctx, limit int) (int, error)` — 扫 pending 尝试 → 各自 provider.Query(传 `QueryRequest` 上下文快照)→ Settle(查单兜底,设计 §8「对账优先于实时」)。
|
||||
|
||||
- [ ] **Step 1: 写失败测试**
|
||||
|
||||
@@ -1189,6 +1211,33 @@ func TestSettleGuards(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 资金命脉不变量:outbox 入队失败 → 绝不翻转订单(否则"已付但永不通知")。
|
||||
// 渠道拿不到 200 会重投,重投时入队+翻转都幂等,自然恢复。
|
||||
func TestSettleEnqueueFailureKeepsOrderPending(t *testing.T) {
|
||||
g, _, spy, orders := newGateway(t)
|
||||
ctx := context.Background()
|
||||
res, _ := g.CreateOrder(ctx, gateway.CreateOrderInput{SKU: "pro_year", Method: "fake", BizSystem: "pangolin", BizRef: "u-1"})
|
||||
ref := attemptRef(t, orders)
|
||||
ev := &provider.PaidEvent{ProviderRef: ref, Status: provider.PaidSucceeded, PaidAmountMinor: 29990000, PaidCurrency: "USDT"}
|
||||
|
||||
spy.failNext = true
|
||||
if got, err := g.Settle(ctx, ev); got != gateway.SettleFailed || err == nil {
|
||||
t.Fatalf("入队失败应 SettleFailed+err, got %v, %v", got, err)
|
||||
}
|
||||
o, _ := orders.GetOrder(res.OrderNo)
|
||||
if o.Status != model.OrderPendingV2 {
|
||||
t.Fatalf("入队失败后订单必须仍 pending, got %v", o.Status)
|
||||
}
|
||||
|
||||
// 渠道重投 → 入队成功 → 翻转
|
||||
if got, err := g.Settle(ctx, ev); err != nil || got != gateway.SettleProcessed {
|
||||
t.Fatalf("重投应 processed, got %v, %v", got, err)
|
||||
}
|
||||
if len(spy.calls) != 1 {
|
||||
t.Fatalf("恢复后应恰入队 1 次, got %d", len(spy.calls))
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleCallbackAndSync(t *testing.T) {
|
||||
g, fp, _, orders := newGateway(t)
|
||||
ctx := context.Background()
|
||||
@@ -1232,7 +1281,6 @@ package gateway
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/wangjia/pay/internal/model"
|
||||
@@ -1248,14 +1296,22 @@ const (
|
||||
SettleAmountMismatch SettleResult = "amount_mismatch" // 币种不符 / 少付
|
||||
SettleDuplicate SettleResult = "duplicate" // 订单已非 pending(幂等 no-op)
|
||||
SettleProcessed SettleResult = "processed" // 本次真正翻转为 paid
|
||||
SettleFailed SettleResult = "failed" // 暂时性失败(outbox 入队失败等):未翻转,渠道应重投
|
||||
)
|
||||
|
||||
var ErrAmountMismatch = errors.New("gateway: paid amount/currency mismatch")
|
||||
|
||||
// Settle normalizes a PaidEvent into activation: locate order via provider_ref →
|
||||
// attempt → order (设计 §4「归一化 PaidEvent」), reconcile currency+amount, then
|
||||
// idempotently mark paid via P1 MarkAttemptPaid (order=pending 原子守卫). Enqueues
|
||||
// the payment.succeeded webhook only on a real flip (avoids duplicate delivery).
|
||||
// enqueue-before-flip:先幂等入队 payment.succeeded(unique 键,重复 no-op),
|
||||
// 再经 P1 MarkAttemptPaid 幂等翻转(order=pending 原子守卫)。
|
||||
//
|
||||
// 顺序不变量(资金命脉):**订单为 paid ⇒ outbox 行必已存在**。
|
||||
// - 先翻转后入队:两步间崩溃 → 已收钱但业务方永不知情,且无任何机制重试 → 客诉才发现。
|
||||
// - 先入队后翻转:两步间崩溃 → outbox 里躺着一条"单还没付"的行;Notifier 投递前有
|
||||
// "订单已付"门禁(Task 6),不会把未付单通知出去。渠道因拿不到 200 会重投回调
|
||||
// (查单兜底同样收敛),重投时入队/翻转都幂等,自愈。
|
||||
// - 入队本身失败:返回 SettleFailed 且不翻转,同样交给渠道重投恢复。
|
||||
func (g *Gateway) Settle(ctx context.Context, ev *provider.PaidEvent) (SettleResult, error) {
|
||||
if ev.Status != provider.PaidSucceeded {
|
||||
return SettleIgnored, nil // 非成功状态:确认收到即可
|
||||
@@ -1271,32 +1327,35 @@ func (g *Gateway) Settle(ctx context.Context, ev *provider.PaidEvent) (SettleRes
|
||||
if ev.PaidCurrency != att.Currency || ev.PaidAmountMinor < att.AmountMinor {
|
||||
return SettleAmountMismatch, ErrAmountMismatch
|
||||
}
|
||||
// paid_at 优先用渠道报的支付时间(对账时与渠道流水对得上),渠道不报才落收到时间。
|
||||
paidAt := time.Now()
|
||||
if ev.PaidAt != nil {
|
||||
paidAt = *ev.PaidAt
|
||||
}
|
||||
|
||||
flipped, err := g.orders.MarkAttemptPaid(att.OutTradeNo, att.Channel, ev.ProviderRef, time.Now())
|
||||
if err := g.enqueuePaymentSucceeded(att, paidAt); err != nil {
|
||||
return SettleFailed, err // 未入队绝不翻转;渠道重投时幂等恢复
|
||||
}
|
||||
flipped, err := g.orders.MarkAttemptPaid(att.OutTradeNo, att.Channel, ev.ProviderRef, paidAt)
|
||||
if err != nil {
|
||||
return SettleNotFound, err
|
||||
return SettleFailed, err
|
||||
}
|
||||
if !flipped {
|
||||
return SettleDuplicate, nil // 已处理过 / 已取消 / 已过期 → 幂等 no-op
|
||||
}
|
||||
|
||||
g.enqueuePaymentSucceeded(att)
|
||||
return SettleProcessed, nil
|
||||
}
|
||||
|
||||
// enqueuePaymentSucceeded 组 webhook 领域 payload 并入队(失败只记日志,不回滚入账)。
|
||||
func (g *Gateway) enqueuePaymentSucceeded(att *model.Attempt) {
|
||||
// enqueuePaymentSucceeded 组 webhook 领域 payload 并幂等入队。仅订单仍 pending 时入队:
|
||||
// 订单已 paid 说明翻转已发生,而翻转严格发生在成功入队之后(顺序不变量),行必已存在;
|
||||
// 订单已 canceled 则不该通知(晚到支付走 P4 退款/P6 对账,不自动开通)。
|
||||
func (g *Gateway) enqueuePaymentSucceeded(att *model.Attempt, paidAt time.Time) error {
|
||||
o, err := g.orders.GetOrder(att.OutTradeNo)
|
||||
if err != nil {
|
||||
log.Printf("[settle] 入队 webhook 取单失败 %s: %v", att.OutTradeNo, err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
if o.BizSystem == "" {
|
||||
return // 独立收款,无业务方回调
|
||||
}
|
||||
paidAt := ""
|
||||
if o.PaidAt != nil {
|
||||
paidAt = o.PaidAt.Format(time.RFC3339)
|
||||
if o.BizSystem == "" || o.Status != model.OrderPendingV2 {
|
||||
return nil // 独立收款无业务方回调;或已翻转/已取消
|
||||
}
|
||||
data := map[string]any{
|
||||
"event_type": "payment.succeeded",
|
||||
@@ -1306,11 +1365,9 @@ func (g *Gateway) enqueuePaymentSucceeded(att *model.Attempt) {
|
||||
"amount_minor": o.AmountMinor,
|
||||
"currency": o.Currency,
|
||||
"channel": att.Channel,
|
||||
"paid_at": paidAt,
|
||||
}
|
||||
if err := g.webhook.Enqueue(o.OutTradeNo, o.BizSystem, "payment.succeeded", data); err != nil {
|
||||
log.Printf("[settle] 入队 webhook 失败 %s: %v", o.OutTradeNo, err)
|
||||
"paid_at": paidAt.Format(time.RFC3339),
|
||||
}
|
||||
return g.webhook.Enqueue(o.OutTradeNo, o.BizSystem, "payment.succeeded", data)
|
||||
}
|
||||
|
||||
// HandleCallback runs a channel's raw callback through its Provider.VerifyCallback
|
||||
@@ -1342,7 +1399,12 @@ func (g *Gateway) SyncPendingAttempts(ctx context.Context, limit int) (int, erro
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
ev, err := prov.Query(ctx, att.ProviderRef)
|
||||
// 查单带尝试完整上下文(crypto 需要地址/金额/时间窗才能扫链核对)。
|
||||
ev, err := prov.Query(ctx, provider.QueryRequest{
|
||||
ProviderRef: att.ProviderRef, OutTradeNo: att.OutTradeNo, AccountID: att.AccountID,
|
||||
AmountMinor: att.AmountMinor, Currency: att.Currency,
|
||||
CreatedAt: att.CreatedAt, ExpiresAt: att.ExpiresAt,
|
||||
})
|
||||
if err != nil || ev == nil {
|
||||
continue
|
||||
}
|
||||
@@ -1383,7 +1445,7 @@ git commit -m "feat(v2): 入账管线 Settle/HandleCallback/SyncPending(归一
|
||||
- Produces:
|
||||
- `model.WebhookDelivery{ Base; OutTradeNo/EventType(uniqueIndex uq_delivery); BizSystem; Payload; Delivered bool; Attempts int; LastError string }`。
|
||||
- `store.WebhookStore` · `NewWebhookStore(db)` · `EnqueueDelivery(outTradeNo,bizSystem,eventType,payload string) error`(ON CONFLICT DO NOTHING 幂等)· `ListUndelivered(limit) ([]WebhookDelivery,error)` · `MarkDelivered(id uint64) error` · `MarkFailed(id uint64, errMsg string) error`。
|
||||
- `webhook.Notifier` · `NewNotifier(ws *store.WebhookStore, bizConfig func(string)(config.BizSystemConfig,bool)) *Notifier`。实现 `Enqueue(outTradeNo,bizSystem,eventType string, data map[string]any) error`(JSON 序列化 → EnqueueDelivery)· `DeliverPending(limit int) (int,error)` · `Start(interval time.Duration)`。
|
||||
- `webhook.Notifier` · `NewNotifier(ws *store.WebhookStore, bizConfig func(string)(config.BizSystemConfig,bool), orderPaid func(outTradeNo string)(bool,error)) *Notifier`。实现 `Enqueue(outTradeNo,bizSystem,eventType string, data map[string]any) error`(JSON 序列化 → EnqueueDelivery)· `DeliverPending(limit int) (int,error)` · `Start(interval time.Duration)`。**投递门禁**:`deliverOne` 先经 `orderPaid` 确认订单已付才发(enqueue-before-flip 顺序不变量的另一半,防把"未付单"通知出去);未付则跳过本轮、不计失败,等翻转后自然放行。
|
||||
- Consumes: `util.HMACSign`、`config.BizSystemConfig`。
|
||||
|
||||
- [ ] **Step 1: 写失败测试**
|
||||
@@ -1473,7 +1535,8 @@ func TestNotifierDeliversSignedEvent(t *testing.T) {
|
||||
}
|
||||
return config.BizSystemConfig{}, false
|
||||
}
|
||||
n := webhook.NewNotifier(ws, bizCfg)
|
||||
alwaysPaid := func(string) (bool, error) { return true, nil }
|
||||
n := webhook.NewNotifier(ws, bizCfg, alwaysPaid)
|
||||
|
||||
// 经 Enqueuer 接口入队(gateway 就是这么调的)。
|
||||
err := n.Enqueue("PAY-1", "pangolin", "payment.succeeded", map[string]any{
|
||||
@@ -1522,7 +1585,7 @@ func TestNotifierRetriesOnFailure(t *testing.T) {
|
||||
ws := store.NewWebhookStore(model.OpenTestDB(t))
|
||||
n := webhook.NewNotifier(ws, func(string) (config.BizSystemConfig, bool) {
|
||||
return config.BizSystemConfig{CallbackURL: srv.URL, Secret: "x"}, true
|
||||
})
|
||||
}, func(string) (bool, error) { return true, nil })
|
||||
_ = n.Enqueue("PAY-3", "pangolin", "payment.succeeded", map[string]any{"event_type": "payment.succeeded"})
|
||||
|
||||
if sent, _ := n.DeliverPending(10); sent != 0 {
|
||||
@@ -1537,6 +1600,38 @@ func TestNotifierRetriesOnFailure(t *testing.T) {
|
||||
t.Fatalf("应重试第二次, hits=%d", hits)
|
||||
}
|
||||
}
|
||||
|
||||
// 投递门禁:订单未付(settle 崩在"入队后、翻转前"的窗口)绝不把 payment.succeeded
|
||||
// 发给业务方;也不计失败次数,等订单翻转后自然放行。
|
||||
func TestNotifierGateSkipsUnpaidOrder(t *testing.T) {
|
||||
var hits int
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
hits++
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("SUCCESS"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
ws := store.NewWebhookStore(model.OpenTestDB(t))
|
||||
paid := false
|
||||
n := webhook.NewNotifier(ws, func(string) (config.BizSystemConfig, bool) {
|
||||
return config.BizSystemConfig{CallbackURL: srv.URL, Secret: "x"}, true
|
||||
}, func(string) (bool, error) { return paid, nil })
|
||||
_ = n.Enqueue("PAY-4", "pangolin", "payment.succeeded", map[string]any{"event_type": "payment.succeeded"})
|
||||
|
||||
if sent, _ := n.DeliverPending(10); sent != 0 || hits != 0 {
|
||||
t.Fatalf("未付单不应投递, sent=%d hits=%d", sent, hits)
|
||||
}
|
||||
pend, _ := ws.ListUndelivered(10)
|
||||
if len(pend) != 1 || pend[0].Attempts != 0 {
|
||||
t.Fatalf("门禁跳过不应计失败, got %+v", pend)
|
||||
}
|
||||
|
||||
paid = true // 订单翻转后放行
|
||||
if sent, _ := n.DeliverPending(10); sent != 1 || hits != 1 {
|
||||
t.Fatalf("翻转后应投递, sent=%d hits=%d", sent, hits)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 跑测试确认失败**
|
||||
@@ -1670,14 +1765,21 @@ import (
|
||||
// BizConfigFunc resolves a business system's callback URL + HMAC secret.
|
||||
type BizConfigFunc func(system string) (config.BizSystemConfig, bool)
|
||||
|
||||
// OrderPaidFunc reports whether an order is settled (paid). Delivery gate:
|
||||
// settle 是"先入队后翻转",崩溃窗口里 outbox 可能存在"未付单"的行——投递前必须
|
||||
// 门禁,否则会把 payment.succeeded 发给业务方、白给权益。
|
||||
type OrderPaidFunc func(outTradeNo string) (bool, error)
|
||||
|
||||
type Notifier struct {
|
||||
deliveries *store.WebhookStore
|
||||
bizConfig BizConfigFunc
|
||||
orderPaid OrderPaidFunc
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func NewNotifier(ws *store.WebhookStore, bizConfig BizConfigFunc) *Notifier {
|
||||
return &Notifier{deliveries: ws, bizConfig: bizConfig, client: &http.Client{Timeout: 10 * time.Second}}
|
||||
func NewNotifier(ws *store.WebhookStore, bizConfig BizConfigFunc, orderPaid OrderPaidFunc) *Notifier {
|
||||
return &Notifier{deliveries: ws, bizConfig: bizConfig, orderPaid: orderPaid,
|
||||
client: &http.Client{Timeout: 10 * time.Second}}
|
||||
}
|
||||
|
||||
// Enqueue implements gateway.WebhookEnqueuer: serialize the domain payload and
|
||||
@@ -1706,6 +1808,11 @@ func (n *Notifier) DeliverPending(limit int) (int, error) {
|
||||
}
|
||||
|
||||
func (n *Notifier) deliverOne(d *store.WebhookDeliveryRow) bool {
|
||||
// 门禁:订单未付不投递(不计失败,等 settle 翻转后自然放行)。
|
||||
paid, err := n.orderPaid(d.OutTradeNo)
|
||||
if err != nil || !paid {
|
||||
return false
|
||||
}
|
||||
cfg, found := n.bizConfig(d.BizSystem)
|
||||
if !found || cfg.CallbackURL == "" {
|
||||
_ = n.deliveries.MarkFailed(d.ID, "biz system not configured")
|
||||
@@ -1791,7 +1898,7 @@ git commit -m "feat(v2): webhook v2 outbox + Notifier(event_type + HMAC 双向 +
|
||||
|
||||
---
|
||||
|
||||
### Task 7: HTTP 接线 — /v1 路由 + handler + 端到端联通 + main 装配
|
||||
### Task 7: HTTP 接线 — /api/v2 路由 + handler + 端到端联通 + main 装配
|
||||
|
||||
**Files:**
|
||||
- Create: `internal/handler/gateway.go`(GatewayHandler:CreateOrder/GetStatus/Retry/Cancel/Callback)
|
||||
@@ -1802,7 +1909,7 @@ git commit -m "feat(v2): webhook v2 outbox + Notifier(event_type + HMAC 双向 +
|
||||
**Interfaces:**
|
||||
- Produces:
|
||||
- `type GatewayHandler struct{...}` · `NewGatewayHandler(g *gateway.Gateway) *GatewayHandler`。
|
||||
- `router.SetupV2(r *gin.Engine, g *gateway.Gateway)` 挂:`POST /v1/orders`、`GET /v1/orders/:order_no`、`POST /v1/orders/:order_no/retry`、`POST /v1/orders/:order_no/cancel`、`POST /v1/callback/:method`。
|
||||
- `router.SetupV2(r *gin.Engine, g *gateway.Gateway)` 挂:`POST /api/v2/orders`、`GET /api/v2/orders/:order_no`、`POST /api/v2/orders/:order_no/retry`、`POST /api/v2/orders/:order_no/cancel`、`POST /api/v2/callback/:method`。
|
||||
- Consumes: `gateway.Gateway`(T4/T5)、`util.RespondError/RespondSuccess`。业务方下单签名沿用 v1 `verifyBizSign` 范式(`biz_system` 非空即校验)。
|
||||
|
||||
- [ ] **Step 1: 写失败测试(端到端:下单→查→回调→已付)**
|
||||
@@ -1874,7 +1981,7 @@ func TestV2OrderLifecycle(t *testing.T) {
|
||||
r := buildEngine(t)
|
||||
|
||||
// 下单(独立收款,无 biz_system → 无需签名)
|
||||
w, out := do(t, r, http.MethodPost, "/v1/orders", map[string]any{"sku": "pro_year", "method": "fake"})
|
||||
w, out := do(t, r, http.MethodPost, "/api/v2/orders", map[string]any{"sku": "pro_year", "method": "fake"})
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("create code=%d body=%v", w.Code, out)
|
||||
}
|
||||
@@ -1886,7 +1993,7 @@ func TestV2OrderLifecycle(t *testing.T) {
|
||||
}
|
||||
|
||||
// 查单:pending
|
||||
_, out2 := do(t, r, http.MethodGet, "/v1/orders/"+orderNo, nil)
|
||||
_, out2 := do(t, r, http.MethodGet, "/api/v2/orders/"+orderNo, nil)
|
||||
if out2["data"].(map[string]any)["status"] != "pending" {
|
||||
t.Fatalf("status = %v", out2["data"])
|
||||
}
|
||||
@@ -1894,10 +2001,10 @@ func TestV2OrderLifecycle(t *testing.T) {
|
||||
// 取 provider_ref:直接构造 fake 回调体(provider_ref 由 payload 无法拿,需查尝试)
|
||||
// 这里用 callback 端点 + fake JSON:先取尝试 ref。测试通过再次下单太绕,
|
||||
// 改为:回调体里 provider_ref 用 order_no 反查——fake ref 前缀 FAKE-<orderNo>。
|
||||
// 直接命中:构造 verify 输入需真实 ref,故经 /v1/callback 前先查库拿 ref。
|
||||
// 直接命中:构造 verify 输入需真实 ref,故经 /api/v2/callback 前先查库拿 ref。
|
||||
// 简化:暴露一个内部查询——本测试用 status 已足够验证下单/查单闭环;
|
||||
// 回调闭环在 gateway settle_test 已覆盖。此处验证 callback 路由存在且 404 语义:
|
||||
wc, _ := do(t, r, http.MethodPost, "/v1/callback/fake", map[string]any{
|
||||
wc, _ := do(t, r, http.MethodPost, "/api/v2/callback/fake", map[string]any{
|
||||
"provider_ref": "GHOST", "status": "succeeded", "amount_minor": 1, "currency": "USDT",
|
||||
})
|
||||
if wc.Code != http.StatusOK { // not_found 也回 200(渠道无需重投未知单)
|
||||
@@ -1905,7 +2012,7 @@ func TestV2OrderLifecycle(t *testing.T) {
|
||||
}
|
||||
|
||||
// 取消
|
||||
wCancel, outCancel := do(t, r, http.MethodPost, "/v1/orders/"+orderNo+"/cancel", nil)
|
||||
wCancel, outCancel := do(t, r, http.MethodPost, "/api/v2/orders/"+orderNo+"/cancel", nil)
|
||||
if wCancel.Code != http.StatusOK || outCancel["data"].(map[string]any)["canceled"] != true {
|
||||
t.Fatalf("cancel = %d %v", wCancel.Code, outCancel)
|
||||
}
|
||||
@@ -1954,7 +2061,7 @@ type createV2Request struct {
|
||||
ReturnURL string `json:"return_url,omitempty"`
|
||||
}
|
||||
|
||||
// CreateOrder POST /v1/orders —— 下单,返回 {order_no, session:{render_type, payload}}。
|
||||
// CreateOrder POST /api/v2/orders —— 下单,返回 {order_no, session:{render_type, payload}}。
|
||||
// biz_system 非空 → 校验 HMAC 签名(复用 v1 verifyBizSign)。
|
||||
func (h *GatewayHandler) CreateOrder(c *gin.Context) {
|
||||
raw, err := io.ReadAll(http.MaxBytesReader(c.Writer, c.Request.Body, maxOrderBodyBytes))
|
||||
@@ -1987,7 +2094,7 @@ func (h *GatewayHandler) CreateOrder(c *gin.Context) {
|
||||
util.RespondSuccess(c, res)
|
||||
}
|
||||
|
||||
// GetStatus GET /v1/orders/:order_no
|
||||
// GetStatus GET /api/v2/orders/:order_no
|
||||
func (h *GatewayHandler) GetStatus(c *gin.Context) {
|
||||
v, err := h.g.GetOrder(c.Param("order_no"))
|
||||
if err != nil {
|
||||
@@ -2001,7 +2108,7 @@ type retryRequest struct {
|
||||
Method string `json:"method"`
|
||||
}
|
||||
|
||||
// Retry POST /v1/orders/:order_no/retry
|
||||
// Retry POST /api/v2/orders/:order_no/retry
|
||||
func (h *GatewayHandler) Retry(c *gin.Context) {
|
||||
var req retryRequest
|
||||
_ = c.ShouldBindJSON(&req)
|
||||
@@ -2025,7 +2132,7 @@ func (h *GatewayHandler) Retry(c *gin.Context) {
|
||||
util.RespondSuccess(c, res)
|
||||
}
|
||||
|
||||
// Cancel POST /v1/orders/:order_no/cancel
|
||||
// Cancel POST /api/v2/orders/:order_no/cancel
|
||||
func (h *GatewayHandler) Cancel(c *gin.Context) {
|
||||
ok, err := h.g.CancelOrder(c.Param("order_no"))
|
||||
if err != nil {
|
||||
@@ -2035,7 +2142,7 @@ func (h *GatewayHandler) Cancel(c *gin.Context) {
|
||||
util.RespondSuccess(c, gin.H{"canceled": ok})
|
||||
}
|
||||
|
||||
// Callback POST /v1/callback/:method —— 渠道异步回调;经 provider.VerifyCallback → Settle。
|
||||
// Callback POST /api/v2/callback/:method —— 渠道异步回调;经 provider.VerifyCallback → Settle。
|
||||
// 已受理(含未知单/幂等/金额不符,都不需要渠道重投)一律回 200。
|
||||
func (h *GatewayHandler) Callback(c *gin.Context) {
|
||||
method := c.Param("method")
|
||||
@@ -2084,16 +2191,19 @@ import (
|
||||
"github.com/wangjia/pay/internal/gateway"
|
||||
)
|
||||
|
||||
// SetupV2 装配 pay v2 统一网关路由(/v1)。与 v1 /api/v1 并存,逐步收口。
|
||||
// SetupV2 装配 pay v2 统一网关路由(/api/v2,与旧版同一 /api 前缀风格)。
|
||||
// 旧 /api/v1 仅为存量支付宝当面付部署保留(路径 POST /api/v1/orders 与 v2 新契约
|
||||
// 同名不同形,无法在同一前缀下并存);P3 支付宝 adapter 迁入 v2 后整组删除,
|
||||
// 最终对外只剩 /api/v2 一套。
|
||||
func SetupV2(r *gin.Engine, g *gateway.Gateway) {
|
||||
h := handler.NewGatewayHandler(g)
|
||||
v1 := r.Group("/v1")
|
||||
v2 := r.Group("/api/v2")
|
||||
{
|
||||
v1.POST("/orders", h.CreateOrder)
|
||||
v1.GET("/orders/:order_no", h.GetStatus)
|
||||
v1.POST("/orders/:order_no/retry", h.Retry)
|
||||
v1.POST("/orders/:order_no/cancel", h.Cancel)
|
||||
v1.POST("/callback/:method", h.Callback)
|
||||
v2.POST("/orders", h.CreateOrder)
|
||||
v2.GET("/orders/:order_no", h.GetStatus)
|
||||
v2.POST("/orders/:order_no/retry", h.Retry)
|
||||
v2.POST("/orders/:order_no/cancel", h.Cancel)
|
||||
v2.POST("/callback/:method", h.Callback)
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -2110,10 +2220,16 @@ Expected: PASS。
|
||||
// v2 统一网关装配(P2):provider 注册表 + gateway + webhook notifier。
|
||||
pReg := provider.NewRegistry()
|
||||
// P3 起在此 Register 真实渠道:crypto / alipay / stripe …(fake 仅测试用,不注册进生产)。
|
||||
webhookStore := store.NewWebhookStore(db)
|
||||
notifier := webhook.NewNotifier(webhookStore, config.C.BizByName)
|
||||
notifier.Start(60 * time.Second)
|
||||
orderStore := store.NewOrderStore(db)
|
||||
webhookStore := store.NewWebhookStore(db)
|
||||
notifier := webhook.NewNotifier(webhookStore, config.C.BizByName, func(no string) (bool, error) {
|
||||
o, err := orderStore.GetOrder(no) // 投递门禁:订单已付才发(enqueue-before-flip 不变量)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return o.Status == model.OrderPaidV2, nil
|
||||
})
|
||||
notifier.Start(60 * time.Second)
|
||||
productResolver := gateway.NewDBProductResolver(db, "CNY") // 币种按部署区配(cn=CNY / global=USDT)
|
||||
acctReg := accounts.New(config.C.Accounts)
|
||||
gw := gateway.New(orderStore, pReg, acctReg, productResolver, notifier, "cn")
|
||||
@@ -2133,7 +2249,7 @@ Expected: 编译通过,全部包 `ok`(含 handler/gateway/provider/store/webhook
|
||||
```bash
|
||||
cd /Users/wangjia/code/pay
|
||||
git add internal/handler/gateway.go internal/router/router.go main.go
|
||||
git commit -m "feat(v2): /v1 网关路由 + handler(下单/查/重试/取消/回调)+ main 装配 gateway/notifier"
|
||||
git commit -m "feat(v2): /api/v2 网关路由 + handler(下单/查/重试/取消/回调)+ main 装配 gateway/notifier"
|
||||
```
|
||||
|
||||
---
|
||||
@@ -2143,8 +2259,8 @@ git commit -m "feat(v2): /v1 网关路由 + handler(下单/查/重试/取消/回
|
||||
**Spec coverage(P2 范围,对照设计 §3/§4/§5):**
|
||||
- Provider 接口 `capabilities/create/verify_callback/query` + 可选 refund/recurring 留空 + 注册表 `map[method]Provider`(Task 1)= 设计 §4.1 ✓。
|
||||
- fake provider 验证管线,真渠道 P3(Task 2)✓。
|
||||
- 一次性收款管线 `POST /v1/orders → 选 provider → create → 落 Order+Attempt(复用 P1 OrderStore)→ 返回 {order_no, session:{render_type, payload}}`;`GET /v1/orders/{no}`;retry/cancel(Task 4/7)= 设计 §3.1/3.2/4.2 ✓。
|
||||
- 统一开通/入账:webhook 与 query 都产出归一化 `PaidEvent` → 定位(provider_ref→attempt→order)→ 幂等 → 金额/币种核对 → `MarkAttemptPaid`(复用 P1)→ 触发业务方 webhook(Task 5)= 设计 §4 ✓。
|
||||
- 一次性收款管线 `POST /api/v2/orders → 选 provider → create → 落 Order+Attempt(复用 P1 OrderStore)→ 返回 {order_no, session:{render_type, payload}}`;`GET /api/v2/orders/{no}`;retry/cancel(Task 4/7)= 设计 §3.1/3.2/4.2 ✓。
|
||||
- 统一开通/入账:webhook 与 query 都产出归一化 `PaidEvent` → 定位(provider_ref→attempt→order)→ 金额/币种核对 → **先幂等入队、再幂等 `MarkAttemptPaid` 翻转**(Task 5)= 设计 §4 ✓。顺序不变量「paid ⇒ outbox 行存在」消灭"已付但永不通知"的崩溃窗口;反向窗口("入队了但未翻转")由 Notifier 投递门禁(订单已付才发,Task 6)+ 渠道重投/查单兜底自愈。取消单晚到支付:入队 skip(状态非 pending)→ 翻转失败 → duplicate,不自动开通,渠道已扣款场景归 P4 退款/P6 对账。
|
||||
- webhook v2:pay→业务方,payload 带 `event_type`(先 `payment.succeeded`),HMAC 双向签名(复用 `util.HMACSign`),outbox + 重试兜底(Task 6)= 设计 §5 ✓。
|
||||
- 幂等/金额核对贯穿:入账走 P1 条件 UPDATE(order=pending 守卫);webhook 入队走 unique 键 ON CONFLICT;核对币种一致 + 实付 ≥ 应收(Task 5/6)✓。
|
||||
|
||||
@@ -2154,18 +2270,25 @@ git commit -m "feat(v2): /v1 网关路由 + handler(下单/查/重试/取消/回
|
||||
|
||||
**Type consistency:** `provider.Provider`/`Session`/`PaidEvent`/`CreateRequest`/`CallbackInput` 在 Task 1 定义,Task 2(fake)、Task 4/5(gateway)、Task 7(handler)一致消费。`gateway.WebhookEnqueuer.Enqueue(outTradeNo,bizSystem,eventType,data)` 由 Task 5 消费、Task 6 `webhook.Notifier` 实现。`store.WebhookDeliveryRow = model.WebhookDelivery`(别名)让 `webhook` 包不直接依赖 `model`。`config.C.BizByName` 签名恰配 `webhook.BizConfigFunc`。金额一律 `int64 minor + Currency`。
|
||||
|
||||
**评审修订(2026-07-10 用户评审 D3/D4 后,执行前已折入上文各 Task):**
|
||||
- **D3 路由**:v2 从裸 `/v1` 改挂 `/api/v2`(统一 `/api` 前缀;`POST /api/v1/orders` 被旧契约占用无法共存),P3 渠道迁入后删除整组 `/api/v1`。
|
||||
- **D4-A1 崩溃安全顺序**:Settle 由"先翻转后入队"改为"**先幂等入队、再幂等翻转**"+ Notifier"订单已付"投递门禁 + `SettleFailed`(入队失败不翻转,渠道重投自愈)。消灭"已付但业务方永不知情"的窗口。
|
||||
- **D4-A2 Query 带上下文**:`Provider.Query(ctx, providerRef string)` → `Query(ctx, QueryRequest)`(尝试完整快照)。crypto 的查单是"按地址+金额+时间窗扫链",裸 ref 会逼 adapter 自建映射表。
|
||||
- **D4-A3 渠道支付时间**:`PaidEvent` 增 `PaidAt *time.Time`,settle/payload 优先用渠道报的时间,对账时与渠道流水对得上。
|
||||
- **D4 记录未调整项(有意延后)**:回调原始报文落表留痕(P2 先靠 `PaidEvent.Raw` + 服务日志,P3 落 callback_logs 表);Notifier 无退避/无死信上限(P6 加 max attempts + 告警);多账户回调验签需 provider 持账户注册表(P3 装配注入,接口不变);crypto 分笔凑单由 crypto adapter 聚合后才发 succeeded(P3)。
|
||||
|
||||
**已知 scope 取舍(记录,不阻塞 P2):**
|
||||
- webhook payload 未含 `product_biz_code`(设计 §5 列了):`model.OrderV2` 无 biz_code 列(P1 已定),不改 P1 schema;P2 业务方用 `biz_ref` 映射自身订单足够,`product_biz_code` 待 P3 给 order 补 biz_code 列或经 product 关联时补齐。
|
||||
- 路由策略取"首个 enabled 账户"(设计 §3.3 的 round_robin/weighted/limit_aware 在 **P5**)。
|
||||
- 币种维度未进 product(`DBProductResolver` 用单一部署默认币种):多币种 product 在 P3+。
|
||||
- `/v1`(v2)与 `/api/v1`(v1)并存,收口在 pay 定稿后(设计 §1 / brain todo #5)。
|
||||
- v2 走 `/api/v2`(与旧版统一 `/api` 前缀;`POST /api/v1/orders` 已被旧契约占用,同前缀无法并存)。旧 `/api/v1` 仅为存量当面付部署保留,P3 渠道迁入后整组删除,收口为一套(设计 §1 / brain todo #5)。
|
||||
|
||||
---
|
||||
|
||||
## 后续阶段(各自独立成计划,落地前逐一细化)
|
||||
|
||||
- **P3** 首批渠道 adapter 实现 `provider.Provider`:crypto 自托管(包裹 pangolin-pay 单地址/地址池收款 + 链上确认 query)/ 支付宝(迁移 v1 `internal/channel/alipay.go` 到 provider,复用验签)/ Stripe(sdk_handoff + webhook 验签)。各自 `Register` 进注册表;补 order 的 biz_code / 多币种。
|
||||
- **P4** 退款:`POST /v1/refunds` + `provider.RefundingProvider` + 三向(业务发起/crypto 人工 sweep/平台通知)+ `refund.succeeded`/`refund.failed` 事件 + order 退款态机(refunding/partially_refunded/refunded)。
|
||||
- **P4** 退款:`POST /api/v2/refunds` + `provider.RefundingProvider` + 三向(业务发起/crypto 人工 sweep/平台通知)+ `refund.succeeded`/`refund.failed` 事件 + order 退款态机(refunding/partially_refunded/refunded)。
|
||||
- **P5** 多账户路由策略(round_robin/weighted/limit_aware/by_region/crypto 地址池)替换"首个 enabled"。
|
||||
- **P6** 对账 job:周期 `SyncPendingAttempts` + 每 provider `query` 拉渠道流水 vs 本地订单,防掉单/防重复授权。
|
||||
- **P7** codes 共享库(A 嵌入各产品)。
|
||||
|
||||
Reference in New Issue
Block a user