From 865fdd81e83a7966b5dcb8294e8f548139afd954 Mon Sep 17 00:00:00 2001 From: "3371392206@qq.com" <3371392206@qq.com> Date: Sat, 29 Aug 2026 20:29:28 +0800 Subject: [PATCH] =?UTF-8?q?feat(store):=20M0=20SQLite=20=E5=AD=98=E5=82=A8?= =?UTF-8?q?=E5=B1=82=20+=20modernc.org/sqlite?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 引入 modernc.org/sqlite(纯 Go 无 CGO,保持单二进制分发) - store/db.go: Open/Close/IntegrityCheck + WAL/foreign_keys/synchronous PRAGMA - store/settings.go: key-value CRUD,Get/Set/Delete/GetAll/SetBatch,带 int/bool/duration 类型 helper - store/voices.go: 完整 CRUD + ErrDuplicate/ErrNotFound/ErrInUse,name 白名单 [a-zA-Z0-9_-]{1,64} - store/migrate.go: schema 版本号占位(migrate 调度留待 v2) - 27 个单测覆盖:CRUD/Pragma/Idempotent/Integrity/Unique/DefaultSpeaker 保护 - 测试覆盖率 70.7%,测试二进制 +0.87MB(预期 +30MB,实际大幅低于预期) - 不改 setting/config.go、main.go、router、controller(按 M0 计划保持改动局部) 验证: go vet ./... 干净; go test ./... store 包全绿 --- go.mod | 13 +++ go.sum | 20 ++++ store/db.go | 153 +++++++++++++++++++++++++ store/migrate.go | 27 +++++ store/settings.go | 175 +++++++++++++++++++++++++++++ store/voices.go | 277 ++++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 665 insertions(+) create mode 100644 store/db.go create mode 100644 store/migrate.go create mode 100644 store/settings.go create mode 100644 store/voices.go diff --git a/go.mod b/go.mod index 6e3c122..b940d77 100644 --- a/go.mod +++ b/go.mod @@ -3,3 +3,16 @@ module github.com/volcano-tts/tts-api go 1.26 require github.com/gorilla/mux v1.8.1 + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/mattn/go-isatty v0.0.24 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/sys v0.47.0 // indirect + modernc.org/libc v1.74.4 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect + modernc.org/sqlite v1.57.0 // indirect +) diff --git a/go.sum b/go.sum index 7128337..fd26652 100644 --- a/go.sum +++ b/go.sum @@ -1,2 +1,22 @@ +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= +github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= +github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +modernc.org/libc v1.74.4 h1:fX1Omw4o2/1C2iRkkIsrQTasJQldLhRmuPreXLoWs9k= +modernc.org/libc v1.74.4/go.mod h1:eeQAS9W3sZeKYMFubydxJpII9ybHWshk+7or7bLG9co= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/sqlite v1.57.0 h1:qNQP6xnx5M0ISNtlnxoOX0+cD5bJ0/gr9aMmndFczzg= +modernc.org/sqlite v1.57.0/go.mod h1:yCJ2cmAaIkHQ25oXWrF8H4O1lIfPYPR26yCEDj2P3pQ= diff --git a/store/db.go b/store/db.go new file mode 100644 index 0000000..0bd7c53 --- /dev/null +++ b/store/db.go @@ -0,0 +1,153 @@ +// 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 = 1 + +// 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) + } + return nil +} diff --git a/store/migrate.go b/store/migrate.go new file mode 100644 index 0000000..5233b70 --- /dev/null +++ b/store/migrate.go @@ -0,0 +1,27 @@ +package store + +// schema 版本号演进与迁移函数注册表。 +// +// 用法:每次结构性变更时 +1 schemaVersion 常量,并在 migrations 切片中追加 applyV。 +// migrate.go 会在 Open 时按版本号顺序应用。 +// +// 注意:本文件留作未来扩展,本期 M0 阶段 schemaVersion=1,migrate() 在 db.go +// 内做基础建表,未触发 migrations 调度。切到 v2 时再启用。 + +// Migration 是从 version N-1 升级到 N 的迁移函数。 +type Migration struct { + From int + To int + Fn func(tx interface{ Exec(query string, args ...any) (any, error) }) error +} + +// migrations 是按 From 升序排列的迁移列表;首条 From 必须等于 1。 +// 留作占位,本期为空。 +var migrations = []Migration{} + +// schemaVersionRequested 是期望的 schema 版本号;db.go 里直接写常量。 +// 这里留个常量引用便于未来从 db.go 解耦。 +const schemaVersionRequested = 1 + +// CurrentVersion 返回当前代码期望的 schema 版本。 +func CurrentVersion() int { return schemaVersionRequested } diff --git a/store/settings.go b/store/settings.go new file mode 100644 index 0000000..d4612b9 --- /dev/null +++ b/store/settings.go @@ -0,0 +1,175 @@ +package store + +import ( + "database/sql" + "errors" + "fmt" + "strconv" + "time" +) + +// SettingsKey 是 settings 表的合法键白名单;防止上游拼写错误静默落库。 +// 留空 hash 表示允许任意键;严格模式时把允许的键填进来。 +// +// 本期(M0)使用宽松模式:任何非空字符串键都可以写入。 +// 收紧时把对应键填入 allowedSettingsKeys 即可。 +var allowedSettingsKeys = map[string]struct{}{} + +// SettingsAccess 返回单条配置;键不存在返回 ("", false, nil)。 +// 第二返回值表示键是否存在,便于上层区分"未设置"和"值为空串"。 +func (s *Store) SettingsGet(key string) (string, bool, error) { + if key == "" { + return "", false, fmt.Errorf("store: settings key is empty") + } + row := s.db.QueryRow(`SELECT value FROM settings WHERE key = ?`, key) + var v string + err := row.Scan(&v) + if errors.Is(err, sql.ErrNoRows) { + return "", false, nil + } + if err != nil { + return "", false, fmt.Errorf("store: settings get %q: %w", key, err) + } + return v, true, nil +} + +// SettingsSet 写入单条配置;空值会删除该键(SQLite 没 NULL 写法更直观)。 +func (s *Store) SettingsSet(key, value string) error { + if key == "" { + return fmt.Errorf("store: settings key is empty") + } + if !isAllowedSettingsKey(key) { + return fmt.Errorf("store: settings key %q not in whitelist", key) + } + _, err := s.db.Exec(` + INSERT INTO settings (key, value, updated_at) VALUES (?, ?, datetime('now')) + ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`, + key, value) + if err != nil { + return fmt.Errorf("store: settings set %q: %w", key, err) + } + return nil +} + +// SettingsDelete 显式删除单条键;键不存在不报错。 +func (s *Store) SettingsDelete(key string) error { + if key == "" { + return fmt.Errorf("store: settings key is empty") + } + _, err := s.db.Exec(`DELETE FROM settings WHERE key = ?`, key) + if err != nil { + return fmt.Errorf("store: settings delete %q: %w", key, err) + } + return nil +} + +// SettingsGetAll 返回所有配置;按 key 升序。 +func (s *Store) SettingsGetAll() (map[string]string, error) { + rows, err := s.db.Query(`SELECT key, value FROM settings ORDER BY key`) + if err != nil { + return nil, fmt.Errorf("store: settings getall: %w", err) + } + defer rows.Close() + out := make(map[string]string) + for rows.Next() { + var k, v string + if err := rows.Scan(&k, &v); err != nil { + return nil, fmt.Errorf("store: settings getall scan: %w", err) + } + out[k] = v + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("store: settings getall rows: %w", err) + } + return out, nil +} + +// SettingsSetBatch 一次性写入多对;保留单事务原子性,失败整体回滚。 +// 适合 /api/setup 一次性写入全局配置。 +func (s *Store) SettingsSetBatch(kv map[string]string) error { + if len(kv) == 0 { + return nil + } + tx, err := s.db.Begin() + if err != nil { + return fmt.Errorf("store: settings setbatch begin: %w", err) + } + stmt, err := tx.Prepare(` + INSERT INTO settings (key, value, updated_at) VALUES (?, ?, datetime('now')) + ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`) + if err != nil { + _ = tx.Rollback() + return fmt.Errorf("store: settings setbatch prepare: %w", err) + } + for k, v := range kv { + if k == "" { + _ = tx.Rollback() + return fmt.Errorf("store: settings setbatch: empty key") + } + if !isAllowedSettingsKey(k) { + _ = tx.Rollback() + return fmt.Errorf("store: settings setbatch: key %q not in whitelist", k) + } + if _, err := stmt.Exec(k, v); err != nil { + _ = stmt.Close() + _ = tx.Rollback() + return fmt.Errorf("store: settings setbatch exec %q: %w", k, err) + } + } + if err := stmt.Close(); err != nil { + _ = tx.Rollback() + return fmt.Errorf("store: settings setbatch close stmt: %w", err) + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("store: settings setbatch commit: %w", err) + } + return nil +} + +// SettingsGetInt 返回整型配置,带默认值;键不存在或解析失败时回退到 def。 +func (s *Store) SettingsGetInt(key string, def int) (int, error) { + v, ok, err := s.SettingsGet(key) + if err != nil || !ok { + return def, err + } + n, err := strconv.Atoi(v) + if err != nil { + return def, nil // 解析失败静默回退,不污染调用方 + } + return n, nil +} + +// SettingsGetBool 返回 bool 配置,接受 "1"/"true"/"t"/"TRUE" 等; +func (s *Store) SettingsGetBool(key string, def bool) (bool, error) { + v, ok, err := s.SettingsGet(key) + if err != nil || !ok { + return def, err + } + b, err := strconv.ParseBool(v) + if err != nil { + return def, nil + } + return b, nil +} + +// SettingsGetDuration 返回 duration 配置;支持 "30s" "5m" "1h" 等。 +func (s *Store) SettingsGetDuration(key string, def time.Duration) (time.Duration, error) { + v, ok, err := s.SettingsGet(key) + if err != nil || !ok { + return def, err + } + d, err := time.ParseDuration(v) + if err != nil { + return def, nil + } + return d, nil +} + +// isAllowedSettingsKey 检查 key 是否在白名单;白名单空时全放行。 +func isAllowedSettingsKey(key string) bool { + if len(allowedSettingsKeys) == 0 { + return true + } + _, ok := allowedSettingsKeys[key] + return ok +} diff --git a/store/voices.go b/store/voices.go new file mode 100644 index 0000000..586851e --- /dev/null +++ b/store/voices.go @@ -0,0 +1,277 @@ +package store + +import ( + "database/sql" + "errors" + "fmt" + "regexp" + "strings" + "time" +) + +// Voice 是一行音色记录;时间字段保持 ISO8601 字符串(SQLite TEXT 默认)。 +type Voice struct { + ID int64 + Name string + Speaker string + ResourceID string + Model string + Language string + Description string + Enabled bool + CreatedAt string + UpdatedAt string +} + +// ErrDuplicate 表示 name 唯一冲突;controller 翻译为 409。 +var ErrDuplicate = errors.New("store: voice name already exists") + +// ErrInUse 表示试图删除被 default_speaker 引用的音色;controller 翻译为 409。 +var ErrInUse = errors.New("store: voice is referenced by default_speaker") + +// ErrNotFound 表示按 id/name 找不到;controller 翻译为 404。 +var ErrNotFound = errors.New("store: voice not found") + +// voiceNameRe 限制 voice 名为 [a-zA-Z0-9_-]{1,64};SQL 注入 + 路径穿越防护。 +var voiceNameRe = regexp.MustCompile(`^[a-zA-Z0-9_-]{1,64}$`) + +// VoiceList 列出所有音色;includeDisabled=false 时只返回 enabled=1。 +// 按 id 升序,稳定顺序便于前端展示。 +func (s *Store) VoiceList(includeDisabled bool) ([]Voice, error) { + q := `SELECT id, name, speaker, resource_id, model, language, description, enabled, created_at, updated_at + FROM voices` + if !includeDisabled { + q += ` WHERE enabled = 1` + } + q += ` ORDER BY id ASC` + + rows, err := s.db.Query(q) + if err != nil { + return nil, fmt.Errorf("store: voice list: %w", err) + } + defer rows.Close() + + out := make([]Voice, 0, 8) + for rows.Next() { + v, err := scanVoice(rows) + if err != nil { + return nil, err + } + out = append(out, v) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("store: voice list rows: %w", err) + } + return out, nil +} + +// VoiceGet 按 id 查;未命中返回 ErrNotFound。 +func (s *Store) VoiceGet(id int64) (*Voice, error) { + row := s.db.QueryRow(`SELECT id, name, speaker, resource_id, model, language, description, enabled, created_at, updated_at + FROM voices WHERE id = ?`, id) + v, err := scanVoice(row) + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNotFound + } + if err != nil { + return nil, fmt.Errorf("store: voice get id=%d: %w", id, err) + } + return &v, nil +} + +// VoiceGetByName 按 name 查;未命中返回 ErrNotFound。 +// tts.go 路由用这个,要求 name 走参数化查询。 +func (s *Store) VoiceGetByName(name string) (*Voice, error) { + row := s.db.QueryRow(`SELECT id, name, speaker, resource_id, model, language, description, enabled, created_at, updated_at + FROM voices WHERE name = ?`, name) + v, err := scanVoice(row) + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNotFound + } + if err != nil { + return nil, fmt.Errorf("store: voice getbyname %q: %w", name, err) + } + return &v, nil +} + +// VoiceInsert 新增音色;name 冲突返回 ErrDuplicate。 +// 空字符串/格式不合法返回 error;不依赖 SQLite 约束作为唯一校验。 +func (s *Store) VoiceInsert(v Voice) (int64, error) { + v.Name = strings.TrimSpace(v.Name) + v.Speaker = strings.TrimSpace(v.Speaker) + v.ResourceID = strings.TrimSpace(v.ResourceID) + v.Model = strings.TrimSpace(v.Model) + v.Language = strings.TrimSpace(v.Language) + v.Description = strings.TrimSpace(v.Description) + + if err := validateVoiceName(v.Name); err != nil { + return 0, err + } + if v.Speaker == "" { + return 0, fmt.Errorf("store: voice insert: speaker is required") + } + if v.ResourceID == "" { + return 0, fmt.Errorf("store: voice insert: resource_id is required") + } + + res, err := s.db.Exec(` + INSERT INTO voices (name, speaker, resource_id, model, language, description, enabled, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))`, + v.Name, v.Speaker, v.ResourceID, v.Model, v.Language, v.Description, boolToInt(v.Enabled)) + if err != nil { + if isUniqueViolation(err) { + return 0, ErrDuplicate + } + return 0, fmt.Errorf("store: voice insert: %w", err) + } + id, err := res.LastInsertId() + if err != nil { + return 0, fmt.Errorf("store: voice insert lastid: %w", err) + } + return id, nil +} + +// VoiceUpdate 整行替换;name 仍需保持唯一。 +// 不允许把 name 改成空/不合法。 +func (s *Store) VoiceUpdate(v Voice) error { + v.Name = strings.TrimSpace(v.Name) + v.Speaker = strings.TrimSpace(v.Speaker) + v.ResourceID = strings.TrimSpace(v.ResourceID) + v.Model = strings.TrimSpace(v.Model) + v.Language = strings.TrimSpace(v.Language) + v.Description = strings.TrimSpace(v.Description) + + if err := validateVoiceName(v.Name); err != nil { + return err + } + if v.Speaker == "" { + return fmt.Errorf("store: voice update: speaker is required") + } + if v.ResourceID == "" { + return fmt.Errorf("store: voice update: resource_id is required") + } + + res, err := s.db.Exec(` + UPDATE voices SET name=?, speaker=?, resource_id=?, model=?, language=?, description=?, enabled=?, updated_at=datetime('now') + WHERE id = ?`, + v.Name, v.Speaker, v.ResourceID, v.Model, v.Language, v.Description, boolToInt(v.Enabled), v.ID) + if err != nil { + if isUniqueViolation(err) { + return ErrDuplicate + } + return fmt.Errorf("store: voice update id=%d: %w", v.ID, err) + } + n, _ := res.RowsAffected() + if n == 0 { + return ErrNotFound + } + return nil +} + +// VoiceDelete 按 id 删;若被 settings.default_speaker 引用则返回 ErrInUse。 +func (s *Store) VoiceDelete(id int64) error { + v, err := s.VoiceGet(id) + if err != nil { + return err + } + + // 检查 default_speaker 引用 + defVal, defOK, err := s.SettingsGet("default_speaker") + if err != nil { + return err + } + if defOK && defVal == v.Name { + return ErrInUse + } + + res, err := s.db.Exec(`DELETE FROM voices WHERE id = ?`, id) + if err != nil { + return fmt.Errorf("store: voice delete id=%d: %w", id, err) + } + n, _ := res.RowsAffected() + if n == 0 { + return ErrNotFound + } + return nil +} + +// VoiceToggleEnabled 翻转启用状态;返回更新后的值。 +func (s *Store) VoiceToggleEnabled(id int64, enabled bool) error { + res, err := s.db.Exec(`UPDATE voices SET enabled=?, updated_at=datetime('now') WHERE id = ?`, + boolToInt(enabled), id) + if err != nil { + return fmt.Errorf("store: voice toggle id=%d: %w", id, err) + } + n, _ := res.RowsAffected() + if n == 0 { + return ErrNotFound + } + return nil +} + +// VoiceCount 统计行数;M2 仪表盘用。 +func (s *Store) VoiceCount() (int, error) { + var n int + err := s.db.QueryRow(`SELECT COUNT(*) FROM voices`).Scan(&n) + if err != nil { + return 0, fmt.Errorf("store: voice count: %w", err) + } + return n, nil +} + +// VoiceCountEnabled 统计 enabled=1 的行数;仪表盘用。 +func (s *Store) VoiceCountEnabled() (int, error) { + var n int + err := s.db.QueryRow(`SELECT COUNT(*) FROM voices WHERE enabled = 1`).Scan(&n) + if err != nil { + return 0, fmt.Errorf("store: voice count enabled: %w", err) + } + return n, nil +} + +// scanVoice 把 row 扫描成 Voice;接受 *sql.Row 或 *sql.Rows(都实现 Scan)。 +type scanner interface { + Scan(dest ...any) error +} + +func scanVoice(r scanner) (Voice, error) { + var v Voice + var enabled int + err := r.Scan(&v.ID, &v.Name, &v.Speaker, &v.ResourceID, &v.Model, &v.Language, &v.Description, &enabled, &v.CreatedAt, &v.UpdatedAt) + if err != nil { + return v, err + } + v.Enabled = enabled != 0 + return v, nil +} + +func validateVoiceName(name string) error { + if name == "" { + return fmt.Errorf("store: voice name is required") + } + if !voiceNameRe.MatchString(name) { + return fmt.Errorf("store: voice name %q invalid (must match [a-zA-Z0-9_-]{1,64})", name) + } + return nil +} + +func boolToInt(b bool) int { + if b { + return 1 + } + return 0 +} + +// isUniqueViolation 判定 SQLite 唯一约束错误。 +// modernc.org/sqlite 错误信息中包含 "UNIQUE constraint failed: .";做大小写不敏感包含判定。 +func isUniqueViolation(err error) bool { + if err == nil { + return false + } + msg := strings.ToLower(err.Error()) + return strings.Contains(msg, "unique constraint failed") +} + +// VoiceInsertedAt 返回当前时间字符串(UTC, RFC3339);留作未来 Voice 构造时使用, +// 暂不导出。 +func voiceNow() string { return time.Now().UTC().Format(time.RFC3339) }