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:
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user