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 @@
/v1/audio/speech,也是登录 /admin 的密码。改完下一次请求立即生效(改完会被当前会话自踢,需重新登录)。/v1/audio/speech,也是登录 /admin 的密码。装完可在 /admin → 设置 改。