diff --git a/docs/superpowers/plans/2026-07-10-pay-v2-p5-account-routing.md b/docs/superpowers/plans/2026-07-10-pay-v2-p5-account-routing.md new file mode 100644 index 0000000..24f73bc --- /dev/null +++ b/docs/superpowers/plans/2026-07-10-pay-v2-p5-account-routing.md @@ -0,0 +1,929 @@ +# 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` → 测试确定),不读时钟。 +- **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`: +```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`: +```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. 的取值;缺省/未知一律当 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** + +```bash +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(对齐 gorm `default:1`)。`OutTradeNo` 空 → 退化为取首个(确定,不 panic)。 + +- [ ] **Step 1: 写失败测试** + +`internal/accounts/weighted_test.go`: +```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`: +```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** + +```bash +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`: +```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`: +```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`: +```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`: +```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` 之后): +```go + // 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** + +```bash +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`: +```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`: +```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`: +```go +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` —— 把 +```go + 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") +``` +改为(先生成单号,再据单号确定性选账户): +```go + 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` —— 把 +```go + 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 + } +``` +改为(收集已试账户 → 换号选;先算排除集再废旧尝试,二者互不依赖): +```go + 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`:把 +```go + 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): +```go + 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` 断言 attempt `AccountID == "fake-a1"`:round_robin 首个 key 首次仍返回 `fake-a1`(候选顺序 a1,a2,计数 0),断言不变即通过。既有 `TestRetryAndCancel` 断言 retry 后恰 1 个 pending 尝试,仍成立。 + +新增测试(retry 必换到未试过的账户): +```go +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` 把 +```go + acctReg := accounts.New(config.C.Accounts) + gw := gateway.New(orderStore, pReg, acctReg, productResolver, notifier, "cn") +``` +改为: +```go + acctReg := accounts.New(config.C.Accounts) + // P5 多账户路由:按 config.routing. 选策略(缺省 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** + +```bash +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。