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:
wangjia
2026-07-10 18:42:20 +08:00
parent ff65a06bf2
commit 02b2fcfa41
6 changed files with 270 additions and 6 deletions
+10 -2
View File
@@ -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)