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:
+52
-1
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user