package gateway import ( "context" "errors" "fmt" "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, 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 4/5/6 处理器占位(本 Task 只需 default 分支可用 + onSubscriptionActivated)。 // 保持本 Task 独立可编译;后续 Task 各自替换实现 + 补测试。 // 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 唯一索引双保险;出问题让 Stripe 重投,重投时 created=false 不重复入队,自愈。 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 } if !created || sub.BizSystem == "" { return SettleDuplicate, nil // 重投 / 独立收款 } if err := 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), }); err != nil { return SettleFailed, err } return SettleProcessed, nil } func (g *Gateway) markSubscriptionPastDue(ctx context.Context, method string, ev *provider.PaidEvent) (SettleResult, error) { return SettleFailed, fmt.Errorf("not implemented: %s", ev.Kind) } func (g *Gateway) settleSubscriptionCanceled(ctx context.Context, ev *provider.PaidEvent) (SettleResult, error) { return SettleFailed, fmt.Errorf("not implemented: %s", ev.Kind) } func (g *Gateway) recordChargeback(ctx context.Context, method string, ev *provider.PaidEvent) (SettleResult, error) { return SettleFailed, fmt.Errorf("not implemented: %s", ev.Kind) } // 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 }