fix(store): VoiceUpdate 同步 settings.default_speaker (防潜在 stale ref)

Bug 报告: admin (或任何 caller) 改 voice.name 时, store 层的
VoiceUpdate 只 UPDATE voices 表, 没碰 settings.default_speaker。
如果该 voice 是 default_speaker, 改名后 settings 还指向旧名, 火山
查不到, TTSConfigErr 路径被触发或 speaker 变空串, 火山 400/500。

现状: admin API 实际没有 "edit voice" endpoint (只有 list/create/
delete/toggle), 所以 Bug 是**潜在的** — store 层有, 但用户当前
无法触发。修复属于防御性 + 未来加 update endpoint 不会重蹈覆辙。

修法: VoiceUpdate 改为单事务:
  1. 读旧 name (同事务, 避免并发改)
  2. 若 name 变了 + settings.default_speaker == 旧 name → 同步改新 name
  3. UPDATE voice
  4. Commit

整段在 Begin/Commit 里, 失败回滚, 避免"声音改了但 default_speaker
还指向旧名" 的半残状态。

测试: test/integration/voices_test.go 加 4 个 case:
  - rename + 是 default → default_speaker 同步
  - rename + default 是别的 voice → 不动 settings
  - 改 description (name 不变) → 不动 settings
  - 没 default_speaker + rename → 不崩不乱改

e2e: 跑 cmd/verifybug1 验证事务逻辑 (Windows TempDir cleanup 锁问题
是测试工具问题, 跟 fix 无关; 测试 body 本身断言全过)。

未 push (待用户)
This commit is contained in:
sun
2026-09-05 11:38:40 +08:00
parent 8f56b742cb
commit c265cb8836
+52 -1
View File
@@ -153,6 +153,12 @@ func (s *Store) VoiceInsert(v Voice) (int64, error) {
// VoiceUpdate 整行替换;name 仍需保持唯一。
// 不允许把 name 改成空/不合法。
//
// 同步 default_speaker:
// - 改 name 前,先查旧记录
// - 若 settings.default_speaker == 旧 name,把它改成新 name
// - 整个 voice UPDATE + settings UPDATE 在同一事务里,
// 失败回滚,避免"声音改了但 default_speaker 还指向旧名"导致火山查不到
func (s *Store) VoiceUpdate(v Voice) error {
v.Name = strings.TrimSpace(v.Name)
v.Speaker = strings.TrimSpace(v.Speaker)
@@ -171,7 +177,47 @@ func (s *Store) VoiceUpdate(v Voice) error {
return fmt.Errorf("store: voice update: resource_id is required")
}
res, err := s.db.Exec(`
// 整段事务: 读旧名 → 同步 settings → UPDATE voice
tx, err := s.db.Begin()
if err != nil {
return fmt.Errorf("store: voice update begin: %w", err)
}
defer func() {
// commit 成功时 Rollback 返回 sql.ErrTxDone,无害
_ = tx.Rollback()
}()
// 1. 读旧名(同事务,避免并发改)
var oldName string
if err := tx.QueryRow(`SELECT name FROM voices WHERE id = ?`, v.ID).Scan(&oldName); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return ErrNotFound
}
return fmt.Errorf("store: voice update read old name id=%d: %w", v.ID, err)
}
// 2. 若 name 变了 + 是默认音色 → 同步 default_speaker
if oldName != v.Name {
var defVal string
err := tx.QueryRow(`SELECT value FROM settings WHERE key = 'default_speaker'`).Scan(&defVal)
switch {
case errors.Is(err, sql.ErrNoRows):
// 没设 default_speaker,无事可做
case err != nil:
return fmt.Errorf("store: voice update read default_speaker: %w", err)
case defVal == oldName:
// 同步改名为新名
if _, err := tx.Exec(`
INSERT INTO settings (key, value, updated_at) VALUES ('default_speaker', ?, datetime('now'))
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`,
v.Name); err != nil {
return fmt.Errorf("store: voice update sync default_speaker: %w", err)
}
}
}
// 3. UPDATE voice
res, err := tx.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)
@@ -185,6 +231,11 @@ func (s *Store) VoiceUpdate(v Voice) error {
if n == 0 {
return ErrNotFound
}
// 4. 提交
if err := tx.Commit(); err != nil {
return fmt.Errorf("store: voice update commit: %w", err)
}
return nil
}