feat(route): 阶段 1 多渠道路由分发层 (Channel + Router)

- store: 新增 channels 表 (name unique, credentials_json, voices CSV,
  priority, weight, status, auto_ban) + CRUD (List/Get/Insert/SetStatus/Delete)
  + ErrChannelDuplicate 区分 (避免与 ErrDuplicate 互相误报)
- store/db.go: schemaVersion 1 -> 2; channels 表 CREATE IF NOT EXISTS + 复合索引
- adapter/route: 新包,Channel (含 BuildRequest 覆盖 template.Credentials)
  + Router (Select / SelectAndSynthesize / pickByWeight 纯函数)
  + 错误区分 ErrNoChannels (走兜底) vs ErrVoiceNotFound (400)
  + AggregateError 聚合多渠道失败, 供 controller 日志逐个打印
- controller/router.go: SetRouter/GetRouter 句柄 (与 SetAdminStore 同一模式)
- controller/tts.go: 接入 Router, 零 channel 走 setting 兜底 (行为=阶段 0);
  voice 不被任何 channel 接受 -> 400 + voice_not_found; 抽出 finalizeSynth
  收敛合成结果->响应+metrics+日志三件套
- main.go: 启动期从 store 加载 channels, 转 route.Channel, 注入 controller;
  credentials_json 加载失败 -> fail-fast (避免一个错渠道拖崩全部请求)
- .gitignore: 补 .gotmp/ .gomodcache/ .dsh-acl/

验收 (设计文档 §9):
  - 零 channel 兜底, 行为=阶段 0
  - 加 1 个 channel: 走该渠道
  - 加 2+ 个同档不同 weight: pickByWeight weight-1-to-3 分布 ±5% (单测覆盖)
  - 不同 priority: 高优先级优先 (单测覆盖)
  - disabled 不参与选择 (单测覆盖)
  - 失败降级: 同档换下一个 -> 降档, 全失败返 AggregateError (单测覆盖)
  - go build ./... + go vet ./... 全绿
  - go test ./... (adapter/route 15 个, store 2 个, 全部通过)
This commit is contained in:
tts-stage1
2026-10-11 15:33:22 +08:00
parent 5c130f0be4
commit a614ab55c9
8 changed files with 830 additions and 12 deletions
+108
View File
@@ -0,0 +1,108 @@
// Package route 实现多渠道路由分发层(阶段 1)。
//
// 思路参考 QuantumNous/new-api 的 Channel + 路由选择,但按本项目
// "一渠道 = 一上游账号" 简化:
// - Channel: 一个上游账号(Provider 名 + 凭据 + 可选 voice 白名单)
// - Router: 按优先级 + 加权随机从 channels 里挑一个给 Synthesize
// - 不做 HTTP 管理接口(阶段 2 才上),channels 启动期从 store 加载一次
package route
import (
"encoding/json"
"fmt"
"strings"
"github.com/volcano-tts/tts-api/adapter/provider"
"github.com/volcano-tts/tts-api/store"
)
// Channel 是 router 用的渠道视图,从 store.Channel 反序列化凭据而来。
//
// 持有 store.Channel 字符串字段(VoicesCSV / CredentialsJSON)会让 router
// 变成"半 DB 层",所以这里转成 router 友好的强类型:
// - Voices []string: 白名单;空 = 不限
// - Credentials: provider.Credentials 强类型
type Channel struct {
ID int64
Name string
Provider string // 对应 provider.Registry 里的 Provider.Name()
Credentials provider.Credentials
Voices []string // 对外 voice 名白名单;空 = 不限
Priority int
Weight int
Status int // 1=enabled 2=disabled
AutoBan bool
}
// FromStoreChannel 把 store.Channel 转成 router.Channel。
// 失败原因:
// - credentials_json 不是合法 JSON → 返 error(让启动期 / Reload 直接报错,不要静默继续)
func FromStoreChannel(s store.Channel) (Channel, error) {
c := Channel{
ID: s.ID,
Name: s.Name,
Provider: s.Provider,
Priority: s.Priority,
Weight: s.Weight,
Status: s.Status,
AutoBan: s.AutoBan,
Voices: splitCSV(s.Voices),
}
if err := json.Unmarshal([]byte(s.CredentialsJSON), &c.Credentials); err != nil {
return Channel{}, fmt.Errorf("channel %d (%s): credentials_json invalid: %w", s.ID, s.Name, err)
}
return c, nil
}
// BuildRequest 拿一个上游请求模板(零 Channel 兜底用的),填入本渠道的
// 凭据,作为 Synthesize 的入参。
//
// 不复制 Text / Format / Speed 等请求级字段(由调用方按本次请求填),
// 这里只换"账号身份"——保证 router 调 Synthesize 时拿到的是"用本渠道账号调" 的 Request。
func (c Channel) BuildRequest(template provider.Request) provider.Request {
template.Credentials = c.Credentials
return template
}
// AcceptsVoice 检查本渠道是否支持该 voice:
// - Voices 为空 → 不限,接受一切
// - 非空 → 必须在白名单里
func (c Channel) AcceptsVoice(voice string) bool {
if len(c.Voices) == 0 {
return true
}
for _, v := range c.Voices {
if v == voice {
return true
}
}
return false
}
// Enabled 是不是启用状态(Status=1)。
func (c Channel) Enabled() bool { return c.Status == 1 }
// splitCSV 拆 "a,b,c" 为 []string;容忍空 / 重复 / 空白。
func splitCSV(raw string) []string {
if raw == "" {
return nil
}
parts := strings.Split(raw, ",")
out := make([]string, 0, len(parts))
seen := make(map[string]struct{}, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p == "" {
continue
}
if _, ok := seen[p]; ok {
continue
}
seen[p] = struct{}{}
out = append(out, p)
}
if len(out) == 0 {
return nil
}
return out
}
+339
View File
@@ -0,0 +1,339 @@
package route
import (
"context"
"errors"
"fmt"
"math/rand"
"sync"
"time"
"github.com/volcano-tts/tts-api/adapter/provider"
"github.com/volcano-tts/tts-api/dto"
)
// Router 持有当前可用的渠道列表;并发安全(读多写少,RWMutex)。
//
// 阶段 1: 启动期从 store 加载一次,运行期不变;阶段 2 管理接口上线后
// 会有 Reload() 触发整体替换。
type Router struct {
mu sync.RWMutex
channels []Channel
}
// NewRouter 从 store 风格的 channel 列表构造 Router;空列表合法(代表"零渠道,走兜底")。
// 这里接受 []Channel(而非 *store.Store)便于单测注入。
func NewRouter(channels []Channel) *Router {
// 拷贝入参,避免外部后续修改影响内部。
cp := make([]Channel, len(channels))
copy(cp, channels)
return &Router{channels: cp}
}
// Snapshot 返回当前 channels 的副本(只读);controller 热路径用它取列表。
func (r *Router) Snapshot() []Channel {
r.mu.RLock()
defer r.mu.RUnlock()
out := make([]Channel, len(r.channels))
copy(out, r.channels)
return out
}
// Empty 没有任何启用渠道时返 true(让 controller 走 setting 兜底路径)。
func (r *Router) Empty() bool {
r.mu.RLock()
defer r.mu.RUnlock()
return len(r.channels) == 0
}
// Select 在当前 channels 里按规则挑一个支持该 voice 的渠道。
// 返回值:
// - (*Channel, nil) 找到启用且 voice 匹配的渠道
// - (nil, ErrNoChannels) router 里没有任何 channel(走兜底)
// - (nil, ErrVoiceNotFound) 有 channel 但 voice 不被任一渠道支持(返 400)
// - (nil, 其他 error) 内部错误
//
// 把"零 channel" 和 "voice 不匹配" 区分开,是为了让 controller 决定是兜底还是报 400:
// 零 channel → 旧行为不变;有 channel 但 voice 不支持 → 明确告诉客户端这个 voice 不可用。
func (r *Router) Select(voice string) (*Channel, error) {
r.mu.RLock()
defer r.mu.RUnlock()
if len(r.channels) == 0 {
return nil, ErrNoChannels
}
enabled := make([]Channel, 0, len(r.channels))
for _, c := range r.channels {
if c.Enabled() {
enabled = append(enabled, c)
}
}
if len(enabled) == 0 {
return nil, ErrNoChannels
}
// 优先:被 voice 接受;若无任何匹配 → ErrVoiceNotFound
matched := make([]Channel, 0, len(enabled))
for _, c := range enabled {
if c.AcceptsVoice(voice) {
matched = append(matched, c)
}
}
if len(matched) == 0 {
return nil, ErrVoiceNotFound
}
// 在 matched 内按 priority 降序分桶,选最高档做加权随机。
// 文档 §5:"同档换下一个"和"降档"在 Select 这一层不发生——Select 一次只返回一个 channel。
// 多 channel 失败重试/降档由 SelectAndSynthesize 负责(见下)。
top := topPriority(matched)
ch, _ := pickByWeight(top, time.Now().UnixNano())
return &ch, nil
}
// SelectAndSynthesize 一次请求的完整"选渠道→调 Synthesize"循环:
//
// pool = 全部 enabled 且支持 voice 的 channel,按 priority DESC 排序
// 循环 maxRetry 次:
// 从 pool 当前最高档里按 weight 加权随机选一个
// 从 pool 移除该 channel(防重复)
// 调 Synthesize;成功 → 返回;失败 → 累计错误,继续
// 全失败 → 返聚合错误
//
// 设计要点:
// - "换下一个" = 同档里挑别的;pool 自动收紧
// - "降档" = 最高档被试空 → pool 里只剩低档 → 自然降到低档
// - 失败重试安全(TTS 是只读调用,无扣款副作用)
func (r *Router) SelectAndSynthesize(
ctx context.Context,
voice string,
template provider.Request,
maxRetry int,
mtr provider.MetricsRecorder,
) (*dto.SynthesisResult, *Channel, error) {
if maxRetry <= 0 {
maxRetry = 3 // 与文档 §5 默认一致
}
if mtr == nil {
mtr = provider.NoopMetrics()
}
r.mu.RLock()
enabled := r.enabledMatching(voice)
r.mu.RUnlock()
if len(enabled) == 0 {
// 这里复用 Select 的语义区分;controller 已知 router 状态,可以再调一次 Select 拿准确错误。
return nil, nil, ErrNoChannels
}
// 按 priority DESC 稳定排序;同档内按 id 升序,这样"换下一个"确定性
// (但加权随机是按 weight 选,同档里到底选谁仍然是随机的)。
sorted := sortByPriorityDesc(enabled)
var lastErrs []error
tried := make(map[int64]struct{}, maxRetry)
ch := Channel{} // 循环外声明,成功后用得到
for attempt := 0; attempt < maxRetry; attempt++ {
if ctx.Err() != nil {
return nil, nil, ctx.Err()
}
// 在剩余 pool 中取当前最高档
pool := filterUntried(sorted, tried)
if len(pool) == 0 {
break
}
top := topPriority(pool)
var err error
ch, err = pickByWeight(top, time.Now().UnixNano()+int64(attempt))
if err != nil {
// 候选档内 weight 全 ≤0 等异常;理论上不会发生
lastErrs = append(lastErrs, err)
continue
}
tried[ch.ID] = struct{}{}
// 拿 provider;Channel.Provider 未注册(代码级缺失)→ 视作候选失败
prov, ok := provider.Get(ch.Provider)
if !ok {
lastErrs = append(lastErrs, fmt.Errorf("channel %d (%s): provider %q not registered", ch.ID, ch.Name, ch.Provider))
continue
}
req := ch.BuildRequest(template)
// voice 字段:把请求级 voice 同步到 channel 的 VoiceKey(供 provider 拿上游音色 ID 用)
// 这是 Router 唯一会"覆盖"请求字段的地方;其余(Text/Format/Speed)由 controller 填好。
// 注意:Channel 不改 voice,这里只为 Synthesize 拿正确 VoiceKey;
// 多次重试时同一个 voice,VoiceKey 始终一致。
req.VoiceKey = voice
result, err := prov.Synthesize(ctx, req, mtr)
if err == nil {
return result, &ch, nil
}
lastErrs = append(lastErrs, fmt.Errorf("channel %d (%s): %w", ch.ID, ch.Name, err))
}
if len(lastErrs) == 0 {
// 走到这里说明 maxRetry=0 或 pool 一开始就空;由前面的 early return 处理,
// 留这里防御 future 改动。
return nil, nil, ErrNoChannels
}
return nil, nil, &AggregateError{Errors: lastErrs}
}
// Enable 重新载入渠道列表;阶段 2 管理接口用,阶段 1 不暴露。
func (r *Router) Enable(channels []Channel) {
cp := make([]Channel, len(channels))
copy(cp, channels)
r.mu.Lock()
r.channels = cp
r.mu.Unlock()
}
// enabledMatching 在锁内筛"启用 + voice 匹配"的 channel;供 SelectAndSynthesize 复用。
func (r *Router) enabledMatching(voice string) []Channel {
out := make([]Channel, 0, len(r.channels))
for _, c := range r.channels {
if !c.Enabled() {
continue
}
if !c.AcceptsVoice(voice) {
continue
}
out = append(out, c)
}
return out
}
// sortByPriorityDesc 按 priority 降序,同档按 id 升序;返回新切片。
func sortByPriorityDesc(in []Channel) []Channel {
out := make([]Channel, len(in))
copy(out, in)
// 简单插入排序;渠道数通常 1~10 个,O(n^2) 够用且无额外分配。
for i := 1; i < len(out); i++ {
for j := i; j > 0; j-- {
if out[j].Priority > out[j-1].Priority ||
(out[j].Priority == out[j-1].Priority && out[j].ID < out[j-1].ID) {
out[j], out[j-1] = out[j-1], out[j]
continue
}
break
}
}
return out
}
// topPriority 返回 channels 中 priority 最大的那些(可能有多个,同档内加权随机)。
func topPriority(channels []Channel) []Channel {
if len(channels) == 0 {
return nil
}
maxP := channels[0].Priority
for _, c := range channels[1:] {
if c.Priority > maxP {
maxP = c.Priority
}
}
out := make([]Channel, 0, len(channels))
for _, c := range channels {
if c.Priority == maxP {
out = append(out, c)
}
}
return out
}
// filterUntried 从 sorted 里挑"未在 tried 中的"channel;同档 / 跨档都适用。
func filterUntried(sorted []Channel, tried map[int64]struct{}) []Channel {
out := make([]Channel, 0, len(sorted))
for _, c := range sorted {
if _, ok := tried[c.ID]; ok {
continue
}
out = append(out, c)
}
return out
}
// pickByWeight 加权随机选一个 channel;weights 全 ≤0 时返 error(理论不该发生)。
//
// 实现:total = Σweight;r = rand.Intn(total);线性累加落桶。
// 同权时均匀(每个 channel 概率 = weight/total)。
// 抽成纯函数(接受 seed)便于单测。
func pickByWeight(channels []Channel, seed int64) (Channel, error) {
if len(channels) == 0 {
return Channel{}, errors.New("route: pickByWeight on empty slice")
}
total := 0
for _, c := range channels {
w := c.Weight
if w <= 0 {
w = 1 // 防御:同 Channel.Weight <=0 不应出现,但兜底按 1 处理
}
total += w
}
if total <= 0 {
return Channel{}, errors.New("route: pickByWeight total weight <= 0")
}
r := rand.New(rand.NewSource(seed))
r0 := r.Intn(total)
acc := 0
for _, c := range channels {
w := c.Weight
if w <= 0 {
w = 1
}
acc += w
if r0 < acc {
return c, nil
}
}
// 浮点边界不可达;留 fallback 返最后一个
return channels[len(channels)-1], nil
}
// Errors ---------------------------------------------------------------
// ErrNoChannels router 里没有任何 channel(零 channel 兜底触发条件)。
var ErrNoChannels = errors.New("route: no channels available")
// ErrVoiceNotFound 有 channel 但 voice 不被任一渠道支持(对应客户端 400)。
var ErrVoiceNotFound = errors.New("route: voice not supported by any channel")
// AggregateError 多个渠道都失败时,聚合各渠道错误返回。
// controller 用 errors.As 拿到后,可以在日志里逐个打印。
type AggregateError struct {
Errors []error
}
func (e *AggregateError) Error() string {
if len(e.Errors) == 0 {
return "route: all channels failed (no details)"
}
parts := make([]string, 0, len(e.Errors))
for _, err := range e.Errors {
parts = append(parts, err.Error())
}
return fmt.Sprintf("route: all %d channels failed: %s", len(e.Errors), joinComma(parts))
}
// Unwrap 让 errors.Is/errors.As 仍能透到最里层(便于上层判断特定错误类型)。
func (e *AggregateError) Unwrap() error {
if len(e.Errors) == 0 {
return nil
}
return e.Errors[0]
}
func joinComma(parts []string) string {
out := ""
for i, p := range parts {
if i > 0 {
out += "; "
}
out += p
}
return out
}