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
+191
View File
@@ -0,0 +1,191 @@
package installer
import (
"errors"
"fmt"
"log"
"os"
"path/filepath"
"time"
"github.com/volcano-tts/tts-api/store"
)
// Mode 表示服务当前的运行模式。
// 启动期由 Detect 确定,运行期不变。
type Mode int
const (
// ModeSetup 未安装,只放行 /setup + /api/setup/*。
ModeSetup Mode = iota
// ModeNormal 已安装,全部路由可用。
ModeNormal
)
func (m Mode) String() string {
switch m {
case ModeSetup:
return "setup"
case ModeNormal:
return "normal"
}
return "unknown"
}
// CurrentMode 是 Detect 确定的运行期模式;供 controller/middleware 双保险使用。
// 进程内只有一个二进制实例,所以全局变量是合适的;不必走 DI。
var CurrentMode Mode = ModeSetup // 默认 setup,Detect 后会被覆盖
// SetMode 在 Detect 完成后调用,设置进程级模式。
func SetMode(m Mode) { CurrentMode = m }
// GetMode 返回进程级模式;Controller 双保险用。
func GetMode() Mode { return CurrentMode }
// Result 是 Detect 的完整输出;调用方关心 Mode + 一些诊断信息。
type Result struct {
Mode Mode
DBPath string // 实际打开的 db 路径
LockPath string
Corrupted bool // 这次启动是否从损坏回退
BackupTo string // 损坏回退时备份文件路径
}
// ErrInUse 标识在 Detect 期间发现 db 正在被另一个进程占用;
// 这种情况下不应该自动 rename,会破坏另一个进程的运行。
// 上层应记录日志并按"装模式"启动,等下次重启再处理。
var ErrInUse = errors.New("installer: database is locked by another process")
// Detect 是启动期的总入口:打开/创建 db、判定 lock、检测损坏并自愈。
//
// 流程:
// 1. Open db(可能新建)
// 2. 检查 lock:
// - 不存在 → ModeSetup
// - 存在 → 跑 IntegrityCheck
// - 通过 → ModeNormal
// - 不通过 → 备份 db.corrupt-<ts> + 删 lock + ModeSetup(并标记 Corrupted=true)
//
// 返回的 *store.Store 必须由调用方在进程退出时 Close。
func Detect(dbPath string) (*store.Store, Result, error) {
if dbPath == "" {
return nil, Result{}, fmt.Errorf("installer: db path is empty")
}
res := Result{
DBPath: dbPath,
LockPath: LockPath(dbPath),
}
s, err := store.Open(dbPath)
if err != nil {
// 打开失败通常意味着文件损坏;走自愈回退。
// 重要:不要区分"不存在"和"损坏"——SQLite 第一次 Open 会自动建空库,
// 如果"不存在"能走到这里说明更严重的系统错误,也不该贸然启动。
if backup, ok := tryBackupCorrupt(dbPath, err); ok {
res.Corrupted = true
res.BackupTo = backup
log.Printf("[installer] 检测到损坏 db,已备份到 %q,删除 lock,回退到安装模式", backup)
} else {
return nil, res, fmt.Errorf("installer: open db %q failed: %w", dbPath, err)
}
}
// lock 状态判定
exists, err := LockExists(dbPath)
if err != nil {
if s != nil {
_ = s.Close()
}
return nil, res, fmt.Errorf("installer: lock check failed: %w", err)
}
if !exists {
res.Mode = ModeSetup
SetMode(ModeSetup)
if !res.Corrupted {
log.Printf("[installer] 启动模式: 安装模式(无 lock 文件)")
}
return s, res, nil
}
// lock 在,跑完整性检查
if s == nil {
// 自愈回退已经走完,应该删了 lock;但保险起见再删一次
if err := DeleteLock(dbPath); err != nil {
return nil, res, fmt.Errorf("installer: delete lock after fallback: %w", err)
}
res.Mode = ModeSetup
SetMode(ModeSetup)
return nil, res, nil
}
check, err := s.IntegrityCheck()
if err != nil {
_ = s.Close()
// integrity_check 自身报错,等同损坏,走自愈
backup, ok := tryBackupCorrupt(dbPath, err)
if !ok {
return nil, res, fmt.Errorf("installer: integrity_check failed: %w", err)
}
_ = DeleteLock(dbPath)
res.Corrupted = true
res.BackupTo = backup
res.Mode = ModeSetup
SetMode(ModeSetup)
log.Printf("[installer] integrity_check 错误,已备份到 %q,删除 lock,回退到安装模式", backup)
return nil, res, nil
}
if check != "ok" {
_ = s.Close()
backup, ok := tryBackupCorrupt(dbPath, fmt.Errorf("integrity_check returned: %s", check))
if !ok {
return nil, res, fmt.Errorf("installer: integrity_check = %q (not ok)", check)
}
_ = DeleteLock(dbPath)
res.Corrupted = true
res.BackupTo = backup
res.Mode = ModeSetup
SetMode(ModeSetup)
log.Printf("[installer] 库不完整(integrity_check=%q),已备份到 %q,删除 lock,回退到安装模式", check, backup)
return nil, res, nil
}
res.Mode = ModeNormal
SetMode(ModeNormal)
log.Printf("[installer] 启动模式: 正常模式(lock=%s)", res.LockPath)
return s, res, nil
}
// tryBackupCorrupt 尝试把损坏的 db 文件 rename 为 .corrupt-<unix-ms>;
// 成功返回 (新路径, true),失败 (任何原因) 返回 ("", false)。
// 注意:这里不返回 error,因为 "无法备份" 不应阻止回退(可以后续人工排查)。
func tryBackupCorrupt(dbPath string, reason error) (string, bool) {
if dbPath == "" {
return "", false
}
// 不存在的话没法 rename(也没必要)
if _, err := os.Stat(dbPath); err != nil {
return "", false
}
ts := time.Now().UnixMilli()
backup := fmt.Sprintf("%s.corrupt-%d", dbPath, ts)
if err := os.Rename(dbPath, backup); err != nil {
log.Printf("[installer] 备份损坏 db 失败: %v(将直接重建空库)", err)
return "", false
}
log.Printf("[installer] 损坏原因: %v", reason)
return backup, true
}
// EnsureDBDir 确保 dbPath 所在目录存在(对首次安装很有用;
// 当 dbDir 是新目录时 store.Open 之前需要先 mkdir)。
func EnsureDBDir(dbPath string) error {
if dbPath == "" {
return nil
}
dir := filepath.Dir(dbPath)
if dir == "" || dir == "." {
return nil
}
return os.MkdirAll(dir, 0o755)
}
+121
View File
@@ -0,0 +1,121 @@
// Package installer 负责安装状态判定:lock 文件检测/创建/删除、
// 启动期 Detect 流程、损坏自愈回退。
//
// 设计要点:
// - lock 文件路径: <dbDir>/installed.lock,与 tts.db 同目录
// - lock 不存在 = 未安装(进入安装模式)
// - lock 存在 + 库 OK = 已安装(正常模式)
// - lock 存在 + 库损坏 = 自动备份 + 删 lock + 回到安装模式
// - 写顺序: 先写库,后写 lock(避免 lock 在、库是半成品)
// - 写锁用临时文件 + 原子 rename,避免崩溃中途留半成品 lock
package installer
import (
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"time"
)
// lockFileName 是 lock 文件名;固定不变,所有部署共用。
const lockFileName = "installed.lock"
// schemaVersion 是 lock 内容里的 schema 版本号;留作未来版本兼容判断。
// 未来若有破坏性升级,可读这个值决定是否要重装/迁移。
const schemaVersion = "1"
// LockPath 返回给定 db 路径下 lock 文件的绝对路径。
// dbPath 通常是 .db 文件路径(不是目录);若传入目录则直接拼 lockFileName。
func LockPath(dbPath string) string {
if dbPath == "" {
return lockFileName
}
// 如果 dbPath 是已存在的目录,直接拼文件名
if info, err := os.Stat(dbPath); err == nil && info.IsDir() {
return filepath.Join(dbPath, lockFileName)
}
dir := filepath.Dir(dbPath)
return filepath.Join(dir, lockFileName)
}
// ErrLockExists 表示 lock 已存在;CreateLock 会返回这个,提醒上层别覆盖。
var ErrLockExists = errors.New("installer: lock already exists")
// LockExists 检测 lock 文件是否存在;不存在不算错误(常见的"未安装"状态)。
func LockExists(dbPath string) (bool, error) {
p := LockPath(dbPath)
_, err := os.Stat(p)
if err == nil {
return true, nil
}
if errors.Is(err, os.ErrNotExist) {
return false, nil
}
return false, fmt.Errorf("installer: stat lock %q: %w", p, err)
}
// CreateLock 原子写入 lock 文件;lock 已存在返回 ErrLockExists。
// 内容: "version <schemaVersion> <RFC3339 时间戳>"
func CreateLock(dbPath string) error {
exists, err := LockExists(dbPath)
if err != nil {
return err
}
if exists {
return ErrLockExists
}
p := LockPath(dbPath)
content := fmt.Sprintf("version %s %s\n", schemaVersion, time.Now().UTC().Format(time.RFC3339))
// 原子写入:先写临时文件,再 rename
dir := filepath.Dir(p)
tmp, err := os.CreateTemp(dir, ".installed.lock.*.tmp")
if err != nil {
return fmt.Errorf("installer: create lock tmp: %w", err)
}
tmpName := tmp.Name()
// 确保临时文件最终被清理(出错时)
defer func() {
_ = os.Remove(tmpName)
}()
if _, err := tmp.WriteString(content); err != nil {
_ = tmp.Close()
return fmt.Errorf("installer: write lock tmp: %w", err)
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("installer: close lock tmp: %w", err)
}
if err := os.Rename(tmpName, p); err != nil {
return fmt.Errorf("installer: rename lock tmp: %w", err)
}
return nil
}
// DeleteLock 删 lock;不存在不报错。
// 主要用于损坏自愈流程和测试清理。
func DeleteLock(dbPath string) error {
p := LockPath(dbPath)
err := os.Remove(p)
if err == nil || errors.Is(err, os.ErrNotExist) {
return nil
}
return fmt.Errorf("installer: remove lock %q: %w", p, err)
}
// ReadLock 读 lock 内容;主要用于诊断日志和未来版本兼容判断。
// 不存在返回 ("", nil)。
func ReadLock(dbPath string) (string, error) {
p := LockPath(dbPath)
b, err := os.ReadFile(p)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return "", nil
}
return "", fmt.Errorf("installer: read lock %q: %w", p, err)
}
return strings.TrimSpace(string(b)), nil
}