fix(setup): settings + voices 改为事务原子提交,失败整体回滚不再半残

原版 controller/setup.go 提交流程:
  SettingsSetBatch → 循环 VoiceInsert
任一 voice 失败时,settings 已写、voice 1/N 已落,db 处于半残状态
(settings 指向不存在的 default_speaker,部分 voice 残留,其它丢失)。
注释里甚至自白 '不回滚 settings(用户重启后会重新 setup)' — 有意的妥协,
但用户重启后还要踩 '已装但配置不完整' 的坑,且下次 setup 还会撞 ErrDuplicate
(已插入的 voices 留着没回滚)。

修复:
- 新增 store.SetupApply(settingsKV, voices) (inserted int, err error):
  单事务包 settings 写入 + 所有 voice 插入,任一失败整体回滚,db 保持
  setup 前的状态(无脏数据)。
- 内部抽 settingsSetBatchTx / voiceInsertTx 两个 helper,逻辑跟现有
  SettingsSetBatch / VoiceInsert 一致,只是用 *sql.Tx 代替 s.db。
- ErrDuplicate 静默跳过(兼容 '重复 setup 同一组 voice' 场景),其它
  voice 错误整体回滚。ErrInvalid 校验错误沿用上一条 fix 的 400/500 模式
  (errors.Is(err, ErrInvalid) → 400,其它 → 500)。
- 锁文件 installer.CreateLock 仍在事务外(controller 层),它本就不属于
  db 事务能管的事,这次不动它的失败语义。

controller/setup.go 改用 s.SetupApply 一次调用,删除原 SettingsSetBatch
+ VoiceInsert 内联循环 + '清空旧 voices' 注释(原代码注释承认这逻辑是
'妥协')。响应体字段不变(voices 用 SetupApply 返回的 count)。

store 单 connection (SetMaxOpenConns(1)) 已在 db.go 设置,事务安全。
This commit is contained in:
sun
2026-09-21 21:31:46 +08:00
parent 01bc5ea5bf
commit e738fc8e23
2 changed files with 182 additions and 46 deletions
+132
View File
@@ -0,0 +1,132 @@
package store
import (
"database/sql"
"errors"
"fmt"
"strings"
)
// SetupApply 原子提交:settings 批量写入 + voices 全部插入,任一失败整体回滚,
// 保证 db 不会留下半残状态(原 controller 直接调 SettingsSetBatch + 循环
// VoiceInsert 时,voice 第 3 条失败 → settings 已写、voice 1/2 已落、voice 4
// 没了,db 处于"装了一半"的脏状态,只能靠重启救)。
//
// 行为契约:
// - settingsKV 全部写入(settings 白名单校验复用 SettingsSetBatch 逻辑);
// - voices 逐条插入;ErrInvalid 校验错误 → 整体回滚,errors.Is(err, ErrInvalid) 仍可用;
// - voices 中已存在的 name 命中 ErrDuplicate → 静默跳过(不计 inserted,事务继续),
// 兼容"重复 setup 同一组 voice"场景;
// - 其它 voice 错误 → 整体回滚;
// - 全部成功 → tx.Commit,返回 inserted count(不含被 ErrDuplicate 跳过的)。
//
// 不动 lock 文件:lock 由 controller 层(installer.CreateLock)管理,
// 失败/成功都不应影响 db 事务(事务外)。
func (s *Store) SetupApply(settingsKV map[string]string, voices []Voice) (inserted int, err error) {
tx, err := s.db.Begin()
if err != nil {
return 0, fmt.Errorf("store: setup apply begin: %w", err)
}
// defer Rollback:Commit 成功时 Rollback 返 sql.ErrTxDone,无害。
defer func() {
_ = tx.Rollback()
}()
// 1) 写 settings(同事务)
if len(settingsKV) > 0 {
if err := settingsSetBatchTx(tx, settingsKV); err != nil {
return 0, err
}
}
// 2) 逐条插 voice;ErrDuplicate 跳过,ErrInvalid/其它整体回滚
for i, v := range voices {
// trim 各字段,跟 VoiceInsert 保持一致(防止 controller 已经 trim 过但
// 未来调用方不 trim 时行为不一致)
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)
// Enabled 走 setup 语义:用户主动配置时保留(允许 admin 预设 disabled);
// 但 controller.SetupSubmitHandler 走的是用户首次安装,统一 enabled=true。
// 这里不强制覆盖,保持原值(等同 VoiceInsert 行为)。
id, err := voiceInsertTx(tx, v)
if err != nil {
if errors.Is(err, ErrDuplicate) {
// 已存在,跳过(不计 inserted)
continue
}
// ErrInvalid / DB 错误等:整体回滚,把原始 error 透传(已 wrap ErrInvalid)
return 0, fmt.Errorf("store: setup apply voice[%d] %q: %w", i, v.Name, err)
}
_ = id // id 当前用不到,后续如果 controller 需要可加返回值
inserted++
}
if err := tx.Commit(); err != nil {
return 0, fmt.Errorf("store: setup apply commit: %w", err)
}
return inserted, nil
}
// settingsSetBatchTx 在已有 tx 上写 settings;逻辑跟 SettingsSetBatch 一致
// 但用 tx 代替 s.db。失败时**不**回滚(交给 caller 决定);caller 拿 err 后
// defer Rollback 兜底。
func settingsSetBatchTx(tx *sql.Tx, kv map[string]string) error {
if len(kv) == 0 {
return nil
}
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 {
return fmt.Errorf("store: settings setbatch prepare: %w", err)
}
defer stmt.Close()
for k, v := range kv {
if k == "" {
return fmt.Errorf("store: settings setbatch: empty key")
}
if !isAllowedSettingsKey(k) {
return fmt.Errorf("store: settings setbatch: key %q not in whitelist", k)
}
if _, err := stmt.Exec(k, v); err != nil {
return fmt.Errorf("store: settings setbatch exec %q: %w", k, err)
}
}
return nil
}
// voiceInsertTx 在已有 tx 上插 voice;跟 VoiceInsert 逻辑一致。
// 校验(name 格式 / speaker / resource_id)用 ErrInvalid wrap;
// 唯一冲突返 ErrDuplicate;其它错误返 wrap 的 db error。
func voiceInsertTx(tx *sql.Tx, v Voice) (int64, error) {
if err := validateVoiceName(v.Name); err != nil {
return 0, fmt.Errorf("%w: %s", ErrInvalid, err.Error())
}
if v.Speaker == "" {
return 0, fmt.Errorf("%w: speaker is required", ErrInvalid)
}
if v.ResourceID == "" {
return 0, fmt.Errorf("%w: resource_id is required", ErrInvalid)
}
res, err := tx.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
}