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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user