Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
41 KiB
pay v2 · P5 多账户路由策略 Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
设计文档(全景蓝图):
docs/pay-v2-unified-gateway-design.html§3.3(Account 多账户 + 路由:round_robin / weighted / limit_aware / by_region / crypto 地址池;一单按策略选一个账户,Attempt 绑定 account_id,回调/退款/结算走同一账户)。 前置计划:docs/superpowers/plans/2026-07-10-pay-v2-p2-pipeline.md(P2 已落地:internal/provider、internal/gateway、internal/store扩展、internal/accounts.Registry)。本计划是 P5,替换 P2 里两处accts[0](取首个 enabled)的临时取舍。
Goal: 在 internal/accounts 上加一层策略路由:Picker.Pick(channel, region, hint) 按配置的 route_strategy 从「该渠道该区域的 enabled 账户」里选一个账户,取代 P2 的「首个 enabled」。落地三个策略 —— round_robin(进程内轮询计数器)、weighted(按 Weight,以 hash(OutTradeNo) 取模做确定性选择,不引不可控 rand)、limit_aware(按 DailyLimit 过滤超额账户 + 复合一个基础策略;用量数据源以 UsageSource 接口留位,本期内存实现 + 生产空源 = 退化为 round_robin,真实用量待 P6 对账)。gateway.CreateOrder/RetryOrder 改经 Picker;retry 经 hint.ExcludeAccounts 换一个账户重试(同渠道换号是本功能的真实价值:分摊限额 / 抗风控冻卡 / crypto 换地址)。
Architecture: 干净分层 —— Registry.EnabledFor(channel, region)(P1,负责过滤)只出候选切片;Strategy.Pick(key, candidates, hint) 是纯函数式选择器(round_robin 靠自身按 key 分桶的进程计数器、weighted 靠 hash(OutTradeNo)、limit_aware 靠 UsageSource + 组合基础策略),对候选切片操作 → 确定性、可单测、无 Registry 依赖。Router(实现 Picker)负责:EnabledFor 取候选 → 按 hint.ExcludeAccounts 过滤(过滤到空则回退不过滤,保证单账户渠道 retry 不失败)→ 按 channel 的配置 route_strategy 派发到对应 Strategy。gateway 只持有 accounts.Picker 接口,不认具体策略。
Tech Stack: Go 1.26.1 · github.com/wangjia/pay · internal/accounts(P1 Registry/config.AccountConfig)· internal/gateway(P2)· internal/store(P1/P2 OrderStore)· hash/fnv(标准库,确定性 hash)· sync(进程计数器 mutex)。全程 :memory:/纯内存单测,免 docker。
Global Constraints
- 复用 P1/P2,不重造:候选过滤用
accounts.Registry.EnabledFor(channel, region)(已按 channel+enabled+region 过滤);账户模型config.AccountConfig(AccountID/Channel/Weight/Enabled/DailyLimit/Region/CredentialEnvPrefix);gateway 现有签名/管线(CreateOrder/RetryOrder)只改「选账户」那一步,其余不动。 - 确定性铁律:测试禁
rand/time.Now()做决策依赖。- round_robin:进程内计数器(
atomic/mutex),按调用顺序确定 → 测试串行调用断言 a1,a2,a1,a2。重启归零(进程级、不落库),可接受(见 D2)。 - weighted:决策源 =
fnv32a(hint.OutTradeNo) % Σweight,同 OutTradeNo → 恒同账户,跨重启/多节点一致,不注入 rand(见 D1 论证)。 - limit_aware:用量来自注入的
UsageSource(内存MemUsage可Add→ 测试确定),不读时钟。
- round_robin:进程内计数器(
- Attempt 绑定选中账户(设计 §3.3):
gateway建 Attempt 时AccountID = acct.AccountID(P2 已如此),回调/退款/结算据此走同一账户 —— 本期不改这条,只改「acct 从哪来」。 - retry 换账户:
RetryOrder收集该单该渠道已试过的 account_id,经hint.ExcludeAccounts传入 → Router 过滤后策略在剩余账户里选;剩余为空(单账户渠道)则回退用全集(retry 允许命中同账户,好过报错)。 - 默认策略 = round_robin(见 D3):配置缺省/空串一律当 round_robin;单账户渠道下 round_robin ≡ weighted ≡ limit_aware ≡「首个 enabled」(计数/权重/过滤对唯一候选都收敛到它),故默认切 round_robin 对现网单账户零行为变化、对多账户严格更优,无需配置迁移。
- crypto 地址池(设计 §3.3):crypto 渠道下「account = 一个 xpub」,round_robin/weighted 选账户 = 地址来源轮换;地址派生在 P3 crypto adapter 的
create()内(消费provider.CreateRequest.Account)。P5 只负责把 account 选对,不碰地址派生 —— 与 P3 接口(以CreateRequest.Account为界)不冲突。 - 每步
go build ./...通过;测试go test ./...(纯内存,免 docker)。 - 每任务严格 bite-sized TDD:写失败测试 → 跑失败 → 实现 → 跑通过 → commit。禁占位。
关键决策(执行前定案)
- D1 weighted 确定性方案:hash(OutTradeNo) 取模,而非注入 rand。 理由:①可复现 —— 同一单无论在哪台节点、重启前后,选账户结果恒定,便于排障与对账("这单为什么落 a2"有确定答案);②天然幂等友好 —— 无副作用、无共享随机态;③retry 换号由
ExcludeAccounts显式驱动(见 D4),不依赖"下次随机不同",避免 rand 方案里"重试仍抽中同账户"的抖动。分布正确性靠大样本不同 OutTradeNo 的 hash 均匀性(fnv32a 足够),测试用 1000 个合成单号校验两账户都被选中 + 3:1 权重的粗略分布容差。 - D2 round_robin 状态:进程内计数器,重启归零、不落库、不跨节点共享。 理由:轮询只为"大致均摊",不是强配额;落库/共享会给每次下单加一次写 + 锁争用,不值当。重启后从头轮询,长期仍均匀。多实例部署下各实例独立轮询,合起来仍近似均摊(可接受)。强配额语义由 limit_aware(D5)承担,不靠 round_robin。
- D3 默认策略 = round_robin(零值兼容)。 见 Global Constraints;单账户渠道下与 P2「首个 enabled」逐位等价,现网零行为变化。
- D4 retry 换账户 =
hint.ExcludeAccounts。RetryOrder传入本单本渠道已试账户集,Router 在派发策略前先从候选里剔除它们 → 任何策略都自动在"没试过的账户"里选,换号是路由层通用能力,不需每个策略各自实现。过滤到空 → 回退全集(单账户渠道 retry 仍可用)。 - D5 limit_aware 取舍:本期实现内存计数骨架 +
UsageSource接口留 DB 位(方案 a),不延到 P6。 理由:①逻辑本期就该被测到 —— "used+amount 是否越 DailyLimit → 过滤该账户 → 在合格集里走基础策略"这套判定,靠注入MemUsage即可确定性单测,不必等真实数据;②安全退化 —— 生产装配传空源(NopUsage,恒返回 0 已用),则所有账户永不越限 → limit_aware 行为等同 round_robin,不会因为"P6 未就绪"而错误拒单;③P6 零改接口接入 —— 对账 job 就绪后,实现一个读对账用量的UsageSource注入 Router 即可,LimitAware/Router一行不改。相比"纯枚举保留、逻辑延到 P6"(方案 b),方案 a 让该策略现在就可用且被测,且 P6 只补数据源不补逻辑,风险更低。明确不做:真实用量数据源(P6 对账提供)、跨 region 故障转移(later)。
Task 1: Picker/Strategy 核心类型 + round_robin 策略
Files:
- Create:
internal/accounts/picker.go(Picker/Strategy接口 +PickHint+ 策略名常量 +ErrNoAccount+RoundRobin) - Test:
internal/accounts/picker_test.go
Interfaces:
-
Consumes:
config.AccountConfig(P1)。 -
Produces:
var ErrNoAccount = errors.New("accounts: no candidate account for channel/region")type PickHint struct{ OutTradeNo string; AmountMinor int64; ExcludeAccounts []string }(OutTradeNo=weighted 确定性锚点 + 地址池亲和;AmountMinor=limit_aware 判定;ExcludeAccounts=retry 换号)。type Picker interface{ Pick(channel, region string, hint PickHint) (config.AccountConfig, error) }(gateway 面向此接口)。type Strategy interface{ Pick(key string, candidates []config.AccountConfig, hint PickHint) (config.AccountConfig, error) }(纯函数式:对已过滤候选选一个;key = channel+"|"+region,round_robin 用它分桶计数,其余策略忽略)。- 策略名常量:
StrategyRoundRobin = "round_robin"、StrategyWeighted = "weighted"、StrategyLimitAware = "limit_aware"。 type RoundRobin struct{...}·func NewRoundRobin() *RoundRobin(实现Strategy,进程内按 key 分桶轮询计数器,mutex 保护)。
-
Step 1: 写失败测试
internal/accounts/picker_test.go:
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)
}
}
- Step 2: 跑测试确认失败
Run: cd /Users/wangjia/code/pay && go test ./internal/accounts/ -run 'RoundRobin|StrategyEmpty' -v
Expected: 编译失败 —— Picker/Strategy/RoundRobin 未定义。
- Step 3: 写实现
internal/accounts/picker.go:
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
}
- Step 4: 跑测试确认通过
Run: cd /Users/wangjia/code/pay && go test ./internal/accounts/ -v
Expected: 新增 3 测试 + P1 既有 TestEnabledForAndCredential 全 PASS。
- Step 5: Commit
cd /Users/wangjia/code/pay
git add internal/accounts/picker.go internal/accounts/picker_test.go
git commit -m "feat(v2): 账户路由 Picker/Strategy 接口 + round_robin 策略(替换首个 enabled)"
Task 2: weighted 策略(按 Weight,hash(OutTradeNo) 确定性)
Files:
- Create:
internal/accounts/weighted.go(Weighted策略) - Test:
internal/accounts/weighted_test.go
Interfaces:
-
Consumes:
config.AccountConfig、PickHint、Strategy(Task 1)。 -
Produces:
type Weighted struct{}·func NewWeighted() *Weighted(实现Strategy)。- 决策:
target = fnv32a(hint.OutTradeNo) % Σweight,按候选顺序累加weight命中区间;Weight<=0记 1(对齐 gormdefault:1)。OutTradeNo空 → 退化为取首个(确定,不 panic)。
-
Step 1: 写失败测试
internal/accounts/weighted_test.go:
package accounts_test
import (
"testing"
"github.com/wangjia/pay/config"
"github.com/wangjia/pay/internal/accounts"
)
func TestWeightedDeterministic(t *testing.T) {
w := accounts.NewWeighted()
cs := cands("a1", "a2", "a3")
// 同一 OutTradeNo 必落同一账户(确定性,可复现)。
first, _ := w.Pick("fake|global", cs, accounts.PickHint{OutTradeNo: "PAY-XYZ"})
for i := 0; i < 5; i++ {
got, _ := w.Pick("fake|global", cs, accounts.PickHint{OutTradeNo: "PAY-XYZ"})
if got.AccountID != first.AccountID {
t.Fatalf("同单号应恒定命中 %s, 第 %d 次得 %s", first.AccountID, i, got.AccountID)
}
}
}
func TestWeightedDistributesAndRespectsWeight(t *testing.T) {
w := accounts.NewWeighted()
// a1 权重 3,a2 权重 1 → 长跑大致 3:1。
cs := []config.AccountConfig{
{AccountID: "a1", Weight: 3},
{AccountID: "a2", Weight: 1},
}
counts := map[string]int{}
for i := 0; i < 4000; i++ {
no := "PAY-" + string(rune('A'+i%26)) + string(rune('0'+i/26%10)) + string(rune('a'+i/260%26)) + string(rune('0'+i%7))
got, _ := w.Pick("fake|global", cs, accounts.PickHint{OutTradeNo: no})
counts[got.AccountID]++
}
if counts["a1"] == 0 || counts["a2"] == 0 {
t.Fatalf("两账户都应被选中, got %+v", counts)
}
// a1 应显著多于 a2(3:1 容差:a1 至少是 a2 的 2 倍)。
if counts["a1"] < counts["a2"]*2 {
t.Fatalf("权重 3:1 未体现, got %+v", counts)
}
}
func TestWeightedZeroWeightTreatedAsOne(t *testing.T) {
w := accounts.NewWeighted()
cs := []config.AccountConfig{{AccountID: "a1", Weight: 0}, {AccountID: "a2", Weight: 0}}
counts := map[string]int{}
for i := 0; i < 400; i++ {
got, _ := w.Pick("fake|global", cs, accounts.PickHint{OutTradeNo: "N" + string(rune('0'+i%10)) + string(rune('a'+i%26))})
counts[got.AccountID]++
}
if counts["a1"] == 0 || counts["a2"] == 0 {
t.Fatalf("weight=0 应当 1 处理、两账户都可选, got %+v", counts)
}
}
- Step 2: 跑测试确认失败
Run: cd /Users/wangjia/code/pay && go test ./internal/accounts/ -run Weighted -v
Expected: 编译失败 —— Weighted 未定义。
- Step 3: 写实现
internal/accounts/weighted.go:
package accounts
import (
"hash/fnv"
"github.com/wangjia/pay/config"
)
// Weighted 按 Weight 加权选账户,决策源为 hash(OutTradeNo)(确定性、可复现、跨节点一致,
// 见计划 D1)。Weight<=0 记 1(对齐 model.Account gorm default:1)。retry 换号由
// Router 的 ExcludeAccounts 过滤驱动,不依赖随机。
type Weighted struct{}
func NewWeighted() *Weighted { return &Weighted{} }
func (Weighted) Pick(_ string, candidates []config.AccountConfig, hint PickHint) (config.AccountConfig, error) {
if len(candidates) == 0 {
return config.AccountConfig{}, ErrNoAccount
}
var total uint32
for _, c := range candidates {
total += weightOf(c)
}
if total == 0 { // 理论不达(weightOf>=1),兜底
return candidates[0], nil
}
h := fnv.New32a()
_, _ = h.Write([]byte(hint.OutTradeNo))
target := h.Sum32() % total
var acc uint32
for _, c := range candidates {
acc += weightOf(c)
if target < acc {
return c, nil
}
}
return candidates[len(candidates)-1], nil // 浮点无关的整数走位,兜底不达
}
func weightOf(c config.AccountConfig) uint32 {
if c.Weight <= 0 {
return 1
}
return uint32(c.Weight)
}
- Step 4: 跑测试确认通过
Run: cd /Users/wangjia/code/pay && go test ./internal/accounts/ -v
Expected: weighted 3 测试 + Task 1 + P1 全 PASS。
- Step 5: Commit
cd /Users/wangjia/code/pay
git add internal/accounts/weighted.go internal/accounts/weighted_test.go
git commit -m "feat(v2): weighted 路由策略(按 Weight,hash(OutTradeNo) 确定性)"
Task 3: limit_aware 策略 + UsageSource + Router(按配置派发)
Files:
- Create:
internal/accounts/limit_aware.go(UsageSource/NopUsage/MemUsage+LimitAware) - Create:
internal/accounts/router.go(Router实现Picker:候选取用 + Exclude 过滤 + 策略派发) - Edit:
config/config.go(Config增Routing map[string]string) - Test:
internal/accounts/limit_aware_test.go - Test:
internal/accounts/router_test.go
Interfaces:
-
Consumes:
Registry.EnabledFor(P1)、Strategy/RoundRobin/Weighted(Task 1/2)、config.AccountConfig。 -
Produces:
type UsageSource interface{ TodayUsedMinor(accountID string) int64 }(今日已收金额,minor)。type NopUsage struct{}→ 恒 0(生产装配用,P6 前 limit_aware 退化为基础策略)。type MemUsage struct{...}·func NewMemUsage() *MemUsage·Add(accountID string, minor int64)(测试/内存计数)。type LimitAware struct{ base Strategy; usage UsageSource }·func NewLimitAware(base Strategy, usage UsageSource) *LimitAware(过滤DailyLimit>0 && used+amount>limit的账户,再交base选;全越限 →ErrNoAccount)。type Router struct{...}·func NewRouter(reg *Registry, routing map[string]string, usage UsageSource) *Router(实现Picker)。routing:channel→策略名;缺省/未知→round_robin。usage=nil→NopUsage。config.Config增Routing map[string]string mapstructure:"routing"。
-
Step 1: 写失败测试
internal/accounts/limit_aware_test.go:
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。
g1, _ := la.Pick("fake|global", cs, accounts.PickHint{AmountMinor: 999})
g2, _ := la.Pick("fake|global", cs, accounts.PickHint{AmountMinor: 999})
if g1.AccountID != "a1" || g2.AccountID != "a2" {
t.Fatalf("NopUsage 下应退化为轮询 a1,a2, got %s,%s", g1.AccountID, g2.AccountID)
}
}
internal/accounts/router_test.go:
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)
}
}
- Step 2: 跑测试确认失败
Run: cd /Users/wangjia/code/pay && go test ./internal/accounts/ -run 'LimitAware|NopUsage|Router' -v
Expected: 编译失败 —— LimitAware/MemUsage/Router 未定义。
- Step 3: 写 limit_aware 实现
internal/accounts/limit_aware.go:
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)
}
- Step 4: 写 Router 实现
internal/accounts/router.go:
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
}
- Step 5: config 增 Routing 字段
config/config.go 的 Config struct 末尾追加(在 Accounts []AccountConfig 之后):
// Routing 路由策略:channel → 策略名(round_robin/weighted/limit_aware)。
// 缺省/未知一律当 round_robin(见 P5 计划 D3)。密钥无关,可写 config.yaml。
Routing map[string]string `mapstructure:"routing"`
- Step 6: 跑测试确认通过
Run: cd /Users/wangjia/code/pay && go build ./... && go test ./internal/accounts/ -v
Expected: limit_aware 4 + router 4 + Task1/2 + P1 全 PASS;config 编译通过。
- Step 7: Commit
cd /Users/wangjia/code/pay
git add internal/accounts/limit_aware.go internal/accounts/router.go internal/accounts/limit_aware_test.go internal/accounts/router_test.go config/config.go
git commit -m "feat(v2): limit_aware 策略(UsageSource 留位)+ Router 按配置派发 + config.Routing"
Task 4: gateway 接入 Picker(CreateOrder/RetryOrder 换号)+ store 查已试账户 + 装配
Files:
- Edit:
internal/gateway/gateway.go(Gateway.accounts *accounts.Registry→picker accounts.Picker;两处accts[0]→picker.Pick;retry 传ExcludeAccounts) - Create:
internal/store/attempt_accounts.go(AttemptAccountIDs(outTradeNo, channel)) - Edit:
main.go(装配accounts.NewRouter传入 gateway) - Edit:
internal/gateway/gateway_test.go(newGateway用 Router 构造) - Test:
internal/store/attempt_accounts_test.go - Test:
internal/gateway/gateway_test.go增 retry 换号断言
Interfaces:
-
Consumes:
accounts.Picker/NewRouter/PickHint(Task 1-3)、store.OrderStore、config.C.Accounts/config.C.Routing。 -
Produces:
store.OrderStore.AttemptAccountIDs(outTradeNo, channel string) ([]string, error)—— 该单该渠道所有 attempt 的 distinct account_id(供 retry 排除)。gateway.New(orders, providers, picker accounts.Picker, products, webhook, region)—— 第 3 参由*accounts.Registry改accounts.Picker。CreateOrder/RetryOrder经picker.Pick;ErrNoAccount映射保持(errors.Is(err, accounts.ErrNoAccount)→gateway.ErrNoAccount)。
-
Step 1: 写失败测试(store)
internal/store/attempt_accounts_test.go:
package store_test
import (
"testing"
"github.com/wangjia/pay/internal/model"
"github.com/wangjia/pay/internal/store"
)
func TestAttemptAccountIDs(t *testing.T) {
s := store.NewOrderStore(model.OpenTestDB(t))
if err := s.CreateOrder(&model.OrderV2{OutTradeNo: "PAY-AC1", Subject: "x", AmountMinor: 100, Currency: "USDT", Status: model.OrderPendingV2}); err != nil {
t.Fatalf("order: %v", err)
}
for _, tc := range []struct{ ref, acct string }{{"R1", "a1"}, {"R2", "a2"}, {"R3", "a1"}} {
if err := s.CreateAttempt(&model.Attempt{
OutTradeNo: "PAY-AC1", Channel: "fake", AccountID: tc.acct, Provider: "fake",
ProviderRef: tc.ref, RenderType: "crypto_address", AmountMinor: 100, Currency: "USDT",
Status: model.AttemptPending,
}); err != nil {
t.Fatalf("attempt %s: %v", tc.ref, err)
}
}
ids, err := s.AttemptAccountIDs("PAY-AC1", "fake")
if err != nil {
t.Fatalf("AttemptAccountIDs: %v", err)
}
set := map[string]bool{}
for _, id := range ids {
set[id] = true
}
if len(set) != 2 || !set["a1"] || !set["a2"] {
t.Fatalf("应含 distinct a1,a2, got %v", ids)
}
// 其它渠道 / 其它单不串。
if got, _ := s.AttemptAccountIDs("PAY-AC1", "alipay"); len(got) != 0 {
t.Fatalf("别的渠道应空, got %v", got)
}
}
- Step 2: 跑测试确认失败
Run: cd /Users/wangjia/code/pay && go test ./internal/store/ -run AttemptAccountIDs -v
Expected: 编译失败 —— AttemptAccountIDs 未定义。
- Step 3: 写 store 实现
internal/store/attempt_accounts.go:
package store
import (
"fmt"
"github.com/wangjia/pay/internal/model"
)
// AttemptAccountIDs returns the distinct account_ids already tried for an order on a
// channel (used by retry to switch to a not-yet-tried account via PickHint.ExcludeAccounts).
func (s *OrderStore) AttemptAccountIDs(outTradeNo, channel string) ([]string, error) {
var ids []string
if err := s.db.Model(&model.Attempt{}).
Where("out_trade_no = ? AND channel = ? AND account_id <> ''", outTradeNo, channel).
Distinct().Pluck("account_id", &ids).Error; err != nil {
return nil, fmt.Errorf("store.AttemptAccountIDs: %w", err)
}
return ids, nil
}
- Step 4: 跑测试确认通过(store)
Run: cd /Users/wangjia/code/pay && go test ./internal/store/ -v
Expected: 新增 + P1/P2 既有全 PASS。
- Step 5: 改 gateway 接入 Picker
internal/gateway/gateway.go:①import 保留(已含 errors、accounts);②Gateway 字段 accounts *accounts.Registry 改为 picker accounts.Picker;③New 第 3 参改类型;④两处选账户改经 picker。
Gateway struct + New:
type Gateway struct {
orders *store.OrderStore
providers *provider.Registry
picker accounts.Picker
products ProductResolver
webhook WebhookEnqueuer
region string
}
func New(orders *store.OrderStore, providers *provider.Registry, picker accounts.Picker,
products ProductResolver, webhook WebhookEnqueuer, region string) *Gateway {
return &Gateway{orders: orders, providers: providers, picker: picker,
products: products, webhook: webhook, region: region}
}
CreateOrder —— 把
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")
改为(先生成单号,再据单号确定性选账户):
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
}
(注意:outNo 声明整体上移到 acct 之前;下方 CreateOrder(&model.OrderV2{OutTradeNo: outNo...}) 不变。)
RetryOrder —— 把
accts := g.accounts.EnabledFor(method, g.region)
if len(accts) == 0 {
return nil, ErrNoAccount
}
acct := accts[0]
if _, err := g.orders.ExpirePendingAttempts(outTradeNo); err != nil {
return nil, err
}
改为(收集已试账户 → 换号选;先算排除集再废旧尝试,二者互不依赖):
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
}
if _, err := g.orders.ExpirePendingAttempts(outTradeNo); err != nil {
return nil, err
}
RetryOrder里prov, err := g.providers.Get(method)之前已用err;上面新增块复用同名err(用=而非:=对已声明变量;tried, err :=首次声明tried用:=合法)。实现时确认err作用域,如与既有err冲突按 Go 规则调整为:=/=。
- Step 6: 改 gateway_test 装配 + 增 retry 换号断言
internal/gateway/gateway_test.go 的 newGateway:把
areg := accounts.New([]config.AccountConfig{
{AccountID: "fake-a1", Channel: "fake", Region: "global", Enabled: true, Weight: 1},
})
spy := &spyEnqueuer{}
g := gateway.New(orders, preg, areg, stubResolver{}, spy, "global")
改为(两个账户,好测 retry 换号 + round_robin;Router 默认 round_robin):
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, picker, stubResolver{}, spy, "global")
既有
TestCreateOrderPipeline断言 attemptAccountID == "fake-a1":round_robin 首个 key 首次仍返回fake-a1(候选顺序 a1,a2,计数 0),断言不变即通过。既有TestRetryAndCancel断言 retry 后恰 1 个 pending 尝试,仍成立。
新增测试(retry 必换到未试过的账户):
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)
}
}
- Step 7: 改 main.go 装配 Router
main.go 把
acctReg := accounts.New(config.C.Accounts)
gw := gateway.New(orderStore, pReg, acctReg, productResolver, notifier, "cn")
改为:
acctReg := accounts.New(config.C.Accounts)
// P5 多账户路由:按 config.routing.<channel> 选策略(缺省 round_robin)。
// limit_aware 用量数据源 P6 对账就绪前用空源(NopUsage,退化为 round_robin)。
acctPicker := accounts.NewRouter(acctReg, config.C.Routing, accounts.NopUsage{})
gw := gateway.New(orderStore, pReg, acctPicker, productResolver, notifier, "cn")
acctReg仍需构造并传入NewRouter(Registry 提供EnabledFor+ P3 的Credential);其它渠道 adapter 装配若也用acctReg不受影响。
- Step 8: 全量编译 + 测试
Run: cd /Users/wangjia/code/pay && go build ./... && go test ./...
Expected: 全部包 ok(accounts/gateway/store/handler/provider/... 全绿)。
- Step 9: Commit
cd /Users/wangjia/code/pay
git add internal/gateway/gateway.go internal/gateway/gateway_test.go internal/store/attempt_accounts.go internal/store/attempt_accounts_test.go main.go
git commit -m "feat(v2): gateway 经 Picker 路由选账户 + retry 换号(ExcludeAccounts);替换首个 enabled"
Self-Review
Spec coverage(P5 范围,对照设计 §3.3):
- 三层「channel → account(N) → 每单按策略选一个」:
Registry.EnabledFor出候选 →Router派发Strategy选一个(Task 3)= 设计 §3.3 ✓。 - round_robin(轮询分摊,Task 1)/ weighted(按 Weight,Task 2)/ limit_aware(按 DailyLimit 分摊避免超额触风控,Task 3)三策略落地 ✓;
by_region / amount / biz未做 —— region 已由EnabledFor承担粗过滤,amount/biz 维度明确延后(设计列作可选,非本期硬需求)。 - crypto 地址池:crypto 渠道 round_robin/weighted 选账户 = 地址来源轮换;地址派生在 P3 crypto adapter
create()(消费CreateRequest.Account),P5 只选账户,与 P3(以CreateRequest.Account为界)不冲突(Global Constraints)✓。 - Attempt 绑定选中 account_id(P2 已建、本期沿用),回调/退款/结算走同一账户 ✓。
- 替换 P2 两处
accts[0](CreateOrder/RetryOrder)为picker.Pick(Task 4)✓。
复用 P1/P2(不重造): accounts.Registry.EnabledFor(候选过滤)、config.AccountConfig(全字段 Weight/DailyLimit/Region)、gateway 管线与 store.OrderStore 既有方法、model.Attempt.AccountID 绑定 —— 全直接复用。新增仅:Picker/Strategy/三策略/Router/UsageSource(accounts)、store.AttemptAccountIDs、config.Routing 字段、gateway 选账户一步的改线。
确定性(禁 rand/time.Now 决策): round_robin=进程计数器(调用顺序确定)· weighted=fnv32a(OutTradeNo)(同单恒同账户)· limit_aware=注入 MemUsage(测试可控)· retry 换号=显式 ExcludeAccounts(不靠"下次随机不同")。全部单测纯内存、免 docker。
关键决策落定: D1 weighted 用 hash(OutTradeNo) 而非注入 rand(可复现/幂等友好)· D2 round_robin 进程内计数器重启归零(只均摊不强配额,可接受)· D3 默认 round_robin(单账户渠道与 P2「首个 enabled」逐位等价,现网零变化)· D4 retry 换号走路由层 ExcludeAccounts(通用于所有策略,过滤到空回退全集)· D5 limit_aware 本期实现内存计数骨架 + UsageSource 接口留 DB 位、生产空源退化为 round_robin、P6 零改接口接真实用量。
Type consistency: accounts.Picker(Router 实现)被 gateway.Gateway 持有;accounts.Strategy(RoundRobin/Weighted/LimitAware 实现)被 Router 派发;PickHint{OutTradeNo,AmountMinor,ExcludeAccounts} 在 Task 1 定义、Task 2-4 一致消费;ErrNoAccount 由 accounts 抛出、gateway 经 errors.Is 映射为 gateway.ErrNoAccount(handler 的 HTTP no_account 映射不变)。金额一律 int64 minor。
已知 scope 取舍(记录,不阻塞 P5):
- limit_aware 真实用量数据源在 P6(对账);本期
NopUsage生产装配 → 退化 round_robin,不误拒。 - round_robin 计数器不跨实例共享(多副本部署各自轮询,合起来近似均摊);强配额靠 limit_aware。
by_region / by_amount / by_biz策略未做(region 已由EnabledFor粗过滤;amount/biz 维度 later 按需加,新增一个Strategy实现 + 注册进 Router.byName 即可,接口不变)。- 跨 region 故障转移(某 region 无账户时降级到其它 region)later。
后续阶段衔接
- P6 对账 job:实现读对账用量的
accounts.UsageSource注入NewRouter,limit_aware 即由内存空源切换到真实日累计,LimitAware/Router代码不改。 - P3 crypto adapter:在
create()内按选中 account 的 xpub 派生地址(P5 已把 account 选对);地址池 = 对 crypto 渠道配 round_robin/weighted。