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 01/12] =?UTF-8?q?fix(store):=20VoiceUpdate=20=E5=90=8C?= =?UTF-8?q?=E6=AD=A5=20settings.default=5Fspeaker=20(=E9=98=B2=E6=BD=9C?= =?UTF-8?q?=E5=9C=A8=20stale=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 } From 59c00271225458e6600fe7fc39a87aa04166428d Mon Sep 17 00:00:00 2001 From: "3371392206@qq.com" <3371392206@qq.com> Date: Sat, 5 Sep 2026 11:42:48 +0800 Subject: [PATCH 02/12] =?UTF-8?q?fix(cors):=20SettingsCORSRequest.Origins?= =?UTF-8?q?=20=E6=94=B9=20*string,=E6=94=AF=E6=8C=81=E6=98=BE=E5=BC=8F?= =?UTF-8?q?=E6=B8=85=E7=A9=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug 2 报告: controller/settings.go:330-332 的死代码 if body.AllowAll == nil && trimAll(body.Origins) == "" && body.Origins != "" { // 空 body 不算错误 } 该条件永远 false (trim 空 != "" 矛盾), 整个 if 块只注释不执行。 更严重的是: 用户无法清空 origins — 原代码 body.Origins == "" 就 400, 但用户可能就是想传空串清空。 struct 字段是 string, 区分不出"未传"和"传空串"。 修法: - struct.SettingsCORSRequest.Origins: string → *string - nil = 未传 (不动) - *"" = 传空串 (清空) - *"url1\nurl2" = 覆盖 - 删掉 line 330-332 死代码 - 改校验逻辑: - 2 个字段都 nil → 400 no_fields - 至少一个非 nil → 200 - 空串不校验 origin 格式 (无 origin 可校), 直接存 行为变更: - {"origins": "https://a.com"} → 写, 同以前 - {"origins": ""} → 清空 (新功能, 以前返 400) - {"origins": null} → 视为未传, 不动 - {"allow_all": true} → 写, 同以前 - {"allow_all": false} → 设为 false, 同以前 - {} → 400 no_fields - {"origins": "not-a-url"} → 400 origin_invalid (同以前) 测试: test/integration/settings_test.go (gitignored) 5 个 case - EmptyOrigins_ClearsOrigins - OriginsNotInBody_NotTouched - EmptyBody_400 - NullOrigins_TreatedAsMissing - InvalidOrigin_400 e2e: 4 场景全过 (设/清空/空 body 400/null 不动)。 不向后兼容: SettingsCORSRequest 内部 DTO, 仅 admin webui 调, 不暴露给外部 OpenAI 客户端。 未 push (待用户) --- controller/settings.go | 51 +++++++++++++++++++++++------------------- 1 file changed, 28 insertions(+), 23 deletions(-) diff --git a/controller/settings.go b/controller/settings.go index 9fcee12..9d7e832 100644 --- a/controller/settings.go +++ b/controller/settings.go @@ -299,12 +299,13 @@ func SettingsAuthKeyHandler(w http.ResponseWriter, r *http.Request) { } // SettingsCORSRequest 是 PUT /api/settings/cors 的 body。 -// 两个字段都可选(至少给一个): -// - allow_all: true → 任意 Origin 都接受(*);设了之后 origins 失效 -// - origins: 一行一个 origin,后端 trim + lower + 去末尾 / +// 两个字段都可选(至少给一个),用指针区分"未传"和"传空串": +// - allow_all 指针: nil=未传(不动) *true=开 *false=关 +// - origins 字符串: nil=未传(不动) ""=传空串(清空) "url1\nurl2"=覆盖 +// 这样用户能精确表达意图(保留 / 改 / 清空),不会被 0/"" 歧义坑死。 type SettingsCORSRequest struct { - AllowAll *bool `json:"allow_all,omitempty"` - Origins string `json:"origins,omitempty"` // 也接受 string 数组(任一形式) + AllowAll *bool `json:"allow_all,omitempty"` + Origins *string `json:"origins,omitempty"` // *string 区分"未传(nil)"和"传空串" } // SettingsCORSHandler PUT /api/settings/cors @@ -327,10 +328,9 @@ func SettingsCORSHandler(w http.ResponseWriter, r *http.Request) { middleware.SendJSONError(w, http.StatusBadRequest, "invalid JSON body", "invalid_request_error", "bad_request") return } - if body.AllowAll == nil && trimAll(body.Origins) == "" && body.Origins != "" { - // 空 body 不算错误,用户可能是想"清空"(只清 origins 保留现状) - } - if body.AllowAll == nil && body.Origins == "" { + // 至少要给一个字段(allow_all 或 origins) + // 指针为 nil 表示"未传",不计入 + if body.AllowAll == nil && body.Origins == nil { middleware.SendJSONError(w, http.StatusBadRequest, "at least one of allow_all / origins required", "invalid_request_error", "no_fields") @@ -341,22 +341,27 @@ func SettingsCORSHandler(w http.ResponseWriter, r *http.Request) { if body.AllowAll != nil { updates["cors_allow_all"] = boolToStr(*body.AllowAll) } - if body.Origins != "" { - // 校验每个 origin 至少像 http(s)://... (防止用户填空或填乱字符) - for _, line := range strings.Split(body.Origins, "\n") { - line = strings.TrimSpace(line) - if line == "" { - continue - } - low := strings.ToLower(line) - if !strings.HasPrefix(low, "http://") && !strings.HasPrefix(low, "https://") { - middleware.SendJSONError(w, http.StatusBadRequest, - fmt.Sprintf("invalid origin: %q (must start with http:// or https://)", line), - "invalid_request_error", "origin_invalid") - return + if body.Origins != nil { + // *Origins == "" 表示用户要清空(保留 nil 表示"不动") + origins := *body.Origins + if origins != "" { + // 校验每个 origin 至少像 http(s)://... (防止用户填乱字符) + for _, line := range strings.Split(origins, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + low := strings.ToLower(line) + if !strings.HasPrefix(low, "http://") && !strings.HasPrefix(low, "https://") { + middleware.SendJSONError(w, http.StatusBadRequest, + fmt.Sprintf("invalid origin: %q (must start with http:// or https://)", line), + "invalid_request_error", "origin_invalid") + return + } } } - updates["cors_origins"] = body.Origins + // 空串也能存(表示"清空");trim/lower 在 LoadRuntimeConfig 那侧做 + updates["cors_origins"] = origins } if err := s.SettingsSetBatch(updates); err != nil { log.Printf("[settings] cors set: %v", err) From 9353790899c72fe71c5883d4922961f22d5b35f8 Mon Sep 17 00:00:00 2001 From: "3371392206@qq.com" <3371392206@qq.com> Date: Sat, 5 Sep 2026 11:59:06 +0800 Subject: [PATCH 03/12] =?UTF-8?q?refactor:=20=E5=88=A0=20trimAll=20+=20?= =?UTF-8?q?=E4=BF=AE=20isSensitive,=E7=BB=9F=E4=B8=80=E7=94=A8=20stdlib?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 两处自实现 / 半成品: 1. controller/settings.go trimAll (11 行) - 自己写循环 trim 4 个 ASCII 字符 (' ' \t \n \r) - 等价于 strings.TrimSpace (后者 trim 所有 Unicode 空白) - strings 包已 import (其他代码用着), 没引用代价 - 改: 7 个调用点全替, 删函数定义 2. cmd/dumpdb/main.go isSensitive (13 行) - 自实现 strings.Contains 循环, 没调 strings.ToLower - 变量名叫 lowK 但实际从未小写化 - Bug: API_KEY / Auth_Token 这类大写 key 不会被判为敏感, 漏打码 - 修: 用 strings.ToLower + strings.Contains, 大小写不敏感 - 顺手加 strings import 收益: - 行数: +15 / -27 (净删 12 行) - 一致性: 看到 strings.TrimSpace / strings.Contains 就懂, 不必读自实现 - 安全: 大写 key 之前漏打码, 修了 e2e 验证 (verifydbsens 临时脚本, 已删): api_key -> sensitive ✓ auth_key -> sensitive ✓ API_KEY_UPPER -> sensitive ✓ (修前漏) Auth_Token_Mixed -> sensitive ✓ (修前漏) default_speaker -> sensitive ✓ default_resource_id -> not sensitive ✓ note -> not sensitive ✓ build + go test ./... 全绿 未 push (待用户) --- cmd/dumpdb/main.go | 16 ++++++++-------- controller/settings.go | 26 +++++++------------------- 2 files changed, 15 insertions(+), 27 deletions(-) diff --git a/cmd/dumpdb/main.go b/cmd/dumpdb/main.go index 440c859..cb0fa1f 100644 --- a/cmd/dumpdb/main.go +++ b/cmd/dumpdb/main.go @@ -11,6 +11,7 @@ import ( "database/sql" "fmt" "os" + "strings" _ "modernc.org/sqlite" ) @@ -116,14 +117,13 @@ func main() { } func isSensitive(key string) bool { - lowK := key - // 简单 contains (不引 strings 包保持精简) - markers := []string{"key", "token", "speaker"} - for _, m := range markers { - for i := 0; i+len(m) <= len(lowK); i++ { - if lowK[i:i+len(m)] == m { - return true - } + // 标记 key 名包含 "key" / "token" / "speaker" 即视为敏感,值打码。 + // 大小写不敏感: API_KEY / Auth_Token 等大写 key 也会被命中 + // (避免漏打码)。 + low := strings.ToLower(key) + for _, m := range []string{"key", "token", "speaker"} { + if strings.Contains(low, m) { + return true } } return false diff --git a/controller/settings.go b/controller/settings.go index 9d7e832..788016d 100644 --- a/controller/settings.go +++ b/controller/settings.go @@ -121,7 +121,7 @@ func SettingsUpdateHandler(w http.ResponseWriter, r *http.Request) { // 收集要更新的键值对 updates := make(map[string]string) if body.DefaultResourceID != nil { - v := trimAll(*body.DefaultResourceID) + v := strings.TrimSpace(*body.DefaultResourceID) if v == "" { middleware.SendJSONError(w, http.StatusBadRequest, "default_resource_id cannot be empty", "invalid_request_error", "missing_field") return @@ -129,7 +129,7 @@ func SettingsUpdateHandler(w http.ResponseWriter, r *http.Request) { updates["default_resource_id"] = v } if body.DefaultSpeaker != nil { - v := trimAll(*body.DefaultSpeaker) + v := strings.TrimSpace(*body.DefaultSpeaker) if v == "" { middleware.SendJSONError(w, http.StatusBadRequest, "default_speaker cannot be empty", "invalid_request_error", "missing_field") return @@ -144,7 +144,7 @@ func SettingsUpdateHandler(w http.ResponseWriter, r *http.Request) { updates["default_speaker"] = v } if body.DefaultFormat != nil { - v := trimAll(*body.DefaultFormat) + v := strings.TrimSpace(*body.DefaultFormat) if !isValidFormat(v) { middleware.SendJSONError(w, http.StatusBadRequest, fmt.Sprintf("default_format %q invalid; valid: mp3/wav/opus/pcm/aac/flac", v), @@ -163,13 +163,13 @@ func SettingsUpdateHandler(w http.ResponseWriter, r *http.Request) { updates["sample_rate"] = strconv.Itoa(v) } if body.Model != nil { - updates["model"] = trimAll(*body.Model) + updates["model"] = strings.TrimSpace(*body.Model) } if body.ModelType != nil { updates["model_type"] = strconv.Itoa(*body.ModelType) } if body.ExplicitLanguage != nil { - updates["explicit_language"] = trimAll(*body.ExplicitLanguage) + updates["explicit_language"] = strings.TrimSpace(*body.ExplicitLanguage) } if body.EnableSubtitle != nil { updates["enable_subtitle"] = boolToStr(*body.EnableSubtitle) @@ -232,7 +232,7 @@ func SettingsAPIKeyHandler(w http.ResponseWriter, r *http.Request) { middleware.SendJSONError(w, http.StatusBadRequest, "invalid JSON body", "invalid_request_error", "bad_request") return } - key := trimAll(body.APIKey) + key := strings.TrimSpace(body.APIKey) if key == "" { middleware.SendJSONError(w, http.StatusBadRequest, "api_key cannot be empty", "invalid_request_error", "missing_field") return @@ -280,7 +280,7 @@ func SettingsAuthKeyHandler(w http.ResponseWriter, r *http.Request) { middleware.SendJSONError(w, http.StatusBadRequest, "invalid JSON body", "invalid_request_error", "bad_request") return } - key := trimAll(body.AuthKey) + key := strings.TrimSpace(body.AuthKey) if key == "" { middleware.SendJSONError(w, http.StatusBadRequest, "auth_key cannot be empty", "invalid_request_error", "missing_field") return @@ -409,18 +409,6 @@ func maskAPIKeyField(s string) string { return s[:4] + "****" + s[len(s)-4:] } -func trimAll(s string) string { - // 简单 trim 前后空白;不剥中间空格 - out := s - for len(out) > 0 && (out[0] == ' ' || out[0] == '\t' || out[0] == '\n' || out[0] == '\r') { - out = out[1:] - } - for len(out) > 0 && (out[len(out)-1] == ' ' || out[len(out)-1] == '\t' || out[len(out)-1] == '\n' || out[len(out)-1] == '\r') { - out = out[:len(out)-1] - } - return out -} - func isValidFormat(s string) bool { switch s { case "mp3", "wav", "opus", "pcm", "aac", "flac", "": From a2d72efb34c35292376178ede4de0038e7047af7 Mon Sep 17 00:00:00 2001 From: "3371392206@qq.com" <3371392206@qq.com> Date: Sat, 12 Sep 2026 14:03:56 +0800 Subject: [PATCH 04/12] =?UTF-8?q?fix(cors):=20=E9=A2=84=E6=A3=80=20Allow-M?= =?UTF-8?q?ethods=20=E8=A1=A5=20PUT/PATCH/DELETE,=E4=BF=AE=20admin=20?= =?UTF-8?q?=E6=94=B9=E8=AE=BE=E7=BD=AE/=E5=88=A0=E9=9F=B3=E8=89=B2?= =?UTF-8?q?=E8=B7=A8=E5=9F=9F=E5=A4=B1=E8=B4=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- middleware/cors.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/middleware/cors.go b/middleware/cors.go index 8e3dc3c..a9d59cd 100644 --- a/middleware/cors.go +++ b/middleware/cors.go @@ -148,7 +148,7 @@ func CORS(next http.Handler) http.Handler { // Origin 匹配:设置 CORS 响应头 w.Header().Set("Access-Control-Allow-Origin", allowOrigin) - w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS") w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") w.Header().Set("Access-Control-Expose-Headers", "X-Request-Id") w.Header().Set("Access-Control-Max-Age", corsMaxAgeHeader) From ef2fea4c25178ffcd212d413af30fbeafce03039 Mon Sep 17 00:00:00 2001 From: "3371392206@qq.com" <3371392206@qq.com> Date: Sat, 12 Sep 2026 14:34:06 +0800 Subject: [PATCH 05/12] =?UTF-8?q?fix(security):=20=E5=90=AF=E5=8A=A8=20/?= =?UTF-8?q?=20=E8=B7=AF=E7=94=B1=20/=20=E4=B8=8A=E6=B8=B8=20log=20?= =?UTF-8?q?=E5=AF=B9=20resource=5Fid=20=E4=B8=80=E5=B9=B6=E6=89=93?= =?UTF-8?q?=E7=A0=81,=E8=A1=A5=20telemetry.MaskResourceID?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- adapter/volcano/synthesis.go | 2 +- controller/tts.go | 2 +- main.go | 2 +- setting/config.go | 2 +- telemetry/labels.go | 19 +++++++++++++++++-- 5 files changed, 21 insertions(+), 6 deletions(-) diff --git a/adapter/volcano/synthesis.go b/adapter/volcano/synthesis.go index 1df6ffc..4eece0f 100644 --- a/adapter/volcano/synthesis.go +++ b/adapter/volcano/synthesis.go @@ -84,7 +84,7 @@ func Synthesis( if common.DebugLog { log.Printf("TTS upstream: resource_id=%s speaker=%s model=%q format=%s sample_rate=%d speech_rate=%d additions=%q", - opts.ResourceID, telemetry.MaskSpeaker(opts.Speaker), opts.Model, opts.Format, opts.SampleRate, opts.SpeechRate, extractAdditionsForLog(body)) + telemetry.MaskResourceID(opts.ResourceID), telemetry.MaskSpeaker(opts.Speaker), opts.Model, opts.Format, opts.SampleRate, opts.SpeechRate, extractAdditionsForLog(body)) } resp, err := client.PostStream(ctx, "https://openspeech.bytedance.com/api/v3/tts/unidirectional", headers, body) diff --git a/controller/tts.go b/controller/tts.go index 4b103f3..d0c6e05 100644 --- a/controller/tts.go +++ b/controller/tts.go @@ -197,7 +197,7 @@ func OpenaiTTSHandler(w http.ResponseWriter, r *http.Request) { opts.Model = v.Model } log.Printf("[tts] voice=%s 命中 (speaker=%s resource=%s model=%s) - 客户端=%s", - req.Voice, telemetry.MaskSpeaker(v.Speaker), v.ResourceID, v.Model, middleware.GetClientIP(r)) + req.Voice, telemetry.MaskSpeaker(v.Speaker), telemetry.MaskResourceID(v.ResourceID), v.Model, middleware.GetClientIP(r)) } ctx, cancel := context.WithTimeout(r.Context(), setting.TTSTimeout) diff --git a/main.go b/main.go index 5612b46..7c2aad0 100644 --- a/main.go +++ b/main.go @@ -78,7 +78,7 @@ func main() { log.Printf("[main][WARN] TTS 运行时配置加载失败 (setup mode, 需先 /setup): %v", err) } else { log.Printf("[main] TTS 运行时配置已加载(api_key=***, speaker=%s, resource=%s, format=%s)", - setting.TTSOptions.Speaker, setting.TTSOptions.ResourceID, setting.TTSOptions.Format) + telemetry.MaskSpeaker(setting.TTSOptions.Speaker), telemetry.MaskResourceID(setting.TTSOptions.ResourceID), setting.TTSOptions.Format) } } diff --git a/setting/config.go b/setting/config.go index 189326a..3ca4a2d 100644 --- a/setting/config.go +++ b/setting/config.go @@ -449,7 +449,7 @@ func LogStartupSummary() { } checks := []ttsCheck{ {"BYTEDANCE_TTS_API_KEY", maskAPIKey(TTSOptions.APIKey), TTSOptions.APIKey != ""}, - {"BYTEDANCE_TTS_RESOURCE_ID", TTSOptions.ResourceID, TTSOptions.ResourceID != ""}, + {"BYTEDANCE_TTS_RESOURCE_ID", telemetry.MaskResourceID(TTSOptions.ResourceID), TTSOptions.ResourceID != ""}, // speaker 是火山复刻音色 ID(用户付费资产),日志里打码,避免明文落盘 {"BYTEDANCE_TTS_SPEAKER", telemetry.MaskSpeaker(TTSOptions.Speaker), TTSOptions.Speaker != ""}, } diff --git a/telemetry/labels.go b/telemetry/labels.go index 7dfaa29..a45604a 100644 --- a/telemetry/labels.go +++ b/telemetry/labels.go @@ -41,14 +41,29 @@ func SpeakerLabel(s string) string { // - 其它 → 前 4 + **** + 后 4 (保留前缀便于肉眼区分 "S_xx 开头" vs "BV001_...") // 例子: "S_G8tEKnaJ1" → "S_G8****naJ1" func MaskSpeaker(s string) string { + return maskWithAffix(s, "(未设置)") +} + +// MaskResourceID 把火山 TTS 资源 ID 部分打码用于日志输出。 +// 资源 ID 同样属于用户付费/敏感资产(指向 V3 复刻项目),与 speaker 走同一规则。 +// - 空 → "(未设置)" +// - 长度 ≤ 4 → 全打码 +// - 其它 → 前 4 + **** + 后 4 +// 例子: "volc.megatts.icl" → "volc****.icl"; "seed-icl-2.0" → "seed****2.0" +func MaskResourceID(s string) string { + return maskWithAffix(s, "(未设置)") +} + +// maskWithAffix 共用的"前 4 + **** + 后 4"打码逻辑,空串返回 emptyLabel。 +func maskWithAffix(s, emptyLabel string) string { if s == "" { - return "(未设置)" + return emptyLabel } if len(s) <= 4 { return strings.Repeat("*", len(s)) } // 找前 4 字符中第一个非 [A-Za-z0-9_] 字符做截断,避免截到奇怪位置 - // (虽然火山 ID 实际都是 S_xxx 字母数字组合,这里保险) + // (虽然火山 ID 实际都是字母数字组合,这里保险) prefix := s[:4] suffix := s[len(s)-4:] return prefix + "****" + suffix From d6aafa41ff8370782ef2f6a206bc3f9f4d3356ca Mon Sep 17 00:00:00 2001 From: "3371392206@qq.com" <3371392206@qq.com> Date: Sat, 12 Sep 2026 15:53:07 +0800 Subject: [PATCH 06/12] =?UTF-8?q?fix(tts):=20OpenaiTTSHandler=20=E5=93=8D?= =?UTF-8?q?=E5=BA=94=20Write=20=E9=94=99=E8=AF=AF=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E5=90=9E,=E8=AE=B0=E6=97=A5=E5=BF=97=E6=8E=92=E6=9F=A5?= =?UTF-8?q?=E5=AE=A2=E6=88=B7=E7=AB=AF=E6=96=AD=E5=BC=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- controller/tts.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/controller/tts.go b/controller/tts.go index d0c6e05..3c4e7b7 100644 --- a/controller/tts.go +++ b/controller/tts.go @@ -232,7 +232,11 @@ func OpenaiTTSHandler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Length", fmt.Sprintf("%d", len(result.AudioData))) w.Header().Set("X-Request-Id", result.ReqID) w.WriteHeader(http.StatusOK) - w.Write(result.AudioData) + if n, err := w.Write(result.AudioData); err != nil { + // header 已发,无法改 status code;只记日志供排查(常见:客户端中途断开 → broken pipe / connection reset) + log.Printf("警告: 响应写入失败 - 路径=%s 客户端=%s 已写=%d/%d 错误=%v", + r.URL.Path, middleware.GetClientIP(r), n, len(result.AudioData), err) + } } func classifyStatus(err error) string { From 19d83f7a3b89d22f7f83163362cecbcdc4749b4f Mon Sep 17 00:00:00 2001 From: "3371392206@qq.com" <3371392206@qq.com> Date: Mon, 21 Sep 2026 10:50:03 +0800 Subject: [PATCH 07/12] =?UTF-8?q?fix(setting):=20runtime=20config=20?= =?UTF-8?q?=E5=85=A8=E5=B1=80=E5=8F=98=E9=87=8F=E5=8A=A0=20sync.RWMutex=20?= =?UTF-8?q?=E4=BF=9D=E6=8A=A4,=E9=81=BF=E5=85=8D=E5=8D=8A=E5=86=99?= =?UTF-8?q?=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TTSOptions / TTSTimeout / TTSConfigErr / Auth.APIKeys / CORS 在运行期会被 LoadRuntimeConfig(PUT /api/settings 触发)整体替换,struct 整体赋值不是原子的, 若 TTS 请求正并发读,可能拿到半写状态。单用户场景概率低,属正确性隐患。 改动: - setting/config.go: 把可变运行时配置迁到包内私有变量,新增 ttsMu (sync.RWMutex) 统一保护,提供 Get*/Set* 访问器 - GetTTSOptions/SetTTSOptions: 值类型快照,读端无锁开销 - GetTTSTimeout/SetTTSTimeout - GetTTSConfigErr/SetTTSConfigErr - GetAuthAPIKeys/SetAuthAPIKeys: 拷贝进出,防止外部持有底层 slice - GetCORSAllowAll/GetCORSOrigins/SetCORS: AllowAll 和 Origins 拆开, 避免热路径 CORSConfig 整体读时锁粒度过粗 - 删除导出可变全局 var TTSOptions/TTSTimeout/TTSConfigErr/Auth/CORS, 编译期强制所有读写走 Get*/Set* - Server.Port/TrustedProxyHops/SetupToken 仅启动期写,运行期无并发修改, 保持原状不加锁 - LoadRuntimeConfig/InitAuthConfig/InitCORSConfig 全切到 Set* - CheckEnvironmentVariables/LogStartupSummary 一次性拿快照, 缩短锁占用窗口,避免多次 log.Printf 之间数据被替换 调用点: - controller/tts.go: 热路径 opts := setting.GetTTSOptions(), context 超时走 GetTTSTimeout(),/health 块用 GetTTSConfigErr - controller/settings.go: 写改 SetAuthAPIKeys/SetCORS - controller/setup.go: 写改 SetAuthAPIKeys - middleware/auth.go: 鉴权改 GetAuthAPIKeys()(在 RLock 下拿拷贝) - middleware/admin_auth.go: 同上 - middleware/cors.go: matchOrigin 改 GetCORSAllowAll/GetCORSOrigins 读多写少,RWMutex 读路径不互斥,不会拖慢热路径。 --- controller/settings.go | 21 ++-- controller/setup.go | 6 +- controller/tts.go | 22 ++-- main.go | 3 +- middleware/admin_auth.go | 4 +- middleware/auth.go | 5 +- middleware/cors.go | 4 +- setting/config.go | 260 ++++++++++++++++++++++++++++----------- 8 files changed, 221 insertions(+), 104 deletions(-) diff --git a/controller/settings.go b/controller/settings.go index 788016d..0a0b0eb 100644 --- a/controller/settings.go +++ b/controller/settings.go @@ -261,7 +261,7 @@ type SettingsAuthKeyRequest struct { } // SettingsAuthKeyHandler PUT /api/settings/auth-key -// 鉴权: RequireAdmin。改完立即更新 setting.Auth.APIKeys(进程内生效), +// 鉴权: RequireAdmin。改完立即刷新鉴权 key 列表(setting.SetAuthAPIKeys,进程内生效), // 下一个请求就用新 key — admin 自己改完要等下一次请求才能验证(避免改完立刻自踢)。 func SettingsAuthKeyHandler(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPut { @@ -292,7 +292,7 @@ func SettingsAuthKeyHandler(w http.ResponseWriter, r *http.Request) { } // 立即生效:不重新 LoadRuntimeConfig(那会覆盖其它字段), // 只单独刷新 Auth.APIKeys - setting.Auth.APIKeys = []string{key} + setting.SetAuthAPIKeys([]string{key}) log.Printf("[settings] auth_key updated, runtime active (next request uses new key)") w.Header().Set("Content-Type", "application/json; charset=utf-8") _ = json.NewEncoder(w).Encode(map[string]any{"ok": true}) @@ -309,7 +309,7 @@ type SettingsCORSRequest struct { } // SettingsCORSHandler PUT /api/settings/cors -// 鉴权: RequireAdmin。改完立即更新 setting.CORS(进程内生效,跨域请求从下个请求开始按新配置)。 +// 鉴权: RequireAdmin。改完立即刷新 CORS(setting.SetCORS,进程内生效,跨域请求从下个请求开始按新配置)。 // 同源豁免由 middleware/cors.go 的 isSameOrigin 处理,不在这里管。 func SettingsCORSHandler(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPut { @@ -369,7 +369,7 @@ func SettingsCORSHandler(w http.ResponseWriter, r *http.Request) { return } - // 立即刷新 setting.CORS,跨域请求从下个请求开始按新配置生效 + // 立即刷新 CORS(setting.SetCORS),跨域请求从下个请求开始按新配置生效 // 复用 LoadRuntimeConfig 的解析逻辑(只取 cors 部分,避免覆盖其它运行时字段) corsAllowAll, _ := s.SettingsGetBool("cors_allow_all", false) originsStr := "" @@ -377,20 +377,17 @@ func SettingsCORSHandler(w http.ResponseWriter, r *http.Request) { originsStr = v } if corsAllowAll { - setting.CORS.AllowAll = true - setting.CORS.Origins = nil + setting.SetCORS(true, nil) } else if originsStr != "" { - setting.CORS.AllowAll = false - setting.CORS.Origins = setting.SplitOriginsForCORS(originsStr) + setting.SetCORS(false, setting.SplitOriginsForCORS(originsStr)) } else { - setting.CORS.AllowAll = false - setting.CORS.Origins = nil + setting.SetCORS(false, nil) } - log.Printf("[settings] cors updated (allow_all=%v origins=%q), runtime active", setting.CORS.AllowAll, originsStr) + log.Printf("[settings] cors updated (allow_all=%v origins=%q), runtime active", setting.GetCORSAllowAll(), originsStr) w.Header().Set("Content-Type", "application/json; charset=utf-8") _ = json.NewEncoder(w).Encode(map[string]any{ "ok": true, - "allow_all": setting.CORS.AllowAll, + "allow_all": setting.GetCORSAllowAll(), "origins": originsStr, "cors_active": true, }) diff --git a/controller/setup.go b/controller/setup.go index f89335e..de61ade 100644 --- a/controller/setup.go +++ b/controller/setup.go @@ -169,11 +169,11 @@ func SetupSubmitHandler(w http.ResponseWriter, r *http.Request) { return } - // 立即把 auth_key 灌到 setting.Auth.APIKeys,这样后续 /v1/audio/speech 和 /admin - // 在本进程内能立刻用新 key(无需等 LoadRuntimeConfig)。 + // 立即把 auth_key 灌到鉴权 key 列表(setting.SetAuthAPIKeys),这样后续 + // /v1/audio/speech 和 /admin 在本进程内能立刻用新 key(无需等 LoadRuntimeConfig)。 authKey := strings.TrimSpace(body.Settings["auth_key"]) if authKey != "" { - setting.Auth.APIKeys = []string{authKey} + setting.SetAuthAPIKeys([]string{authKey}) } // 装完 reload TTS 全局配置(让 TTSOptions 立即有可用的 api_key/speaker/resource_id, diff --git a/controller/tts.go b/controller/tts.go index 3c4e7b7..5292674 100644 --- a/controller/tts.go +++ b/controller/tts.go @@ -40,7 +40,7 @@ func truncateForLog(b []byte, max int) string { } // resolveClientFormat 把 OpenAI 风格的 response_format 映射为最终输出格式; -// 不识别或未指定时回退到 setting.TTSOptions.Format。 +// 不识别或未指定时回退到 setting.GetTTSOptions().Format。 func resolveClientFormat(reqFmt string) string { switch strings.ToLower(reqFmt) { case "mp3", "wav", "opus", "pcm", "aac", "flac": @@ -49,7 +49,7 @@ func resolveClientFormat(reqFmt string) string { } return strings.ToLower(reqFmt) } - return setting.TTSOptions.Format + return setting.GetTTSOptions().Format } // OpenaiTTSHandler 是 /v1/audio/speech 的入口。 @@ -81,9 +81,9 @@ func OpenaiTTSHandler(w http.ResponseWriter, r *http.Request) { return } - if setting.TTSConfigErr != nil { + if err := setting.GetTTSConfigErr(); err != nil { log.Printf("警告: TTS配置未就绪,拒绝请求 - 错误=%v 路径=%s 客户端=%s", - setting.TTSConfigErr, r.URL.Path, middleware.GetClientIP(r)) + err, r.URL.Path, middleware.GetClientIP(r)) middleware.SendJSONError(w, http.StatusServiceUnavailable, "TTS service configuration error. Please check environment variables and restart the service.", "configuration_error", "service_unavailable") return } @@ -151,7 +151,7 @@ func OpenaiTTSHandler(w http.ResponseWriter, r *http.Request) { clientFormat := resolveClientFormat(req.ResponseFormat) - opts := setting.TTSOptions + opts := setting.GetTTSOptions() opts.Text = req.Input // M3: voice 路由 @@ -183,7 +183,7 @@ func OpenaiTTSHandler(w http.ResponseWriter, r *http.Request) { "voice lookup failed", "server_error", "db_read_failed") return } - // 覆盖 opts(API key / UID 保留自 setting.TTSOptions) + // 覆盖 opts(API key / UID 保留自 setting.GetTTSOptions 快照) if !v.Enabled { log.Printf("警告: voice=%q 已禁用 - 客户端=%s", req.Voice, middleware.GetClientIP(r)) middleware.SendJSONError(w, http.StatusForbidden, @@ -200,7 +200,7 @@ func OpenaiTTSHandler(w http.ResponseWriter, r *http.Request) { req.Voice, telemetry.MaskSpeaker(v.Speaker), telemetry.MaskResourceID(v.ResourceID), v.Model, middleware.GetClientIP(r)) } - ctx, cancel := context.WithTimeout(r.Context(), setting.TTSTimeout) + ctx, cancel := context.WithTimeout(r.Context(), setting.GetTTSTimeout()) defer cancel() result, err := volcano.Synthesis(ctx, volcanoClient, opts, req.Input, clientFormat, speed, adapterRec) @@ -282,7 +282,7 @@ func HealthHandler(w http.ResponseWriter, r *http.Request) { mode := installer.GetMode() if mode == installer.ModeSetup { w.WriteHeader(http.StatusOK) // 200,因为进程活着,只是还没初始化 - } else if setting.TTSConfigErr != nil { + } else if setting.GetTTSConfigErr() != nil { w.WriteHeader(http.StatusServiceUnavailable) } else { w.WriteHeader(http.StatusOK) @@ -308,8 +308,8 @@ func HealthHandler(w http.ResponseWriter, r *http.Request) { Memory: collectMemorySnapshot(), ConfigStatus: dto.ConfigStatusResponse{ AllRequiredVarsSet: allRequired, - ConfigError: setting.TTSConfigErr != nil, - Error: configErrorMessage(setting.TTSConfigErr), + ConfigError: setting.GetTTSConfigErr() != nil, + Error: configErrorMessage(setting.GetTTSConfigErr()), }, Installed: mode == installer.ModeNormal, Mode: mode.String(), @@ -317,7 +317,7 @@ func HealthHandler(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(resp) } -// configErrorMessage 把 setting.TTSConfigErr 安全地转成可对外暴露的字符串。 +// configErrorMessage 把运行时配置错误(setting.GetTTSConfigErr())安全地转成可对外暴露的字符串。 // 仅在 normal 模式且有错时调用, error 为 nil 时返 "" (被 omitempty 跳过)。 func configErrorMessage(err error) string { if err == nil { diff --git a/main.go b/main.go index 7c2aad0..80c4d7f 100644 --- a/main.go +++ b/main.go @@ -77,8 +77,9 @@ func main() { } log.Printf("[main][WARN] TTS 运行时配置加载失败 (setup mode, 需先 /setup): %v", err) } else { + opts := setting.GetTTSOptions() log.Printf("[main] TTS 运行时配置已加载(api_key=***, speaker=%s, resource=%s, format=%s)", - telemetry.MaskSpeaker(setting.TTSOptions.Speaker), telemetry.MaskResourceID(setting.TTSOptions.ResourceID), setting.TTSOptions.Format) + telemetry.MaskSpeaker(opts.Speaker), telemetry.MaskResourceID(opts.ResourceID), opts.Format) } } diff --git a/middleware/admin_auth.go b/middleware/admin_auth.go index 22e20b7..b1395a6 100644 --- a/middleware/admin_auth.go +++ b/middleware/admin_auth.go @@ -15,7 +15,7 @@ import ( // - Authorization 头 Bearer token 在列表中 → 放行 // - 其它 → 401 + JSON {error: 'unauthorized', code: 'admin_auth_failed'} // -// 设计: 与现有 /v1/audio/speech 用的 setting.Auth 共享同一份 keys, +// 设计: 与现有 /v1/audio/speech 用的鉴权 key 列表(setting.GetAuthAPIKeys)共享同一份 keys, // 用户只用管一个 env 变量(OPENAI_TTS_API_KEY)。 func RequireAdmin(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -25,7 +25,7 @@ func RequireAdmin(next http.Handler) http.Handler { return } - keys := setting.Auth.APIKeys + keys := setting.GetAuthAPIKeys() if len(keys) == 0 { // 没配 admin key,等同无鉴权 next.ServeHTTP(w, r) diff --git a/middleware/auth.go b/middleware/auth.go index 59dc2e7..f0324d8 100644 --- a/middleware/auth.go +++ b/middleware/auth.go @@ -10,7 +10,8 @@ import ( ) func ValidateAPIKey(r *http.Request) bool { - if len(setting.Auth.APIKeys) == 0 { + keys := setting.GetAuthAPIKeys() + if len(keys) == 0 { return true } @@ -24,7 +25,7 @@ func ValidateAPIKey(r *http.Request) bool { } token := strings.TrimPrefix(authHeader, "Bearer ") - for _, validKey := range setting.Auth.APIKeys { + for _, validKey := range keys { if subtle.ConstantTimeCompare([]byte(token), []byte(validKey)) == 1 { return true } diff --git a/middleware/cors.go b/middleware/cors.go index a9d59cd..d9f1ec3 100644 --- a/middleware/cors.go +++ b/middleware/cors.go @@ -29,11 +29,11 @@ func matchOrigin(origin string) (string, bool) { if !isValidOrigin(origin) { return "", false } - if setting.CORS.AllowAll { + if setting.GetCORSAllowAll() { return "*", true } normalized := strings.ToLower(strings.TrimRight(strings.TrimSpace(origin), "/")) - for _, allowed := range setting.CORS.Origins { + for _, allowed := range setting.GetCORSOrigins() { if allowed == normalized { return origin, true } diff --git a/setting/config.go b/setting/config.go index 3ca4a2d..748fee8 100644 --- a/setting/config.go +++ b/setting/config.go @@ -8,6 +8,7 @@ import ( "os" "strconv" "strings" + "sync" "time" "github.com/volcano-tts/tts-api/adapter/volcano" @@ -16,30 +17,129 @@ import ( ) // 全部环境变量读取的单一入口:其它包不允许直接 os.Getenv,只读这里的全局 Config。 - -// TTSOptions 是火山 v3 TTS 调用的完整参数集合,启动期由 LoadRuntimeConfig 从 store 填充。 -// 业务侧(controller)直接读取并传入 volcano.Synthesis。 +// +// 并发模型: +// - TTSOptions / TTSTimeout / TTSConfigErr / Auth.APIKeys / CORS 是运行期可被 +// LoadRuntimeConfig(由 PUT /api/settings 触发)整体替换的"运行时配置快照"。 +// struct 整体赋值不是原子的,若 TTS 请求正在读,可能拿到半写状态。 +// - 解决: 用 ttsMu(RWMutex)统一保护这些字段;读路径用 Get* 拿快照(RLock), +// 写路径用 Set* 整体替换(Lock)。读多写少,RWMutex 读不互斥,不会显著拖慢热路径。 +// - Server.Port / TrustedProxyHops / SetupToken 仅启动期写、运行期读,无并发修改, +// 保持原样不加锁。 var ( - TTSOptions volcano.Options - TTSConfigErr error - // TTSTimeout 单次合成请求的超时;controller 用来派生 context。 - TTSTimeout time.Duration = common.DefaultTimeout + ttsMu sync.RWMutex + ttsOptions volcano.Options + ttsTimeout time.Duration = common.DefaultTimeout + ttsConfigErr error + authAPIKeys []string + // corsAllowAll / corsOrigins 拆成两个独立字段,各自在 RLock 下读取, + // 避免 CORSConfig 整体读时被 Lock 阻塞热路径。 + corsAllowAll bool + corsOrigins []string ) -// AuthConfig OpenAI 兼容接口的客户端 API Key 鉴权配置。 -type AuthConfig struct { - APIKeys []string +// GetTTSOptions 读 TTSOptions 快照(值类型,使用方可以放心使用,不会被并发写破坏)。 +// 提供给业务侧(controller)的统一读取入口;LoadRuntimeConfig 写入时用 SetTTSOptions 整体替换。 +// 业务侧不要缓存这个值跨 goroutine 使用(应该每次调用时重新拿)。 +func GetTTSOptions() volcano.Options { + ttsMu.RLock() + defer ttsMu.RUnlock() + return ttsOptions } -var Auth AuthConfig - -// CORSConfig 跨域白名单配置。 -type CORSConfig struct { - Origins []string - AllowAll bool +// SetTTSOptions 整体替换 TTSOptions。LoadRuntimeConfig 写路径专用。 +func SetTTSOptions(o volcano.Options) { + ttsMu.Lock() + defer ttsMu.Unlock() + ttsOptions = o } -var CORS CORSConfig +// GetTTSTimeout 读当前超时;controller 用它派生 context。 +func GetTTSTimeout() time.Duration { + ttsMu.RLock() + defer ttsMu.RUnlock() + return ttsTimeout +} + +// SetTTSTimeout 整体替换超时。 +func SetTTSTimeout(d time.Duration) { + ttsMu.Lock() + defer ttsMu.Unlock() + ttsTimeout = d +} + +// GetTTSConfigErr 读运行时配置错误;nil 表示就绪。 +func GetTTSConfigErr() error { + ttsMu.RLock() + defer ttsMu.RUnlock() + return ttsConfigErr +} + +// SetTTSConfigErr 设运行时配置错误;nil 表示清错。 +func SetTTSConfigErr(err error) { + ttsMu.Lock() + defer ttsMu.Unlock() + ttsConfigErr = err +} + +// GetAuthAPIKeys 读鉴权 key 列表;返回拷贝防止业务侧持有底层 slice 后被并发写破坏。 +// 外部不应直接读 Auth.APIKeys,统一走 Get*。 +func GetAuthAPIKeys() []string { + ttsMu.RLock() + defer ttsMu.RUnlock() + if len(authAPIKeys) == 0 { + return nil + } + out := make([]string, len(authAPIKeys)) + copy(out, authAPIKeys) + return out +} + +// SetAuthAPIKeys 整体替换鉴权 key 列表;入参被复制以防外部后续修改影响内部状态。 +func SetAuthAPIKeys(keys []string) { + ttsMu.Lock() + defer ttsMu.Unlock() + if len(keys) == 0 { + authAPIKeys = nil + return + } + out := make([]string, len(keys)) + copy(out, keys) + authAPIKeys = out +} + +// GetCORSAllowAll 读 CORS 是否放行所有来源。 +func GetCORSAllowAll() bool { + ttsMu.RLock() + defer ttsMu.RUnlock() + return corsAllowAll +} + +// GetCORSOrigins 读 CORS 白名单;返回拷贝防止业务侧持有后被并发写破坏。 +func GetCORSOrigins() []string { + ttsMu.RLock() + defer ttsMu.RUnlock() + if len(corsOrigins) == 0 { + return nil + } + out := make([]string, len(corsOrigins)) + copy(out, corsOrigins) + return out +} + +// SetCORS 整体替换 CORS 配置;LoadRuntimeConfig 和 PUT /api/settings/cors 写路径专用。 +func SetCORS(allowAll bool, origins []string) { + ttsMu.Lock() + defer ttsMu.Unlock() + corsAllowAll = allowAll + if len(origins) == 0 { + corsOrigins = nil + return + } + out := make([]string, len(origins)) + copy(out, origins) + corsOrigins = out +} // ServerConfig HTTP 服务监听配置。 type ServerConfig struct { @@ -52,6 +152,8 @@ var Server ServerConfig // 表示当前 XFF 解析模式:0=启发式,N>0=精确 N 跳。 // setting.LogStartupSummary 读这个字段以展示运行期配置, // 不直接调用 middleware(避免循环 import)。 +// +// 【并发】仅启动期被 InitRateLimiter 写一次,运行期只读,无并发问题,不加锁。 var TrustedProxyHops int // SetupToken 是安装模式下的初始化凭证。 @@ -60,6 +162,8 @@ var TrustedProxyHops int // 打印到日志(/api/setup 提交时必须带这个 token) // // 安装完成后,/api/setup 端点永久关闭,SetupToken 失去意义但保留在内存。 +// +// 【并发】仅 setup 阶段使用,运行期不会再写,无并发问题,不加锁。 var SetupToken string // SetupTokenSource 标记 SetupToken 的来源,便于日志区分。 @@ -87,7 +191,7 @@ func InitServerConfig() { func InitAuthConfig() { raw := os.Getenv("OPENAI_TTS_API_KEY") if raw == "" { - Auth.APIKeys = nil + SetAuthAPIKeys(nil) return } parts := strings.Split(raw, ",") @@ -98,27 +202,27 @@ func InitAuthConfig() { keys = append(keys, k) } } - Auth.APIKeys = keys + SetAuthAPIKeys(keys) } func InitCORSConfig() { raw := os.Getenv("ALLOWED_ORIGINS") - CORS.Origins = nil - CORS.AllowAll = false - if raw == "" { - return - } - for _, p := range strings.Split(raw, ",") { - o := strings.TrimSpace(p) - if o == "" { - continue + allowAll := false + var origins []string + if raw != "" { + for _, p := range strings.Split(raw, ",") { + o := strings.TrimSpace(p) + if o == "" { + continue + } + if o == "*" { + allowAll = true + continue + } + origins = append(origins, normalizeOrigin(o)) } - if o == "*" { - CORS.AllowAll = true - continue - } - CORS.Origins = append(CORS.Origins, normalizeOrigin(o)) } + SetCORS(allowAll, origins) } func normalizeOrigin(origin string) string { @@ -129,7 +233,7 @@ func normalizeOrigin(origin string) string { // SplitOriginsForCORS 解析逗号/换行/空格分隔的 origins 列表, // 全部小写、trim 末尾 / 后面统一比较。导出供 controller 复用 -// (PUT /api/settings/cors 写完立即刷新 setting.CORS 用)。 +// (PUT /api/settings/cors 写完立即刷新 CORS 用)。 func SplitOriginsForCORS(s string) []string { return splitAndLowerOrigins(s) } @@ -160,6 +264,9 @@ func splitAndLowerOrigins(s string) []string { // - 失败时 TTSConfigErr 被设置,/v1/audio/speech 路由会返 503 // - 成功时清空 TTSConfigErr // +// 【并发】所有写都走 Set* 系列函数,在 ttsMu.Lock 下完成;运行中的 TTS 请求 +// 用 Get* 拿快照不会读到半写状态。 +// // 字段映射(原 env → store key): // BYTEDANCE_TTS_API_KEY → api_key // BYTEDANCE_TTS_RESOURCE_ID → default_resource_id @@ -180,8 +287,9 @@ func splitAndLowerOrigins(s string) []string { func LoadRuntimeConfig(s Store) error { all, err := s.SettingsGetAll() if err != nil { - TTSConfigErr = fmt.Errorf("read settings failed: %w", err) - return TTSConfigErr + wrapped := fmt.Errorf("read settings failed: %w", err) + SetTTSConfigErr(wrapped) + return wrapped } apiKey := all["api_key"] @@ -198,8 +306,9 @@ func LoadRuntimeConfig(s Store) error { missing = append(missing, "default_speaker") } if len(missing) > 0 { - TTSConfigErr = fmt.Errorf("missing required settings: %v", missing) - return TTSConfigErr + wrapped := fmt.Errorf("missing required settings: %v", missing) + SetTTSConfigErr(wrapped) + return wrapped } // 【BUG 修复 · 第二轮】default_speaker 是 voice **名字**(如 "chun"), @@ -248,14 +357,15 @@ func LoadRuntimeConfig(s Store) error { } } - TTSTimeout = common.DefaultTimeout + timeout := common.DefaultTimeout if v, err := s.SettingsGetDuration("timeout", common.DefaultTimeout); err == nil { - TTSTimeout = v + timeout = v } else { - TTSTimeout = common.DefaultTimeout + timeout = common.DefaultTimeout } + SetTTSTimeout(timeout) - TTSOptions = volcano.Options{ + SetTTSOptions(volcano.Options{ APIKey: apiKey, ResourceID: resourceId, UID: "uid", @@ -268,18 +378,17 @@ func LoadRuntimeConfig(s Store) error { LoudnessRate: 0, EnableSubtitle: enableSubtitle, Additions: adds, - } + }) // 鉴权 key:DB > env(向后兼容) authKey := all["auth_key"] if authKey == "" { authKey = os.Getenv("OPENAI_TTS_API_KEY") } - // 用临时 slice 避免和 InitAuthConfig 抢同一个 Auth.APIKeys 底层 if authKey != "" { - Auth.APIKeys = []string{authKey} + SetAuthAPIKeys([]string{authKey}) } else { - Auth.APIKeys = nil + SetAuthAPIKeys(nil) } // CORS 配置:DB > env @@ -298,17 +407,14 @@ func LoadRuntimeConfig(s Store) error { originsStr = os.Getenv("ALLOWED_ORIGINS") } if corsAllowAll { - CORS.AllowAll = true - CORS.Origins = nil + SetCORS(true, nil) } else if originsStr != "" { - CORS.AllowAll = false - CORS.Origins = SplitOriginsForCORS(originsStr) + SetCORS(false, SplitOriginsForCORS(originsStr)) } else { - CORS.AllowAll = false - CORS.Origins = nil + SetCORS(false, nil) } - TTSConfigErr = nil + SetTTSConfigErr(nil) return nil } @@ -388,10 +494,16 @@ func InitSetupToken() { // CheckEnvironmentVariables 返回 /health 用的环境变量状态快照。 func CheckEnvironmentVariables() map[string]interface{} { + // 一次性拿所有需要的快照,缩短锁占用窗口;后续只读本地变量。 + opts := GetTTSOptions() + authKeys := GetAuthAPIKeys() + allowAll := GetCORSAllowAll() + origins := GetCORSOrigins() + required := map[string]bool{ - "BYTEDANCE_TTS_API_KEY": TTSOptions.APIKey != "", - "BYTEDANCE_TTS_RESOURCE_ID": TTSOptions.ResourceID != "", - "BYTEDANCE_TTS_SPEAKER": TTSOptions.Speaker != "", + "BYTEDANCE_TTS_API_KEY": opts.APIKey != "", + "BYTEDANCE_TTS_RESOURCE_ID": opts.ResourceID != "", + "BYTEDANCE_TTS_SPEAKER": opts.Speaker != "", } missing := []string{} for k, ok := range required { @@ -400,12 +512,12 @@ func CheckEnvironmentVariables() map[string]interface{} { } } optional := map[string]bool{ - "BYTEDANCE_TTS_MODEL": TTSOptions.Model != "", - "BYTEDANCE_TTS_FORMAT": TTSOptions.Format != "mp3", - "BYTEDANCE_TTS_SAMPLE_RATE": TTSOptions.SampleRate != 24000, - "BYTEDANCE_TTS_EXPLICIT_LANGUAGE": TTSOptions.Additions != nil && TTSOptions.Additions.ExplicitLanguage != "", - "OPENAI_TTS_API_KEY": len(Auth.APIKeys) > 0, - "ALLOWED_ORIGINS": CORS.AllowAll || len(CORS.Origins) > 0, + "BYTEDANCE_TTS_MODEL": opts.Model != "", + "BYTEDANCE_TTS_FORMAT": opts.Format != "mp3", + "BYTEDANCE_TTS_SAMPLE_RATE": opts.SampleRate != 24000, + "BYTEDANCE_TTS_EXPLICIT_LANGUAGE": opts.Additions != nil && opts.Additions.ExplicitLanguage != "", + "OPENAI_TTS_API_KEY": len(authKeys) > 0, + "ALLOWED_ORIGINS": allowAll || len(origins) > 0, "PORT": Server.Port != common.DefaultPort, } return map[string]interface{}{ @@ -421,18 +533,21 @@ func LogStartupSummary() { log.Printf("=== 环境配置汇总 ===") log.Printf("服务端口: %s", Server.Port) - if len(Auth.APIKeys) == 0 { + authKeys := GetAuthAPIKeys() + if len(authKeys) == 0 { log.Printf("OPENAI_TTS_API_KEY: 未设置(所有请求无需鉴权)") } else { - log.Printf("OPENAI_TTS_API_KEY: 已设置 %d 个有效密钥", len(Auth.APIKeys)) + log.Printf("OPENAI_TTS_API_KEY: 已设置 %d 个有效密钥", len(authKeys)) } - if CORS.AllowAll { + allowAll := GetCORSAllowAll() + origins := GetCORSOrigins() + if allowAll { log.Printf("ALLOWED_ORIGINS: *(允许所有跨域;不可与鉴权共用)") - } else if len(CORS.Origins) == 0 { + } else if len(origins) == 0 { log.Printf("ALLOWED_ORIGINS: 未设置(跨域请求将被拒绝)") } else { - log.Printf("ALLOWED_ORIGINS: 已配置 %d 个允许的跨域来源白名单", len(CORS.Origins)) + log.Printf("ALLOWED_ORIGINS: 已配置 %d 个允许的跨域来源白名单", len(origins)) } if h := TrustedProxyHops; h == 0 { @@ -441,6 +556,9 @@ func LogStartupSummary() { log.Printf("TRUSTED_PROXY_HOPS: 精确模式,信任 %d 跳反代", h) } + // 一次性拿 TTSOptions 快照;后续只读本地变量,避免在多个 log.Printf 调用之间 + // 被 LoadRuntimeConfig 整体替换导致打印出不连贯的数据。 + opts := GetTTSOptions() log.Printf("火山 TTS 必填项状态:") type ttsCheck struct { name string @@ -448,10 +566,10 @@ func LogStartupSummary() { ok bool } checks := []ttsCheck{ - {"BYTEDANCE_TTS_API_KEY", maskAPIKey(TTSOptions.APIKey), TTSOptions.APIKey != ""}, - {"BYTEDANCE_TTS_RESOURCE_ID", telemetry.MaskResourceID(TTSOptions.ResourceID), TTSOptions.ResourceID != ""}, + {"BYTEDANCE_TTS_API_KEY", maskAPIKey(opts.APIKey), opts.APIKey != ""}, + {"BYTEDANCE_TTS_RESOURCE_ID", telemetry.MaskResourceID(opts.ResourceID), opts.ResourceID != ""}, // speaker 是火山复刻音色 ID(用户付费资产),日志里打码,避免明文落盘 - {"BYTEDANCE_TTS_SPEAKER", telemetry.MaskSpeaker(TTSOptions.Speaker), TTSOptions.Speaker != ""}, + {"BYTEDANCE_TTS_SPEAKER", telemetry.MaskSpeaker(opts.Speaker), opts.Speaker != ""}, } missingCount := 0 for _, c := range checks { @@ -467,7 +585,7 @@ func LogStartupSummary() { log.Printf(" %s %s: %s", mark, c.name, val) } - if TTSConfigErr != nil { + if err := GetTTSConfigErr(); err != nil { log.Printf("火山 TTS 整体: 初始化失败(%d 个必填项缺失),/v1/audio/speech 路由将全部返回 500", missingCount) } else { log.Printf("火山 TTS 整体: 初始化成功") From 6ed25e8e4b67bcd2ddccd2be4f570a1d1878663b Mon Sep 17 00:00:00 2001 From: "3371392206@qq.com" <3371392206@qq.com> Date: Mon, 21 Sep 2026 11:14:48 +0800 Subject: [PATCH 08/12] =?UTF-8?q?fix(ratelimit):=20=E5=BC=BA=E5=88=B6?= =?UTF-8?q?=E6=B8=85=E7=90=86=E6=8C=89=E6=9C=80=E6=97=A7=E6=B4=BB=E8=B7=83?= =?UTF-8?q?=E6=97=B6=E9=97=B4=E6=8E=92=E5=BA=8F,=E9=81=BF=E5=85=8D?= =?UTF-8?q?=E9=9A=8F=E6=9C=BA=E5=88=A0=E6=B4=BB=E8=B7=83=E7=94=A8=E6=88=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原版用 'for k := range rl.requests' 直接删除超限条目,Go map 遍历顺序 随机,可能命中 timestamp 仍在窗口内的活跃用户,导致其下次请求拿到新配额 (限流语义被破坏)。 同时存在 abuse vector: 攻击者用大量伪造 IP 撑爆 map 触发清理,反而 '清洗'掉自己留的活跃条目,绕过限流。 修复: - 收集 (key, lastTs) 到 slice,按 lastTs 升序排(最旧在前) - lastTs 取 timestamps 末尾(Allow 总 append 到末尾,即最近请求时间) - 从前往后逐个 delete,直到 len(rl.requests) <= MaxRateLimiterEntries/2 - 阈值/触发条件/warning 日志保持不变 复杂度 O(n log n),仅在超 MaxRateLimiterEntries(10w)时触发,代价可接受。 cleanup 仍在 Allow 入口的 rl.mutex.Lock() 区域内,无需额外锁。 --- middleware/ratelimit.go | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/middleware/ratelimit.go b/middleware/ratelimit.go index 8cddfc4..9a98c23 100644 --- a/middleware/ratelimit.go +++ b/middleware/ratelimit.go @@ -5,6 +5,7 @@ import ( "net" "net/http" "os" + "sort" "strconv" "strings" "sync" @@ -128,11 +129,32 @@ func (rl *RateLimiter) cleanup() { if len(rl.requests) > common.MaxRateLimiterEntries { log.Printf("警告: 限流器条目数 %d 超过上限 %d,触发强制清理", len(rl.requests), common.MaxRateLimiterEntries) - for k := range rl.requests { + // 【修复】原版用 `for k := range rl.requests` 删,Go map 遍历顺序随机, + // 会随机删掉活跃用户(其条目 timestamp 仍在窗口内),导致该用户下次请求 + // 拿到新配额 — 攻击者可用大量伪造 IP 撑爆 map 触发清理,反而"清洗" + // 自己留的活跃条目,绕过限流。 + // 修复:按"最近一次请求时间(lastTs)"升序排序,删最旧的(最可能已离开/低频), + // 保留最活跃用户,语义符合"限流器只淘汰冷条目"的预期。 + // 排序复杂度 O(n log n),但只在超 10w 条目时触发,代价可接受。 + type entry struct { + key string + lastTs time.Time + } + entries := make([]entry, 0, len(rl.requests)) + for k, v := range rl.requests { + // 走到这里 v 一定非空(cleanup 第一阶段会把空 timestamps 删掉), + // 取最后一个 timestamp 作为"最近活跃时间"。 + lastTs := v[len(v)-1] + entries = append(entries, entry{key: k, lastTs: lastTs}) + } + sort.Slice(entries, func(i, j int) bool { + return entries[i].lastTs.Before(entries[j].lastTs) + }) + for _, e := range entries { if len(rl.requests) <= common.MaxRateLimiterEntries/2 { break } - delete(rl.requests, k) + delete(rl.requests, e.key) } } } From 01bc5ea5bf707d08eea44b43c094ebeeb3c35bf0 Mon Sep 17 00:00:00 2001 From: "3371392206@qq.com" <3371392206@qq.com> Date: Mon, 21 Sep 2026 17:22:45 +0800 Subject: [PATCH 09/12] =?UTF-8?q?fix(admin):=20VoiceInsert=20=E9=94=99?= =?UTF-8?q?=E8=AF=AF=E6=8C=89=E5=AE=A2=E6=88=B7=E7=AB=AF/=E6=9C=8D?= =?UTF-8?q?=E5=8A=A1=E7=AB=AF=E5=88=86=E6=B5=81,400/500=20=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E6=B7=B7=E6=B7=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原版 controller/admin.go VoiceInsert 错误处理的 default 分支把所有非 ErrDuplicate 错误(含 DB 连接失败等真服务端错误)都当 400 + voice_invalid 返回。问题: - 客户端拿到 400 不会重试,但实际是服务端问题 - 4xx 通常被监控忽略,DB 故障等不到告警 - err.Error() 透传前端,可能泄漏 DB schema / SQL 细节 修复:加 store.ErrInvalid 哨兵,把客户端校验错误(name 格式 / speaker / resource_id 缺失)统一 wrap 进 ErrInvalid,controller 改用 errors.Is 三向分流: - ErrDuplicate → 409 voice_duplicate - ErrInvalid → 400 voice_invalid(剥掉 store 前缀,返用户友好 detail) - 其它(DB 失败 / LastInsertId 失败)→ 500 voice_insert_failed (详细 err 走 log.Printf,不外抛,避免信息泄漏) 新增 stripInvalidPrefix helper 处理 'store: voice invalid: ' 前缀剥离, 保留底层 detail(例如 'speaker is required')给前端展示。 VoiceUpdate 保持不动(本任务范围只到 VoiceInsert);后续若发现类似问题 可复用同一 ErrInvalid 哨兵模式。 --- controller/admin.go | 28 +++++++++++++++++++++++++--- store/voices.go | 16 ++++++++++++---- 2 files changed, 37 insertions(+), 7 deletions(-) diff --git a/controller/admin.go b/controller/admin.go index 490a006..630282f 100644 --- a/controller/admin.go +++ b/controller/admin.go @@ -2,9 +2,11 @@ package controller import ( "encoding/json" + "errors" "fmt" "log" "net/http" + "strings" "sync" "time" @@ -191,14 +193,23 @@ func AdminVoiceCreateHandler(w http.ResponseWriter, r *http.Request) { } id, err := s.VoiceInsert(v) if err != nil { - switch err { - case store.ErrDuplicate: + switch { + case err == store.ErrDuplicate: middleware.SendJSONError(w, http.StatusConflict, fmt.Sprintf("voice name %q already exists", v.Name), "invalid_request_error", "voice_duplicate") + case errors.Is(err, store.ErrInvalid): + // 客户端输入不合法(name 格式 / speaker / resource_id 缺失):400。 + // 走 ErrInvalid 哨兵 + 原始 detail,前端可以直接显示。 + middleware.SendJSONError(w, http.StatusBadRequest, + stripInvalidPrefix(err.Error()), + "invalid_request_error", "voice_invalid") default: + // 兜底:DB 连接失败 / Exec 错误 / LastInsertId 失败等都是服务端问题, + // 返 500 + 通用 message,详细 err 走日志(不直接暴露给前端,避免信息泄漏)。 log.Printf("[admin] voice insert: %v", err) - middleware.SendJSONError(w, http.StatusBadRequest, err.Error(), "invalid_request_error", "voice_invalid") + middleware.SendJSONError(w, http.StatusInternalServerError, + "failed to insert voice", "server_error", "voice_insert_failed") } return } @@ -299,3 +310,14 @@ func AdminVoiceToggleHandler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json; charset=utf-8") _ = json.NewEncoder(w).Encode(updated) } + +// stripInvalidPrefix 把 wrap 后的 ErrInvalid 错误字符串中的 "store: voice invalid: " 前缀剥掉, +// 只保留底层 detail(例如 "speaker is required"),让前端能直接展示用户友好文案。 +// 如果上游 wrap 形态变化(没匹配到前缀),原样返回,避免误删关键信息。 +func stripInvalidPrefix(s string) string { + const prefix = "store: voice invalid: " + if strings.HasPrefix(s, prefix) { + return s[len(prefix):] + } + return s +} diff --git a/store/voices.go b/store/voices.go index 132843c..6d265b8 100644 --- a/store/voices.go +++ b/store/voices.go @@ -36,6 +36,11 @@ var ErrInUse = errors.New("store: voice is referenced by default_speaker") // ErrNotFound 表示按 id/name 找不到;controller 翻译为 404。 var ErrNotFound = errors.New("store: voice not found") +// ErrInvalid 表示客户端输入不合法(name 格式 / 必填字段缺失); +// controller 用 errors.Is(err, ErrInvalid) 翻译为 400。 +// 服务端错误(DB 失败等)不会被 wrap,controller 应翻译为 500。 +var ErrInvalid = errors.New("store: voice invalid") + // voiceNameRe 限制 voice 名为 [a-zA-Z0-9_-]{1,64};SQL 注入 + 路径穿越防护。 var voiceNameRe = regexp.MustCompile(`^[a-zA-Z0-9_-]{1,64}$`) @@ -115,7 +120,9 @@ func (s *Store) GetVoiceForTTS(name string) (speaker, resourceID, model string, } // VoiceInsert 新增音色;name 冲突返回 ErrDuplicate。 -// 空字符串/格式不合法返回 error;不依赖 SQLite 约束作为唯一校验。 +// 客户端输入错误(name 格式 / 必填字段缺失)返回 wrap ErrInvalid 的 error; +// 服务端错误(DB 失败等)不被 wrap,controller 用 errors.Is 区分。 +// 不依赖 SQLite 约束作为唯一校验。 func (s *Store) VoiceInsert(v Voice) (int64, error) { v.Name = strings.TrimSpace(v.Name) v.Speaker = strings.TrimSpace(v.Speaker) @@ -125,13 +132,14 @@ func (s *Store) VoiceInsert(v Voice) (int64, error) { v.Description = strings.TrimSpace(v.Description) if err := validateVoiceName(v.Name); err != nil { - return 0, err + // validateVoiceName 返纯文本;这里 wrap 进 ErrInvalid 让 controller 用 errors.Is 判定。 + return 0, fmt.Errorf("%w: %s", ErrInvalid, err.Error()) } if v.Speaker == "" { - return 0, fmt.Errorf("store: voice insert: speaker is required") + return 0, fmt.Errorf("%w: speaker is required", ErrInvalid) } if v.ResourceID == "" { - return 0, fmt.Errorf("store: voice insert: resource_id is required") + return 0, fmt.Errorf("%w: resource_id is required", ErrInvalid) } res, err := s.db.Exec(` From e738fc8e23bb85b09430b613dbd74fadd2c21b02 Mon Sep 17 00:00:00 2001 From: "3371392206@qq.com" <3371392206@qq.com> Date: Mon, 21 Sep 2026 21:31:46 +0800 Subject: [PATCH 10/12] =?UTF-8?q?fix(setup):=20settings=20+=20voices=20?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=E4=BA=8B=E5=8A=A1=E5=8E=9F=E5=AD=90=E6=8F=90?= =?UTF-8?q?=E4=BA=A4,=E5=A4=B1=E8=B4=A5=E6=95=B4=E4=BD=93=E5=9B=9E?= =?UTF-8?q?=E6=BB=9A=E4=B8=8D=E5=86=8D=E5=8D=8A=E6=AE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原版 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 设置,事务安全。 --- controller/setup.go | 96 +++++++++++++++++--------------- store/setup.go | 132 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 182 insertions(+), 46 deletions(-) create mode 100644 store/setup.go 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 +} From 9245c1339abc1d43d6069a2258fd150136aa7b9f Mon Sep 17 00:00:00 2001 From: "3371392206@qq.com" <3371392206@qq.com> Date: Mon, 21 Sep 2026 23:20:59 +0800 Subject: [PATCH 11/12] =?UTF-8?q?refactor(admin):=20=E6=8A=8A=E5=8D=95?= =?UTF-8?q?=E6=96=87=E4=BB=B6=20admin.html=20(1075=20=E8=A1=8C)=20?= =?UTF-8?q?=E6=8B=86=E6=88=90=E5=A4=9A=E9=A1=B5=20SPA,URL=20=E8=B7=AF?= =?UTF-8?q?=E7=94=B1=E5=88=87=E6=8D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原 admin.html 是 1075 行的单文件 SPA:登录/仪表盘/音色/设置 4 个面板 挤在一个 Vue 实例里 tab 切换(login -> app shell -> 3 个 section)。 文件太大,样式 + script + template 混在一起,改一个面板要 scroll 整页找位置。 拆分后: router/admin.html 5.4 KB Dashboard (重定向目标,默认页) router/admin-login.html 2.1 KB 登录页 (无 sidebar) router/admin-voices.html 13.3 KB 音色管理 + Add modal router/admin-settings.html 14.1 KB 系统设置 router/admin.css 19.8 KB 共享样式 (4 个页面 + admin-shell.js 共用) router/admin-shell.js 6.9 KB 共享:http client (401 自动清 key)、toast 系统、 login helper、sidebar 渲染、format helpers 最大的页面 14 KB,平均 10 KB,各页只负责自己逻辑。 技术细节: - 栈保持: Vue 3 + Axios 走 CDN,纯静态 HTML,零构建 - URL 路由: /admin /admin/login /admin/voices /admin/settings, router.go 加 4 条 ServeFile 路由 + 2 条静态资源路由 (/admin/admin.css /admin/admin-shell.js) - 抽 serveAdmin(body []byte) http.HandlerFunc helper 避免 4 段重复 - 鉴权: 各页 onMounted 检 sessionStorage.ttsAdminKey,无则 location.href 跳 /admin/login;http client 401 响应拦截器清 key,下个请求被跳到 login - Sidebar 一致: shell.js mountShell({activeNav, overview, voiceCount, ...}) 渲染