Files
pay/internal/gateway/settle.go
T
wangjia b8bdb2c193 fix(v2): 订阅激活门改按结算结果(unpaid session 不再无付款激活)
HandleCallback 原门 serr==nil && SubscriptionRef!="" 挡不住 Settle() 对非
PaidSucceeded 事件返回 (SettleIgnored, nil) 的情况(如 Stripe
checkout.session.completed 异步支付 payment_status=unpaid),导致
onSubscriptionActivated 无确认付款先激活订阅。改为按结算结果开门
(SettleProcessed/SettleDuplicate),并在 onSubscriptionActivated 内加
o.Status.Settled() 纵深防御。
2026-07-10 18:09:56 +08:00

240 lines
11 KiB
Go

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
}