package gateway import ( "context" "errors" "log" "time" "github.com/wangjia/pay/internal/model" "github.com/wangjia/pay/internal/provider" "github.com/wangjia/pay/internal/store" ) type SettleResult string const ( SettleIgnored SettleResult = "ignored" // 非成功状态(pending/failed) SettleNotFound SettleResult = "not_found" // provider_ref 无对应 attempt 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 // 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 // 非成功状态:确认收到即可 } att, err := g.orders.AttemptByProviderRef(ev.ProviderRef) if err != nil { if errors.Is(err, store.ErrAttemptNotFound) { return SettleNotFound, nil } // 读库瞬时失败是可重试态,不能与"查无此单"终态混淆。 return SettleFailed, err } // 金额/币种核对:币种须一致,实付须 ≥ 应收(允许 crypto 多付,拒少付)。 if ev.PaidCurrency != att.Currency || ev.PaidAmountMinor < att.AmountMinor { return SettleAmountMismatch, ErrAmountMismatch } // paid_at 优先用渠道报的支付时间(对账时与渠道流水对得上),渠道不报才落收到时间。 paidAt := time.Now() if ev.PaidAt != nil { paidAt = *ev.PaidAt } 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 SettleFailed, err } if !flipped { return SettleDuplicate, nil // 已处理过 / 已取消 / 已过期 → 幂等 no-op } return SettleProcessed, nil } // 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 { return err } if o.BizSystem == "" || o.Status != model.OrderPendingV2 { return nil // 独立收款无业务方回调;或已翻转/已取消 } data := map[string]any{ "event_type": "payment.succeeded", "out_trade_no": o.OutTradeNo, "biz_system": o.BizSystem, "biz_ref": o.BizRef, "product_biz_code": o.BizCode, // 设计 §5:业务方按套餐码映射权益(时长/档位),不硬编码 product_id "amount_minor": o.AmountMinor, "currency": o.Currency, "channel": att.Channel, "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 // (验签/解析封死在渠道内) then dispatches the normalized event by Kind(设计 §5): // 一次性/首期支付走既有 Settle(零 fork);订阅续费/催收/取消/拒付各有专属处理器 // (Task 4/5/6,本 Task 先占 stub 保证独立可编译)。 func (g *Gateway) HandleCallback(ctx context.Context, method string, in provider.CallbackInput) (SettleResult, error) { prov, err := g.providers.Get(method) if err != nil { return SettleNotFound, err } ev, err := prov.VerifyCallback(ctx, in) if err != nil { return SettleNotFound, err } switch ev.Kind { case provider.EventSubscriptionRenewal: return g.settleRenewal(ctx, method, ev) // Task 4 case provider.EventSubscriptionPastDue: return g.markSubscriptionPastDue(ctx, method, ev) // Task 5 case provider.EventSubscriptionCanceled: return g.settleSubscriptionCanceled(ctx, method, ev) // Task 5 case provider.EventChargeback: return g.recordChargeback(ctx, method, ev) // Task 6 default: // EventPayment:一次性 / 订阅首期 res, serr := g.Settle(ctx, ev) // 门必须按结算结果开,不能只看 serr==nil:Settle 对非成功状态(如 Stripe // checkout.session.completed 的 payment_status=unpaid 异步支付)返回 // (SettleIgnored, nil)——serr 为 nil 但订单并未结算,不能诞生订阅。 // Duplicate = 成功结算的重放(已 paid/已终态),安全且保证重投幂等路径仍能补建订阅行。 if (res == SettleProcessed || res == SettleDuplicate) && ev.SubscriptionRef != "" { if aerr := g.onSubscriptionActivated(ctx, ev); aerr != nil { return SettleFailed, aerr // 诞生订阅失败可重试(Stripe 重投) } } return res, serr } } // --- Task 6 处理器占位(Task 4 续费/Task 5 past_due+取消已替换实现,见 subscription.go)。 // settleRenewal 处理续费 invoice.paid(设计 §5/§4 决策记录):每期铸独立 renewal OrderV2 // (out_trade_no = 首购单号 + "-r-" + invoice id),建即 paid(续费不经收银台,无 pending 中间态)。 // 幂等靠 renewal attempt 的 (channel,provider_ref=invoice.ID) 唯一索引 + renewal order 的 // out_trade_no 唯一索引双保险。 // // 入队纪律(与文件头 Settle 的顺序不变量同根同源,但形态不同——续费建单是"单步已 paid", // 没有 Settle 那种"翻转严格发生在成功入队之后"的两段式可依赖):**duplicate 分支(created= // false)也必须尝试入队**,不能像旧实现那样直接 return。理由:首次入队若瞬时失败,订单/attempt // 已在同一事务内落为 paid(不回滚,幂等键护着),而 outbox 从未有行;Stripe 拿不到 200 会重投 // 同一 invoice.paid,此时 created 必为 false——若 duplicate 分支不入队,该续费通知永久丢失 // (SyncPendingAttempts 也救不了:续费 attempt 生来就是 AttemptPaid,不在 pending 轮询范围)。 // outbox 唯一键 (out_trade_no,event_type,refund_id) 上 ON CONFLICT DO NOTHING 天然幂等:行已 // 存在则本次 no-op,行曾丢失则本次补建——重投即自愈,任何一分支入队失败都仍返回 SettleFailed // 交渠道再重投。 func (g *Gateway) settleRenewal(ctx context.Context, method string, ev *provider.PaidEvent) (SettleResult, error) { // channel = 触发本次回调的 method(与 markSubscriptionPastDue/recordChargeback 同式); // 订阅诞生时 Subscription.Channel 落的正是 att.Channel=method,查询须对齐,不能硬编码字面量 // "stripe"(测试固定用 "substripe" 注册子供应商,生产 Stripe 适配器 Method()="stripe")。 sub, err := g.subs.GetByProviderRef(method, ev.SubscriptionRef) if err != nil { if errors.Is(err, store.ErrSubNotFound) { log.Printf("[renewal] 未知订阅 provider_ref=%s(未诞生/已清理),忽略", ev.SubscriptionRef) return SettleIgnored, nil } return SettleFailed, err } paidAt := time.Now() if ev.PaidAt != nil { paidAt = *ev.PaidAt } renewalNo := sub.OutTradeNo + "-r-" + ev.InvoiceRef created, err := g.orders.CreateRenewalPaid( &model.OrderV2{ OutTradeNo: renewalNo, BizSystem: sub.BizSystem, BizRef: sub.BizRef, BizCode: sub.BizCode, Subject: "续费", AmountMinor: ev.PaidAmountMinor, Currency: ev.PaidCurrency, Status: model.OrderPaidV2, PaidAt: &paidAt, }, &model.Attempt{ OutTradeNo: renewalNo, Channel: sub.Channel, Provider: sub.Channel, ProviderRef: ev.InvoiceRef, AmountMinor: ev.PaidAmountMinor, Currency: ev.PaidCurrency, Status: model.AttemptPaid, PaidAt: &paidAt, }) if err != nil { return SettleFailed, err } // 续费成功即恢复/维持 active,刷新续费锚点(period_end 最小可行取 paidAt+30d;精确值后续从 invoice.period_end 下发)。 nextEnd := paidAt.Add(30 * 24 * time.Hour) if _, err := g.subs.Activate(sub.SubID, &nextEnd); err != nil { return SettleFailed, err } // 无论 created 与否都尝试入队(见函数注释的入队纪律);独立收款(BizSystem=="")无下游可发,跳过。 if sub.BizSystem != "" { if err := g.enqueueRenewed(sub, renewalNo, ev, paidAt); err != nil { return SettleFailed, err // 已建单的事实不回滚(幂等键护着);渠道重投时补入队 } } if !created { return SettleDuplicate, nil // 重投:订单/attempt 已在(幂等 no-op),outbox 已补齐 } return SettleProcessed, nil // 首过(含无业务方的独立收款,对齐 enqueuePaymentSucceeded 语义) } // enqueueRenewed 组 subscription.renewed 领域 payload 并幂等入队(抽出供 settleRenewal 的 // created/duplicate 两分支共用,避免复制两份 payload 构造)。 func (g *Gateway) enqueueRenewed(sub *model.Subscription, renewalNo string, ev *provider.PaidEvent, paidAt time.Time) error { return g.webhook.Enqueue(renewalNo, sub.BizSystem, EvtSubscriptionRenewed, "", map[string]any{ "event_type": EvtSubscriptionRenewed, "out_trade_no": renewalNo, "sub_id": sub.SubID, "biz_system": sub.BizSystem, "biz_ref": sub.BizRef, "product_biz_code": sub.BizCode, "amount_minor": ev.PaidAmountMinor, "currency": ev.PaidCurrency, "channel": sub.Channel, "paid_at": paidAt.Format(time.RFC3339), }) } // recordChargeback 处理入站 charge.dispute.created(P8 Task6,设计 §6「钱到账不可逆」的 // 例外形态)。只 Stripe(卡)有此语义;alipay/微信本轮无拒付流,crypto 收款永无 // chargeback——三者均不产出 EventChargeback,本函数只会被 stripe adapter 触发。 // // 决策记录:不自动回收权益(§6,业务方裁量),只做三件事——①落 Chargeback(幂等 by // dispute_ref,DisputeRef 重投 no-op,不双记)②给原订单打 Disputed 标(不改状态机) // ③入队 chargeback.received 给业务方自行冲正。out_trade_no 解析失败(订阅拒付/查单失败) // 时仍落 Chargeback 留痕 + log 告警,但不阻断、不转发(无法定位业务单)。 // // 入队不按 created 分叉(与 settleRenewal/finalizeCanceled/markSubscriptionPastDue 同型的 // P8 反纪律修法):Chargeback.Create 与 Enqueue 是两次独立写,不在同一事务——若首次 Create // 成功但 Enqueue 瞬时失败,调用方(HandleCallback)拿到 err 后 Stripe 会重投同一 // charge.dispute.created,此时 created 必为 false;若像"created=false→直接 return // SettleDuplicate"那样跳过下面的打标+入队,chargeback.received 通知永久丢失、Disputed // 标也永远打不上。改为无论 created 与否都走完打标+入队,outbox 唯一键 ON CONFLICT DO // NOTHING + MarkDisputed 条件 UPDATE 天然双重幂等——重投即自愈,不会双记/双发。 func (g *Gateway) recordChargeback(ctx context.Context, method string, ev *provider.PaidEvent) (SettleResult, error) { created, err := g.chargebacks.Create(&model.Chargeback{ DisputeRef: ev.DisputeRef, OutTradeNo: ev.OutTradeNo, Channel: method, ProviderPaymentRef: ev.ProviderPaymentRef, AmountMinor: ev.PaidAmountMinor, Currency: ev.PaidCurrency, Reason: ev.Reason, Status: "received", }) if err != nil { return SettleFailed, err } result := SettleProcessed if !created { result = SettleDuplicate // 拒付重投:Chargeback 已记录过,但仍需补齐下面的打标/入队(见函数注释) } if ev.OutTradeNo == "" { log.Printf("[chargeback] dispute=%s 无法定位业务单(订阅/无 metadata),已记录未转发", ev.DisputeRef) return result, nil } o, err := g.orders.GetOrder(ev.OutTradeNo) if err != nil { log.Printf("[chargeback] dispute=%s out_trade_no=%s 查单失败: %v", ev.DisputeRef, ev.OutTradeNo, err) return result, nil // 已记录 chargeback;定位失败不阻断 } if _, err := g.orders.MarkDisputed(o.OutTradeNo); err != nil { // 打标不改状态机;条件 UPDATE 幂等 return SettleFailed, err } if o.BizSystem == "" { return result, nil } if err := g.webhook.Enqueue(o.OutTradeNo, o.BizSystem, EvtChargebackReceived, "", map[string]any{ "event_type": EvtChargebackReceived, "out_trade_no": o.OutTradeNo, "dispute_ref": ev.DisputeRef, "biz_system": o.BizSystem, "biz_ref": o.BizRef, "product_biz_code": o.BizCode, "amount_minor": ev.PaidAmountMinor, "currency": ev.PaidCurrency, "reason": ev.Reason, "received_at": time.Now().Format(time.RFC3339), }); err != nil { return SettleFailed, err } return result, nil } // SyncPendingAttempts polls every pending attempt via its Provider.Query and // settles hits — the query-based backstop for lost webhooks (设计 §8 对账优先). // Returns how many attempts were newly settled to paid. func (g *Gateway) SyncPendingAttempts(ctx context.Context, limit int) (int, error) { atts, err := g.orders.ListAttemptsByStatus(model.AttemptPending, limit) if err != nil { return 0, err } settled := 0 for i := range atts { att := &atts[i] prov, err := g.providers.Get(att.Channel) if err != nil { continue } // 查单带尝试完整上下文(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 } res, serr := g.Settle(ctx, ev) switch res { case SettleProcessed: settled++ case SettleIgnored, SettleDuplicate: // 常态:未付/已处理,不刷日志 default: // not_found / amount_mismatch / failed —— 对账兜底的盲区,必须可见 log.Printf("[settle-sync] attempt=%s channel=%s result=%s err=%v", att.ProviderRef, att.Channel, res, serr) } } return settled, nil }