diff --git a/internal/accounts/picker.go b/internal/accounts/picker.go new file mode 100644 index 0000000..3ee2806 --- /dev/null +++ b/internal/accounts/picker.go @@ -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. 的取值;缺省/未知一律当 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 +} diff --git a/internal/accounts/picker_test.go b/internal/accounts/picker_test.go new file mode 100644 index 0000000..6cdccb5 --- /dev/null +++ b/internal/accounts/picker_test.go @@ -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) + } +}