diff --git a/controller/settings.go b/controller/settings.go index 10e1bd3..56c2319 100644 --- a/controller/settings.go +++ b/controller/settings.go @@ -17,6 +17,8 @@ import ( type SettingsResponse struct { APIKey string `json:"api_key"` // 打码形式,例如 S_G8****naJ1 APIKeySet bool `json:"api_key_set"` // 是否已设置(用于前端判断要不要提示必填) + AuthKey string `json:"auth_key"` // 鉴权 key 打码(客户端访问 + admin 登录用) + AuthKeySet bool `json:"auth_key_set"` DefaultResourceID string `json:"default_resource_id"` DefaultSpeaker string `json:"default_speaker"` DefaultFormat string `json:"default_format"` @@ -50,6 +52,8 @@ func SettingsGetHandler(w http.ResponseWriter, r *http.Request) { resp := SettingsResponse{ APIKey: maskAPIKeyField(all["api_key"]), APIKeySet: all["api_key"] != "", + AuthKey: maskAPIKeyField(all["auth_key"]), + AuthKeySet: all["auth_key"] != "", DefaultResourceID: all["default_resource_id"], DefaultSpeaker: all["default_speaker"], DefaultFormat: all["default_format"], @@ -243,6 +247,50 @@ func SettingsAPIKeyHandler(w http.ResponseWriter, r *http.Request) { _ = json.NewEncoder(w).Encode(map[string]any{"ok": true}) } +// SettingsAuthKeyRequest 是 PUT /api/settings/auth-key 的 body。 +// auth_key 是 admin 鉴权和 /v1/audio/speech 鉴权用的 key(火山上游 key 是 api_key,这是两套)。 +type SettingsAuthKeyRequest struct { + AuthKey string `json:"auth_key"` +} + +// SettingsAuthKeyHandler PUT /api/settings/auth-key +// 鉴权: RequireAdmin。改完立即更新 setting.Auth.APIKeys(进程内生效), +// 下一个请求就用新 key — admin 自己改完要等下一次请求才能验证(避免改完立刻自踢)。 +func SettingsAuthKeyHandler(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) + var body SettingsAuthKeyRequest + 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.AuthKey) + if key == "" { + middleware.SendJSONError(w, http.StatusBadRequest, "auth_key cannot be empty", "invalid_request_error", "missing_field") + return + } + if err := s.SettingsSet("auth_key", key); err != nil { + log.Printf("[settings] auth-key set: %v", err) + middleware.SendJSONError(w, http.StatusInternalServerError, "write auth_key failed", "server_error", "db_write_failed") + return + } + // 立即生效:不重新 LoadRuntimeConfig(那会覆盖其它字段), + // 只单独刷新 Auth.APIKeys + setting.Auth.APIKeys = []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}) +} + // maskAPIKeyField 复用 setting 包的打码风格(前 4 后 4 中间 ****)。 // 单独导出版本避免从 setting 包拉整个 APIKeyMask 之类的工具(那个是 unexported)。 func maskAPIKeyField(s string) string { diff --git a/controller/setup.go b/controller/setup.go index e758f7c..61065ae 100644 --- a/controller/setup.go +++ b/controller/setup.go @@ -169,6 +169,13 @@ func SetupSubmitHandler(w http.ResponseWriter, r *http.Request) { return } + // 立即把 auth_key 灌到 setting.Auth.APIKeys,这样后续 /v1/audio/speech 和 /admin + // 在本进程内能立刻用新 key(无需等 LoadRuntimeConfig)。 + authKey := strings.TrimSpace(body.Settings["auth_key"]) + if authKey != "" { + setting.Auth.APIKeys = []string{authKey} + } + // 清空旧 voices 再插入(假设是首次安装;若不是,name 冲突会变成 409) // 这里选择 "清空+插入" 语义,符合"setup 是首次安装"的产品定位 // 如果想保留旧 voices,可以改成 UPSERT,但 M1 不做 @@ -224,8 +231,10 @@ func SetupSubmitHandler(w http.ResponseWriter, r *http.Request) { } // validateSetupSettings 校验必填项。 +// auth_key (鉴权) 也是必填 — 让 setup 成为"单一配置入口", +// 用户装完不用再回去设 OPENAI_TTS_API_KEY env。 func validateSetupSettings(m map[string]string) error { - required := []string{"api_key", "default_resource_id", "default_speaker"} + required := []string{"api_key", "auth_key", "default_resource_id", "default_speaker"} var missing []string for _, k := range required { if strings.TrimSpace(m[k]) == "" { diff --git a/main.go b/main.go index 66cf546..0120ac3 100644 --- a/main.go +++ b/main.go @@ -29,11 +29,11 @@ func main() { log.SetFlags(log.LstdFlags | log.Lshortfile) log.SetPrefix("[TTS-Server] ") - // 1) 加载引导环境变量(PORT / OPENAI_TTS_API_KEY / TTS_ADMIN_KEY 等) + // 1) 加载引导环境变量(PORT / TTS_ADMIN_KEY / OPENAI_TTS_API_KEY 兜底) + // 注: Auth.APIKeys 在 InitAllConfigs 里先读 env,后被 LoadRuntimeConfig 覆盖为 DB 值。 setting.InitAllConfigs() metrics.Init() middleware.InitRateLimiter() - setting.LogStartupSummary() // 2) 启动期关键步骤:打开/建库 → 检测 lock → 判定模式 dbPath := ttsDBPath() @@ -47,23 +47,28 @@ func main() { if res.Corrupted { log.Printf("[main] 注意: 启动时检测到 db 损坏并已自愈回退(备份=%s)", res.BackupTo) } - // 注入 setup 控制器需要的 store + dbPath(无论哪种模式都注入,正常模式下备用) + + // 3) 注入 setup/admin 控制器需要的句柄(M1+M2) controller.SetSetupState(st, dbPath) - // 注入 admin 控制器需要的 store + 指标文本写入器(M2) controller.SetAdminStore(st) controller.SetMetricsTextWriter(func(w http.ResponseWriter) error { metrics.Meter.Handler().ServeHTTP(w, &http.Request{}) return nil }) - // M3: 从 store 加载运行时 TTS 配置(替代原来的 env-based InitTTSConfig) + + // 4) M3: 从 store 加载运行时 TTS 配置(替代原来的 env-based InitTTSConfig) + // 必须在 LogStartupSummary 之前,这样日志显示的是真实状态(API key 已从 DB 加载,不再读 env) if st != nil { if err := setting.LoadRuntimeConfig(st); err != nil { - log.Printf("[main] TTS 运行时配置加载失败:%v(将保持 install mode 或返 503)", err) + log.Printf("[main] TTS 运行时配置加载失败:%v", err) } else { log.Printf("[main] TTS 运行时配置已加载(api_key=***, speaker=%s, resource=%s, format=%s)", setting.TTSOptions.Speaker, setting.TTSOptions.ResourceID, setting.TTSOptions.Format) } } + + // 5) 启动摘要日志(此时 Auth.APIKeys 已是 DB 值,日志反映真实状态) + setting.LogStartupSummary() 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 1b7335f..690fc1f 100644 --- a/router/admin.html +++ b/router/admin.html @@ -228,8 +228,21 @@
-
火山 TTS 凭证
-
改完点保存,立即生效,无需重启服务。
+
鉴权 Key (登录 + 客户端调用)
+
客户端用这个 Key 调 /v1/audio/speech,也是登录 /admin 的密码。改完下一次请求立即生效(改完会被当前会话自踢,需重新登录)。
+
+ +
+ + +
+
独立端点 /api/settings/auth-key。改了之后,你的浏览器 401 自动跳回登录页,用新 Key 重登。
+
+
+ +
+
火山 TTS 上游凭证
+
调火山 v3 上游用的凭证。改完点保存,立即生效,无需重启服务。
@@ -331,6 +344,7 @@ const settings = ref({}); const settingsForm = ref({ default_resource_id: '', default_speaker: '', default_format: 'mp3', sample_rate: 24000, model: '' }); const apiKeyInput = ref(''); + const authKeyInput = ref(''); const savingSettings = ref(false); const settingsErr = ref(''); const settingsOk = ref(false); @@ -422,6 +436,23 @@ settingsErr.value = e.response?.data?.error?.message || e.message; } }; + const saveAuthKey = async () => { + settingsErr.value = ''; settingsOk.value = false; + if (!authKeyInput.value) { settingsErr.value = 'Auth Key 不能为空'; return; } + try { + await http.put('/settings/auth-key', { auth_key: authKeyInput.value }); + authKeyInput.value = ''; + // 改完自己会 401,清掉 session key 让用户重新登录 + setTimeout(() => { + sessionStorage.removeItem('ttsAdminKey'); + apiKey.value = ''; + }, 200); + settingsOk.value = true; + setTimeout(() => settingsOk.value = false, 3000); + } catch (e) { + settingsErr.value = e.response?.data?.error?.message || e.message; + } + }; const resetSettingsForm = () => { loadSettings(); }; const openAdd = () => { @@ -466,8 +497,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, + settings, settingsForm, apiKeyInput, authKeyInput, savingSettings, settingsErr, settingsOk, + loadSettings, saveSettings, saveApiKey, saveAuthKey, resetSettingsForm, formatUptime, shortPath, reloadAll }; }, }).mount('#app'); diff --git a/router/router.go b/router/router.go index 55687c2..fc301ce 100644 --- a/router/router.go +++ b/router/router.go @@ -84,6 +84,7 @@ func Setup() *mux.Router { 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.Handle("/api/settings/auth-key", middleware.RequireAdmin(http.HandlerFunc(controller.SettingsAuthKeyHandler))).Methods("PUT") // 业务路由 r.HandleFunc("/v1/audio/speech", controller.OpenaiTTSHandler).Methods("POST", "OPTIONS") diff --git a/router/setup.html b/router/setup.html index 0e131ca..957873b 100644 --- a/router/setup.html +++ b/router/setup.html @@ -96,11 +96,17 @@
-
火山引擎 TTS 凭证
-
从火山引擎控制台获取,API Key 仅在此页面输入,不会回显。
+
OpenAI 鉴权 + 火山引擎 TTS 凭证
+
从控制台/面板获取。两个 Key 仅在此页面输入,不会回显,装完不用再碰 env。
- + + +
客户端用这个 Key 调 /v1/audio/speech,也是登录 /admin 的密码。装完可在 /admin → 设置 改。
+
+
+ +
调火山 v3 上游用的凭证,从 火山控制台 获取。
@@ -166,6 +172,7 @@ createApp({ setup() { const form = ref({ + auth_key: '', api_key: '', default_resource_id: 'volc.megatts.default', default_speaker: '', @@ -200,8 +207,8 @@ const submit = async () => { errorMsg.value = ''; - if (!form.value.api_key || !form.value.default_resource_id || !form.value.default_speaker || !form.value.token) { - errorMsg.value = '请填写所有必填项(API Key / 资源 ID / 默认音色 / Token)'; + if (!form.value.auth_key || !form.value.api_key || !form.value.default_resource_id || !form.value.default_speaker || !form.value.token) { + errorMsg.value = '请填写所有必填项(OpenAI Key / 火山 API Key / 资源 ID / 默认音色 / Token)'; return; } if (!form.value.voices.length || form.value.voices.some(v => !v.name || !v.speaker || !v.resource_id)) { @@ -213,6 +220,7 @@ const r = await axios.post('/api/setup', { token: form.value.token, settings: { + auth_key: form.value.auth_key, api_key: form.value.api_key, default_resource_id: form.value.default_resource_id, default_speaker: form.value.default_speaker, diff --git a/setting/config.go b/setting/config.go index e1c546c..0fcfa28 100644 --- a/setting/config.go +++ b/setting/config.go @@ -127,7 +127,7 @@ func normalizeOrigin(origin string) string { return strings.ToLower(origin) } -// LoadRuntimeConfig 从 store 加载 TTS 全局配置到 TTSOptions / TTSTimeout 内存。 +// LoadRuntimeConfig 从 store 加载 TTS 全局配置到 TTSOptions / TTSTimeout / Auth.APIKeys 内存。 // 启动期(master 模式)调一次,或 PUT /api/settings 后调一次(改完立即生效)。 // // 与原 InitTTSConfig 的区别: @@ -148,6 +148,11 @@ func normalizeOrigin(origin string) string { // BYTEDANCE_TTS_EXPLICIT_LANGUAGE → explicit_language // BYTEDANCE_TTS_ENABLE_SUBTITLE → enable_subtitle (默认 false) // BYTEDANCE_TTS_TIMEOUT → TTSTimeout (默认 30s) +// +// 鉴权 key(auth_key)优先级:DB > env OPENAI_TTS_API_KEY +// - 首次启动(无 DB 数据):用 env,保证向后兼容 +// - 已 install:用 DB,env 不再读 +// - DB 没 auth_key 但 env 有(env fallback):仍用 env func LoadRuntimeConfig(s Store) error { all, err := s.SettingsGetAll() if err != nil { @@ -217,6 +222,19 @@ func LoadRuntimeConfig(s Store) error { 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} + } else { + Auth.APIKeys = nil + } + TTSConfigErr = nil return nil }