merge: P8 订阅/recurring + 拒付 chargeback 并入(订阅生命周期/续费/取消/past_due/chargeback/事件集收口)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u

# Conflicts:
#	internal/model/testdb.go
#	internal/provider/provider.go
#	internal/router/router.go
#	internal/store/order_query_test.go
#	main.go
This commit is contained in:
wangjia
2026-07-10 19:43:32 +08:00
32 changed files with 2826 additions and 52 deletions
+24 -2
View File
@@ -35,7 +35,15 @@ func (stubResolver) Resolve(sku, currency string) (int64, string, string, error)
type spyEnqueuer struct {
calls []map[string]any
failNext bool // 置 true 模拟 outbox 入队失败(settle 崩溃窗口测试用)
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",这里镜像真实幂等语义。
}
func (s *spyEnqueuer) Enqueue(outTradeNo, bizSystem, eventType, refundID string, data map[string]any) error {
@@ -43,6 +51,18 @@ 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)
}
if s.seen[key] {
return nil // 幂等 no-op,行已存在
}
s.seen[key] = true
s.calls = append(s.calls, data)
return nil
}
@@ -52,6 +72,8 @@ func newGateway(t *testing.T) (*gateway.Gateway, *fake.Provider, *spyEnqueuer, *
db := model.OpenTestDB(t)
orders := store.NewOrderStore(db)
refunds := store.NewRefundStore(db)
subs := store.NewSubscriptionStore(db)
chargebacks := store.NewChargebackStore(db)
preg := provider.NewRegistry()
fp := fake.New()
preg.Register(fp)
@@ -62,7 +84,7 @@ func newGateway(t *testing.T) (*gateway.Gateway, *fake.Provider, *spyEnqueuer, *
})
picker := accounts.NewRouter(areg, nil, nil) // 默认 round_robin
spy := &spyEnqueuer{}
g := gateway.New(orders, refunds, preg, picker, stubResolver{}, spy, "global")
g := gateway.New(orders, refunds, preg, picker, stubResolver{}, spy, "global", subs, chargebacks)
return g, fp, spy, orders
}