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,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, ",")
|
||||
}
|
||||
+28
-1
@@ -18,7 +18,7 @@ import (
|
||||
|
||||
// schemaVersion 是当前 schema 版本号;每次结构性变更 +1。
|
||||
// migrate.go 负责在 Open 时按版本号增量应用。
|
||||
const schemaVersion = 1
|
||||
const schemaVersion = 2
|
||||
|
||||
// Store 是 SQLite 访问层的统一入口;所有 settings/voices 操作都通过它。
|
||||
type Store struct {
|
||||
@@ -149,5 +149,32 @@ func (s *Store) migrate() error {
|
||||
INSERT OR IGNORE INTO schema_version (version) VALUES (?)`, schemaVersion); err != nil {
|
||||
return fmt.Errorf("insert schema_version: %w", err)
|
||||
}
|
||||
|
||||
// channels 表(多渠道路由,阶段 1 引入)。
|
||||
// - name 唯一:管理侧标识,不允许重名
|
||||
// - credentials_json: 序列化后的 provider.Credentials(见 store/channels.go 注释)
|
||||
// - voices CSV: 限定该渠道支持的对外音色;空 = 不限
|
||||
// - priority/weight: 大者优先 + 同档加权随机(由 adapter/route 实现)
|
||||
// - status: 1=enabled 2=disabled(留扩展位,管理接口放阶段 2 上)
|
||||
// - auto_ban: new-api 风格连续失败自动禁用标记,阶段 1 仅存,未接自动逻辑
|
||||
if _, err := s.db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS channels (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
provider TEXT NOT NULL,
|
||||
credentials_json TEXT NOT NULL,
|
||||
voices TEXT NOT NULL DEFAULT '',
|
||||
priority INTEGER NOT NULL DEFAULT 0,
|
||||
weight INTEGER NOT NULL DEFAULT 1,
|
||||
status INTEGER NOT NULL DEFAULT 1,
|
||||
auto_ban INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)`); err != nil {
|
||||
return fmt.Errorf("create channels: %w", err)
|
||||
}
|
||||
if _, err := s.db.Exec(`CREATE INDEX IF NOT EXISTS idx_channels_status_priority ON channels(status, priority DESC)`); err != nil {
|
||||
return fmt.Errorf("create idx_channels_status_priority: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user