From 02b2fcfa41a65129259c73bf4bf68118747a1101 Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Fri, 10 Jul 2026 18:42:20 +0800 Subject: [PATCH] =?UTF-8?q?fix(v2):=20=E5=8F=96=E6=B6=88=E5=A4=B1=E9=85=8D?= =?UTF-8?q?=E6=94=B6=E6=95=9B=E2=80=94=E2=80=94=E6=B8=A0=E9=81=93=E5=B7=B2?= =?UTF-8?q?=E5=8F=96=E6=B6=88(=E5=93=A8=E5=85=B5)=E2=86=92=E6=9C=AC?= =?UTF-8?q?=E5=9C=B0=E8=A1=A5=E9=BD=90+=E4=BA=8B=E4=BB=B6,=E4=B8=8D?= =?UTF-8?q?=E5=86=8D500?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CancelSubscription 本地 active/past_due 但渠道已先行取消(dashboard 手工 / 竞态未消费的 deleted webhook)时,stripe adapter 原样透传渠道拒绝,handler 映 成 500 cancel_failed——渠道取消这一事实明明已成立。新增 provider.ErrSubAlready Canceled 哨兵,stripe adapter 识别 resource_missing / "already been canceled" 两种真实 Stripe 错误形态并 wrap;gateway.CancelSubscription 命中哨兵后走与入 站 webhook 相同的 finalizeCanceled 本地收敛,两路对同一终态天然幂等。 同 re-review 顺手核掉同型缺口:onSubscriptionActivated 的 `!created→return nil` 在入队 subscription.created 之前短路,首次入队失败后 Stripe 重投会因 created=false 永久跳过入队——通知永久丢失。改为无论 created 与否都无条件入队,outbox 唯一键 ON CONFLICT DO NOTHING 天然幂等自愈,与 finalizeCanceled/markSubscriptionPastDue/settleRenewal 同一写法。 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u --- internal/gateway/gateway_test.go | 12 +- internal/gateway/subscription.go | 24 +++- internal/gateway/subscription_test.go | 142 ++++++++++++++++++++++++ internal/provider/provider.go | 7 ++ internal/provider/stripe/stripe.go | 29 +++++ internal/provider/stripe/stripe_test.go | 62 +++++++++++ 6 files changed, 270 insertions(+), 6 deletions(-) diff --git a/internal/gateway/gateway_test.go b/internal/gateway/gateway_test.go index 590f34f..f15487a 100644 --- a/internal/gateway/gateway_test.go +++ b/internal/gateway/gateway_test.go @@ -35,8 +35,12 @@ func (stubResolver) Resolve(sku, currency string) (int64, string, string, error) type spyEnqueuer struct { calls []map[string]any - failNext bool // 置 true 模拟 outbox 入队失败(settle 崩溃窗口测试用) - seen map[string]bool // 镜像真实 WebhookStore.EnqueueDelivery 的 (out_trade_no,event_type, + failNext bool // 置 true 模拟 outbox 入队失败(settle 崩溃窗口测试用),命中后消费一次即清零。 + // failOnEventType 非空时,下一次遇到该 event_type 的入队调用失败一次(消费后清空)——用于 + // 测试"同一 HandleCallback 内先后两次 Enqueue,只让第二次(如 subscription.created)失败, + // 第一次(如 payment.succeeded)先成功"这类场景,failNext 做不到按 event_type 区分。 + failOnEventType string + seen map[string]bool // 镜像真实 WebhookStore.EnqueueDelivery 的 (out_trade_no,event_type, // refund_id) 唯一键 ON CONFLICT DO NOTHING:重复 key 静默 no-op(不追加 calls,也不算失败)。 // 修复"重投补入队自愈"后,业务代码会在 duplicate/重投分支也调用 Enqueue,若 spy 仍是无脑 // 计数器就会把 outbox 天然幂等的重复行误判成"多发了一次 webhook",这里镜像真实幂等语义。 @@ -47,6 +51,10 @@ func (s *spyEnqueuer) Enqueue(outTradeNo, bizSystem, eventType, refundID string, s.failNext = false return errors.New("outbox down") } + if s.failOnEventType != "" && eventType == s.failOnEventType { + s.failOnEventType = "" + return errors.New("outbox down") + } key := outTradeNo + "|" + eventType + "|" + refundID if s.seen == nil { s.seen = make(map[string]bool) diff --git a/internal/gateway/subscription.go b/internal/gateway/subscription.go index 51a8aa2..930ee07 100644 --- a/internal/gateway/subscription.go +++ b/internal/gateway/subscription.go @@ -100,6 +100,8 @@ func (g *Gateway) CreateSubscription(ctx context.Context, in CreateSubscriptionI // onSubscriptionActivated 幂等诞生订阅 + 入队 subscription.created。首期支付回调触发。 // created 事件走首购 order 的 out_trade_no + event_type=subscription.created(唯一键天然不撞 payment.succeeded)。 +// 入队失败原样返回 err,调用方(HandleCallback)映射 SettleFailed,驱动 Stripe 侧重投自愈 +// (见下方入队处注释)。 func (g *Gateway) onSubscriptionActivated(ctx context.Context, ev *provider.PaidEvent) error { att, err := g.orders.AttemptByProviderRef(ev.ProviderRef) if err != nil { @@ -116,7 +118,8 @@ func (g *Gateway) onSubscriptionActivated(ctx context.Context, ev *provider.Paid return nil } subID := "SUB-" + att.OutTradeNo // 与 CreateSubscription 同式派生 → 重投算出同一 SubID,Create 幂等 - created, err := g.subs.Create(&model.Subscription{ + // created(首次诞生 true / 重投幂等 false)不再驱动入队分叉,见下方入队处注释——不必单独取用。 + _, err = g.subs.Create(&model.Subscription{ SubID: subID, OutTradeNo: o.OutTradeNo, BizSystem: o.BizSystem, BizRef: o.BizRef, BizCode: o.BizCode, Channel: att.Channel, ProviderSubRef: ev.SubscriptionRef, RecurringKind: provider.RecurringKindGatewayScheduled, AmountMinor: o.AmountMinor, Currency: o.Currency, Status: model.SubActive, @@ -124,9 +127,15 @@ func (g *Gateway) onSubscriptionActivated(ctx context.Context, ev *provider.Paid if err != nil { return err } - if !created || o.BizSystem == "" { - return nil // 已诞生过(重投)/ 独立收款无业务方回调 + if o.BizSystem == "" { + return nil // 独立收款无业务方回调 } + // 入队不按 created 分叉(P8 Task4/5 审计发现的同型反纪律,同 commit 一并修——settleRenewal/ + // finalizeCanceled/markSubscriptionPastDue 已是这个写法):若首次 Create 成功但入队瞬时失败, + // 调用方(HandleCallback)拿到 err 后 Stripe 会重投同一 checkout.session.completed;此时 + // subs.Create 已幂等、created=false,若像旧实现那样 `!created→return nil` 直接跳过,outbox + // 永远补不上这一行,subscription.created 通知永久丢失。改为无论 created 与否都尝试入队, + // outbox 唯一键 ON CONFLICT DO NOTHING 天然幂等:行已存在则 no-op,行曾丢失则本次补建。 return g.webhook.Enqueue(o.OutTradeNo, o.BizSystem, EvtSubscriptionCreated, "", map[string]any{ "event_type": EvtSubscriptionCreated, "out_trade_no": o.OutTradeNo, "sub_id": subID, "biz_system": o.BizSystem, "biz_ref": o.BizRef, "product_biz_code": o.BizCode, @@ -158,6 +167,13 @@ func (g *Gateway) GetSubscription(subID string) (*SubscriptionView, error) { // 幂等 no-op(不再打渠道,避免重复取消命中渠道 400)→ 渠道侧取消 → 本地翻 canceled + 入队 // subscription.canceled。Stripe 随后异步发 customer.subscription.deleted,入站处理器 // (settleSubscriptionCanceled)再次调用同一 finalizeCanceled,两路收敛同一终态、天然幂等。 +// +// 本地 active/past_due 但渠道已先行取消(dashboard 手工取消 / 竞态下未消费的 deleted webhook +// 抢先落地)时,渠道会拒绝二次 Cancel;adapter 将其识别并 wrap 成 provider.ErrSubAlreadyCanceled +// (errors.Is 可判)。这不是"取消失败"而是"取消事实已在渠道成立"——按此哨兵走与入站 webhook +// 相同的本地收敛(finalizeCanceled),而非把渠道拒绝原样透传成 500。与随后可能补投的 +// customer.subscription.deleted 双路收敛于同一 finalizeCanceled:MarkCanceled 是状态条件 +// UPDATE(只在非 canceled 时翻转)+ outbox 唯一键 ON CONFLICT DO NOTHING,谁先谁后都幂等。 func (g *Gateway) CancelSubscription(ctx context.Context, subID string) error { sub, err := g.subs.GetBySubID(subID) if err != nil { @@ -171,7 +187,7 @@ func (g *Gateway) CancelSubscription(ctx context.Context, subID string) error { return err } if sp, ok := prov.(provider.SubscriptionProvider); ok { - if err := sp.CancelSubscription(ctx, sub.ProviderSubRef); err != nil { + if err := sp.CancelSubscription(ctx, sub.ProviderSubRef); err != nil && !errors.Is(err, provider.ErrSubAlreadyCanceled) { return err } } diff --git a/internal/gateway/subscription_test.go b/internal/gateway/subscription_test.go index 3d57400..45f0017 100644 --- a/internal/gateway/subscription_test.go +++ b/internal/gateway/subscription_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "fmt" "strings" "testing" @@ -454,6 +455,79 @@ func TestCancelSubscriptionIdempotentNoOp(t *testing.T) { } } +// TestCancelSubscriptionChannelAlreadyCanceledConverges 覆盖渠道已先行取消(dashboard 手工 / +// 竞态下未消费的 deleted webhook 抢先落地)的失配场景:本地仍 active,adapter 打渠道拿到 +// provider.ErrSubAlreadyCanceled(此处用死脚手架 fp.cancelErr 模拟 stripe adapter 已 wrap 好的 +// 哨兵错误)——CancelSubscription 不应再报错(修复前会把这个 err 原样透传,handler 会映成 500 +// cancel_failed;修复后按哨兵走本地收敛),本地翻 canceled 且恰好入队一次 subscription.canceled。 +func TestCancelSubscriptionChannelAlreadyCanceledConverges(t *testing.T) { + g, fp, spy, _, subs := newSubGateway(t) + subID, providerSubRef, orderNo := activateSub(t, g, fp) + spy.calls = nil + fp.cancelErr = fmt.Errorf("%w: stripe simulated already-canceled", provider.ErrSubAlreadyCanceled) + + if err := g.CancelSubscription(context.Background(), subID); err != nil { + t.Fatalf("CancelSubscription(渠道已取消场景应本地收敛,不应报错): %v", err) + } + if len(fp.cancelCalls) != 1 || fp.cancelCalls[0] != providerSubRef { + t.Fatalf("cancelCalls = %+v, want [%s](渠道确实被打过一次,只是回了'已取消'错误)", fp.cancelCalls, providerSubRef) + } + sub, err := subs.GetBySubID(subID) + if err != nil { + t.Fatalf("GetBySubID: %v", err) + } + if sub.Status != model.SubCanceled || sub.CanceledAt == nil { + t.Fatalf("subscription after cancel(渠道失配场景) = %+v, want locally canceled", sub) + } + if len(spy.calls) != 1 { + t.Fatalf("webhook calls = %d, want 1: %+v", len(spy.calls), spy.calls) + } + c := spy.calls[0] + if c["event_type"] != gateway.EvtSubscriptionCanceled || c["sub_id"] != subID || c["out_trade_no"] != orderNo { + t.Fatalf("cancel webhook payload = %+v, want event_type=%s sub_id=%s out_trade_no=%s", + c, gateway.EvtSubscriptionCanceled, subID, orderNo) + } +} + +// TestCancelSubscriptionWebhookThenAPIConverge 覆盖反向竞态:入站 customer.subscription.deleted +// 先到(本地先翻 canceled + 发一次 webhook),随后业务方/用户侧发起的主动取消 API 才姗姗来迟。 +// 此时本地已是终态,CancelSubscription 应在打渠道之前就早退(sub.Status==canceled 分支), +// 绝不二次调用渠道、也绝不重复入队——即便 fp.cancelErr 被设成"渠道已取消"哨兵(模拟万一实现 +// 顺序有误、真打了渠道也不该出错),结果仍应是幂等 no-op。 +func TestCancelSubscriptionWebhookThenAPIConverge(t *testing.T) { + g, fp, spy, _, subs := newSubGateway(t) + ctx := context.Background() + subID, providerSubRef, _ := activateSub(t, g, fp) + spy.calls = nil + fp.cancelErr = fmt.Errorf("%w: stripe simulated already-canceled", provider.ErrSubAlreadyCanceled) + + raw, err := json.Marshal(provider.PaidEvent{Kind: provider.EventSubscriptionCanceled, SubscriptionRef: providerSubRef}) + if err != nil { + t.Fatalf("marshal: %v", err) + } + result, err := g.HandleCallback(ctx, "substripe", provider.CallbackInput{Raw: raw}) + if err != nil || result != gateway.SettleProcessed { + t.Fatalf("HandleCallback deleted(webhook 先到) = %v, %v", result, err) + } + if len(spy.calls) != 1 { + t.Fatalf("webhook calls after inbound deleted = %d, want 1", len(spy.calls)) + } + + if err := g.CancelSubscription(ctx, subID); err != nil { + t.Fatalf("CancelSubscription(webhook 已先到,应幂等 no-op 不报错): %v", err) + } + if len(fp.cancelCalls) != 0 { + t.Fatalf("cancelCalls = %+v, want 0(本地已终态,不应再打渠道)", fp.cancelCalls) + } + if len(spy.calls) != 1 { + t.Fatalf("webhook calls after API cancel(webhook 先到之后) = %d, want still 1(不重复入队)", len(spy.calls)) + } + sub, err := subs.GetBySubID(subID) + if err != nil || sub.Status != model.SubCanceled { + t.Fatalf("subscription = %+v, %v", sub, err) + } +} + // TestInboundSubscriptionDeletedConverges 覆盖入站 customer.subscription.deleted:同订阅 // 幂等标 canceled + 入队一次;重投(渠道 webhook 常见重投)不重复发 webhook。 func TestInboundSubscriptionDeletedConverges(t *testing.T) { @@ -760,6 +834,74 @@ func TestSettleRenewalEnqueueFailureThenRetryRecovers(t *testing.T) { } } +// TestSubscriptionActivatedEnqueueFailureThenRetryRecovers 镜像 +// TestSettleRenewalEnqueueFailureThenRetryRecovers,覆盖同型缺口(T5 re-review 发现): +// onSubscriptionActivated 先 subs.Create(先于入队幂等诞生订阅行)后 Enqueue subscription.created +// ——首次入队失败(瞬时)后,checkout.session.completed 重投走 created=false 的分支必须仍尝试 +// 入队(outbox 唯一键幂等,行不存在则补建),否则 subscription.created 永久丢失(旧实现 +// `!created→return nil` 直接跳过,行永远补不上)。 +// +// 用 spy.failOnEventType 只让 subscription.created 这一次入队失败(而非 Settle 内更早的 +// payment.succeeded)——同一 HandleCallback 里先后两次 Enqueue,只想复现"第二次失败"这个窗口。 +// +// 断言:①首次:payment.succeeded 已入队(Settle 已 processed,订单已 paid),订阅行已幂等 +// 诞生 active(Create 先于 Enqueue),但 subscription.created 入队失败 → HandleCallback 整体 +// SettleFailed+err ②同一事件重投 → subscription.created 最终恰入队一次(payment.succeeded +// 不因重投重发,outbox 唯一键幂等)③重投不双铸订阅行(SubID 幂等派生)。 +func TestSubscriptionActivatedEnqueueFailureThenRetryRecovers(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) + } + raw, err := json.Marshal(provider.PaidEvent{ + Kind: provider.EventPayment, ProviderRef: fp.sessionRef, Status: provider.PaidSucceeded, + PaidAmountMinor: 2999, PaidCurrency: "USD", SubscriptionRef: "sub_new", + }) + if err != nil { + t.Fatalf("marshal activate event: %v", err) + } + + spy.failOnEventType = gateway.EvtSubscriptionCreated + result, err := g.HandleCallback(ctx, "substripe", provider.CallbackInput{Raw: raw}) + if err == nil || result != gateway.SettleFailed { + t.Fatalf("subscription.created 入队失败应 SettleFailed+err, got %v, %v", result, err) + } + o, err := orders.GetOrder(res.OrderNo) + if err != nil || o.Status != model.OrderPaidV2 { + t.Fatalf("order after first(失败) attempt = %+v, %v, want paid(payment.succeeded 已先成功入队+翻转)", o, err) + } + subID := "SUB-" + res.OrderNo + sub, err := subs.GetBySubID(subID) + if err != nil || sub.Status != model.SubActive { + t.Fatalf("subscription after first(失败) attempt = %+v, %v, want already active(Create 先于 Enqueue,幂等诞生)", sub, err) + } + if len(spy.calls) != 1 || spy.calls[0]["event_type"] != "payment.succeeded" { + t.Fatalf("首次 calls = %+v, want 仅 payment.succeeded 一条(subscription.created 那次入队失败,未记入)", spy.calls) + } + + // Stripe 拿不到 200 会重投同一 checkout.session.completed。 + result2, err := g.HandleCallback(ctx, "substripe", provider.CallbackInput{Raw: raw}) + if err != nil { + t.Fatalf("HandleCallback retry: %v", err) + } + _ = result2 // duplicate(created=false,订单/订阅已在)——本用例只关心自愈,不断言具体 result 值 + if len(spy.calls) != 2 { + t.Fatalf("重投后 calls = %d, want 2(payment.succeeded 幂等 no-op 不重发 + subscription.created 补建恰一次): %+v", len(spy.calls), spy.calls) + } + if spy.calls[1]["event_type"] != gateway.EvtSubscriptionCreated || spy.calls[1]["sub_id"] != subID { + t.Fatalf("payload = %+v, want event_type=%s sub_id=%s", spy.calls[1], gateway.EvtSubscriptionCreated, subID) + } + + subAfter, err := subs.GetBySubID(subID) + if err != nil || subAfter.Status != model.SubActive { + t.Fatalf("subscription after retry = %+v, %v", subAfter, err) + } +} + // TestSettleRenewalNoBizSystemStillProcessed 独立收款(无业务方回调,BizSystem=="")的续费首过 // 应与 enqueuePaymentSucceeded 的"无业务方=跳过入队但仍 processed"语义对齐,不能误判 duplicate // (created=true 是真正的首次成交,只是没有下游 webhook 可发)。 diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 3acb383..9b05b90 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -122,6 +122,13 @@ type QueryRequest struct { var ( ErrUnknownMethod = errors.New("provider: unknown method") ErrNotSupported = errors.New("provider: capability not supported") + + // ErrSubAlreadyCanceled — SubscriptionProvider.CancelSubscription 的哨兵:渠道侧订阅 + // 已处于取消终态(dashboard 手工取消 / 竞态下未消费的 deleted webhook 抢先落地),本地 + // 发起的主动取消打到渠道时渠道拒绝(如 Stripe "already been canceled" / resource_missing)。 + // 调用方(gateway.CancelSubscription)须将其视为"取消事实已成立",走本地收敛而非报错—— + // 具体渠道 adapter 负责把渠道原生错误 wrap 成本哨兵(参见 stripe.isAlreadyCanceledErr)。 + ErrSubAlreadyCanceled = errors.New("provider: subscription already canceled at channel") ) // Provider — 每个支付渠道实现的统一接口(设计 §4.1 PaymentProvider)。 diff --git a/internal/provider/stripe/stripe.go b/internal/provider/stripe/stripe.go index 6a40208..44007ca 100644 --- a/internal/provider/stripe/stripe.go +++ b/internal/provider/stripe/stripe.go @@ -7,6 +7,7 @@ package stripe import ( "context" "encoding/json" + "errors" "fmt" "strings" "time" @@ -159,13 +160,41 @@ func (p *Provider) CreateSubscriptionCheckout(_ context.Context, req provider.Cr // CancelSubscription 立即取消 Stripe 订阅(不等本期末)。Stripe 随后发 customer.subscription.deleted, // 入站处理器幂等标 canceled,与本地主动标一致收敛。 +// +// 渠道已先行取消(dashboard 手工 / 竞态下未消费的 deleted webhook 抢先落地)时,Stripe 会拒绝 +// 二次 Cancel:识别出这类错误后 wrap 成 provider.ErrSubAlreadyCanceled(errors.Is 可判),不是 +// "取消失败"而是"取消已成立"——调用方(gateway.CancelSubscription)据此走本地收敛而非报错。 func (p *Provider) CancelSubscription(_ context.Context, providerSubRef string) error { if _, err := p.sc.Subscriptions.Cancel(providerSubRef, nil); err != nil { + if isAlreadyCanceledErr(err) { + return fmt.Errorf("%w: %v", provider.ErrSubAlreadyCanceled, err) + } return fmt.Errorf("stripe: 取消订阅失败: %w", err) } return nil } +// isAlreadyCanceledErr 判定"渠道侧订阅已处于取消终态"这一场景,对应 vendored v79 +// (github.com/stripe/stripe-go/v79 error.go)实测/文档记录的两种 *stripe.Error 形态: +// +// - 订阅对象已被彻底删除(引用旧 id 查不到):Type=invalid_request_error, +// Code=resource_missing(有明确机器可读 Code,见 error.go ErrorCodeResourceMissing)。 +// - 订阅对象仍在但 status=canceled(二次 Cancel 同一仍存在的订阅):Type=invalid_request_error, +// **无 Code**(Stripe 对这种校验类拒绝不下发机器可读 code,仅给 Msg 文案 +// "This subscription has already been canceled."),只能按已知文案兜底、大小写不敏感匹配, +// 避免因标点/大小写细节波动误判。 +func isAlreadyCanceledErr(err error) bool { + var stripeErr *gostripe.Error + if !errors.As(err, &stripeErr) { + return false + } + if stripeErr.Code == gostripe.ErrorCodeResourceMissing { + return true + } + return stripeErr.Type == gostripe.ErrorTypeInvalidRequest && + strings.Contains(strings.ToLower(stripeErr.Msg), "already been canceled") +} + func (p *Provider) VerifyCallback(_ context.Context, in provider.CallbackInput) (*provider.PaidEvent, error) { sig := in.Headers["Stripe-Signature"] // stripe-go 默认 ConstructEvent 会额外校验 event.api_version == SDK 编译期常量 diff --git a/internal/provider/stripe/stripe_test.go b/internal/provider/stripe/stripe_test.go index 0a05d24..34b4c6c 100644 --- a/internal/provider/stripe/stripe_test.go +++ b/internal/provider/stripe/stripe_test.go @@ -5,6 +5,7 @@ import ( "crypto/hmac" "crypto/sha256" "encoding/hex" + "errors" "fmt" "io" "net/http" @@ -40,6 +41,20 @@ func fakeStripeAPI(t *testing.T) *httptest.Server { fmt.Fprint(w, `{"id":"cs_test_123","object":"checkout.session","amount_total":2999,"currency":"usd","payment_status":"paid"}`) case r.Method == http.MethodDelete && strings.Contains(r.URL.Path, "/v1/subscriptions/sub_cancel_ok"): fmt.Fprint(w, `{"id":"sub_cancel_ok","object":"subscription","status":"canceled"}`) + case r.Method == http.MethodDelete && strings.Contains(r.URL.Path, "/v1/subscriptions/sub_resource_missing"): + // 渠道已彻底删除该订阅对象:invalid_request_error + code=resource_missing(有明确 + // 机器可读 Code)。 + w.WriteHeader(http.StatusNotFound) + fmt.Fprint(w, `{"error":{"type":"invalid_request_error","code":"resource_missing","message":"No such subscription: 'sub_resource_missing'"}}`) + case r.Method == http.MethodDelete && strings.Contains(r.URL.Path, "/v1/subscriptions/sub_already_canceled"): + // 订阅对象仍在但 status=canceled,二次 Cancel:invalid_request_error,**无 code**, + // 只有 Stripe 实测的固定文案。 + w.WriteHeader(http.StatusBadRequest) + fmt.Fprint(w, `{"error":{"type":"invalid_request_error","message":"This subscription has already been canceled."}}`) + case r.Method == http.MethodDelete && strings.Contains(r.URL.Path, "/v1/subscriptions/sub_cancel_other_error"): + // 与"已取消"无关的普通渠道拒绝(如权限/网络类),不应被误判成哨兵。 + w.WriteHeader(http.StatusBadRequest) + fmt.Fprint(w, `{"error":{"type":"invalid_request_error","message":"Something else went wrong."}}`) default: http.Error(w, `{"error":{"message":"not found"}}`, http.StatusNotFound) } @@ -82,6 +97,53 @@ func TestCancelSubscription(t *testing.T) { } } +// TestCancelSubscriptionResourceMissingWrapsSentinel 覆盖"渠道已彻底删除该订阅对象"这一 +// 已取消形态:*stripe.Error{Type:invalid_request_error, Code:resource_missing} → wrap 成 +// provider.ErrSubAlreadyCanceled(errors.Is 可判),不是不可判别的裸字符串错误。 +func TestCancelSubscriptionResourceMissingWrapsSentinel(t *testing.T) { + ts := fakeStripeAPI(t) + defer ts.Close() + p := newStripe(t, ts) + err := p.CancelSubscription(context.Background(), "sub_resource_missing") + if err == nil { + t.Fatalf("cancel resource_missing: want error, got nil") + } + if !errors.Is(err, provider.ErrSubAlreadyCanceled) { + t.Fatalf("cancel resource_missing err = %v, want wraps provider.ErrSubAlreadyCanceled", err) + } +} + +// TestCancelSubscriptionAlreadyCanceledMessageWrapsSentinel 覆盖"订阅对象仍在但 status=canceled +// 二次 Cancel"这一形态:Stripe 对此场景**不下发机器可读 Code**,只有 invalid_request_error 类型 +// + 固定文案"already been canceled"——同样应 wrap 成哨兵。 +func TestCancelSubscriptionAlreadyCanceledMessageWrapsSentinel(t *testing.T) { + ts := fakeStripeAPI(t) + defer ts.Close() + p := newStripe(t, ts) + err := p.CancelSubscription(context.Background(), "sub_already_canceled") + if err == nil { + t.Fatalf("cancel already_canceled: want error, got nil") + } + if !errors.Is(err, provider.ErrSubAlreadyCanceled) { + t.Fatalf("cancel already_canceled err = %v, want wraps provider.ErrSubAlreadyCanceled", err) + } +} + +// TestCancelSubscriptionOtherErrorNotWrapped 反例:与"已取消"无关的渠道拒绝不应被误判成 +// 哨兵,原样透传成普通错误(不能 errors.Is 命中)。 +func TestCancelSubscriptionOtherErrorNotWrapped(t *testing.T) { + ts := fakeStripeAPI(t) + defer ts.Close() + p := newStripe(t, ts) + err := p.CancelSubscription(context.Background(), "sub_cancel_other_error") + if err == nil { + t.Fatalf("cancel other error: want error, got nil") + } + if errors.Is(err, provider.ErrSubAlreadyCanceled) { + t.Fatalf("cancel other error err = %v, 不应误判成 ErrSubAlreadyCanceled", err) + } +} + func TestCapabilitiesRecurring(t *testing.T) { ts := fakeStripeAPI(t) defer ts.Close()