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

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

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

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

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

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

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

二进制大小: 9.77 → 15.87 MB (+6MB,大部分是 setup.html embed + 新包)
This commit is contained in:
sun
2026-08-29 20:44:33 +08:00
parent 865fdd81e8
commit 7984c1880a
12 changed files with 1015 additions and 15 deletions
+41
View File
@@ -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{