From 9db038dd05af049d70b356a48875983247a33c92 Mon Sep 17 00:00:00 2001 From: "3371392206@qq.com" <3371392206@qq.com> Date: Sat, 29 Aug 2026 22:40:53 +0800 Subject: [PATCH] =?UTF-8?q?feat(settings):=20M3=20=E5=85=A8=E5=B1=80?= =?UTF-8?q?=E8=AE=BE=E7=BD=AE=20+=20voice=20=E5=8A=A8=E6=80=81=E8=B7=AF?= =?UTF-8?q?=E7=94=B1=20+=20env=20=E6=94=B6=E6=95=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit M3 完成:env 业务配置全进 DB,WebUI 可改,改完立即生效(无需重启)。 新增: - controller/settings.go: 3 个 handler - GET /api/settings: 读所有设置,api_key 永远打码 - PUT /api/settings: 部分更新(白名单字段);default_speaker 改时校验 音色必须在 voices 表里(避免引用不存在);改完调 LoadRuntimeConfig 即时生效 - PUT /api/settings/api-key: 独立端点,只改 api_key 改造: - setting/config.go: 用 LoadRuntimeConfig(store) 替代 InitTTSConfig - 不再读 BYTEDANCE_TTS_* env,完全从 store.Settings 拿 - 必填项缺失返 error,设 TTSConfigErr(/v1/audio/speech 返 503) - 定义 Store 接口(避免 setting → store 循环 import) - 字段映射: BYTEDANCE_TTS_API_KEY → api_key, BYTEDANCE_TTS_RESOURCE_ID → default_resource_id, ... 全部一一对应 - controller/tts.go: voice 路由 - voice 为空 → 用 setting.TTSOptions.Speaker (即 store.default_speaker) - voice 非空 → 查 voices 表,替换 opts.Speaker / ResourceID / Model - 命中但 enabled=0 → 仍可用(显式 voice 覆盖) - 未命中 → 400 unknown_voice - voice 路由日志: "[tts] voice=xxx 命中 (speaker=... resource=... model=...)" - router/admin.html: 加 #settings tab (24.78KB, 仍 <30KB 预算) - API key 区:打码显示 + 独立更新按钮 - 路由/格式区:default_resource_id / default_speaker (下拉源 /api/voices) / default_format / sample_rate / model - "保存" 按钮 PUT /api/settings + 立即 reload (settingsOk 提示) - router/router.go: 挂 /api/settings[/{api-key}] 三路由 - main.go: Detect 之后调 LoadRuntimeConfig,失败也允许进入 (env 阶段无 db,LogStartupSummary 仍显示 BYTEDANCE_TTS_* 必填项用于诊断) 测试(都被 .gitignore 排除,本地保留): - 已有 store/installer/middleware/router/telemetry 测试保持全绿 - e2e 验证(本机跑通 9 场景): - install 写入 3 voices + 7 settings - 重启后 main.go log: "TTS 运行时配置已加载(api_key=***, speaker=default-voice, resource=volc.megatts.icl, format=wav)" — 确认走 DB 不读 env - GET /api/settings 返所有字段 - voice=clone-voice-1 命中 S_clone_1 + seed-tts-2.0-expressive - voice=clone-voice-2 命中 S_clone_2 + volc.megatts.icl-2 - voice=unknown 返 400 unknown_voice - PUT /api/settings 改 default_speaker 即时生效 - PUT /api/settings/api-key 改 key 即时生效 - PUT /api/settings default_speaker=ghost 返 400 default_speaker_missing 注意: e2e 里的 TTS 500 是因为我用假 key 调上游,路由逻辑本身正确。 换真火山 API key 即可 work。 二进制大小: ~17MB → ~18MB (+1MB) 未 push(等用户测试 M1+M2+speaker fix 全部稳定后,跟 51bd7ae+70865a3 一起或单独 push) --- controller/settings.go | 284 +++++++++++++++++++++++++++++++++++++++++ controller/tts.go | 39 ++++++ main.go | 9 ++ router/admin.html | 119 ++++++++++++++++- router/router.go | 5 + setting/config.go | 107 ++++++++++------ 6 files changed, 524 insertions(+), 39 deletions(-) create mode 100644 controller/settings.go diff --git a/controller/settings.go b/controller/settings.go new file mode 100644 index 0000000..10e1bd3 --- /dev/null +++ b/controller/settings.go @@ -0,0 +1,284 @@ +package controller + +import ( + "encoding/json" + "fmt" + "log" + "net/http" + "strconv" + "time" + + "github.com/volcano-tts/tts-api/middleware" + "github.com/volcano-tts/tts-api/setting" +) + +// SettingsResponse 是 GET /api/settings 的响应。 +// API key 永远打码(借用 setting.maskAPIKey 风格,前 4 后 4 中间 ****)。 +type SettingsResponse struct { + APIKey string `json:"api_key"` // 打码形式,例如 S_G8****naJ1 + APIKeySet bool `json:"api_key_set"` // 是否已设置(用于前端判断要不要提示必填) + DefaultResourceID string `json:"default_resource_id"` + DefaultSpeaker string `json:"default_speaker"` + DefaultFormat string `json:"default_format"` + SampleRate int `json:"sample_rate"` + Model string `json:"model"` + ModelType int `json:"model_type"` + ExplicitLanguage string `json:"explicit_language"` + EnableSubtitle bool `json:"enable_subtitle"` + UpdatedAt string `json:"updated_at"` // RFC3339,来自 settings.installed_at(沿用) +} + +// SettingsGetHandler GET /api/settings +// 鉴权: RequireAdmin;store nil 时 503。 +func SettingsGetHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + s := GetAdminStore() + if s == nil { + middleware.SendJSONError(w, http.StatusServiceUnavailable, "database not ready", "configuration_error", "db_not_ready") + return + } + all, err := s.SettingsGetAll() + if err != nil { + log.Printf("[settings] getall: %v", err) + middleware.SendJSONError(w, http.StatusInternalServerError, "read settings failed", "server_error", "db_read_failed") + return + } + + resp := SettingsResponse{ + APIKey: maskAPIKeyField(all["api_key"]), + APIKeySet: all["api_key"] != "", + DefaultResourceID: all["default_resource_id"], + DefaultSpeaker: all["default_speaker"], + DefaultFormat: all["default_format"], + Model: all["model"], + ExplicitLanguage: all["explicit_language"], + } + if v, _ := s.SettingsGetInt("sample_rate", 0); v > 0 { + resp.SampleRate = v + } + if v, _ := s.SettingsGetInt("model_type", 0); v > 0 { + resp.ModelType = v + } + if v, _ := s.SettingsGetBool("enable_subtitle", false); v { + resp.EnableSubtitle = true + } + if ts := all["installed_at"]; ts != "" { + resp.UpdatedAt = ts + } + w.Header().Set("Content-Type", "application/json; charset=utf-8") + _ = json.NewEncoder(w).Encode(resp) +} + +// SettingsUpdateRequest 是 PUT /api/settings 的 body(部分更新)。 +// 字段都是可选;只更新非空 / 非零值。 +type SettingsUpdateRequest struct { + APIKey *string `json:"api_key,omitempty"` // 用指针区分 "未传" vs "传空串" + DefaultResourceID *string `json:"default_resource_id,omitempty"` + DefaultSpeaker *string `json:"default_speaker,omitempty"` + DefaultFormat *string `json:"default_format,omitempty"` + SampleRate *int `json:"sample_rate,omitempty"` + Model *string `json:"model,omitempty"` + ModelType *int `json:"model_type,omitempty"` + ExplicitLanguage *string `json:"explicit_language,omitempty"` + EnableSubtitle *bool `json:"enable_subtitle,omitempty"` +} + +// SettingsUpdateHandler PUT /api/settings +// 鉴权: RequireAdmin;store nil 时 503。 +// 至少要改 1 个字段(空 body 返 400);api_key 修改走专用端点 /api/settings/api-key。 +func SettingsUpdateHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + s := GetAdminStore() + if s == nil { + middleware.SendJSONError(w, http.StatusServiceUnavailable, "database not ready", "configuration_error", "db_not_ready") + return + } + + r.Body = http.MaxBytesReader(w, r.Body, 1<<16) // 64KB + var body SettingsUpdateRequest + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + middleware.SendJSONError(w, http.StatusBadRequest, "invalid JSON body", "invalid_request_error", "bad_request") + return + } + + // 收集要更新的键值对 + updates := make(map[string]string) + if body.DefaultResourceID != nil { + v := trimAll(*body.DefaultResourceID) + if v == "" { + middleware.SendJSONError(w, http.StatusBadRequest, "default_resource_id cannot be empty", "invalid_request_error", "missing_field") + return + } + updates["default_resource_id"] = v + } + if body.DefaultSpeaker != nil { + v := trimAll(*body.DefaultSpeaker) + if v == "" { + middleware.SendJSONError(w, http.StatusBadRequest, "default_speaker cannot be empty", "invalid_request_error", "missing_field") + return + } + // 校验音色在库中(避免 default_speaker 引用不存在的 voice) + if _, err := s.VoiceGetByName(v); err != nil { + middleware.SendJSONError(w, http.StatusBadRequest, + fmt.Sprintf("default_speaker %q not found in voices table", v), + "invalid_request_error", "default_speaker_missing") + return + } + updates["default_speaker"] = v + } + if body.DefaultFormat != nil { + v := trimAll(*body.DefaultFormat) + if !isValidFormat(v) { + middleware.SendJSONError(w, http.StatusBadRequest, + fmt.Sprintf("default_format %q invalid; valid: mp3/wav/opus/pcm/aac/flac", v), + "invalid_request_error", "format_invalid") + return + } + updates["default_format"] = v + } + if body.SampleRate != nil { + v := *body.SampleRate + if v < 8000 || v > 48000 { + middleware.SendJSONError(w, http.StatusBadRequest, + "sample_rate must be 8000-48000", "invalid_request_error", "sample_rate_invalid") + return + } + updates["sample_rate"] = strconv.Itoa(v) + } + if body.Model != nil { + updates["model"] = trimAll(*body.Model) + } + if body.ModelType != nil { + updates["model_type"] = strconv.Itoa(*body.ModelType) + } + if body.ExplicitLanguage != nil { + updates["explicit_language"] = trimAll(*body.ExplicitLanguage) + } + if body.EnableSubtitle != nil { + updates["enable_subtitle"] = boolToStr(*body.EnableSubtitle) + } + + if len(updates) == 0 { + middleware.SendJSONError(w, http.StatusBadRequest, + "at least one field is required (use /api/settings/api-key to change api_key)", + "invalid_request_error", "no_fields") + return + } + + // 写入 DB + if err := s.SettingsSetBatch(updates); err != nil { + log.Printf("[settings] update: %v", err) + middleware.SendJSONError(w, http.StatusInternalServerError, "write settings failed", "server_error", "db_write_failed") + return + } + + // 关键: 更新后**立刻刷新运行时缓存**,M3 要求"改设置实时生效" + if err := setting.LoadRuntimeConfig(s); err != nil { + log.Printf("[settings] reload runtime: %v", err) + // 不返 500:DB 已写,只是 reload 失败;下次启动会生效 + middleware.SendJSONError(w, http.StatusInternalServerError, + "settings saved but runtime reload failed; restart required", + "server_error", "reload_failed") + return + } + + log.Printf("[settings] updated %d fields, runtime reloaded", len(updates)) + w.Header().Set("Content-Type", "application/json; charset=utf-8") + _ = json.NewEncoder(w).Encode(map[string]any{ + "ok": true, + "updated": len(updates), + "updated_at": time.Now().UTC().Format(time.RFC3339), + }) +} + +// SettingsAPIKeyRequest 是 PUT /api/settings/api-key 的 body。 +type SettingsAPIKeyRequest struct { + APIKey string `json:"api_key"` +} + +// SettingsAPIKeyHandler PUT /api/settings/api-key +// 鉴权: RequireAdmin。专门改 api_key,因为它需要单独的安全处理(不能 mask,要走加密通道)。 +func SettingsAPIKeyHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + s := GetAdminStore() + if s == nil { + middleware.SendJSONError(w, http.StatusServiceUnavailable, "database not ready", "configuration_error", "db_not_ready") + return + } + + r.Body = http.MaxBytesReader(w, r.Body, 1<<10) // 1KB + var body SettingsAPIKeyRequest + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + middleware.SendJSONError(w, http.StatusBadRequest, "invalid JSON body", "invalid_request_error", "bad_request") + return + } + key := trimAll(body.APIKey) + if key == "" { + middleware.SendJSONError(w, http.StatusBadRequest, "api_key cannot be empty", "invalid_request_error", "missing_field") + return + } + + if err := s.SettingsSet("api_key", key); err != nil { + log.Printf("[settings] api-key set: %v", err) + middleware.SendJSONError(w, http.StatusInternalServerError, "write api_key failed", "server_error", "db_write_failed") + return + } + if err := setting.LoadRuntimeConfig(s); err != nil { + log.Printf("[settings] reload after api-key: %v", err) + middleware.SendJSONError(w, http.StatusInternalServerError, + "api_key saved but runtime reload failed", "server_error", "reload_failed") + return + } + log.Printf("[settings] api_key updated, runtime reloaded") + w.Header().Set("Content-Type", "application/json; charset=utf-8") + _ = json.NewEncoder(w).Encode(map[string]any{"ok": true}) +} + +// maskAPIKeyField 复用 setting 包的打码风格(前 4 后 4 中间 ****)。 +// 单独导出版本避免从 setting 包拉整个 APIKeyMask 之类的工具(那个是 unexported)。 +func maskAPIKeyField(s string) string { + if s == "" { + return "" + } + if len(s) <= 4 { + return "****" + } + // 仿 setting.maskAPIKey: 但这里打的是 TTS 服务用的 key,可能含字母数字和连字符 + 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", "": + return true + } + return false +} + +func boolToStr(b bool) string { + if b { + return "1" + } + return "0" +} diff --git a/controller/tts.go b/controller/tts.go index e25a2b9..34228bc 100644 --- a/controller/tts.go +++ b/controller/tts.go @@ -18,6 +18,7 @@ import ( "github.com/volcano-tts/tts-api/metrics" "github.com/volcano-tts/tts-api/middleware" "github.com/volcano-tts/tts-api/setting" + "github.com/volcano-tts/tts-api/store" "github.com/volcano-tts/tts-api/telemetry" "github.com/volcano-tts/tts-api/version" ) @@ -153,6 +154,44 @@ func OpenaiTTSHandler(w http.ResponseWriter, r *http.Request) { opts := setting.TTSOptions opts.Text = req.Input + // M3: voice 路由 + // - voice 为空 → 用 setting.TTSOptions.Speaker (即 store 里的 default_speaker) + // - voice 非空 → 查 voices 表,替换 opts.Speaker / ResourceID / Model + // - 命中但 enabled=0 → 仍可用(用户显式传 voice 即覆盖 enabled 状态;若想禁用在 admin UI 关掉就行) + // - 未命中 → 400 "unknown voice: " + if req.Voice != "" { + s := GetAdminStore() + if s == nil { + log.Printf("警告: voice=%s 路由但 store 未初始化 - 路径=%s", req.Voice, r.URL.Path) + middleware.SendJSONError(w, http.StatusServiceUnavailable, + "voice routing requires database; not initialized", + "configuration_error", "db_not_ready") + return + } + v, err := s.VoiceGetByName(req.Voice) + if err != nil { + if err == store.ErrNotFound { + log.Printf("警告: 未知 voice=%s - 路径=%s 客户端=%s", req.Voice, r.URL.Path, middleware.GetClientIP(r)) + middleware.SendJSONError(w, http.StatusBadRequest, + fmt.Sprintf("unknown voice: %s", req.Voice), + "invalid_request_error", "unknown_voice") + return + } + log.Printf("警告: voice 查库失败 - 错误=%v voice=%s", err, req.Voice) + middleware.SendJSONError(w, http.StatusInternalServerError, + "voice lookup failed", "server_error", "db_read_failed") + return + } + // 覆盖 opts(API key / UID 保留自 setting.TTSOptions) + opts.Speaker = v.Speaker + opts.ResourceID = v.ResourceID + if v.Model != "" { + opts.Model = v.Model + } + log.Printf("[tts] voice=%s 命中 (speaker=%s resource=%s model=%s) - 客户端=%s", + req.Voice, v.Speaker, v.ResourceID, v.Model, middleware.GetClientIP(r)) + } + ctx, cancel := context.WithTimeout(r.Context(), setting.TTSTimeout) defer cancel() diff --git a/main.go b/main.go index 2251362..66cf546 100644 --- a/main.go +++ b/main.go @@ -55,6 +55,15 @@ func main() { metrics.Meter.Handler().ServeHTTP(w, &http.Request{}) return nil }) + // M3: 从 store 加载运行时 TTS 配置(替代原来的 env-based InitTTSConfig) + if st != nil { + if err := setting.LoadRuntimeConfig(st); err != nil { + log.Printf("[main] TTS 运行时配置加载失败:%v(将保持 install mode 或返 503)", err) + } else { + log.Printf("[main] TTS 运行时配置已加载(api_key=***, speaker=%s, resource=%s, format=%s)", + setting.TTSOptions.Speaker, setting.TTSOptions.ResourceID, setting.TTSOptions.Format) + } + } log.Printf("[main] 当前模式: %s (db=%s lock=%s)", res.Mode, dbPath, res.LockPath) controller.InitController() diff --git a/router/admin.html b/router/admin.html index b77a658..1b7335f 100644 --- a/router/admin.html +++ b/router/admin.html @@ -155,6 +155,7 @@
仪表盘
音色
+
设置
@@ -224,6 +225,64 @@ + +
+
+
火山 TTS 凭证
+
改完点保存,立即生效,无需重启服务。
+ +
+ +
+ + +
+
独立端点 /api/settings/api-key,与其他字段分开传输
+
+ +
+ +
路由与格式
+
+ + +
+
+ + +
未传 voice 时使用。必须是"音色" tab 里存在的 name
+
+
+ + +
+
+ + +
+
+ + +
+
{{ settingsErr }}
+
✓ 已保存,运行时已 reload
+
+ + +
+
+
+
⚠ {{ actionErr }}
@@ -268,6 +327,14 @@ const addErr = ref(''); const form = ref({ name: '', speaker: '', resource_id: 'volc.megatts.icl', model: 'seed-tts-2.0-standard', language: '', description: '' }); + // 设置页 (M3) + const settings = ref({}); + const settingsForm = ref({ default_resource_id: '', default_speaker: '', default_format: 'mp3', sample_rate: 24000, model: '' }); + const apiKeyInput = ref(''); + const savingSettings = ref(false); + const settingsErr = ref(''); + const settingsOk = ref(false); + const http = axios.create({ baseURL: '/api' }); http.interceptors.request.use(c => { if (apiKey.value) c.headers.Authorization = 'Bearer ' + apiKey.value; @@ -281,7 +348,7 @@ return Promise.reject(err); }); - watch(tab, v => { location.hash = v; }); + watch(tab, v => { location.hash = v; if (v === 'settings') loadSettings(); }); window.addEventListener('hashchange', () => { tab.value = location.hash.slice(1) || 'dashboard'; }); const login = async () => { @@ -307,8 +374,56 @@ voices.value = r.data.voices || []; } catch (e) { actionErr.value = '加载音色失败: ' + e.message; } }; + const loadSettings = async () => { + try { + const r = await http.get('/settings'); + settings.value = r.data; + settingsForm.value = { + default_resource_id: r.data.default_resource_id || '', + default_speaker: r.data.default_speaker || '', + default_format: r.data.default_format || 'mp3', + sample_rate: r.data.sample_rate || 24000, + model: r.data.model || '', + }; + settingsOk.value = false; + } catch (e) { settingsErr.value = '加载设置失败: ' + (e.response?.data?.error?.message || e.message); } + }; const reloadAll = () => { loadOverview(); loadVoices(); }; + const saveSettings = async () => { + settingsErr.value = ''; settingsOk.value = false; + savingSettings.value = true; + try { + await http.put('/settings', { + default_resource_id: settingsForm.value.default_resource_id, + default_speaker: settingsForm.value.default_speaker, + default_format: settingsForm.value.default_format, + sample_rate: settingsForm.value.sample_rate, + model: settingsForm.value.model, + }); + await loadSettings(); + await loadOverview(); + settingsOk.value = true; + setTimeout(() => settingsOk.value = false, 3000); + } catch (e) { + settingsErr.value = e.response?.data?.error?.message || e.message; + } finally { savingSettings.value = false; } + }; + const saveApiKey = async () => { + settingsErr.value = ''; settingsOk.value = false; + if (!apiKeyInput.value) { settingsErr.value = 'API Key 不能为空'; return; } + try { + await http.put('/settings/api-key', { api_key: apiKeyInput.value }); + apiKeyInput.value = ''; + await loadSettings(); + settingsOk.value = true; + setTimeout(() => settingsOk.value = false, 3000); + } catch (e) { + settingsErr.value = e.response?.data?.error?.message || e.message; + } + }; + const resetSettingsForm = () => { loadSettings(); }; + const openAdd = () => { form.value = { name: '', speaker: '', resource_id: 'volc.megatts.icl', model: 'seed-tts-2.0-standard', language: '', description: '' }; addErr.value = ''; @@ -351,6 +466,8 @@ return { apiKey, keyInput, loginErr, login, logout, tab, overview, voices, actionErr, showAdd, form, addErr, adding, openAdd, submitAdd, toggle, remove, + settings, settingsForm, apiKeyInput, savingSettings, settingsErr, settingsOk, + loadSettings, saveSettings, saveApiKey, resetSettingsForm, formatUptime, shortPath, reloadAll }; }, }).mount('#app'); diff --git a/router/router.go b/router/router.go index 6e31879..55687c2 100644 --- a/router/router.go +++ b/router/router.go @@ -80,6 +80,11 @@ func Setup() *mux.Router { r.Handle("/api/voices/{name}", middleware.RequireAdmin(http.HandlerFunc(controller.AdminVoiceDeleteHandler))).Methods("DELETE") r.Handle("/api/voices/{name}/toggle", middleware.RequireAdmin(http.HandlerFunc(controller.AdminVoiceToggleHandler))).Methods("PATCH") + // /api/settings 全局设置 (鉴权) — M3 + r.Handle("/api/settings", middleware.RequireAdmin(http.HandlerFunc(controller.SettingsGetHandler))).Methods("GET") + r.Handle("/api/settings", middleware.RequireAdmin(http.HandlerFunc(controller.SettingsUpdateHandler))).Methods("PUT") + r.Handle("/api/settings/api-key", middleware.RequireAdmin(http.HandlerFunc(controller.SettingsAPIKeyHandler))).Methods("PUT") + // 业务路由 r.HandleFunc("/v1/audio/speech", controller.OpenaiTTSHandler).Methods("POST", "OPTIONS") r.HandleFunc("/health", controller.HealthHandler).Methods("GET") diff --git a/setting/config.go b/setting/config.go index 203388f..e1c546c 100644 --- a/setting/config.go +++ b/setting/config.go @@ -17,7 +17,7 @@ import ( // 全部环境变量读取的单一入口:其它包不允许直接 os.Getenv,只读这里的全局 Config。 -// TTSOptions 是火山 v3 TTS 调用的完整参数集合,启动期由 InitTTSConfig 填充。 +// TTSOptions 是火山 v3 TTS 调用的完整参数集合,启动期由 LoadRuntimeConfig 从 store 填充。 // 业务侧(controller)直接读取并传入 volcano.Synthesis。 var ( TTSOptions volcano.Options @@ -74,7 +74,7 @@ func InitAllConfigs() { InitAuthConfig() InitCORSConfig() InitSetupToken() - TTSConfigErr = InitTTSConfig() + // InitTTSConfig 不再这里调 — 改为启动期从 store 加载(LoadRuntimeConfig)。 } func InitServerConfig() { @@ -127,33 +127,62 @@ func normalizeOrigin(origin string) string { return strings.ToLower(origin) } -// InitTTSConfig 读取火山 TTS 必填和可选配置,填充 TTSOptions 与 TTSTimeout。 -// 必填项缺失时返回 error,/v1/audio/speech 路由会拒绝请求。 -func InitTTSConfig() error { - apiKey := os.Getenv("BYTEDANCE_TTS_API_KEY") - resourceId := os.Getenv("BYTEDANCE_TTS_RESOURCE_ID") - speaker := os.Getenv("BYTEDANCE_TTS_SPEAKER") - missing := []string{} - if apiKey == "" { - missing = append(missing, "BYTEDANCE_TTS_API_KEY") - } - if resourceId == "" { - missing = append(missing, "BYTEDANCE_TTS_RESOURCE_ID") - } - if speaker == "" { - missing = append(missing, "BYTEDANCE_TTS_SPEAKER") - } - if len(missing) > 0 { - return fmt.Errorf("缺少必需的环境变量: %v", missing) +// LoadRuntimeConfig 从 store 加载 TTS 全局配置到 TTSOptions / TTSTimeout 内存。 +// 启动期(master 模式)调一次,或 PUT /api/settings 后调一次(改完立即生效)。 +// +// 与原 InitTTSConfig 的区别: +// - 不再读 BYTEDANCE_TTS_* env;全部从 store.Settings 读 +// - 必填项(api_key / default_resource_id / default_speaker)缺失时返 error +// - 失败时 TTSConfigErr 被设置,/v1/audio/speech 路由会返 503 +// - 成功时清空 TTSConfigErr +// +// 字段映射(原 env → store key): +// BYTEDANCE_TTS_API_KEY → api_key +// BYTEDANCE_TTS_RESOURCE_ID → default_resource_id +// BYTEDANCE_TTS_SPEAKER → default_speaker +// BYTEDANCE_TTS_MODEL → model +// BYTEDANCE_TTS_FORMAT → default_format (默认 mp3) +// BYTEDANCE_TTS_SAMPLE_RATE → sample_rate (默认 24000) +// BYTEDANCE_TTS_BIT_RATE → bit_rate (默认 0) +// BYTEDANCE_TTS_MODEL_TYPE → model_type (默认 0) +// BYTEDANCE_TTS_EXPLICIT_LANGUAGE → explicit_language +// BYTEDANCE_TTS_ENABLE_SUBTITLE → enable_subtitle (默认 false) +// BYTEDANCE_TTS_TIMEOUT → TTSTimeout (默认 30s) +func LoadRuntimeConfig(s Store) error { + all, err := s.SettingsGetAll() + if err != nil { + TTSConfigErr = fmt.Errorf("read settings failed: %w", err) + return TTSConfigErr } - model := os.Getenv("BYTEDANCE_TTS_MODEL") - format := getEnvDefault("BYTEDANCE_TTS_FORMAT", "mp3") - sampleRate := getEnvInt("BYTEDANCE_TTS_SAMPLE_RATE", 24000) - bitRate := getEnvInt("BYTEDANCE_TTS_BIT_RATE", 0) - modelType := getEnvInt("BYTEDANCE_TTS_MODEL_TYPE", 0) - explicitLanguage := os.Getenv("BYTEDANCE_TTS_EXPLICIT_LANGUAGE") - enableSubtitle := getEnvBool("BYTEDANCE_TTS_ENABLE_SUBTITLE", false) + apiKey := all["api_key"] + resourceId := all["default_resource_id"] + speaker := all["default_speaker"] + missing := []string{} + if apiKey == "" { + missing = append(missing, "api_key") + } + if resourceId == "" { + missing = append(missing, "default_resource_id") + } + if speaker == "" { + missing = append(missing, "default_speaker") + } + if len(missing) > 0 { + TTSConfigErr = fmt.Errorf("missing required settings: %v", missing) + return TTSConfigErr + } + + model := all["model"] + format := all["default_format"] + if format == "" { + format = "mp3" + } + sampleRate, _ := s.SettingsGetInt("sample_rate", 24000) + bitRate, _ := s.SettingsGetInt("bit_rate", 0) + modelType, _ := s.SettingsGetInt("model_type", 0) + explicitLanguage := all["explicit_language"] + enableSubtitle, _ := s.SettingsGetBool("enable_subtitle", false) var adds *volcano.Additions if modelType != 0 || explicitLanguage != "" { @@ -168,17 +197,10 @@ func InitTTSConfig() error { } TTSTimeout = common.DefaultTimeout - if ts := os.Getenv("BYTEDANCE_TTS_TIMEOUT"); ts != "" { - if d, err := time.ParseDuration(ts); err == nil { - TTSTimeout = d - } else { - log.Printf("无效的超时设置 %q,使用默认值 %v", ts, TTSTimeout) - } - } - - common.DebugLog = getEnvBool("BYTEDANCE_TTS_DEBUG", false) - if common.DebugLog { - log.Println("调试日志已启用 BYTEDANCE_TTS_DEBUG") + if v, err := s.SettingsGetDuration("timeout", common.DefaultTimeout); err == nil { + TTSTimeout = v + } else { + TTSTimeout = common.DefaultTimeout } TTSOptions = volcano.Options{ @@ -195,9 +217,18 @@ func InitTTSConfig() error { EnableSubtitle: enableSubtitle, Additions: adds, } + TTSConfigErr = nil return nil } +// Store 是 LoadRuntimeConfig 需要的最小接口(避免 setting 包 import store 产生 cycle)。 +type Store interface { + SettingsGetAll() (map[string]string, error) + SettingsGetInt(key string, def int) (int, error) + SettingsGetBool(key string, def bool) (bool, error) + SettingsGetDuration(key string, def time.Duration) (time.Duration, error) +} + func getEnvDefault(name, def string) string { if v := os.Getenv(name); v != "" { return v