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:
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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() {
|
||||||
log.Printf("Starting ByteDance TTS to OpenAI API Adapter Server")
|
if installer.GetMode() == installer.ModeSetup {
|
||||||
log.Printf("Listening on port: %s", setting.Server.Port)
|
log.Printf("Starting TTS Server in SETUP mode")
|
||||||
log.Printf("OpenAI TTS endpoint: http://localhost:%s/v1/audio/speech", setting.Server.Port)
|
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("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()
|
||||||
|
|
||||||
|
|||||||
@@ -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"}`))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
package router
|
|
||||||
|
|
||||||
import _ "embed"
|
|
||||||
|
|
||||||
//go:embed health.html
|
|
||||||
var dashboardHTML []byte
|
|
||||||
+29
-1
@@ -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")
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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{
|
||||||
|
|||||||
Reference in New Issue
Block a user