feat(v2): 账户路由 Picker/Strategy 接口 + round_robin 策略(替换首个 enabled)
This commit is contained in:
@@ -0,0 +1,56 @@
|
|||||||
|
package accounts
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/wangjia/pay/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrNoAccount 表示该 channel/region 下没有可选账户(候选为空,或被策略全部排除)。
|
||||||
|
var ErrNoAccount = errors.New("accounts: no candidate account for channel/region")
|
||||||
|
|
||||||
|
// 路由策略名(配置 routing.<channel> 的取值;缺省/未知一律当 round_robin)。
|
||||||
|
const (
|
||||||
|
StrategyRoundRobin = "round_robin"
|
||||||
|
StrategyWeighted = "weighted"
|
||||||
|
StrategyLimitAware = "limit_aware"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PickHint 选账户的上下文提示(确定性 + retry 换号)。
|
||||||
|
type PickHint struct {
|
||||||
|
OutTradeNo string // weighted 的确定性锚点;crypto 地址池亲和
|
||||||
|
AmountMinor int64 // limit_aware 判定 used+amount<=DailyLimit
|
||||||
|
ExcludeAccounts []string // retry:排除已试过的账户,换一个重试
|
||||||
|
}
|
||||||
|
|
||||||
|
// Picker 面向 gateway 的路由入口:按渠道配置的策略从 enabled 账户里选一个。
|
||||||
|
type Picker interface {
|
||||||
|
Pick(channel, region string, hint PickHint) (config.AccountConfig, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strategy 是纯函数式选择器:对「已过滤的候选切片」选一个账户。
|
||||||
|
// key = channel+"|"+region,round_robin 用它分桶进程计数器,其余策略忽略。
|
||||||
|
type Strategy interface {
|
||||||
|
Pick(key string, candidates []config.AccountConfig, hint PickHint) (config.AccountConfig, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RoundRobin 进程内轮询:按 key 分桶各自递增计数器,mod 候选数。
|
||||||
|
// 重启归零、不落库、不跨实例共享(见计划 D2);只为均摊,不作强配额。
|
||||||
|
type RoundRobin struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
counters map[string]uint64
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRoundRobin() *RoundRobin { return &RoundRobin{counters: map[string]uint64{}} }
|
||||||
|
|
||||||
|
func (r *RoundRobin) Pick(key string, candidates []config.AccountConfig, _ PickHint) (config.AccountConfig, error) {
|
||||||
|
if len(candidates) == 0 {
|
||||||
|
return config.AccountConfig{}, ErrNoAccount
|
||||||
|
}
|
||||||
|
r.mu.Lock()
|
||||||
|
n := r.counters[key]
|
||||||
|
r.counters[key] = n + 1
|
||||||
|
r.mu.Unlock()
|
||||||
|
return candidates[int(n%uint64(len(candidates)))], nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package accounts_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/wangjia/pay/config"
|
||||||
|
"github.com/wangjia/pay/internal/accounts"
|
||||||
|
)
|
||||||
|
|
||||||
|
func cands(ids ...string) []config.AccountConfig {
|
||||||
|
out := make([]config.AccountConfig, 0, len(ids))
|
||||||
|
for _, id := range ids {
|
||||||
|
out = append(out, config.AccountConfig{AccountID: id, Channel: "fake", Region: "global", Enabled: true, Weight: 1})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRoundRobinCyclesInOrder(t *testing.T) {
|
||||||
|
rr := accounts.NewRoundRobin()
|
||||||
|
cs := cands("a1", "a2", "a3")
|
||||||
|
want := []string{"a1", "a2", "a3", "a1", "a2"}
|
||||||
|
for i, w := range want {
|
||||||
|
got, err := rr.Pick("fake|global", cs, accounts.PickHint{})
|
||||||
|
if err != nil || got.AccountID != w {
|
||||||
|
t.Fatalf("call %d: got %v err %v, want %s", i, got.AccountID, err, w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRoundRobinKeysAreIndependent(t *testing.T) {
|
||||||
|
rr := accounts.NewRoundRobin()
|
||||||
|
cs := cands("a1", "a2")
|
||||||
|
// 不同 key(渠道/区域)各自独立计数,互不串扰。
|
||||||
|
g1, _ := rr.Pick("alipay|cn", cs, accounts.PickHint{})
|
||||||
|
g2, _ := rr.Pick("wechat|cn", cs, accounts.PickHint{})
|
||||||
|
if g1.AccountID != "a1" || g2.AccountID != "a1" {
|
||||||
|
t.Fatalf("每个 key 首次都应从 a1 起, got %s / %s", g1.AccountID, g2.AccountID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStrategyEmptyCandidates(t *testing.T) {
|
||||||
|
rr := accounts.NewRoundRobin()
|
||||||
|
if _, err := rr.Pick("fake|global", nil, accounts.PickHint{}); !errors.Is(err, accounts.ErrNoAccount) {
|
||||||
|
t.Fatalf("空候选应 ErrNoAccount, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user