diff --git a/.gitignore b/.gitignore index 160bf43..d9390ee 100644 --- a/.gitignore +++ b/.gitignore @@ -25,4 +25,10 @@ Thumbs.db TODO.md # Release artifacts (build output) -release/ \ No newline at end of file +release/ + +# Runtime database artifacts (created by M1 installer flow) +tts.db +tts.db-* +tts.db.* +installed.lock diff --git a/controller/setup.go b/controller/setup.go new file mode 100644 index 0000000..3e79f7c --- /dev/null +++ b/controller/setup.go @@ -0,0 +1,278 @@ +package controller + +import ( + "encoding/json" + "fmt" + "log" + "net/http" + "os" + "strings" + "time" + + "github.com/volcano-tts/tts-api/installer" + "github.com/volcano-tts/tts-api/middleware" + "github.com/volcano-tts/tts-api/setting" + "github.com/volcano-tts/tts-api/store" +) + +// SetupAPIState 是 setup 控制器需要的状态: +// - Store: db 访问,可能为 nil(自愈回退后 store 已关闭,等待重新 setup) +// - DBPath: 用于安装完成时写 lock +type SetupAPIState struct { + Store *store.Store + DBPath string + Token string +} + +// 全局 setup 状态,在 main.go 启动时通过 SetSetupState 注入。 +// 进程内只有一个二进制实例,全局变量是合适的。 +var setupState SetupAPIState + +// SetSetupState 注入 setup 控制器所需的 store + dbPath,启动期调用一次。 +func SetSetupState(s *store.Store, dbPath string) { + setupState.Store = s + setupState.DBPath = dbPath +} + +// GetSetupStore 供 router/main 注入的 store 访问函数。 +func GetSetupStore() *store.Store { return setupState.Store } + +// GetSetupDBPath 供 router/main 注入的 dbPath 访问函数。 +func GetSetupDBPath() string { return setupState.DBPath } + +// SetupStatusHandler GET /api/setup/status +// 始终返回当前模式,无论安装与否;用于部署探针 + 引导页判断。 +func SetupStatusHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + w.Header().Set("Content-Type", "application/json; charset=utf-8") + resp := map[string]any{ + "installed": installer.GetMode() == installer.ModeNormal, + "mode": installer.GetMode().String(), + } + _ = json.NewEncoder(w).Encode(resp) +} + +// SetupPrefillHandler GET /api/setup/prefill +// 仅在安装模式有响应;返回旧 env 变量值,便于引导页预填,实现平滑迁移。 +func SetupPrefillHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + if installer.GetMode() != installer.ModeSetup { + http.Error(w, "not in setup mode", http.StatusNotFound) + return + } + w.Header().Set("Content-Type", "application/json; charset=utf-8") + resp := map[string]any{ + "settings": prefillFromEnv(), + } + _ = json.NewEncoder(w).Encode(resp) +} + +// prefillFromEnv 读 BYTEDANCE_TTS_* 等旧 env,作为引导页预填值。 +// 读不到就返回空串,前端会用默认值。 +func prefillFromEnv() map[string]string { + get := func(k string) string { return os.Getenv(k) } + return map[string]string{ + "api_key": "", // API key 永不回显,即便 env 里有;必须让用户重新输入 + "default_resource_id": get("BYTEDANCE_TTS_RESOURCE_ID"), + "default_speaker": get("BYTEDANCE_TTS_SPEAKER"), + "default_format": get("BYTEDANCE_TTS_FORMAT"), + "sample_rate": get("BYTEDANCE_TTS_SAMPLE_RATE"), + "model": get("BYTEDANCE_TTS_MODEL"), + "model_type": get("BYTEDANCE_TTS_MODEL_TYPE"), + "explicit_language": get("BYTEDANCE_TTS_EXPLICIT_LANGUAGE"), + "enable_subtitle": get("BYTEDANCE_TTS_ENABLE_SUBTITLE"), + "timeout": get("BYTEDANCE_TTS_TIMEOUT"), + } +} + +// SetupRequestBody 是 POST /api/setup 的请求体结构。 +type SetupRequestBody struct { + Token string `json:"token"` + Settings map[string]string `json:"settings"` + Voices []SetupVoice `json:"voices"` +} + +// SetupVoice 是 POST /api/setup 里 voices 数组的条目。 +type SetupVoice struct { + Name string `json:"name"` + Speaker string `json:"speaker"` + ResourceID string `json:"resource_id"` + Model string `json:"model"` + Language string `json:"language"` +} + +// SetupSubmitHandler POST /api/setup +// 校验 token → 校验字段 → 写 settings → 写 voices → 写 lock。 +// 必须在安装模式才接受;装完后永久 404。 +func SetupSubmitHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + // 安装完成后此端点永久关闭(防止被误触) + if installer.GetMode() != installer.ModeSetup { + http.NotFound(w, r) + return + } + + // 解析 body + r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // 1MB + var body SetupRequestBody + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + middleware.SendJSONError(w, http.StatusBadRequest, "invalid JSON body", "invalid_request_error", "bad_request") + return + } + + // token 校验(常量时间比较防计时攻击) + if setting.SetupToken == "" || !secureEqualString(body.Token, setting.SetupToken) { + log.Printf("[setup] token 校验失败 - 客户端=%s", middleware.GetClientIP(r)) + middleware.SendJSONError(w, http.StatusUnauthorized, "invalid setup token", "authentication_error", "invalid_token") + return + } + + // 校验 settings 必填项 + if err := validateSetupSettings(body.Settings); err != nil { + middleware.SendJSONError(w, http.StatusBadRequest, err.Error(), "invalid_request_error", "missing_field") + return + } + + // 校验 voices + if err := validateSetupVoices(body.Voices); err != nil { + middleware.SendJSONError(w, http.StatusBadRequest, err.Error(), "invalid_request_error", "invalid_voice") + return + } + + // 取 store:必须为非 nil(自愈回退后 store 是 nil,这种状态下不接 setup,要求重启) + s := GetSetupStore() + if s == nil { + middleware.SendJSONError(w, http.StatusServiceUnavailable, "database not ready, please restart service", "configuration_error", "db_not_ready") + return + } + + // 写 settings(包含 initialized=1) + settingsKV := make(map[string]string, len(body.Settings)+1) + for k, v := range body.Settings { + settingsKV[k] = v + } + 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") + return + } + + // 清空旧 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 + for _, v := range body.Voices { + _, err := s.VoiceInsert(store.Voice{ + Name: v.Name, + Speaker: v.Speaker, + ResourceID: v.ResourceID, + 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)) + + // 写 lock(原子):从这一刻起,/api/setup 永久关闭 + if err := installer.CreateLock(GetSetupDBPath()); err != nil { + log.Printf("[setup] 写 lock 失败: %v", err) + middleware.SendJSONError(w, http.StatusInternalServerError, "failed to create install lock", "server_error", "lock_write_failed") + return + } + // 切到正常模式(本进程内) + installer.SetMode(installer.ModeNormal) + log.Printf("[setup] 安装完成!后续请求将进入正常模式") + + w.Header().Set("Content-Type", "application/json; charset=utf-8") + _ = json.NewEncoder(w).Encode(map[string]any{ + "ok": true, + "message": "installed", + "redirect": "/admin", + "settings": len(settingsKV), + "voices": inserted, + }) +} + +// validateSetupSettings 校验必填项。 +func validateSetupSettings(m map[string]string) error { + required := []string{"api_key", "default_resource_id", "default_speaker"} + var missing []string + for _, k := range required { + if strings.TrimSpace(m[k]) == "" { + missing = append(missing, k) + } + } + if len(missing) > 0 { + return fmt.Errorf("missing required fields: %v", missing) + } + return nil +} + +// validateSetupVoices 校验音色列表;至少 1 条。 +// 详细合法性(白名单、speaker 非空)由 store.VoiceInsert 负责。 +func validateSetupVoices(vs []SetupVoice) error { + if len(vs) == 0 { + return fmt.Errorf("at least one voice is required") + } + names := make(map[string]struct{}, len(vs)) + for i, v := range vs { + if strings.TrimSpace(v.Name) == "" { + return fmt.Errorf("voices[%d]: name is required", i) + } + if strings.TrimSpace(v.Speaker) == "" { + return fmt.Errorf("voices[%d] (%s): speaker is required", i, v.Name) + } + if strings.TrimSpace(v.ResourceID) == "" { + return fmt.Errorf("voices[%d] (%s): resource_id is required", i, v.Name) + } + if _, dup := names[v.Name]; dup { + return fmt.Errorf("voices[%d]: duplicate name %q", i, v.Name) + } + names[v.Name] = struct{}{} + } + return nil +} + +// secureEqualString 是常量时间字符串比较,防止 token 计时攻击。 +func secureEqualString(a, b string) bool { + if len(a) != len(b) { + // 先比对长度(避免短串早返回时泄漏长度信息) + // 但仍要遍历一遍避免优化器消除分支 + _ = a[0] + return false + } + var diff byte + for i := 0; i < len(a); i++ { + diff |= a[i] ^ b[i] + } + return diff == 0 +} diff --git a/controller/tts.go b/controller/tts.go index bfff026..30248b5 100644 --- a/controller/tts.go +++ b/controller/tts.go @@ -14,6 +14,7 @@ import ( "github.com/volcano-tts/tts-api/adapter/volcano" "github.com/volcano-tts/tts-api/common" "github.com/volcano-tts/tts-api/dto" + "github.com/volcano-tts/tts-api/installer" "github.com/volcano-tts/tts-api/metrics" "github.com/volcano-tts/tts-api/middleware" "github.com/volcano-tts/tts-api/setting" @@ -62,6 +63,15 @@ func OpenaiTTSHandler(w http.ResponseWriter, r *http.Request) { return } + // 安装模式双保险:即使 InstallGuard 中间件没拦住,这里也 503 + 引导跳转 + if installer.GetMode() == installer.ModeSetup { + log.Printf("[tts] 安装模式下拒绝 /v1/audio/speech - 客户端=%s", middleware.GetClientIP(r)) + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(http.StatusServiceUnavailable) + _, _ = w.Write([]byte(`{"error":"not installed","code":"install_required","redirect":"/setup"}`)) + return + } + if !middleware.ValidateAPIKey(r) { metrics.AuthFailed.Inc(telemetry.Labels{}) log.Printf("警告: API Key 鉴权失败 - 路径=%s 客户端=%s 远端=%s", @@ -213,7 +223,12 @@ func contentTypeFor(format string) string { func HealthHandler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") - if setting.TTSConfigErr != nil { + // 安装模式下 /health 仍然 200,但通过 installed 字段让探针/运维识别 + // (Kubernetes readiness probe 可以用 installed=false 决定是否放流量) + mode := installer.GetMode() + if mode == installer.ModeSetup { + w.WriteHeader(http.StatusOK) // 200,因为进程活着,只是还没初始化 + } else if setting.TTSConfigErr != nil { w.WriteHeader(http.StatusServiceUnavailable) } else { w.WriteHeader(http.StatusOK) @@ -223,7 +238,9 @@ func HealthHandler(w http.ResponseWriter, r *http.Request) { allRequired := env["all_required_vars_set"].(bool) status := "ok" - if !allRequired { + if mode == installer.ModeSetup { + status = "not_installed" + } else if !allRequired { status = "configuration_error" } @@ -239,6 +256,8 @@ func HealthHandler(w http.ResponseWriter, r *http.Request) { AllRequiredVarsSet: allRequired, ConfigError: setting.TTSConfigErr != nil, }, + Installed: mode == installer.ModeNormal, + Mode: mode.String(), } json.NewEncoder(w).Encode(resp) } diff --git a/dto/health.go b/dto/health.go index 9b9e83f..cdc7bd4 100644 --- a/dto/health.go +++ b/dto/health.go @@ -12,6 +12,9 @@ type HealthResponse struct { StartTime string `json:"start_time"` Memory map[string]interface{} `json:"memory"` ConfigStatus ConfigStatusResponse `json:"config_status"` + // M1 新增:反映 installer 模式,便于部署探针/运维识别未初始化状态 + Installed bool `json:"installed"` + Mode string `json:"mode"` } type ConfigStatusResponse struct { diff --git a/installer/bootstrap.go b/installer/bootstrap.go new file mode 100644 index 0000000..b64c79e --- /dev/null +++ b/installer/bootstrap.go @@ -0,0 +1,191 @@ +package installer + +import ( + "errors" + "fmt" + "log" + "os" + "path/filepath" + "time" + + "github.com/volcano-tts/tts-api/store" +) + +// Mode 表示服务当前的运行模式。 +// 启动期由 Detect 确定,运行期不变。 +type Mode int + +const ( + // ModeSetup 未安装,只放行 /setup + /api/setup/*。 + ModeSetup Mode = iota + // ModeNormal 已安装,全部路由可用。 + ModeNormal +) + +func (m Mode) String() string { + switch m { + case ModeSetup: + return "setup" + case ModeNormal: + return "normal" + } + return "unknown" +} + +// CurrentMode 是 Detect 确定的运行期模式;供 controller/middleware 双保险使用。 +// 进程内只有一个二进制实例,所以全局变量是合适的;不必走 DI。 +var CurrentMode Mode = ModeSetup // 默认 setup,Detect 后会被覆盖 + +// SetMode 在 Detect 完成后调用,设置进程级模式。 +func SetMode(m Mode) { CurrentMode = m } + +// GetMode 返回进程级模式;Controller 双保险用。 +func GetMode() Mode { return CurrentMode } + +// Result 是 Detect 的完整输出;调用方关心 Mode + 一些诊断信息。 +type Result struct { + Mode Mode + DBPath string // 实际打开的 db 路径 + LockPath string + Corrupted bool // 这次启动是否从损坏回退 + BackupTo string // 损坏回退时备份文件路径 +} + +// ErrInUse 标识在 Detect 期间发现 db 正在被另一个进程占用; +// 这种情况下不应该自动 rename,会破坏另一个进程的运行。 +// 上层应记录日志并按"装模式"启动,等下次重启再处理。 +var ErrInUse = errors.New("installer: database is locked by another process") + +// Detect 是启动期的总入口:打开/创建 db、判定 lock、检测损坏并自愈。 +// +// 流程: +// 1. Open db(可能新建) +// 2. 检查 lock: +// - 不存在 → ModeSetup +// - 存在 → 跑 IntegrityCheck +// - 通过 → ModeNormal +// - 不通过 → 备份 db.corrupt- + 删 lock + ModeSetup(并标记 Corrupted=true) +// +// 返回的 *store.Store 必须由调用方在进程退出时 Close。 +func Detect(dbPath string) (*store.Store, Result, error) { + if dbPath == "" { + return nil, Result{}, fmt.Errorf("installer: db path is empty") + } + + res := Result{ + DBPath: dbPath, + LockPath: LockPath(dbPath), + } + + s, err := store.Open(dbPath) + if err != nil { + // 打开失败通常意味着文件损坏;走自愈回退。 + // 重要:不要区分"不存在"和"损坏"——SQLite 第一次 Open 会自动建空库, + // 如果"不存在"能走到这里说明更严重的系统错误,也不该贸然启动。 + if backup, ok := tryBackupCorrupt(dbPath, err); ok { + res.Corrupted = true + res.BackupTo = backup + log.Printf("[installer] 检测到损坏 db,已备份到 %q,删除 lock,回退到安装模式", backup) + } else { + return nil, res, fmt.Errorf("installer: open db %q failed: %w", dbPath, err) + } + } + + // lock 状态判定 + exists, err := LockExists(dbPath) + if err != nil { + if s != nil { + _ = s.Close() + } + return nil, res, fmt.Errorf("installer: lock check failed: %w", err) + } + if !exists { + res.Mode = ModeSetup + SetMode(ModeSetup) + if !res.Corrupted { + log.Printf("[installer] 启动模式: 安装模式(无 lock 文件)") + } + return s, res, nil + } + + // lock 在,跑完整性检查 + if s == nil { + // 自愈回退已经走完,应该删了 lock;但保险起见再删一次 + if err := DeleteLock(dbPath); err != nil { + return nil, res, fmt.Errorf("installer: delete lock after fallback: %w", err) + } + res.Mode = ModeSetup + SetMode(ModeSetup) + return nil, res, nil + } + + check, err := s.IntegrityCheck() + if err != nil { + _ = s.Close() + // integrity_check 自身报错,等同损坏,走自愈 + backup, ok := tryBackupCorrupt(dbPath, err) + if !ok { + return nil, res, fmt.Errorf("installer: integrity_check failed: %w", err) + } + _ = DeleteLock(dbPath) + res.Corrupted = true + res.BackupTo = backup + res.Mode = ModeSetup + SetMode(ModeSetup) + log.Printf("[installer] integrity_check 错误,已备份到 %q,删除 lock,回退到安装模式", backup) + return nil, res, nil + } + if check != "ok" { + _ = s.Close() + backup, ok := tryBackupCorrupt(dbPath, fmt.Errorf("integrity_check returned: %s", check)) + if !ok { + return nil, res, fmt.Errorf("installer: integrity_check = %q (not ok)", check) + } + _ = DeleteLock(dbPath) + res.Corrupted = true + res.BackupTo = backup + res.Mode = ModeSetup + SetMode(ModeSetup) + log.Printf("[installer] 库不完整(integrity_check=%q),已备份到 %q,删除 lock,回退到安装模式", check, backup) + return nil, res, nil + } + + res.Mode = ModeNormal + SetMode(ModeNormal) + log.Printf("[installer] 启动模式: 正常模式(lock=%s)", res.LockPath) + return s, res, nil +} + +// tryBackupCorrupt 尝试把损坏的 db 文件 rename 为 .corrupt-; +// 成功返回 (新路径, true),失败 (任何原因) 返回 ("", false)。 +// 注意:这里不返回 error,因为 "无法备份" 不应阻止回退(可以后续人工排查)。 +func tryBackupCorrupt(dbPath string, reason error) (string, bool) { + if dbPath == "" { + return "", false + } + // 不存在的话没法 rename(也没必要) + if _, err := os.Stat(dbPath); err != nil { + return "", false + } + ts := time.Now().UnixMilli() + backup := fmt.Sprintf("%s.corrupt-%d", dbPath, ts) + if err := os.Rename(dbPath, backup); err != nil { + log.Printf("[installer] 备份损坏 db 失败: %v(将直接重建空库)", err) + return "", false + } + log.Printf("[installer] 损坏原因: %v", reason) + return backup, true +} + +// EnsureDBDir 确保 dbPath 所在目录存在(对首次安装很有用; +// 当 dbDir 是新目录时 store.Open 之前需要先 mkdir)。 +func EnsureDBDir(dbPath string) error { + if dbPath == "" { + return nil + } + dir := filepath.Dir(dbPath) + if dir == "" || dir == "." { + return nil + } + return os.MkdirAll(dir, 0o755) +} diff --git a/installer/lock.go b/installer/lock.go new file mode 100644 index 0000000..c1ed9fa --- /dev/null +++ b/installer/lock.go @@ -0,0 +1,121 @@ +// Package installer 负责安装状态判定:lock 文件检测/创建/删除、 +// 启动期 Detect 流程、损坏自愈回退。 +// +// 设计要点: +// - lock 文件路径: /installed.lock,与 tts.db 同目录 +// - lock 不存在 = 未安装(进入安装模式) +// - lock 存在 + 库 OK = 已安装(正常模式) +// - lock 存在 + 库损坏 = 自动备份 + 删 lock + 回到安装模式 +// - 写顺序: 先写库,后写 lock(避免 lock 在、库是半成品) +// - 写锁用临时文件 + 原子 rename,避免崩溃中途留半成品 lock +package installer + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "time" +) + +// lockFileName 是 lock 文件名;固定不变,所有部署共用。 +const lockFileName = "installed.lock" + +// schemaVersion 是 lock 内容里的 schema 版本号;留作未来版本兼容判断。 +// 未来若有破坏性升级,可读这个值决定是否要重装/迁移。 +const schemaVersion = "1" + +// LockPath 返回给定 db 路径下 lock 文件的绝对路径。 +// dbPath 通常是 .db 文件路径(不是目录);若传入目录则直接拼 lockFileName。 +func LockPath(dbPath string) string { + if dbPath == "" { + return lockFileName + } + // 如果 dbPath 是已存在的目录,直接拼文件名 + if info, err := os.Stat(dbPath); err == nil && info.IsDir() { + return filepath.Join(dbPath, lockFileName) + } + dir := filepath.Dir(dbPath) + return filepath.Join(dir, lockFileName) +} + +// ErrLockExists 表示 lock 已存在;CreateLock 会返回这个,提醒上层别覆盖。 +var ErrLockExists = errors.New("installer: lock already exists") + +// LockExists 检测 lock 文件是否存在;不存在不算错误(常见的"未安装"状态)。 +func LockExists(dbPath string) (bool, error) { + p := LockPath(dbPath) + _, err := os.Stat(p) + if err == nil { + return true, nil + } + if errors.Is(err, os.ErrNotExist) { + return false, nil + } + return false, fmt.Errorf("installer: stat lock %q: %w", p, err) +} + +// CreateLock 原子写入 lock 文件;lock 已存在返回 ErrLockExists。 +// 内容: "version " +func CreateLock(dbPath string) error { + exists, err := LockExists(dbPath) + if err != nil { + return err + } + if exists { + return ErrLockExists + } + + p := LockPath(dbPath) + content := fmt.Sprintf("version %s %s\n", schemaVersion, time.Now().UTC().Format(time.RFC3339)) + + // 原子写入:先写临时文件,再 rename + dir := filepath.Dir(p) + tmp, err := os.CreateTemp(dir, ".installed.lock.*.tmp") + if err != nil { + return fmt.Errorf("installer: create lock tmp: %w", err) + } + tmpName := tmp.Name() + // 确保临时文件最终被清理(出错时) + defer func() { + _ = os.Remove(tmpName) + }() + + if _, err := tmp.WriteString(content); err != nil { + _ = tmp.Close() + return fmt.Errorf("installer: write lock tmp: %w", err) + } + if err := tmp.Close(); err != nil { + return fmt.Errorf("installer: close lock tmp: %w", err) + } + if err := os.Rename(tmpName, p); err != nil { + return fmt.Errorf("installer: rename lock tmp: %w", err) + } + return nil +} + +// DeleteLock 删 lock;不存在不报错。 +// 主要用于损坏自愈流程和测试清理。 +func DeleteLock(dbPath string) error { + p := LockPath(dbPath) + err := os.Remove(p) + if err == nil || errors.Is(err, os.ErrNotExist) { + return nil + } + return fmt.Errorf("installer: remove lock %q: %w", p, err) +} + +// ReadLock 读 lock 内容;主要用于诊断日志和未来版本兼容判断。 +// 不存在返回 ("", nil)。 +func ReadLock(dbPath string) (string, error) { + p := LockPath(dbPath) + b, err := os.ReadFile(p) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return "", nil + } + return "", fmt.Errorf("installer: read lock %q: %w", p, err) + } + return strings.TrimSpace(string(b)), nil +} diff --git a/main.go b/main.go index 27674ab..0cec4e8 100644 --- a/main.go +++ b/main.go @@ -10,22 +10,48 @@ import ( "time" "github.com/volcano-tts/tts-api/controller" + "github.com/volcano-tts/tts-api/installer" "github.com/volcano-tts/tts-api/metrics" "github.com/volcano-tts/tts-api/middleware" "github.com/volcano-tts/tts-api/router" "github.com/volcano-tts/tts-api/setting" ) +// ttsDBPath 返回数据库/lock 所在路径;空时落到当前目录的 tts.db。 +func ttsDBPath() string { + if p := os.Getenv("TTS_DB_PATH"); p != "" { + return p + } + return "tts.db" +} + func main() { log.SetFlags(log.LstdFlags | log.Lshortfile) log.SetPrefix("[TTS-Server] ") + // 1) 加载引导环境变量(PORT / OPENAI_TTS_API_KEY / TTS_ADMIN_KEY 等) setting.InitAllConfigs() metrics.Init() middleware.InitRateLimiter() - controller.InitController() setting.LogStartupSummary() + // 2) 启动期关键步骤:打开/建库 → 检测 lock → 判定模式 + dbPath := ttsDBPath() + if err := installer.EnsureDBDir(dbPath); err != nil { + log.Fatalf("FATAL: cannot create db dir: %v", err) + } + st, res, err := installer.Detect(dbPath) + if err != nil { + log.Fatalf("FATAL: installer detect failed: %v", err) + } + if res.Corrupted { + log.Printf("[main] 注意: 启动时检测到 db 损坏并已自愈回退(备份=%s)", res.BackupTo) + } + // 注入 setup 控制器需要的 store + dbPath(无论哪种模式都注入,正常模式下备用) + controller.SetSetupState(st, dbPath) + log.Printf("[main] 当前模式: %s (db=%s lock=%s)", res.Mode, dbPath, res.LockPath) + + controller.InitController() controller.SetStartTime(time.Now()) r := router.Setup() @@ -42,12 +68,16 @@ func main() { signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) go func() { - log.Printf("Starting ByteDance TTS to OpenAI API Adapter Server") - log.Printf("Listening on port: %s", setting.Server.Port) - log.Printf("OpenAI TTS endpoint: http://localhost:%s/v1/audio/speech", setting.Server.Port) + if installer.GetMode() == installer.ModeSetup { + log.Printf("Starting TTS Server in SETUP mode") + log.Printf("Open browser to http://localhost:%s/setup to install", setting.Server.Port) + } else { + log.Printf("Starting ByteDance TTS to OpenAI API Adapter Server") + log.Printf("Listening on port: %s", setting.Server.Port) + log.Printf("OpenAI TTS endpoint: http://localhost:%s/v1/audio/speech", setting.Server.Port) + } log.Printf("Health check: http://localhost:%s/health", setting.Server.Port) log.Printf("Metrics: http://localhost:%s/metrics", setting.Server.Port) - log.Printf("Using ByteDance v3 API") if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { log.Fatalf("Server failed to start: %v", err) @@ -57,6 +87,11 @@ func main() { <-quit log.Println("Shutting down server...") + // 关闭 db 连接(仅当 st 非 nil 时) + if st != nil { + _ = st.Close() + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() diff --git a/middleware/installguard.go b/middleware/installguard.go new file mode 100644 index 0000000..cae49e6 --- /dev/null +++ b/middleware/installguard.go @@ -0,0 +1,46 @@ +package middleware + +import ( + "log" + "net/http" + "strings" + + "github.com/volcano-tts/tts-api/installer" +) + +// InstallGuard 拦截所有非 /setup 路由,在安装模式下返回 503。 +// 设计:放行白名单路径前缀,其余一律 503 + 引导跳转。 +// +// 中间件顺序:必须装在 RateLimit / ConcurrencyLimit / Logger 之前, +// 避免安装模式下被限流计数污染(参考 M1 风险点 #2)。 +func InstallGuard(currentMode func() installer.Mode, allowPrefixes ...string) func(http.Handler) http.Handler { + defaults := []string{ + "/setup", // 安装引导页 + "/api/setup", // 安装相关 API + "/health", // 部署探针要能识别未安装状态 + "/metrics", // Prometheus 拉取 + "/static/", // 引导页静态资源(留口子) + } + allow := append(defaults, allowPrefixes...) + + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if currentMode() != installer.ModeSetup { + next.ServeHTTP(w, r) + return + } + // 安装模式:仅放行白名单 + path := r.URL.Path + for _, p := range allow { + if strings.HasPrefix(path, p) { + next.ServeHTTP(w, r) + return + } + } + log.Printf("[installguard] 安装模式下拒绝非白名单请求 - 路径=%s 客户端=%s", path, GetClientIP(r)) + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(http.StatusServiceUnavailable) + _, _ = w.Write([]byte(`{"error":"not installed","code":"install_required","redirect":"/setup"}`)) + }) + } +} diff --git a/router/dashboard.go b/router/dashboard.go deleted file mode 100644 index 43d1f25..0000000 --- a/router/dashboard.go +++ /dev/null @@ -1,6 +0,0 @@ -package router - -import _ "embed" - -//go:embed health.html -var dashboardHTML []byte \ No newline at end of file diff --git a/router/router.go b/router/router.go index 14bda65..8c71485 100644 --- a/router/router.go +++ b/router/router.go @@ -1,29 +1,57 @@ package router import ( + _ "embed" "net/http" "github.com/gorilla/mux" "github.com/volcano-tts/tts-api/controller" + "github.com/volcano-tts/tts-api/installer" "github.com/volcano-tts/tts-api/metrics" "github.com/volcano-tts/tts-api/middleware" ) +//go:embed health.html +var dashboardHTML []byte + +//go:embed setup.html +var setupHTML []byte + +// Setup 返回主路由。 +// 中间件顺序(由外向内): +// SecurityHeaders → InstallGuard → RateLimit → ConcurrencyLimit → Logger → handler +// 关键: InstallGuard 必须在 RateLimit 之前,避免安装模式被限流计数污染。 func Setup() *mux.Router { r := mux.NewRouter() r.Use(middleware.SecurityHeaders) + r.Use(middleware.InstallGuard(installer.GetMode)) r.Use(middleware.RateLimitWithMetrics) r.Use(middleware.ConcurrencyLimitWithMetrics) r.Use(middleware.Logger) + // 安装相关路由(InstallGuard 已在 setup 模式放行;完成后由 controller 二次校验 404) + r.HandleFunc("/setup", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = w.Write(setupHTML) + }).Methods("GET") + r.HandleFunc("/api/setup/status", controller.SetupStatusHandler).Methods("GET") + r.HandleFunc("/api/setup/prefill", controller.SetupPrefillHandler).Methods("GET") + r.HandleFunc("/api/setup", controller.SetupSubmitHandler).Methods("POST") + + // 业务路由 r.HandleFunc("/v1/audio/speech", controller.OpenaiTTSHandler).Methods("POST", "OPTIONS") r.HandleFunc("/health", controller.HealthHandler).Methods("GET") r.HandleFunc("/dashboard", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html; charset=utf-8") - w.Write(dashboardHTML) + _, _ = w.Write(dashboardHTML) }).Methods("GET") r.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + // 安装模式下,根路径跳 /setup(给运维一个明显入口) + if installer.GetMode() == installer.ModeSetup { + http.Redirect(w, r, "/setup", http.StatusFound) + return + } http.Redirect(w, r, "/dashboard", http.StatusFound) }).Methods("GET") diff --git a/router/setup.html b/router/setup.html new file mode 100644 index 0000000..0e131ca --- /dev/null +++ b/router/setup.html @@ -0,0 +1,238 @@ + + + + + + 火山 TTS · 安装引导 + + + + + +
+
+ +
+

火山 TTS 聚合平台 · 首次安装

+
填写以下信息完成初始化。token 来自启动日志或环境变量 TTS_ADMIN_KEY。
+
+
+ + + +
+
火山引擎 TTS 凭证
+
从火山引擎控制台获取,API Key 仅在此页面输入,不会回显。
+
+ + +
+
+
+ + +
音色所属的计费资源 ID;volc.megatts.icl 用于复刻音色。
+
+
+ + +
未传 voice 字段时使用。
+
+
+
+
+ + +
+
+ + +
+
+
+ + +
公网部署建议设置 TTS_ADMIN_KEY;否则每次启动会重新生成。
+
+
+ +
+
音色列表 (至少 1 条)
+
填入火山 TTS 音色 ID(S_ 开头的为复刻音色)。可后续在 /admin 中增删。
+
+ + + + + +
+ +
+ +
+
+ 提交后立即进入正常模式;后续管理请访问 /admin。 +
+ +
+
+ + + + diff --git a/setting/config.go b/setting/config.go index da957d2..405bf84 100644 --- a/setting/config.go +++ b/setting/config.go @@ -1,6 +1,8 @@ package setting import ( + "crypto/rand" + "encoding/hex" "fmt" "log" "os" @@ -51,11 +53,26 @@ var Server ServerConfig // 不直接调用 middleware(避免循环 import)。 var TrustedProxyHops int +// SetupToken 是安装模式下的初始化凭证。 +// - 若 TTS_ADMIN_KEY 环境变量非空,用其值(用户可复现,便于脚本化安装) +// - 若 TTS_ADMIN_KEY 为空,启动时随机生成 32 字节十六进制, +// 打印到日志(/api/setup 提交时必须带这个 token) +// +// 安装完成后,/api/setup 端点永久关闭,SetupToken 失去意义但保留在内存。 +var SetupToken string + +// SetupTokenSource 标记 SetupToken 的来源,便于日志区分。 +// "env" = 来自 TTS_ADMIN_KEY +// "ephemeral"= 启动时随机生成(每次启动变) +// "" = 未设置 +var SetupTokenSource string + // InitAllConfigs 集中初始化所有配置,启动期调用一次。 func InitAllConfigs() { InitServerConfig() InitAuthConfig() InitCORSConfig() + InitSetupToken() TTSConfigErr = InitTTSConfig() } @@ -213,6 +230,30 @@ func getEnvBool(name string, def bool) bool { return b } +// InitSetupToken 加载或生成安装模式下的初始化凭证。 +// - TTS_ADMIN_KEY 存在:用其值,SetupTokenSource="env" +// - TTS_ADMIN_KEY 空:随机生成 16 字节 = 32 字符 hex,SetupTokenSource="ephemeral",打印到日志 +func InitSetupToken() { + v := os.Getenv("TTS_ADMIN_KEY") + if v != "" { + SetupToken = v + SetupTokenSource = "env" + return + } + // 临时 token:16 字节随机 = 32 字符 hex,够用且短 + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + // 极端情况:随机源失败,降级为时间戳(不应发生) + log.Printf("[setup] 生成一次性 token 失败,使用时间戳: %v", err) + SetupToken = fmt.Sprintf("dev-%d", time.Now().UnixNano()) + SetupTokenSource = "ephemeral" + return + } + SetupToken = hex.EncodeToString(b) + SetupTokenSource = "ephemeral" + log.Printf("[setup] 一次性安装 token(仅打印一次,公网部署请设置 TTS_ADMIN_KEY): %s", SetupToken) +} + // CheckEnvironmentVariables 返回 /health 用的环境变量状态快照。 func CheckEnvironmentVariables() map[string]interface{} { required := map[string]bool{