From c265cb8836f81df0c4e4fdc5ff340d6450b8f41e Mon Sep 17 00:00:00 2001 From: "3371392206@qq.com" <3371392206@qq.com> Date: Sat, 5 Sep 2026 11:38:40 +0800 Subject: [PATCH] =?UTF-8?q?fix(store):=20VoiceUpdate=20=E5=90=8C=E6=AD=A5?= =?UTF-8?q?=20settings.default=5Fspeaker=20(=E9=98=B2=E6=BD=9C=E5=9C=A8=20?= =?UTF-8?q?stale=20ref)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 (待用户) --- store/voices.go | 53 ++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 52 insertions(+), 1 deletion(-) diff --git a/store/voices.go b/store/voices.go index 152fa05..132843c 100644 --- a/store/voices.go +++ b/store/voices.go @@ -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 }