From 08c885aa6c0a06ae08c5ee8408183c5d9eb8e3eb Mon Sep 17 00:00:00 2001 From: qian Date: Thu, 27 Aug 2026 00:08:31 +0800 Subject: [PATCH] =?UTF-8?q?fix(web):=E4=BF=AE=E5=A4=8D=E5=89=8D=E7=AB=AF?= =?UTF-8?q?=E7=BC=93=E5=AD=98=E5=AF=BC=E8=87=B4=E7=82=B9=E5=87=BB=E9=A1=B5?= =?UTF-8?q?=E9=9D=A2=E6=B2=A1=E5=8F=8D=E5=BA=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/cmd/cmd.go | 94 +++++++++++++++++++-- internal/cmd/spa_handler_test.go | 141 +++++++++++++++++++++++++++++++ web/admin/src/main.ts | 25 ++++++ web/tenant/src/main.ts | 25 ++++++ 4 files changed, 278 insertions(+), 7 deletions(-) create mode 100644 internal/cmd/spa_handler_test.go diff --git a/internal/cmd/cmd.go b/internal/cmd/cmd.go index 337e1df..d382172 100644 --- a/internal/cmd/cmd.go +++ b/internal/cmd/cmd.go @@ -2,6 +2,8 @@ package cmd import ( "context" + "crypto/sha256" + "encoding/hex" "fmt" "io/fs" "net/http" @@ -521,6 +523,54 @@ func registerFrontendRoutes(s *ghttp.Server) { }) } +// staticAssetExts 视为静态资源的扩展名集合:此类请求未命中文件时应返回 404 +// 而非回退 index.html,避免把 HTML 当 JS/JSON 返回触发浏览器严格 MIME 校验报错。 +var staticAssetExts = map[string]struct{}{ + ".js": {}, ".mjs": {}, ".css": {}, ".wasm": {}, ".map": {}, + ".jpg": {}, ".jpeg": {}, ".png": {}, ".gif": {}, ".webp": {}, ".avif": {}, + ".svg": {}, ".ico": {}, ".woff": {}, ".woff2": {}, ".ttf": {}, ".otf": {}, ".eot": {}, + ".mp4": {}, ".webm": {}, ".mp3": {}, ".wav": {}, ".json": {}, ".xml": {}, ".txt": {}, +} + +// isAssetPath 判断请求路径是否具有静态资源特征(assets 目录或带资源扩展名)。 +func isAssetPath(path string) bool { + if strings.HasPrefix(path, "assets/") { + return true + } + ext := strings.ToLower(path[strings.LastIndex(path, "/")+1:]) + dot := strings.LastIndex(ext, ".") + if dot <= 0 { + return false + } + _, ok := staticAssetExts[ext[dot:]] + return ok +} + +// setStaticCacheHeader 按资源类型设置缓存策略: +// Vite 构建产物(assets/ 下 hash 文件名)永久强缓存;入口文件(index.html 等)要求每次进站重新校验, +// 保证发版后用户总能拿到新入口。 +func setStaticCacheHeader(w http.ResponseWriter, path string) { + if strings.HasPrefix(path, "assets/") { + w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") + return + } + w.Header().Set("Cache-Control", "no-cache") +} + +// matchesEtag 判断请求头 If-None-Match 是否包含当前指纹(值可能是逗号分隔列表或通配 *)。 +func matchesEtag(header, tag string) bool { + if header == "" { + return false + } + for _, part := range strings.Split(header, ",") { + part = strings.TrimSpace(part) + if part == "*" || part == tag { + return true + } + } + return false +} + // spaHandler returns an http.HandlerFunc that serves static files from the // given filesystem, falling back to index.html for SPA client-side routing. func spaHandler(root fs.FS, prefix string) http.HandlerFunc { @@ -529,28 +579,58 @@ func spaHandler(root fs.FS, prefix string) http.HandlerFunc { fileServer = http.StripPrefix(prefix, fileServer) } + // 启动时预读入口文件并预计算 SHA-256 指纹作为 ETag: + // index.html 走 no-cache 协商缓存,未重新发布的重复访问返回 304 空响应, + // 既保证发版即生效、又不产生重复下载开销。 + indexBytes, indexErr := fs.ReadFile(root, "index.html") + indexTag := "" + if indexErr == nil { + sum := sha256.Sum256(indexBytes) + indexTag = `"` + hex.EncodeToString(sum[:]) + `"` + } + + serveIndex := func(w http.ResponseWriter, r *http.Request) { + if indexErr != nil { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("ETag", indexTag) + if matchesEtag(r.Header.Get("If-None-Match"), indexTag) { + w.WriteHeader(http.StatusNotModified) + return + } + w.Write(indexBytes) + } + return func(w http.ResponseWriter, r *http.Request) { // Resolve the file path within the embedded FS path := strings.TrimPrefix(r.URL.Path, prefix) path = strings.TrimPrefix(path, "/") - if path == "" { - path = "index.html" + + // 入口文件统一走自建响应(fileServer 无法为 embed FS 提供 Last-Modified/ETag) + if path == "" || path == "index.html" { + serveIndex(w, r) + return } // Try to open the file; if it exists, serve it directly if f, err := root.Open(path); err == nil { f.Close() + setStaticCacheHeader(w, path) fileServer.ServeHTTP(w, r) return } - // File not found — serve index.html (SPA fallback) - indexBytes, err := fs.ReadFile(root, "index.html") - if err != nil { + // 带静态资源特征的请求(如发版后旧 hash chunk)缺失时返回 404, + // 不回退 SPA,方便前端自愈逻辑识别并整页刷新到新版本。 + if isAssetPath(path) { http.NotFound(w, r) return } - w.Header().Set("Content-Type", "text/html; charset=utf-8") - w.Write(indexBytes) + + // Route not found — serve index.html (SPA fallback) + serveIndex(w, r) } } diff --git a/internal/cmd/spa_handler_test.go b/internal/cmd/spa_handler_test.go new file mode 100644 index 0000000..01e030c --- /dev/null +++ b/internal/cmd/spa_handler_test.go @@ -0,0 +1,141 @@ +package cmd + +import ( + "io/fs" + "net/http" + "net/http/httptest" + "strings" + "testing" + "testing/fstest" +) + +// 构造模拟的前端构建产物:入口文件 + 一个带 hash 的静态 chunk +func newSpaTestFS() fs.FS { + return fstest.MapFS{ + "index.html": &fstest.MapFile{ + Data: []byte(""), + }, + "assets/index-x9y8z7.js": &fstest.MapFile{Data: []byte("console.log('chunk')")}, + "favicon.ico": &fstest.MapFile{Data: []byte("ico")}, + "screenshots/page-a.webp": &fstest.MapFile{Data: []byte("img")}, + } +} + +func get(t *testing.T, h http.HandlerFunc, target string, headers map[string]string) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(http.MethodGet, target, nil) + for k, v := range headers { + req.Header.Set(k, v) + } + rec := httptest.NewRecorder() + h(rec, req) + return rec +} + +func assertContains(t *testing.T, rec *httptest.ResponseRecorder, key, want string) { + t.Helper() + if got := rec.Header().Get(key); got != want { + t.Errorf("%s = %q, 期望 %q", key, got, want) + } +} + +// 路由请求回退 index.html,且带 no-cache 与 ETag +func TestSpaHandlerRouteFallback(t *testing.T) { + h := spaHandler(newSpaTestFS(), "") + rec := get(t, h, "/dashboard", nil) + if rec.Code != http.StatusOK { + t.Fatalf("状态码 = %d, 期望 200", rec.Code) + } + if !strings.Contains(rec.Body.String(), "index-x9y8z7.js") { + t.Error("响应体应为 index.html 内容") + } + assertContains(t, rec, "Cache-Control", "no-cache") + if rec.Header().Get("ETag") == "" { + t.Error("index.html 应携带 ETag") + } +} + +// 根路径与显式 /index.html 都走同一入口响应 +func TestSpaHandlerIndexPaths(t *testing.T) { + h := spaHandler(newSpaTestFS(), "") + for _, target := range []string{"/", "/index.html"} { + rec := get(t, h, target, nil) + if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "") { + t.Errorf("%s 应返回 200 的 index.html,实际 %d", target, rec.Code) + } + } +} + +// 未重新发布时重复访问命中 ETag 协商缓存,返回 304 空响应 +func TestSpaHandlerEtagNegotiation(t *testing.T) { + h := spaHandler(newSpaTestFS(), "") + first := get(t, h, "/", nil) + tag := first.Header().Get("ETag") + + rec := get(t, h, "/dashboard", map[string]string{"If-None-Match": tag}) + if rec.Code != http.StatusNotModified { + t.Fatalf("状态码 = %d, 期望 304 Not Modified", rec.Code) + } + if rec.Body.Len() != 0 { + t.Error("304 响应不应携带 body") + } +} + +// 带静态资源特征的缺失文件返回 404 而非回退 SPA HTML(发版后旧 hash chunk 场景) +func TestSpaHandlerMissingAssetReturns404(t *testing.T) { + h := spaHandler(newSpaTestFS(), "") + cases := []string{ + "/assets/stale-old-hash.js", + "/assets/some.css", + "/assets/vendor.wasm", + "/images/missing.png", // 不存在的资源扩展名 + "/manifest.json", + } + for _, target := range cases { + rec := get(t, h, target, nil) + if rec.Code != http.StatusNotFound { + t.Errorf("%s 应返回 404,实际 %d", target, rec.Code) + } + if ct := rec.Header().Get("Content-Type"); strings.Contains(ct, "text/html") { + t.Errorf("%s 的 404 不应是 text/html", target) + } + } +} + +// 无扩展名的未知路径仍回退 index.html(深层路由刷新场景) +func TestSpaHandlerUnknownRouteStillFallbacks(t *testing.T) { + h := spaHandler(newSpaTestFS(), "") + rec := get(t, h, "/team/member-detail/42", nil) + if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "index") { + t.Fatalf("深层路由应回退 index.html,实际 %d", rec.Code) + } +} + +// 存在的 assets 文件命中后强缓存一年;根级非 hash 文件要求协商校验 +func TestSpaHandlerCacheHeaders(t *testing.T) { + h := spaHandler(newSpaTestFS(), "") + + rec := get(t, h, "/assets/index-x9y8z7.js", nil) + assertContains(t, rec, "Cache-Control", "public, max-age=31536000, immutable") + + rec = get(t, h, "/favicon.ico", nil) + assertContains(t, rec, "Cache-Control", "no-cache") +} + +// 带 URL 前缀的 admin 子应用:前缀剥离、前缀下的 404 与缓存规则同样生效 +func TestSpaHandlerWithPrefix(t *testing.T) { + h := spaHandler(newSpaTestFS(), "/admin") + + rec := get(t, h, "/admin/", nil) + if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "") { + t.Fatalf("/admin/ 应返回 index.html,实际 %d", rec.Code) + } + + rec = get(t, h, "/admin/assets/missing.js", nil) + if rec.Code != http.StatusNotFound { + t.Errorf("/admin/assets/missing.js 应返回 404,实际 %d", rec.Code) + } + + rec = get(t, h, "/admin/assets/index-x9y8z7.js", nil) + assertContains(t, rec, "Cache-Control", "public, max-age=31536000, immutable") +} diff --git a/web/admin/src/main.ts b/web/admin/src/main.ts index 4ba0800..0de17d8 100644 --- a/web/admin/src/main.ts +++ b/web/admin/src/main.ts @@ -26,6 +26,28 @@ console.log( 'color: #666;', ) +// 构建发版后旧 hash chunk 失效时自动整页刷新加载新版本;标记防止刷新死循环 +const CHUNK_RELOAD_KEY = 'spa_stale_chunk_reloaded' + +function reloadOnStaleChunk() { + if (!sessionStorage.getItem(CHUNK_RELOAD_KEY)) { + sessionStorage.setItem(CHUNK_RELOAD_KEY, '1') + window.location.reload() + } +} + +window.addEventListener('vite:preloadError', (event) => { + event.preventDefault() + reloadOnStaleChunk() +}) + +router.onError((error) => { + const msg = String(error instanceof Error ? error.message : error) + if (/Failed to fetch dynamically imported module|Importing a module script failed|error loading dynamically imported module/i.test(msg)) { + reloadOnStaleChunk() + } +}) + const app = createApp(App) app.config.errorHandler = (error, _instance, info) => { console.error('[VueError]', info, error) @@ -34,3 +56,6 @@ app.use(createPinia()) app.use(ArcoVue) app.use(router) app.mount('#app') + +// 页面成功启动后清除标记,后续每次发版仍可各自动刷新一次 +sessionStorage.removeItem(CHUNK_RELOAD_KEY) diff --git a/web/tenant/src/main.ts b/web/tenant/src/main.ts index 7cb5e68..104f37d 100644 --- a/web/tenant/src/main.ts +++ b/web/tenant/src/main.ts @@ -29,6 +29,28 @@ console.log( 'color: #666;', ) +// 构建发版后旧 hash chunk 失效时自动整页刷新加载新版本;标记防止刷新死循环 +const CHUNK_RELOAD_KEY = 'spa_stale_chunk_reloaded' + +function reloadOnStaleChunk() { + if (!sessionStorage.getItem(CHUNK_RELOAD_KEY)) { + sessionStorage.setItem(CHUNK_RELOAD_KEY, '1') + window.location.reload() + } +} + +window.addEventListener('vite:preloadError', (event) => { + event.preventDefault() + reloadOnStaleChunk() +}) + +router.onError((error) => { + const msg = String(error instanceof Error ? error.message : error) + if (/Failed to fetch dynamically imported module|Importing a module script failed|error loading dynamically imported module/i.test(msg)) { + reloadOnStaleChunk() + } +}) + const app = createApp(App) app.config.errorHandler = (error, _instance, info) => { console.error('[VueError]', info, error) @@ -38,3 +60,6 @@ app.use(createPinia()) app.use(router) app.use(naive) app.mount('#app') + +// 页面成功启动后清除标记,后续每次发版仍可各自动刷新一次 +sessionStorage.removeItem(CHUNK_RELOAD_KEY)