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
+207
View File
@@ -0,0 +1,207 @@
package store
import (
"database/sql"
"encoding/json"
"errors"
"fmt"
"strings"
)
// Channel 是一行渠道记录:
// - 一个上游账号(Provider=adapter 名 + 该账号的凭据)
// - 可限定支持的对外音色(Voices);空 = 不限(接受一切)
// - Priority 分档(大者优先),Weight 同档加权
//
// JSON tag 跟前台/admin.html 直接读 Go 字段对齐(参见 store/voices.go 顶部说明)。
type Channel struct {
ID int64 `json:"id"`
Name string `json:"name"`
Provider string `json:"provider"`
// CredentialsJSON 是序列化后的 provider.Credentials:
// {"api_key": "...", "scope": {"resource_id": "...", ...}}
// 我们落 JSON 列而非拆字段,因为不同 provider 需要的 scope 维度差异很大
// (火山要 resource_id,Azure 要 region,自建要 base_url ...),
// 拆字段会被某一家绑死。读时反序列化为 provider.Credentials。
CredentialsJSON string `json:"credentials_json"`
Voices string `json:"voices"` // 逗号分隔的对外 voice 名;空 = 不限
Priority int `json:"priority"`
Weight int `json:"weight"`
// Status: 1 = enabled, 2 = disabled(对齐 voices.enabled 的 0/1 风格,
// 但本阶段管理接口未上线,只用 int 留扩展位,后续可加 "维护中" 之类状态)。
Status int `json:"status"`
AutoBan bool `json:"auto_ban"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// ChannelList 列出所有渠道(按 priority DESC, id ASC),includeDisabled=false 时只返回 enabled。
// 排序规则固定,便于 router 启动期一次性加载后做 priority 分桶。
func (s *Store) ChannelList(includeDisabled bool) ([]Channel, error) {
q := `SELECT id, name, provider, credentials_json, voices, priority, weight, status, auto_ban, created_at, updated_at
FROM channels`
if !includeDisabled {
q += ` WHERE status = 1`
}
q += ` ORDER BY priority DESC, id ASC`
rows, err := s.db.Query(q)
if err != nil {
return nil, fmt.Errorf("store: channel list: %w", err)
}
defer rows.Close()
out := make([]Channel, 0, 4)
for rows.Next() {
c, err := scanChannel(rows)
if err != nil {
return nil, err
}
out = append(out, c)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("store: channel list rows: %w", err)
}
return out, nil
}
// ChannelGet 按 id 查;未命中返回 ErrNotFound。
func (s *Store) ChannelGet(id int64) (*Channel, error) {
row := s.db.QueryRow(`SELECT id, name, provider, credentials_json, voices, priority, weight, status, auto_ban, created_at, updated_at
FROM channels WHERE id = ?`, id)
c, err := scanChannel(row)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, fmt.Errorf("store: channel get id=%d: %w", id, err)
}
return &c, nil
}
// ChannelInsert 新增一条渠道;name 冲突返回 ErrDuplicate。
// 输入校验错误(必填项缺失)返回 wrap ErrInvalid;服务端错误(DB 失败等)不被 wrap。
func (s *Store) ChannelInsert(c Channel) (int64, error) {
c.Name = strings.TrimSpace(c.Name)
c.Provider = strings.TrimSpace(c.Provider)
c.CredentialsJSON = strings.TrimSpace(c.CredentialsJSON)
c.Voices = normalizeVoicesCSV(c.Voices)
if c.Name == "" {
return 0, fmt.Errorf("%w: name is required", ErrInvalid)
}
if c.Provider == "" {
return 0, fmt.Errorf("%w: provider is required", ErrInvalid)
}
if c.CredentialsJSON == "" {
return 0, fmt.Errorf("%w: credentials_json is required", ErrInvalid)
}
// credentials_json 必须是合法 JSON;否则后续 router 加载会崩,这里挡掉。
if !json.Valid([]byte(c.CredentialsJSON)) {
return 0, fmt.Errorf("%w: credentials_json is not valid JSON", ErrInvalid)
}
if c.Weight <= 0 {
c.Weight = 1 // 默认权重 1
}
if c.Status != 1 && c.Status != 2 {
c.Status = 1 // 默认启用
}
res, err := s.db.Exec(`
INSERT INTO channels (name, provider, credentials_json, voices, priority, weight, status, auto_ban, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))`,
c.Name, c.Provider, c.CredentialsJSON, c.Voices, c.Priority, c.Weight, c.Status, boolToInt(c.AutoBan))
if err != nil {
if isUniqueViolation(err) {
return 0, ErrChannelDuplicate
}
return 0, fmt.Errorf("store: channel insert: %w", err)
}
id, err := res.LastInsertId()
if err != nil {
return 0, fmt.Errorf("store: channel insert lastid: %w", err)
}
return id, nil
}
// ChannelSetStatus 改状态(1=enabled, 2=disabled);未命中返回 ErrNotFound。
// 阶段 1 没有 admin 接口,但 router 启动期之后可被 auto_ban 流程调用,
// 所以这个方法必须先有,管理接口只是包装。
func (s *Store) ChannelSetStatus(id int64, status int) error {
if status != 1 && status != 2 {
return fmt.Errorf("%w: status must be 1 (enabled) or 2 (disabled)", ErrInvalid)
}
res, err := s.db.Exec(`UPDATE channels SET status=?, updated_at=datetime('now') WHERE id = ?`, status, id)
if err != nil {
return fmt.Errorf("store: channel set status id=%d: %w", id, err)
}
n, _ := res.RowsAffected()
if n == 0 {
return ErrNotFound
}
return nil
}
// ChannelDelete 按 id 删;未命中返回 ErrNotFound。
func (s *Store) ChannelDelete(id int64) error {
res, err := s.db.Exec(`DELETE FROM channels WHERE id = ?`, id)
if err != nil {
return fmt.Errorf("store: channel delete id=%d: %w", id, err)
}
n, _ := res.RowsAffected()
if n == 0 {
return ErrNotFound
}
return nil
}
// ChannelCount 统计行数;阶段 1 暂未使用,保留为未来 admin 仪表盘用。
func (s *Store) ChannelCount() (int, error) {
var n int
err := s.db.QueryRow(`SELECT COUNT(*) FROM channels`).Scan(&n)
if err != nil {
return 0, fmt.Errorf("store: channel count: %w", err)
}
return n, nil
}
// ErrChannelDuplicate 表示 channel name 唯一冲突;与 voices.ErrDuplicate 同名但消息不同,
// 避免 channel 重名被翻译成"voice name already exists"。
var ErrChannelDuplicate = errors.New("store: channel name already exists")
// scanChannel 把 row 扫描成 Channel;接受 *sql.Row 或 *sql.Rows(都实现 Scan)。
func scanChannel(r scanner) (Channel, error) {
var c Channel
var autoBan int
err := r.Scan(&c.ID, &c.Name, &c.Provider, &c.CredentialsJSON, &c.Voices,
&c.Priority, &c.Weight, &c.Status, &autoBan, &c.CreatedAt, &c.UpdatedAt)
if err != nil {
return c, err
}
c.AutoBan = autoBan != 0
return c, nil
}
// normalizeVoicesCSV 把 "a,,b, a ,c" 规整成 "a,b,c" —— 重复项去重,
// 顺序保留(便于 hash 比较和稳定展示);空字符串返 ""(语义=不限)。
func normalizeVoicesCSV(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
parts := strings.Split(raw, ",")
seen := make(map[string]struct{}, len(parts))
out := make([]string, 0, 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)
}
return strings.Join(out, ",")
}