feat(store): M0 SQLite 存储层 + modernc.org/sqlite

- 引入 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 包全绿
This commit is contained in:
sun
2026-08-29 20:29:28 +08:00
parent dc3248d11c
commit 865fdd81e8
6 changed files with 665 additions and 0 deletions
+13
View File
@@ -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
)
+20
View File
@@ -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=
+153
View File
@@ -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
}
+27
View File
@@ -0,0 +1,27 @@
package store
// schema 版本号演进与迁移函数注册表。
//
// 用法:每次结构性变更时 +1 schemaVersion 常量,并在 migrations 切片中追加 applyV<n>。
// 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 }
+175
View File
@@ -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
}
+277
View File
@@ -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: <table>.<col>";做大小写不敏感包含判定。
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) }