- 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 个, 全部通过)
181 lines
6.4 KiB
Go
181 lines
6.4 KiB
Go
// Package store 负责 SQLite 访问层:打开/建表/迁移、settings/voices CRUD、
|
|
// 完整性校验。所有运行时可变配置(全局参数 + 音色库)统一落 SQLite,
|
|
// 环境变量仅作引导参数(TTS_DB_PATH 等)。
|
|
//
|
|
// 设计约束:
|
|
// - 单二进制分发,纯 Go SQLite(modernc.org/sqlite),CGO_ENABLED=0
|
|
// - 单用户自用,SetMaxOpenConns(1) 避免并发写竞争
|
|
// - 所有 SQL 参数化,严禁字符串拼接
|
|
// - schema_version 表保留未来升级钩子
|
|
package store
|
|
|
|
import (
|
|
"database/sql"
|
|
"fmt"
|
|
|
|
_ "modernc.org/sqlite" // 注册 sqlite driver
|
|
)
|
|
|
|
// schemaVersion 是当前 schema 版本号;每次结构性变更 +1。
|
|
// migrate.go 负责在 Open 时按版本号增量应用。
|
|
const schemaVersion = 2
|
|
|
|
// Store 是 SQLite 访问层的统一入口;所有 settings/voices 操作都通过它。
|
|
type Store struct {
|
|
db *sql.DB
|
|
}
|
|
|
|
// Open 打开或创建 SQLite 数据库,自动应用建表与迁移。
|
|
// path 推荐为绝对路径;空字符串会落到临时目录,不应用于生产。
|
|
func Open(path string) (*Store, error) {
|
|
// modernc.org/sqlite 注册名:"sqlite" + "sqlite3" 两种
|
|
// _dsn 参数控制 journal_mode 等;这里先打开,再用 PRAGMA 调整
|
|
db, err := sql.Open("sqlite", path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("store: open %q failed: %w", path, err)
|
|
}
|
|
|
|
// 单用户自用场景,避免并发写竞争
|
|
db.SetMaxOpenConns(1)
|
|
|
|
// PRAGMA 需要连接,触发一次 Ping 拿连接
|
|
if _, err := db.Exec(`PRAGMA journal_mode = WAL`); err != nil {
|
|
_ = db.Close()
|
|
return nil, fmt.Errorf("store: enable WAL failed: %w", err)
|
|
}
|
|
if _, err := db.Exec(`PRAGMA foreign_keys = ON`); err != nil {
|
|
_ = db.Close()
|
|
return nil, fmt.Errorf("store: enable foreign_keys failed: %w", err)
|
|
}
|
|
if _, err := db.Exec(`PRAGMA synchronous = NORMAL`); err != nil {
|
|
_ = db.Close()
|
|
return nil, fmt.Errorf("store: enable synchronous=NORMAL failed: %w", err)
|
|
}
|
|
|
|
s := &Store{db: db}
|
|
if err := s.migrate(); err != nil {
|
|
_ = db.Close()
|
|
return nil, fmt.Errorf("store: migrate failed: %w", err)
|
|
}
|
|
return s, nil
|
|
}
|
|
|
|
// Close 关闭底层连接;调用方应保证只 Close 一次。
|
|
func (s *Store) Close() error {
|
|
if s == nil || s.db == nil {
|
|
return nil
|
|
}
|
|
return s.db.Close()
|
|
}
|
|
|
|
// DB 返回底层 *sql.DB,仅供 store 包内或集成测试使用;
|
|
// 业务代码不应直接拿连接,所有操作走 Store 暴露的方法。
|
|
func (s *Store) DB() *sql.DB { return s.db }
|
|
|
|
// IntegrityCheck 执行 PRAGMA integrity_check;返回 "ok" 即视为库健康。
|
|
// installer 包据此判定是否触发损坏回退。
|
|
func (s *Store) IntegrityCheck() (string, error) {
|
|
row := s.db.QueryRow(`PRAGMA integrity_check`)
|
|
var result string
|
|
if err := row.Scan(&result); err != nil {
|
|
return "", fmt.Errorf("store: integrity_check scan failed: %w", err)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// Path 返回当前 db 的 SQLite 报告路径(用于日志)。
|
|
// 通过 PRAGMA database_list 拿权威值,避免和入参 path 不一致时的混淆。
|
|
func (s *Store) Path() (string, error) {
|
|
rows, err := s.db.Query(`PRAGMA database_list`)
|
|
if err != nil {
|
|
return "", fmt.Errorf("store: database_list query failed: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
if !rows.Next() {
|
|
return "", fmt.Errorf("store: database_list returned no rows")
|
|
}
|
|
var seq int
|
|
var name, file string
|
|
if err := rows.Scan(&seq, &name, &file); err != nil {
|
|
return "", fmt.Errorf("store: database_list scan failed: %w", err)
|
|
}
|
|
return file, nil
|
|
}
|
|
|
|
// migrate 应用 schema 迁移。当前 schema_version=1,只做基础建表。
|
|
// 未来升级:写 applyMigration(n) 函数,n 为目标版本号。
|
|
func (s *Store) migrate() error {
|
|
// schema_version 表记录当前版本
|
|
if _, err := s.db.Exec(`
|
|
CREATE TABLE IF NOT EXISTS schema_version (
|
|
version INTEGER PRIMARY KEY,
|
|
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
)`); err != nil {
|
|
return fmt.Errorf("create schema_version: %w", err)
|
|
}
|
|
|
|
// settings 表(全局配置)
|
|
if _, err := s.db.Exec(`
|
|
CREATE TABLE IF NOT EXISTS settings (
|
|
key TEXT PRIMARY KEY,
|
|
value TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
)`); err != nil {
|
|
return fmt.Errorf("create settings: %w", err)
|
|
}
|
|
|
|
// voices 表(音色库)
|
|
if _, err := s.db.Exec(`
|
|
CREATE TABLE IF NOT EXISTS voices (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
name TEXT NOT NULL UNIQUE,
|
|
speaker TEXT NOT NULL,
|
|
resource_id TEXT NOT NULL,
|
|
model TEXT DEFAULT '',
|
|
language TEXT DEFAULT '',
|
|
description TEXT DEFAULT '',
|
|
enabled INTEGER NOT NULL DEFAULT 1,
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
)`); err != nil {
|
|
return fmt.Errorf("create voices: %w", err)
|
|
}
|
|
if _, err := s.db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_voices_name ON voices(name)`); err != nil {
|
|
return fmt.Errorf("create idx_voices_name: %w", err)
|
|
}
|
|
|
|
// 当前版本
|
|
if _, err := s.db.Exec(`
|
|
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
|
|
}
|