fix(setting): runtime config 全局变量加 sync.RWMutex 保护,避免半写状态

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 读路径不互斥,不会拖慢热路径。
This commit is contained in:
sun
2026-09-21 10:50:03 +08:00
parent d6aafa41ff
commit 19d83f7a3b
8 changed files with 221 additions and 104 deletions
+9 -12
View File
@@ -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,
})
+3 -3
View File
@@ -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,
+11 -11
View File
@@ -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 {