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:
+7
-1
@@ -25,4 +25,10 @@ Thumbs.db
|
||||
TODO.md
|
||||
|
||||
# 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/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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
"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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
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")
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
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{
|
||||
|
||||
Reference in New Issue
Block a user