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 settles the normalized event. 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 } return g.Settle(ctx, ev) } // 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 }