diff --git a/internal/gateway/settle.go b/internal/gateway/settle.go index 99a6b23..906851f 100644 --- a/internal/gateway/settle.go +++ b/internal/gateway/settle.go @@ -120,7 +120,11 @@ func (g *Gateway) HandleCallback(ctx context.Context, method string, in provider return g.recordChargeback(ctx, method, ev) // Task 6 default: // EventPayment:一次性 / 订阅首期 res, serr := g.Settle(ctx, ev) - if serr == nil && ev.SubscriptionRef != "" { + // 门必须按结算结果开,不能只看 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 重投) } diff --git a/internal/gateway/subscription.go b/internal/gateway/subscription.go index 062f6f9..dd58d52 100644 --- a/internal/gateway/subscription.go +++ b/internal/gateway/subscription.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "log" "time" "github.com/wangjia/pay/internal/model" @@ -107,6 +108,12 @@ func (g *Gateway) onSubscriptionActivated(ctx context.Context, ev *provider.Paid if err != nil { return err } + if !o.Status.Settled() { + // 纵深防御:HandleCallback 的门(res==Processed/Duplicate)已挡住未结算订单, + // 这里再核一遍订单状态,防止未来调用点绕过门直接调用本函数。 + log.Printf("[subscription] onSubscriptionActivated 订单未结算 out_trade_no=%s status=%s,拒绝激活", o.OutTradeNo, o.Status) + return nil + } subID := "SUB-" + att.OutTradeNo // 与 CreateSubscription 同式派生 → 重投算出同一 SubID,Create 幂等 created, err := g.subs.Create(&model.Subscription{ SubID: subID, OutTradeNo: o.OutTradeNo, BizSystem: o.BizSystem, BizRef: o.BizRef, BizCode: o.BizCode, diff --git a/internal/gateway/subscription_test.go b/internal/gateway/subscription_test.go index f510114..adc2145 100644 --- a/internal/gateway/subscription_test.go +++ b/internal/gateway/subscription_test.go @@ -306,6 +306,49 @@ func TestSettleRenewal(t *testing.T) { } } +// TestSubscriptionNotActivatedOnUnpaidSession 复现 CRITICAL 发现:Stripe checkout.session.completed +// 在异步支付方式下可能 payment_status=unpaid(映射为 PaidPending)先到达,此时 Settle 返回 +// SettleIgnored——订阅诞生钩子必须跟随结算结果关门,不能只看 serr==nil(nil error 但未结算)。 +// 断言:无 Subscription 行诞生、无 subscription.created 入队、订单仍 pending。 +func TestSubscriptionNotActivatedOnUnpaidSession(t *testing.T) { + g, fp, spy, orders, subs := newSubGateway(t) + ctx := context.Background() + res, err := g.CreateSubscription(ctx, gateway.CreateSubscriptionInput{ + SKU: "pro_monthly", Method: "substripe", BizSystem: "pangolin", BizRef: "u-1", + }) + if err != nil { + t.Fatalf("CreateSubscription: %v", err) + } + + // checkout.session.completed(异步支付,payment_status=unpaid)归一化为 PaidPending。 + raw, err := json.Marshal(provider.PaidEvent{ + Kind: provider.EventPayment, ProviderRef: fp.sessionRef, Status: provider.PaidPending, + PaidAmountMinor: 2999, PaidCurrency: "USD", SubscriptionRef: "sub_new", + }) + if err != nil { + t.Fatalf("marshal event: %v", err) + } + + result, err := g.HandleCallback(ctx, "substripe", provider.CallbackInput{Raw: raw}) + if err != nil { + t.Fatalf("HandleCallback: %v", err) + } + if result != gateway.SettleIgnored { + t.Fatalf("result = %v, want ignored", result) + } + + o, err := orders.GetOrder(res.OrderNo) + if err != nil || o.Status != model.OrderPendingV2 { + t.Fatalf("order after unpaid callback = %+v, %v, want still pending", o, err) + } + if _, err := subs.GetByProviderRef("substripe", "sub_new"); !errors.Is(err, store.ErrSubNotFound) { + t.Fatalf("subscription should not exist for unpaid session, err = %v", err) + } + if len(spy.calls) != 0 { + t.Fatalf("webhook calls = %d, want 0(no premature subscription.created): %+v", len(spy.calls), spy.calls) + } +} + // TestSettleRenewalUnknownSubscription 未知 provider_sub_ref(订阅未诞生/已清理)→ 忽略,不报错。 func TestSettleRenewalUnknownSubscription(t *testing.T) { g, _, spy, _, _ := newSubGateway(t)