diff --git a/controller/setup.go b/controller/setup.go index de61ade..e8797c7 100644 --- a/controller/setup.go +++ b/controller/setup.go @@ -2,6 +2,7 @@ package controller import ( "encoding/json" + "errors" "fmt" "log" "net/http" @@ -163,9 +164,54 @@ func SetupSubmitHandler(w http.ResponseWriter, r *http.Request) { } settingsKV["initialized"] = "1" settingsKV["installed_at"] = time.Now().UTC().Format(time.RFC3339) - if err := s.SettingsSetBatch(settingsKV); err != nil { - log.Printf("[setup] 写 settings 失败: %v", err) - middleware.SendJSONError(w, http.StatusInternalServerError, "failed to write settings", "server_error", "db_write_failed") + + // 【修复】原版先写 settings 再循环插 voice,任一 voice 失败时已写入的 + // settings 不回滚 → db 处于半残状态(用户重启还要踩"已装但配置不完整"的坑)。 + // 改用 store.SetupApply 一次性事务:settings + voices 任一失败整体回滚, + // db 保持 setup 前的状态(无脏数据)。 + // + // voice 行的 resource_id 留空时,自动用 settings.default_resource_id 兜底。 + // 用户在 step 2 填了 default_resource_id 后,step 3 的 voice 行 resource_id + // 可以不填 — 保持一致。否则会出现 "settings 里 seed-icl-2.0,voice 里 volc.megatts.icl" + // 这种 mismatch,运行时 500。 + voices := make([]store.Voice, 0, len(body.Voices)) + defaultResourceID := settingsKV["default_resource_id"] + for _, v := range body.Voices { + voiceResourceID := v.ResourceID + if voiceResourceID == "" { + voiceResourceID = defaultResourceID + log.Printf("[setup] voice %q resource_id 留空,自动用 default_resource_id=%q", v.Name, defaultResourceID) + } + voices = append(voices, store.Voice{ + Name: v.Name, + Speaker: v.Speaker, + ResourceID: voiceResourceID, + Model: v.Model, + Language: v.Language, + Enabled: true, + }) + } + + // 预检 voices 数量(避免空提交也走事务);setup 校验已要求至少 1 条, + // 防御性兜底。 + if len(voices) == 0 { + middleware.SendJSONError(w, http.StatusBadRequest, + "at least one voice is required", "invalid_request_error", "no_voices") + return + } + + inserted, err := s.SetupApply(settingsKV, voices) + if err != nil { + log.Printf("[setup] 提交失败,事务回滚 - 错误=%v", err) + // 区分客户端/服务端错误,沿用 admin.go 的 400/500 模式 + if errors.Is(err, store.ErrInvalid) { + middleware.SendJSONError(w, http.StatusBadRequest, + err.Error(), "invalid_request_error", "voice_invalid") + return + } + middleware.SendJSONError(w, http.StatusInternalServerError, + fmt.Sprintf("failed to apply setup: %v", err), + "server_error", "db_write_failed") return } @@ -182,49 +228,7 @@ func SetupSubmitHandler(w http.ResponseWriter, r *http.Request) { log.Printf("[setup] warning: 装完 LoadRuntimeConfig 失败: %v(下次启动会恢复)", err) } - // 清空旧 voices 再插入(假设是首次安装;若不是,name 冲突会变成 409) - // 这里选择 "清空+插入" 语义,符合"setup 是首次安装"的产品定位 - // 如果想保留旧 voices,可以改成 UPSERT,但 M1 不做 - if existing, _ := s.VoiceList(true); len(existing) > 0 { - // 留作未来:如果是非首次 setup(M2 加 reset 功能),这里需要更精细处理 - log.Printf("[setup] 检测到 %d 条已存在 voices,本次将跳过清空(name 冲突由 ErrDuplicate 处理)", len(existing)) - } - inserted := 0 - // 【UX 改进】voice 行的 resource_id 留空时,自动用 settings.default_resource_id 兜底。 - // 用户在 step 2 填了 default_resource_id 后,step 3 的 voice 行 resource_id - // 可以不填 — 保持一致。否则会出现 "settings 里 seed-icl-2.0,voice 里 volc.megatts.icl" - // 这种 mismatch,运行时 500。 - defaultResourceID := settingsKV["default_resource_id"] - for _, v := range body.Voices { - voiceResourceID := v.ResourceID - if voiceResourceID == "" { - voiceResourceID = defaultResourceID - log.Printf("[setup] voice %q resource_id 留空,自动用 default_resource_id=%q", v.Name, defaultResourceID) - } - _, err := s.VoiceInsert(store.Voice{ - Name: v.Name, - Speaker: v.Speaker, - ResourceID: voiceResourceID, - Model: v.Model, - Language: v.Language, - Enabled: true, - }) - if err != nil { - log.Printf("[setup] 插入 voice %q 失败: %v", v.Name, err) - // 不回滚 settings(用户重启后会重新 setup) - // 但已插入的 voices 会留着,下次 setup 会撞 ErrDuplicate - // 安全:把 ErrDuplicate 视作可继续(用户重复 setup 同一组 voice) - if err == store.ErrDuplicate { - continue - } - middleware.SendJSONError(w, http.StatusInternalServerError, - fmt.Sprintf("failed to insert voice %q: %v", v.Name, err), - "server_error", "voice_insert_failed") - return - } - inserted++ - } - log.Printf("[setup] 写入 settings=%d, voices=%d/%d", len(settingsKV), inserted, len(body.Voices)) + log.Printf("[setup] 写入 settings=%d, voices=%d/%d (事务原子提交)", len(settingsKV), inserted, len(voices)) // 写 lock(原子):从这一刻起,/api/setup 永久关闭 if err := installer.CreateLock(GetSetupDBPath()); err != nil { diff --git a/store/setup.go b/store/setup.go new file mode 100644 index 0000000..33168a7 --- /dev/null +++ b/store/setup.go @@ -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 +}