← 返回文档索引

pay v2 · P2 收款管线 + Provider 抽象 + webhook v2 (阅读版)

执行真相源 docs/superpowers/plans/2026-07-10-pay-v2-p2-pipeline.md(含 checkbox + 每步完整代码/测试)。设计见 pay v2 设计,DAG 见 依赖图

P2 目标:在 P1 数据地基上搭起一次性收款的完整管线 —— Provider 渠道抽象(6 render_type)+ 建单/查单/重试/取消 + 统一入账/开通(webhook 与 query 都归一成 PaidEvent,复用 P1 的幂等标付)+ pay→业务方 webhook v2(带 event_type)。用一个 fake provider 端到端验证管线,真渠道在 P3。全程复用 P1、免 docker。

7 个 TDD 任务

Task 1 · Provider 抽象接口 + render_type + PaidEvent + 注册表

文件 internal/provider/provider.go。核心契约,P3 所有渠道 adapter 依赖它:

type Provider interface {
  Method() string
  Capabilities() Capabilities
  Create(ctx, CreateRequest) (*Session, error)
  VerifyCallback(ctx, CallbackInput) (*PaidEvent, error)
  Query(ctx, QueryRequest) (*PaidEvent, error)  // D4-A2:带尝试上下文快照,非裸 ref
}
// 可选子接口:RefundingProvider(P4)、RecurringProvider(P8)
type Session      struct { ProviderRef string; RenderType string; Payload map[string]any; ExpiresAt *time.Time }
type PaidEvent    struct { ProviderRef string; Status PaidStatus; PaidAmountMinor int64; PaidCurrency, Raw string; PaidAt *time.Time }
type QueryRequest struct { ProviderRef, OutTradeNo, AccountID string; AmountMinor int64; Currency string; CreatedAt time.Time; ExpiresAt *time.Time }

6 个 render_type 常量 + PaidPending/Succeeded/Failed;Registry(Register/Get/Methods + ErrUnknownMethod)。

Task 2 · fake provider(测试用)

internal/provider/fake/fake.go —— 实现 Provider,Method()=="fake"、render=crypto_address;带 SetQueryResult 测试 seam。P2 用它端到端验管线,不接真渠道。

Task 3 · OrderStore 扩展(查询)

internal/store/order_query.go:GetOrder / AttemptByProviderRef / ListAttemptsByStatus / ExpirePendingAttempts + 哨兵错误。为管线定位订单/尝试用。

Task 4 · 一次性收款管线 gateway

internal/gateway/gateway.go:CreateOrder(选 provider→create→落 Order+Attempt→返回 {order_no, session:{render_type,payload}})/ GetOrder / RetryOrder / CancelOrder。接口 ProductResolver.Resolve(sku)(取权威价/币种/subject/bizCode)、WebhookEnqueuer.Enqueue(...)

Task 5 · 统一入账/开通管线 settle

internal/gateway/settle.go:Settle(ctx, *PaidEvent) = 归一 → 定位(provider_ref→attempt→order)→ 币种/金额核对 → 先幂等入队 payment.succeeded,再复用 P1 MarkAttemptPaid 幂等翻转(D4-A1 崩溃安全顺序:不变量「paid ⇒ outbox 行存在」,入队失败返回 SettleFailed 不翻转、渠道重投自愈)。HandleCallback(webhook 入口)、SyncPendingAttempts(query 兜底)都汇入这条。SettleResult:ignored/not_found/amount_mismatch/duplicate/processed/failed。paid_at 优先用渠道报的 PaidAt(D4-A3)。

Task 6 · webhook v2 outbox + Notifier

model.WebhookDelivery(uniqueIndex(out_trade_no,event_type) 幂等)+ store.WebhookStore(Enqueue/ListUndelivered/MarkDelivered/MarkFailed)+ webhook.Notifier(DeliverPending/Start,HMAC 头 X-Pay-System/Event/Timestamp/Nonce/Sign,失败重试)。event_type 从 payment.succeeded投递门禁(D4-A1 另一半):发前确认订单已付,防把"未付单"通知出去。

Task 7 · HTTP /api/v2 接线

router.SetupV2POST /api/v2/ordersGET /api/v2/orders/:no.../retry.../cancelPOST /api/v2/callback/:method(D3:与旧版统一 /api 前缀;POST /api/v1/orders 被旧契约占用无法共存,P3 渠道迁入后删除整组 /api/v1 收口为一套);main.go 装配 provider 注册表 + gateway + notifier(含投递门禁闭包),AutoMigrate 加 WebhookDelivery。

关键设计取舍(计划 Self-Review 已记)

取舍说明
fake provider_ref需纳秒唯一化——P1 Attempt 有 uniqueIndex(channel,provider_ref),retry 同 method 会撞键(真渠道天然不同 ref)
webhook 无 biz_codeP1 OrderV2 无 biz_code 列,不改已落地 schema;业务方暂用 biz_ref 映射,P3 补
路由取首个账户P2 取"首个 enabled 账户",完整路由策略在 P5
币种走部署默认多币种在 P3+
路由 /api/v2D3 已拍:v2 挂 /api/v2(统一 /api 前缀);旧 /api/v1 仅为存量当面付部署保留,P3 渠道迁入后整组删除,最终只剩一套

评审修订(D4 深讲后折入,2026-07-10)

修订内容与理由
A1 崩溃安全顺序Settle 改为先幂等入队 webhook、再幂等翻转订单 + Notifier「订单已付」投递门禁 + SettleFailed。原顺序(先翻转后入队)两步间崩溃 = 已收钱但业务方永不知情且无人重试;新顺序两个方向的窗口都由幂等 + 门禁 + 渠道重投/查单兜底自愈
A2 Query 带上下文Query(ctx, providerRef)Query(ctx, QueryRequest)(尝试完整快照)。crypto 自托管的"查单"是按地址+金额+时间窗扫链核对,裸 ref 会逼 adapter 自建 ref→上下文映射表,重复 pay 已有数据
A3 渠道支付时间PaidEventPaidAt;settle/webhook payload 优先用渠道报的支付时间,否则收到时间。不加则本地 paid_at 全是"回调到达时间",P6 对账与渠道流水对不上产生假差异
有意不调(记录)回调原始报文落表留痕(P3 callback_logs);Notifier 退避/死信上限+告警(P6);多账户回调验签由 P3 装配注入账户注册表(接口不变);crypto 分笔凑单由 adapter 聚合(P3)

相关:pay v2 设计 · 真相源 docs/superpowers/plans/2026-07-10-pay-v2-p2-pipeline.md