feat(v2): limit_aware 策略(UsageSource 留位)+ Router 按配置派发 + config.Routing

This commit is contained in:
wangjia
2026-07-10 13:55:16 +08:00
parent 875093dff2
commit bd50a0d561
5 changed files with 252 additions and 0 deletions
+3
View File
@@ -18,6 +18,9 @@ type Config struct {
Biz map[string]BizSystemConfig `mapstructure:"biz"`
// Accounts 多账户配置注册表(v2):凭证不写死配置,只存 env 前缀,真值运行时从环境变量取。
Accounts []AccountConfig `mapstructure:"accounts"`
// Routing 路由策略:channel → 策略名(round_robin/weighted/limit_aware)。
// 缺省/未知一律当 round_robin(见 P5 计划 D3)。密钥无关,可写 config.yaml。
Routing map[string]string `mapstructure:"routing"`
}
// AccountConfig 单个收款账户的配置(v2 多账户路由用)。凭证严禁写进 config.yaml
+67
View File
@@ -0,0 +1,67 @@
package accounts
import (
"sync"
"github.com/wangjia/pay/config"
)
// UsageSource 提供账户今日已收金额(minor),供 limit_aware 判定是否越 DailyLimit。
// P5 内存实现(MemUsage)+ 生产空源(NopUsage);真实用量数据源由 P6 对账 job 提供,
// 届时实现本接口注入 Router 即可,LimitAware/Router 不改(见计划 D5)。
type UsageSource interface {
TodayUsedMinor(accountID string) int64
}
// NopUsage 恒返回 0:P6 前生产装配用,limit_aware 退化为基础策略(不误拒)。
type NopUsage struct{}
func (NopUsage) TodayUsedMinor(string) int64 { return 0 }
// MemUsage 进程内用量计数(测试确定性 / 简单场景)。
type MemUsage struct {
mu sync.Mutex
used map[string]int64
}
func NewMemUsage() *MemUsage { return &MemUsage{used: map[string]int64{}} }
func (m *MemUsage) Add(accountID string, minor int64) {
m.mu.Lock()
m.used[accountID] += minor
m.mu.Unlock()
}
func (m *MemUsage) TodayUsedMinor(accountID string) int64 {
m.mu.Lock()
defer m.mu.Unlock()
return m.used[accountID]
}
// LimitAware 过滤掉「DailyLimit>0 且 已用+本单 > DailyLimit」的账户,再把合格集交给
// base 策略选(默认 round_robin)。全部越限 → ErrNoAccount(分摊避免单账户触风控冻结)。
type LimitAware struct {
base Strategy
usage UsageSource
}
func NewLimitAware(base Strategy, usage UsageSource) *LimitAware {
if usage == nil {
usage = NopUsage{}
}
return &LimitAware{base: base, usage: usage}
}
func (l *LimitAware) Pick(key string, candidates []config.AccountConfig, hint PickHint) (config.AccountConfig, error) {
eligible := make([]config.AccountConfig, 0, len(candidates))
for _, c := range candidates {
if c.DailyLimit > 0 && l.usage.TodayUsedMinor(c.AccountID)+hint.AmountMinor > c.DailyLimit {
continue
}
eligible = append(eligible, c)
}
if len(eligible) == 0 {
return config.AccountConfig{}, ErrNoAccount
}
return l.base.Pick(key, eligible, hint)
}
+61
View File
@@ -0,0 +1,61 @@
package accounts_test
import (
"errors"
"testing"
"github.com/wangjia/pay/config"
"github.com/wangjia/pay/internal/accounts"
)
func TestLimitAwareFiltersOverLimit(t *testing.T) {
usage := accounts.NewMemUsage()
usage.Add("a1", 9000) // a1 今日已用 9000
la := accounts.NewLimitAware(accounts.NewRoundRobin(), usage)
cs := []config.AccountConfig{
{AccountID: "a1", DailyLimit: 10000}, // 9000+2000=11000 > 10000 → 排除
{AccountID: "a2", DailyLimit: 10000}, // 0+2000 <= 10000 → 合格
}
for i := 0; i < 3; i++ {
got, err := la.Pick("fake|global", cs, accounts.PickHint{AmountMinor: 2000})
if err != nil || got.AccountID != "a2" {
t.Fatalf("越限的 a1 应被过滤、只剩 a2, got %v err %v", got.AccountID, err)
}
}
}
func TestLimitAwareZeroLimitIsUnlimited(t *testing.T) {
usage := accounts.NewMemUsage()
usage.Add("a1", 1_000_000)
la := accounts.NewLimitAware(accounts.NewRoundRobin(), usage)
cs := []config.AccountConfig{{AccountID: "a1", DailyLimit: 0}} // 0=不限
got, err := la.Pick("fake|global", cs, accounts.PickHint{AmountMinor: 999})
if err != nil || got.AccountID != "a1" {
t.Fatalf("DailyLimit=0 应不限, got %v err %v", got.AccountID, err)
}
}
func TestLimitAwareAllOverLimit(t *testing.T) {
usage := accounts.NewMemUsage()
usage.Add("a1", 10000)
la := accounts.NewLimitAware(accounts.NewRoundRobin(), usage)
cs := []config.AccountConfig{{AccountID: "a1", DailyLimit: 10000}}
if _, err := la.Pick("fake|global", cs, accounts.PickHint{AmountMinor: 1}); !errors.Is(err, accounts.ErrNoAccount) {
t.Fatalf("全部越限应 ErrNoAccount, got %v", err)
}
}
func TestNopUsageDegradesToBase(t *testing.T) {
la := accounts.NewLimitAware(accounts.NewRoundRobin(), accounts.NopUsage{})
cs := []config.AccountConfig{
{AccountID: "a1", DailyLimit: 100}, {AccountID: "a2", DailyLimit: 100},
}
// 空用量源 → 谁都不越限 → 行为 = round_robin。
// 注:AmountMinor 须 <= DailyLimit,否则即便 used=0 单笔也会越限(0+amount>limit),
// 那样测的是「单笔超限」而非「NopUsage 退化」,与用例名/注释矛盾——故取 10 而非 999。
g1, _ := la.Pick("fake|global", cs, accounts.PickHint{AmountMinor: 10})
g2, _ := la.Pick("fake|global", cs, accounts.PickHint{AmountMinor: 10})
if g1.AccountID != "a1" || g2.AccountID != "a2" {
t.Fatalf("NopUsage 下应退化为轮询 a1,a2, got %s,%s", g1.AccountID, g2.AccountID)
}
}
+66
View File
@@ -0,0 +1,66 @@
package accounts
import "github.com/wangjia/pay/config"
// Router 是 gateway 面向的 Picker 实现:取候选(EnabledFor)→ 按 hint 排除 → 派发策略。
// 策略实例进程内单例(round_robin 计数器需长存);未配置/未知策略默认 round_robin。
type Router struct {
reg *Registry
strategies map[string]string // channel -> 策略名
byName map[string]Strategy
def Strategy
}
// NewRouter 装配路由。routing:channel→策略名(缺省/未知→round_robin);usage=nil→NopUsage。
// limit_aware 复合 round_robin 作为合格集内的均摊基础策略。
func NewRouter(reg *Registry, routing map[string]string, usage UsageSource) *Router {
rr := NewRoundRobin()
byName := map[string]Strategy{
StrategyRoundRobin: rr,
StrategyWeighted: NewWeighted(),
StrategyLimitAware: NewLimitAware(rr, usage),
}
cp := map[string]string{}
for k, v := range routing {
cp[k] = v
}
return &Router{reg: reg, strategies: cp, byName: byName, def: rr}
}
func (r *Router) strategyFor(channel string) Strategy {
if name, ok := r.strategies[channel]; ok {
if s, ok := r.byName[name]; ok {
return s
}
}
return r.def // 缺省 / 未知策略名 → round_robin(见计划 D3)
}
// Pick 实现 accounts.Picker。
func (r *Router) Pick(channel, region string, hint PickHint) (config.AccountConfig, error) {
cands := r.reg.EnabledFor(channel, region)
if len(cands) == 0 {
return config.AccountConfig{}, ErrNoAccount
}
if len(hint.ExcludeAccounts) > 0 {
if filtered := excludeAccounts(cands, hint.ExcludeAccounts); len(filtered) > 0 {
cands = filtered // 过滤到空则保留全集:单账户渠道 retry 仍可用(见计划 D4)
}
}
return r.strategyFor(channel).Pick(channel+"|"+region, cands, hint)
}
func excludeAccounts(cands []config.AccountConfig, exclude []string) []config.AccountConfig {
skip := make(map[string]struct{}, len(exclude))
for _, id := range exclude {
skip[id] = struct{}{}
}
out := make([]config.AccountConfig, 0, len(cands))
for _, c := range cands {
if _, ok := skip[c.AccountID]; ok {
continue
}
out = append(out, c)
}
return out
}
+55
View File
@@ -0,0 +1,55 @@
package accounts_test
import (
"errors"
"testing"
"github.com/wangjia/pay/config"
"github.com/wangjia/pay/internal/accounts"
)
func newReg() *accounts.Registry {
return accounts.New([]config.AccountConfig{
{AccountID: "f1", Channel: "fake", Region: "global", Enabled: true, Weight: 1},
{AccountID: "f2", Channel: "fake", Region: "global", Enabled: true, Weight: 1},
{AccountID: "f3", Channel: "fake", Region: "global", Enabled: false, Weight: 1}, // 禁用不参与
{AccountID: "a1", Channel: "alipay", Region: "cn", Enabled: true, Weight: 1},
})
}
func TestRouterDefaultRoundRobin(t *testing.T) {
r := accounts.NewRouter(newReg(), nil, nil) // 无 routing → 默认 round_robin
seq := []string{"f1", "f2", "f1"}
for i, w := range seq {
got, err := r.Pick("fake", "global", accounts.PickHint{OutTradeNo: "PAY-1"})
if err != nil || got.AccountID != w {
t.Fatalf("call %d got %v err %v want %s", i, got.AccountID, err, w)
}
}
}
func TestRouterExcludeSwitchesAccount(t *testing.T) {
r := accounts.NewRouter(newReg(), map[string]string{"fake": accounts.StrategyWeighted}, nil)
base, _ := r.Pick("fake", "global", accounts.PickHint{OutTradeNo: "PAY-RETRY"})
// retry:排除首选账户 → 必换到另一个。
got, err := r.Pick("fake", "global", accounts.PickHint{OutTradeNo: "PAY-RETRY", ExcludeAccounts: []string{base.AccountID}})
if err != nil || got.AccountID == base.AccountID {
t.Fatalf("排除 %s 后应换账户, got %v err %v", base.AccountID, got.AccountID, err)
}
}
func TestRouterExcludeEmptyFallsBackToAll(t *testing.T) {
r := accounts.NewRouter(newReg(), nil, nil)
// alipay/cn 只有 a1;排除 a1 后候选空 → 回退全集,仍返回 a1(单账户 retry 不失败)。
got, err := r.Pick("alipay", "cn", accounts.PickHint{OutTradeNo: "PAY-2", ExcludeAccounts: []string{"a1"}})
if err != nil || got.AccountID != "a1" {
t.Fatalf("排除到空应回退全集, got %v err %v", got.AccountID, err)
}
}
func TestRouterNoAccount(t *testing.T) {
r := accounts.NewRouter(newReg(), nil, nil)
if _, err := r.Pick("wechat", "cn", accounts.PickHint{}); !errors.Is(err, accounts.ErrNoAccount) {
t.Fatalf("无账户渠道应 ErrNoAccount, got %v", err)
}
}