fix(v2): 取消失配收敛——渠道已取消(哨兵)→本地补齐+事件,不再500
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user