原版 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 设置,事务安全。
133 lines
4.8 KiB
Go
133 lines
4.8 KiB
Go
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
|
|
}
|