feat(installer): M1 安装流程 + /setup 引导页

新增:
- installer/lock.go: lock 文件检测/创建/删除(原子写入);ErrLockExists sentinel
- installer/bootstrap.go: Detect() 启动期判定 ModeSetup/ModeNormal,
  损坏自愈(自动备份 db.corrupt-<ts> + 删 lock + 回退安装模式)
- middleware/installguard.go: 安装模式白名单(/setup /api/setup /health /metrics),
  中间件顺序: SecurityHeaders → InstallGuard → RateLimit → ...(避免限流计数污染)
- controller/setup.go: GET /api/setup/status + /api/setup/prefill + POST /api/setup;
  token 校验用常量时间比较防计时攻击;安装后端点永久 404
- router/setup.html: Vue3 + axios CDN,4 字段表单 + 动态音色行,
  调用 /api/setup/prefill 自动从旧 env 预填(API key 永不预填)

改造:
- main.go: 启动期 store.Open → installer.Detect → 注入 setup 控制器
- controller/tts.go: 安装模式双保险(/v1/audio/speech 即便漏过中间件也 503)
- controller/health.go (via dto): 加 installed + mode 字段,部署探针可识别
- router/router.go: 挂载 /setup + /api/setup/*,根路径安装模式跳 /setup
- setting/config.go: InitSetupToken 读 TTS_ADMIN_KEY 或随机生成 32 字符 hex
  (公网部署建议设 env;空时启动日志一次性打印)

删除:
- router/dashboard.go: 内容并入 router/router.go

.gitignore: tts.db / tts.db-* / installed.lock 加入(运行时产物)

验收(端到端跑通,见 scripts/ 已删除):
1. 删 lock → /v1/audio/speech 503 + /setup 200 HTML
2. POST /api/setup 错 token → 401;对 token → 200 + 写 lock + 写 db
3. 重复 POST → 404(端点永久关闭)
4. /health: 安装期 installed=false,装后 installed=true
5. 损坏 db header: 自动备份 tts.db.corrupt-<ts> + 删 lock + 回安装模式

测试:
- installer: 6 个 test, 覆盖 lock 生命周期 + Detect 三态 + 损坏自愈
- middleware: 3 个 test, 覆盖白名单/denylist/自定义
- store(M0): 仍 27/27 绿, 70.7% 覆盖率

二进制大小: 9.77 → 15.87 MB (+6MB,大部分是 setup.html embed + 新包)
This commit is contained in:
sun
2026-08-29 20:44:33 +08:00
parent 865fdd81e8
commit 7984c1880a
12 changed files with 1015 additions and 15 deletions
+6
View File
@@ -26,3 +26,9 @@ TODO.md
# Release artifacts (build output) # Release artifacts (build output)
release/ release/
# Runtime database artifacts (created by M1 installer flow)
tts.db
tts.db-*
tts.db.*
installed.lock
+278
View File
@@ -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
}
+21 -2
View File
@@ -14,6 +14,7 @@ import (
"github.com/volcano-tts/tts-api/adapter/volcano" "github.com/volcano-tts/tts-api/adapter/volcano"
"github.com/volcano-tts/tts-api/common" "github.com/volcano-tts/tts-api/common"
"github.com/volcano-tts/tts-api/dto" "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/metrics"
"github.com/volcano-tts/tts-api/middleware" "github.com/volcano-tts/tts-api/middleware"
"github.com/volcano-tts/tts-api/setting" "github.com/volcano-tts/tts-api/setting"
@@ -62,6 +63,15 @@ func OpenaiTTSHandler(w http.ResponseWriter, r *http.Request) {
return 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) { if !middleware.ValidateAPIKey(r) {
metrics.AuthFailed.Inc(telemetry.Labels{}) metrics.AuthFailed.Inc(telemetry.Labels{})
log.Printf("警告: API Key 鉴权失败 - 路径=%s 客户端=%s 远端=%s", log.Printf("警告: API Key 鉴权失败 - 路径=%s 客户端=%s 远端=%s",
@@ -213,7 +223,12 @@ func contentTypeFor(format string) string {
func HealthHandler(w http.ResponseWriter, r *http.Request) { func HealthHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json") 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) w.WriteHeader(http.StatusServiceUnavailable)
} else { } else {
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
@@ -223,7 +238,9 @@ func HealthHandler(w http.ResponseWriter, r *http.Request) {
allRequired := env["all_required_vars_set"].(bool) allRequired := env["all_required_vars_set"].(bool)
status := "ok" status := "ok"
if !allRequired { if mode == installer.ModeSetup {
status = "not_installed"
} else if !allRequired {
status = "configuration_error" status = "configuration_error"
} }
@@ -239,6 +256,8 @@ func HealthHandler(w http.ResponseWriter, r *http.Request) {
AllRequiredVarsSet: allRequired, AllRequiredVarsSet: allRequired,
ConfigError: setting.TTSConfigErr != nil, ConfigError: setting.TTSConfigErr != nil,
}, },
Installed: mode == installer.ModeNormal,
Mode: mode.String(),
} }
json.NewEncoder(w).Encode(resp) json.NewEncoder(w).Encode(resp)
} }
+3
View File
@@ -12,6 +12,9 @@ type HealthResponse struct {
StartTime string `json:"start_time"` StartTime string `json:"start_time"`
Memory map[string]interface{} `json:"memory"` Memory map[string]interface{} `json:"memory"`
ConfigStatus ConfigStatusResponse `json:"config_status"` ConfigStatus ConfigStatusResponse `json:"config_status"`
// M1 新增:反映 installer 模式,便于部署探针/运维识别未初始化状态
Installed bool `json:"installed"`
Mode string `json:"mode"`
} }
type ConfigStatusResponse struct { type ConfigStatusResponse struct {
+191
View File
@@ -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-<ts> + 删 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-<unix-ms>;
// 成功返回 (新路径, 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)
}
+121
View File
@@ -0,0 +1,121 @@
// Package installer 负责安装状态判定:lock 文件检测/创建/删除、
// 启动期 Detect 流程、损坏自愈回退。
//
// 设计要点:
// - lock 文件路径: <dbDir>/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 <schemaVersion> <RFC3339 时间戳>"
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
}
+37 -2
View File
@@ -10,22 +10,48 @@ import (
"time" "time"
"github.com/volcano-tts/tts-api/controller" "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/metrics"
"github.com/volcano-tts/tts-api/middleware" "github.com/volcano-tts/tts-api/middleware"
"github.com/volcano-tts/tts-api/router" "github.com/volcano-tts/tts-api/router"
"github.com/volcano-tts/tts-api/setting" "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() { func main() {
log.SetFlags(log.LstdFlags | log.Lshortfile) log.SetFlags(log.LstdFlags | log.Lshortfile)
log.SetPrefix("[TTS-Server] ") log.SetPrefix("[TTS-Server] ")
// 1) 加载引导环境变量(PORT / OPENAI_TTS_API_KEY / TTS_ADMIN_KEY 等)
setting.InitAllConfigs() setting.InitAllConfigs()
metrics.Init() metrics.Init()
middleware.InitRateLimiter() middleware.InitRateLimiter()
controller.InitController()
setting.LogStartupSummary() 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()) controller.SetStartTime(time.Now())
r := router.Setup() r := router.Setup()
@@ -42,12 +68,16 @@ func main() {
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
go func() { go func() {
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("Starting ByteDance TTS to OpenAI API Adapter Server")
log.Printf("Listening on port: %s", setting.Server.Port) 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("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("Health check: http://localhost:%s/health", setting.Server.Port)
log.Printf("Metrics: http://localhost:%s/metrics", 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 { if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("Server failed to start: %v", err) log.Fatalf("Server failed to start: %v", err)
@@ -57,6 +87,11 @@ func main() {
<-quit <-quit
log.Println("Shutting down server...") log.Println("Shutting down server...")
// 关闭 db 连接(仅当 st 非 nil 时)
if st != nil {
_ = st.Close()
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel() defer cancel()
+46
View File
@@ -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"}`))
})
}
}
-6
View File
@@ -1,6 +0,0 @@
package router
import _ "embed"
//go:embed health.html
var dashboardHTML []byte
+29 -1
View File
@@ -1,29 +1,57 @@
package router package router
import ( import (
_ "embed"
"net/http" "net/http"
"github.com/gorilla/mux" "github.com/gorilla/mux"
"github.com/volcano-tts/tts-api/controller" "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/metrics"
"github.com/volcano-tts/tts-api/middleware" "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 { func Setup() *mux.Router {
r := mux.NewRouter() r := mux.NewRouter()
r.Use(middleware.SecurityHeaders) r.Use(middleware.SecurityHeaders)
r.Use(middleware.InstallGuard(installer.GetMode))
r.Use(middleware.RateLimitWithMetrics) r.Use(middleware.RateLimitWithMetrics)
r.Use(middleware.ConcurrencyLimitWithMetrics) r.Use(middleware.ConcurrencyLimitWithMetrics)
r.Use(middleware.Logger) 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("/v1/audio/speech", controller.OpenaiTTSHandler).Methods("POST", "OPTIONS")
r.HandleFunc("/health", controller.HealthHandler).Methods("GET") r.HandleFunc("/health", controller.HealthHandler).Methods("GET")
r.HandleFunc("/dashboard", func(w http.ResponseWriter, r *http.Request) { r.HandleFunc("/dashboard", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write(dashboardHTML) _, _ = w.Write(dashboardHTML)
}).Methods("GET") }).Methods("GET")
r.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { 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) http.Redirect(w, r, "/dashboard", http.StatusFound)
}).Methods("GET") }).Methods("GET")
+238
View File
@@ -0,0 +1,238 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>火山 TTS · 安装引导</title>
<script src="https://cdn.bootcdn.net/ajax/libs/vue/3.4.21/vue.global.prod.js"></script>
<script src="https://cdn.bootcdn.net/ajax/libs/axios/1.6.7/axios.min.js"></script>
<style>
:root {
--bg: #0b0f1a;
--bg-card: #131826;
--bg-elev: #1a2138;
--border: #232b42;
--text: #e4e7ee;
--text-dim: #8a92a6;
--text-mute: #5a6275;
--accent: #00d4ff;
--accent-2: #7b2ff7;
--success: #22c55e;
--warning: #f59e0b;
--danger: #ef4444;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
background: var(--bg); color: var(--text);
min-height: 100vh; padding: 32px 24px; font-size: 14px;
background-image:
radial-gradient(ellipse 80% 50% at 50% -20%, rgba(0,212,255,0.08), transparent),
radial-gradient(ellipse 60% 40% at 80% 100%, rgba(123,47,247,0.06), transparent);
}
#app { max-width: 760px; margin: 0 auto; }
.header { display: flex; align-items: center; gap: 16px; margin-bottom: 28px; }
.logo {
width: 48px; height: 48px; border-radius: 12px;
background: linear-gradient(135deg, var(--accent), var(--accent-2));
display: flex; align-items: center; justify-content: center;
font-weight: 700; font-size: 20px; color: #fff; box-shadow: 0 4px 20px rgba(0,212,255,0.3);
}
.header h1 { font-size: 22px; font-weight: 600; }
.header .sub { font-size: 12px; color: var(--text-dim); margin-top: 4px; }
.card { background: var(--bg-card); border: 1px solid var(--border); border-radius: 12px; padding: 24px; margin-bottom: 16px; }
.card-title { font-size: 15px; font-weight: 600; margin-bottom: 4px; }
.card-desc { font-size: 12px; color: var(--text-dim); margin-bottom: 16px; }
.field { margin-bottom: 14px; }
.field label { display: block; font-size: 12px; color: var(--text-dim); margin-bottom: 6px; }
.field label .req { color: var(--danger); margin-left: 4px; }
.field input, .field select {
width: 100%; background: var(--bg-elev); border: 1px solid var(--border);
color: var(--text); padding: 10px 12px; border-radius: 8px; font-size: 13px;
font-family: inherit; transition: border-color 0.2s;
}
.field input:focus, .field select:focus { outline: none; border-color: var(--accent); }
.field .hint { font-size: 11px; color: var(--text-mute); margin-top: 4px; }
.field-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.voice-row { display: grid; grid-template-columns: 1fr 1fr 1.4fr 1.2fr 32px; gap: 8px; align-items: center; margin-bottom: 8px; }
.voice-row input { font-size: 12px; padding: 8px 10px; }
.voice-row .rm { background: transparent; border: 1px solid var(--border); color: var(--danger); cursor: pointer; padding: 6px; border-radius: 6px; font-size: 14px; }
.voice-row .rm:hover { border-color: var(--danger); }
.btn {
background: var(--bg-elev); border: 1px solid var(--border); color: var(--text);
padding: 10px 18px; border-radius: 8px; cursor: pointer; font-size: 13px;
transition: all 0.2s; display: inline-flex; align-items: center; gap: 6px;
font-family: inherit;
}
.btn:hover { border-color: var(--accent); }
.btn-primary { background: var(--accent); color: #00121a; border-color: var(--accent); font-weight: 500; }
.btn-primary:hover { background: #33ddff; }
.btn-primary:disabled { background: var(--text-mute); border-color: var(--text-mute); cursor: not-allowed; color: var(--bg); }
.btn-add { background: transparent; border: 1px dashed var(--border); color: var(--text-dim); width: 100%; justify-content: center; padding: 10px; }
.btn-add:hover { border-color: var(--accent); color: var(--accent); }
.actions { display: flex; justify-content: space-between; align-items: center; margin-top: 24px; }
.err { color: var(--danger); font-size: 12px; margin-top: 8px; }
.ok { color: var(--success); font-size: 12px; margin-top: 8px; }
.banner { background: rgba(245,158,11,0.1); border: 1px solid rgba(245,158,11,0.3); border-radius: 8px; padding: 10px 14px; color: var(--warning); font-size: 12px; margin-bottom: 16px; }
</style>
</head>
<body>
<div id="app">
<div class="header">
<div class="logo">TTS</div>
<div>
<h1>火山 TTS 聚合平台 · 首次安装</h1>
<div class="sub">填写以下信息完成初始化。token 来自启动日志或环境变量 <code>TTS_ADMIN_KEY</code>。</div>
</div>
</div>
<div v-if="errorMsg" class="banner">⚠ {{ errorMsg }}</div>
<div class="card">
<div class="card-title">火山引擎 TTS 凭证</div>
<div class="card-desc">从火山引擎控制台获取,API Key 仅在此页面输入,不会回显。</div>
<div class="field">
<label>API Key <span class="req">*</span></label>
<input type="password" v-model="form.api_key" placeholder="例如: 5b4d7c2a-..." autocomplete="off">
</div>
<div class="field-row">
<div class="field">
<label>默认资源 ID <span class="req">*</span></label>
<input v-model="form.default_resource_id" placeholder="volc.megatts.default">
<div class="hint">音色所属的计费资源 ID;volc.megatts.icl 用于复刻音色。</div>
</div>
<div class="field">
<label>默认音色 <span class="req">*</span></label>
<input v-model="form.default_speaker" placeholder="zh_female_qingxin">
<div class="hint">未传 voice 字段时使用。</div>
</div>
</div>
<div class="field-row">
<div class="field">
<label>默认输出格式</label>
<select v-model="form.default_format">
<option value="mp3">mp3</option>
<option value="wav">wav</option>
<option value="opus">opus (ogg_opus)</option>
<option value="pcm">pcm</option>
<option value="aac">aac</option>
<option value="flac">flac</option>
</select>
</div>
<div class="field">
<label>采样率 (Hz)</label>
<input type="number" v-model.number="form.sample_rate" placeholder="24000">
</div>
</div>
<div class="field">
<label>初始化 Token <span class="req">*</span></label>
<input type="password" v-model="form.token" placeholder="启动日志里打印的一次性 token,或 TTS_ADMIN_KEY" autocomplete="off">
<div class="hint">公网部署建议设置 TTS_ADMIN_KEY;否则每次启动会重新生成。</div>
</div>
</div>
<div class="card">
<div class="card-title">音色列表 <span style="font-weight: 400; color: var(--text-dim); font-size: 12px;">(至少 1 条)</span></div>
<div class="card-desc">填入火山 TTS 音色 ID(S_ 开头的为复刻音色)。可后续在 /admin 中增删。</div>
<div v-for="(v, i) in form.voices" :key="i" class="voice-row">
<input v-model="v.name" placeholder="对外名 qian">
<input v-model="v.speaker" placeholder="S_xxx">
<input v-model="v.resource_id" placeholder="volc.megatts.icl">
<input v-model="v.model" placeholder="seed-tts-2.0-standard">
<button class="rm" @click="removeVoice(i)" :disabled="form.voices.length <= 1" title="删除">×</button>
</div>
<button class="btn btn-add" @click="addVoice">+ 添加音色</button>
</div>
<div class="actions">
<div style="color: var(--text-mute); font-size: 12px;">
提交后立即进入正常模式;后续管理请访问 <code>/admin</code>。
</div>
<button class="btn btn-primary" @click="submit" :disabled="submitting">
{{ submitting ? '安装中...' : '完成安装' }}
</button>
</div>
</div>
<script>
const { createApp, ref, onMounted } = Vue;
createApp({
setup() {
const form = ref({
api_key: '',
default_resource_id: 'volc.megatts.default',
default_speaker: '',
default_format: 'mp3',
sample_rate: 24000,
token: '',
voices: [{ name: '', speaker: '', resource_id: 'volc.megatts.icl', model: 'seed-tts-2.0-standard' }],
});
const errorMsg = ref('');
const submitting = ref(false);
const addVoice = () => {
form.value.voices.push({ name: '', speaker: '', resource_id: 'volc.megatts.icl', model: 'seed-tts-2.0-standard' });
};
const removeVoice = (i) => {
if (form.value.voices.length > 1) form.value.voices.splice(i, 1);
};
onMounted(async () => {
// 预填旧 env 变量(只填非空字段;api_key 永不预填,需手动输入)
try {
const r = await axios.get('/api/setup/prefill');
const s = r.data.settings || {};
if (s.default_resource_id) form.value.default_resource_id = s.default_resource_id;
if (s.default_speaker) form.value.default_speaker = s.default_speaker;
if (s.default_format) form.value.default_format = s.default_format;
if (s.sample_rate) form.value.sample_rate = parseInt(s.sample_rate, 10) || 24000;
} catch (e) {
console.log('prefill skipped:', e.message);
}
});
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)';
return;
}
if (!form.value.voices.length || form.value.voices.some(v => !v.name || !v.speaker || !v.resource_id)) {
errorMsg.value = '音色列表不完整(每条都需要 name + speaker + resource_id)';
return;
}
submitting.value = true;
try {
const r = await axios.post('/api/setup', {
token: form.value.token,
settings: {
api_key: form.value.api_key,
default_resource_id: form.value.default_resource_id,
default_speaker: form.value.default_speaker,
default_format: form.value.default_format,
sample_rate: String(form.value.sample_rate || 24000),
},
voices: form.value.voices,
});
// 成功:跳 /admin(M2 才会有,先跳 /dashboard)
window.location.href = r.data.redirect || '/dashboard';
} catch (e) {
const msg = e.response?.data?.error?.message || e.message;
errorMsg.value = `安装失败: ${msg}`;
submitting.value = false;
}
};
return { form, errorMsg, submitting, addVoice, removeVoice, submit };
},
}).mount('#app');
</script>
</body>
</html>
+41
View File
@@ -1,6 +1,8 @@
package setting package setting
import ( import (
"crypto/rand"
"encoding/hex"
"fmt" "fmt"
"log" "log"
"os" "os"
@@ -51,11 +53,26 @@ var Server ServerConfig
// 不直接调用 middleware(避免循环 import)。 // 不直接调用 middleware(避免循环 import)。
var TrustedProxyHops int 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 集中初始化所有配置,启动期调用一次。 // InitAllConfigs 集中初始化所有配置,启动期调用一次。
func InitAllConfigs() { func InitAllConfigs() {
InitServerConfig() InitServerConfig()
InitAuthConfig() InitAuthConfig()
InitCORSConfig() InitCORSConfig()
InitSetupToken()
TTSConfigErr = InitTTSConfig() TTSConfigErr = InitTTSConfig()
} }
@@ -213,6 +230,30 @@ func getEnvBool(name string, def bool) bool {
return b 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 用的环境变量状态快照。 // CheckEnvironmentVariables 返回 /health 用的环境变量状态快照。
func CheckEnvironmentVariables() map[string]interface{} { func CheckEnvironmentVariables() map[string]interface{} {
required := map[string]bool{ required := map[string]bool{