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
+29 -1
View File
@@ -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")