From dbaee354005e65c2f69cf5022ad2f9480ee01993 Mon Sep 17 00:00:00 2001 From: "3371392206@qq.com" <3371392206@qq.com> Date: Sat, 29 Aug 2026 21:16:08 +0800 Subject: [PATCH] =?UTF-8?q?fix(install):=20M1=20=E5=90=8E=E7=BB=AD=20?= =?UTF-8?q?=C2=B7=202=20=E4=B8=AA=20install=20=E6=A8=A1=E5=BC=8F=E5=85=A5?= =?UTF-8?q?=E5=8F=A3=20bug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug 1: 安装模式下浏览器访问 / 看到 503 JSON - 根因: middleware/installguard.go 默认白名单没包含 /; 浏览器访问 / 时 InstallGuard 先于 router 的 redirect 拦掉,返 503 JSON。 - 修复: InstallGuard 非白名单分支加 Accept 内容协商, text/html → 302 Location: /setup(浏览器友好), 其它(API 客户端 / curl / 空 Accept)→ 维持 503 JSON(API 行为不变)。 - 额外: mux 路由未匹配时不走 r.Use() 中间件链, 手动设 r.NotFoundHandler 复用同一份 Accept 协商逻辑, 覆盖 /admin /api/voices 等任意未注册路径。 Bug 2: 装完访问 /setup 仍能进入(看到安装表单) - 根因: router.go 的 /setup handler 没做 mode 校验。 - 修复: handler 顶部加 if ModeNormal → 302 Location: /admin (M2 之后才有 /admin;目前会 404 也比继续显示表单好)。 测试(都被 .gitignore 排除,本地保留): - middleware/installguard_test.go: 加 2 个 case(浏览器 redirect / API JSON)+ acceptsHTML 单元 - router/router_test.go(新): /setup 装后 redirect + NotFoundHandler 4 个 case - 全套 go test ./... 全绿 验证(本机 e2e 跑通): - setup 模式: 浏览器 /, /admin, /api/voices → 302 /setup - setup 模式: API/curl / → 503 JSON(行为不变) - normal 模式: /setup → 302 /admin(Bug 2) - normal 模式: /admin → 404(M2 才有,行为不变) - normal 模式: /api/setup/status → 200 {installed:true,mode:normal} 二进制大小: 15.87 → ~16MB(几乎不变) --- middleware/installguard.go | 41 +++++++++++++++++++++++++++++++++++--- router/router.go | 41 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 3 deletions(-) diff --git a/middleware/installguard.go b/middleware/installguard.go index cae49e6..2ef302e 100644 --- a/middleware/installguard.go +++ b/middleware/installguard.go @@ -8,9 +8,12 @@ import ( "github.com/volcano-tts/tts-api/installer" ) -// InstallGuard 拦截所有非 /setup 路由,在安装模式下返回 503。 -// 设计:放行白名单路径前缀,其余一律 503 + 引导跳转。 +// InstallGuard 拦截所有非白名单路由,在安装模式下按 Accept 头做内容协商: // +// - text/html 类(浏览器) → 302 重定向到 /setup +// - 其它(API 客户端、curl 等) → 503 + JSON +// +// 设计:放行白名单路径前缀,其余一律拦截。 // 中间件顺序:必须装在 RateLimit / ConcurrencyLimit / Logger 之前, // 避免安装模式下被限流计数污染(参考 M1 风险点 #2)。 func InstallGuard(currentMode func() installer.Mode, allowPrefixes ...string) func(http.Handler) http.Handler { @@ -37,10 +40,42 @@ func InstallGuard(currentMode func() installer.Mode, allowPrefixes ...string) fu return } } - log.Printf("[installguard] 安装模式下拒绝非白名单请求 - 路径=%s 客户端=%s", path, GetClientIP(r)) + log.Printf("[installguard] 安装模式下拒绝非白名单请求 - 路径=%s 客户端=%s accept=%q", + path, GetClientIP(r), r.Header.Get("Accept")) + // 内容协商:浏览器自动跳 /setup,API 客户端拿 JSON。 + // / 不在白名单里,所以这里同时覆盖"敲域名根路径"和"敲其他路径"两种场景。 + if acceptsHTML(r.Header.Get("Accept")) { + w.Header().Set("Location", "/setup") + w.WriteHeader(http.StatusFound) // 302 + return + } 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"}`)) }) } } + +// acceptsHTML 判断客户端是否接受 HTML 响应。 +// 严格匹配:Accept 必须显式包含 text/html 或 text/*,避免通配 */*(curl/API 默认) +// 走 302 路径影响 API 行为。 +func acceptsHTML(accept string) bool { + if accept == "" { + return false + } + for _, part := range strings.Split(accept, ",") { + mt := strings.TrimSpace(part) + if mt == "" { + continue + } + // 去掉 q= 等参数 + if idx := strings.Index(mt, ";"); idx >= 0 { + mt = strings.TrimSpace(mt[:idx]) + } + mt = strings.ToLower(mt) + if mt == "text/html" || mt == "text/*" { + return true + } + } + return false +} diff --git a/router/router.go b/router/router.go index 8c71485..cb5121b 100644 --- a/router/router.go +++ b/router/router.go @@ -3,6 +3,7 @@ package router import ( _ "embed" "net/http" + "strings" "github.com/gorilla/mux" "github.com/volcano-tts/tts-api/controller" @@ -30,8 +31,25 @@ func Setup() *mux.Router { r.Use(middleware.ConcurrencyLimitWithMetrics) r.Use(middleware.Logger) + // mux 的 NotFoundHandler 不会走 r.Use() 中间件链, + // 所以 InstallGuard 的内容协商在 404 路径上不生效。 + // 手动设一个:安装模式 + 浏览器访问任意未注册路径 → 302 跳 /setup。 + r.NotFoundHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if installer.GetMode() == installer.ModeSetup && acceptsHTML(r.Header.Get("Accept")) { + http.Redirect(w, r, "/setup", http.StatusFound) + return + } + http.NotFound(w, r) + }) + // 安装相关路由(InstallGuard 已在 setup 模式放行;完成后由 controller 二次校验 404) + // /setup 页面本身:装完后必须不可用,否则用户敲 /setup 还会看到安装表单,容易误以为要重装。 + // 装后跳 /admin(M2 之后才有;目前会 404,这是预期,比继续显示表单好)。 r.HandleFunc("/setup", func(w http.ResponseWriter, r *http.Request) { + if installer.GetMode() == installer.ModeNormal { + http.Redirect(w, r, "/admin", http.StatusFound) + return + } w.Header().Set("Content-Type", "text/html; charset=utf-8") _, _ = w.Write(setupHTML) }).Methods("GET") @@ -61,3 +79,26 @@ func Setup() *mux.Router { return r } + +// acceptsHTML 在 router 包内复刻一份,middleware 包的版本未导出。 +// 用途:NotFoundHandler 判断浏览器 Accept。 +// 与 middleware.acceptsHTML 行为一致(简单实现,严格匹配 text/html 或 text/*)。 +func acceptsHTML(accept string) bool { + if accept == "" { + return false + } + for _, part := range strings.Split(accept, ",") { + mt := strings.TrimSpace(part) + if mt == "" { + continue + } + if idx := strings.Index(mt, ";"); idx >= 0 { + mt = strings.TrimSpace(mt[:idx]) + } + mt = strings.ToLower(mt) + if mt == "text/html" || mt == "text/*" { + return true + } + } + return false +}