feat(v2): gateway 经 Picker 路由选账户 + retry 换号(ExcludeAccounts);替换首个 enabled

This commit is contained in:
wangjia
2026-07-10 14:02:33 +08:00
parent 381288e707
commit c06f6d3a3c
7 changed files with 109 additions and 17 deletions
+25 -13
View File
@@ -37,15 +37,15 @@ type WebhookEnqueuer interface {
type Gateway struct {
orders *store.OrderStore
providers *provider.Registry
accounts *accounts.Registry
picker accounts.Picker
products ProductResolver
webhook WebhookEnqueuer
region string
}
func New(orders *store.OrderStore, providers *provider.Registry, accts *accounts.Registry,
func New(orders *store.OrderStore, providers *provider.Registry, picker accounts.Picker,
products ProductResolver, webhook WebhookEnqueuer, region string) *Gateway {
return &Gateway{orders: orders, providers: providers, accounts: accts,
return &Gateway{orders: orders, providers: providers, picker: picker,
products: products, webhook: webhook, region: region}
}
@@ -80,13 +80,17 @@ func (g *Gateway) CreateOrder(ctx context.Context, in CreateOrderInput) (*OrderR
if err != nil {
return nil, err // ErrUnknownMethod
}
accts := g.accounts.EnabledFor(in.Method, g.region)
if len(accts) == 0 {
return nil, ErrNoAccount
}
acct := accts[0] // 路由策略(round_robin/weighted/…)在 P5;P2 取首个 enabled。
outNo := util.NewOutTradeNo("pay")
acct, err := g.picker.Pick(in.Method, g.region, accounts.PickHint{
OutTradeNo: outNo, AmountMinor: amountMinor,
})
if err != nil {
if errors.Is(err, accounts.ErrNoAccount) {
return nil, ErrNoAccount
}
return nil, err
}
if err := g.orders.CreateOrder(&model.OrderV2{
OutTradeNo: outNo, BizSystem: in.BizSystem, BizRef: in.BizRef,
Subject: subject, AmountMinor: amountMinor, Currency: currency,
@@ -150,11 +154,19 @@ func (g *Gateway) RetryOrder(ctx context.Context, outTradeNo, method string) (*O
if err != nil {
return nil, err
}
accts := g.accounts.EnabledFor(method, g.region)
if len(accts) == 0 {
return nil, ErrNoAccount
tried, err := g.orders.AttemptAccountIDs(outTradeNo, method)
if err != nil {
return nil, err
}
acct, err := g.picker.Pick(method, g.region, accounts.PickHint{
OutTradeNo: outTradeNo, AmountMinor: o.AmountMinor, ExcludeAccounts: tried,
})
if err != nil {
if errors.Is(err, accounts.ErrNoAccount) {
return nil, ErrNoAccount
}
return nil, err
}
acct := accts[0]
if _, err := g.orders.ExpirePendingAttempts(outTradeNo); err != nil {
return nil, err
+18 -1
View File
@@ -48,9 +48,11 @@ func newGateway(t *testing.T) (*gateway.Gateway, *fake.Provider, *spyEnqueuer, *
// fake 注册在 method "fake";账户按 channel="fake" region="global" 配。
areg := accounts.New([]config.AccountConfig{
{AccountID: "fake-a1", Channel: "fake", Region: "global", Enabled: true, Weight: 1},
{AccountID: "fake-a2", Channel: "fake", Region: "global", Enabled: true, Weight: 1},
})
picker := accounts.NewRouter(areg, nil, nil) // 默认 round_robin
spy := &spyEnqueuer{}
g := gateway.New(orders, preg, areg, stubResolver{}, spy, "global")
g := gateway.New(orders, preg, picker, stubResolver{}, spy, "global")
return g, fp, spy, orders
}
@@ -118,3 +120,18 @@ func TestRetryAndCancel(t *testing.T) {
t.Fatalf("canceled 单 retry 应 ErrOrderNotPending, got %v", err)
}
}
func TestRetrySwitchesAccount(t *testing.T) {
g, _, _, orders := newGateway(t)
ctx := context.Background()
res, _ := g.CreateOrder(ctx, gateway.CreateOrderInput{SKU: "pro_year", Method: "fake", BizSystem: "pangolin", BizRef: "u-1"})
// 首单落 fake-a1(round_robin 计数 0);retry 排除 a1 → 必落 fake-a2。
if _, err := g.RetryOrder(ctx, res.OrderNo, "fake"); err != nil {
t.Fatalf("retry: %v", err)
}
pend, _ := orders.ListAttemptsByStatus(model.AttemptPending, 10)
if len(pend) != 1 || pend[0].AccountID != "fake-a2" {
t.Fatalf("retry 应换到 fake-a2, got %+v", pend)
}
}
+2 -1
View File
@@ -120,8 +120,9 @@ func TestSettleTransientReadErrorIsFailed(t *testing.T) {
areg := accounts.New([]config.AccountConfig{
{AccountID: "fake-a1", Channel: "fake", Region: "global", Enabled: true, Weight: 1},
})
picker := accounts.NewRouter(areg, nil, nil)
spy := &spyEnqueuer{}
g := gateway.New(orders, preg, areg, stubResolver{}, spy, "global")
g := gateway.New(orders, preg, picker, stubResolver{}, spy, "global")
ctx := context.Background()
g.CreateOrder(ctx, gateway.CreateOrderInput{SKU: "pro_year", Method: "fake", BizSystem: "pangolin", BizRef: "u-1"})