From 4a1a431f6239a8c6d30eb9b2f5a92bdbe52bcbd1 Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Fri, 10 Jul 2026 19:12:33 +0800 Subject: [PATCH] =?UTF-8?q?fix(v2):=20stripe=20sessionToEvent=20=E5=9B=9E?= =?UTF-8?q?=E5=A1=AB=20SubscriptionRef(=E8=AE=A2=E9=98=85=E9=A6=96?= =?UTF-8?q?=E6=9C=9F=E6=BF=80=E6=B4=BB=E4=B8=BB=E9=93=BE=E8=B7=AF=E7=94=9F?= =?UTF-8?q?=E4=BA=A7=E5=8F=AF=E7=94=A8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit checkout.session.completed 的 sessionToEvent 从不读 sess.Subscription,导致真实 Stripe 路径下 PaidEvent.SubscriptionRef 恒为空,settle.go 的激活门(SubscriptionRef != "")恒 false,订阅创建→首付→激活链路在生产环境整体不工作。fake provider 测试 直塞 SubscriptionRef,掩盖了这个接缝。 补 sess.Subscription != nil 时回填 ID(与既有 inv.Subscription 同套路);Kind 保持 零值 EventPayment——分派门只看 SubscriptionRef,不看 Kind。新增 adapter 测试先红 后绿,验证 VerifyCallback 真实路径产出正确 SubscriptionRef。 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u --- internal/provider/stripe/stripe.go | 11 +++++++++- internal/provider/stripe/stripe_test.go | 28 +++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/internal/provider/stripe/stripe.go b/internal/provider/stripe/stripe.go index 7fe02dd..0d4ab33 100644 --- a/internal/provider/stripe/stripe.go +++ b/internal/provider/stripe/stripe.go @@ -315,11 +315,20 @@ func sessionToEvent(sess *gostripe.CheckoutSession, raw []byte) *provider.PaidEv case gostripe.CheckoutSessionPaymentStatusUnpaid: status = provider.PaidPending } - return &provider.PaidEvent{ + ev := &provider.PaidEvent{ ProviderRef: sess.ID, Status: status, PaidAmountMinor: sess.AmountTotal, // cent PaidCurrency: strings.ToUpper(string(sess.Currency)), Raw: string(raw), } + // mode=subscription 的 Checkout 完成时,sess.Subscription 必被 Stripe 填充 + // (*Subscription;字符串 id 会 unmarshal 成 &Subscription{ID:...})。一次性单 + // (mode=payment)天然为 nil,不影响既有语义。Kind 保持零值 EventPayment——本仓 + // 分派门(settle.go)靠 SubscriptionRef != "" 而非 Kind 判断是否触发订阅激活, + // 见 internal/gateway/settle.go 的 onSubscriptionActivated 调用点。 + if sess.Subscription != nil { + ev.SubscriptionRef = sess.Subscription.ID + } + return ev } diff --git a/internal/provider/stripe/stripe_test.go b/internal/provider/stripe/stripe_test.go index f889253..e40a16e 100644 --- a/internal/provider/stripe/stripe_test.go +++ b/internal/provider/stripe/stripe_test.go @@ -213,6 +213,34 @@ func TestVerifyWebhook(t *testing.T) { } } +// 订阅 Checkout(mode=subscription)完成时,session payload 携带 subscription 字段 +// (stripe-go v79 CheckoutSession.Subscription 为 *Subscription,字符串 id 会 unmarshal +// 成 &Subscription{ID:...})。sessionToEvent 必须回填 PaidEvent.SubscriptionRef——否则 +// gateway.settleRenewal 的激活门(ev.SubscriptionRef != "")恒 false,订阅首期激活主链路 +// 在生产环境(真实 Stripe webhook)整体不工作,只是被 fake provider 的测试掩盖了。 +func TestVerifyWebhookSubscriptionSessionBackfillsSubscriptionRef(t *testing.T) { + ts := fakeStripeAPI(t) + defer ts.Close() + p := newStripe(t, ts) + + payload := `{"id":"evt_sub_1","object":"event","type":"checkout.session.completed","data":{"object":{"id":"cs_sub_123","object":"checkout.session","mode":"subscription","amount_total":2999,"currency":"usd","payment_status":"paid","subscription":"sub_new"}}}` + sig := signStripe(payload, whSecret, time.Now().Unix()) + + ev, err := p.VerifyCallback(context.Background(), provider.CallbackInput{ + Raw: []byte(payload), + Headers: map[string]string{"Stripe-Signature": sig}, + }) + if err != nil { + t.Fatalf("verify: %v", err) + } + if ev.SubscriptionRef != "sub_new" { + t.Fatalf("SubscriptionRef = %q, want %q (event = %+v)", ev.SubscriptionRef, "sub_new", ev) + } + if ev.ProviderRef != "cs_sub_123" || ev.Status != provider.PaidSucceeded { + t.Fatalf("event = %+v", ev) + } +} + // 用错误的签名密钥(冒充攻击者伪造 webhook)→ ConstructEventWithOptions 内部 HMAC 校验 // 必失败,VerifyCallback 必须返回 error,绝不能返回 PaidEvent(哪怕 payload 里状态是 paid)。 func TestVerifyWebhookWrongSecretFails(t *testing.T) {