From 0f379a2ca0540e0df85d5163e64776f47367d7d7 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sat, 12 Sep 2026 09:13:56 +0800 Subject: [PATCH] =?UTF-8?q?fix(static):=20=E7=BB=99=E5=89=8D=E7=AB=AF?= =?UTF-8?q?=E5=8A=A0=E7=BC=93=E5=AD=98=E7=AD=96=E7=95=A5=EF=BC=8C=E4=BF=AE?= =?UTF-8?q?=E6=8E=89=E3=80=8C=E6=8D=A2=E4=BA=86=E6=96=B0=E5=89=8D=E7=AB=AF?= =?UTF-8?q?=E4=BD=86=E7=94=A8=E6=88=B7=E4=BB=8D=E7=9C=8B=E5=88=B0=E6=97=A7?= =?UTF-8?q?=E7=95=8C=E9=9D=A2=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # 起因 用户问「webui 更新了吗」。实测三个入口(本机 / LAN / 公网 mail.jianfgit.xyz) 服务的都是同一份新构建(`index-DUb2s9Ly.css`,DOM 里有 `.app-backdrop`, `--radius-card` 已生效)—— **确实已更新**。但响应头显示: HTTP/1.1 200 OK Content-Type: text/html; charset=utf-8 Vary: Origin (没有 Cache-Control) 入口页没有任何缓存指令 → 浏览器走启发式缓存,可能长期使用旧的 HTML。 而 Vite 给资源按内容加哈希,**新构建生成新文件名**:旧 HTML 引用旧文件名, 于是整站被钉死在那一代资源上。这类故障没有任何报错,只有人肉硬刷新才能发现, 而且每次部署都会重演一次。 # 修法:区分两类资源,而不是一刀切 - **入口页 `no-cache`**(不是 `no-store`):可以落盘,但每次必须先回源确认。 它只有 ~2KB,回源代价可忽略,而它决定了用户拿到哪一代资源。 - **带内容哈希的 `/assets/*` 永久缓存**(`max-age=31536000, immutable`): 内容变了文件名就变,不存在「缓存了旧内容」的问题,连回源都不需要。 - **不带哈希的资源 `no-cache`**:`STATIC_DIR` 指向开发目录时文件名可能没有哈希, 给它们 immutable 会让改动永远不生效 —— 那比缓存旧资源更难查。 哈希判据(`-[A-Za-z0-9_-]{8,}\.[a-z0-9]+$`)刻意**不宽松**:只有真正像 Vite 产出的内容哈希才配 immutable。`short-ab12.css` 这种(哈希不足 8 位, 更像版本号或缩写)按无哈希处理。 # 测试 `internal/static/cache_test.go` 10 条路径判据 + 3 条响应头断言,含两组 **反向对照**: - 带哈希 → immutable,无哈希 → no-cache(证明判据有区分力,不是恒真) - 入口页必须是 `no-cache` 而**不是** `no-store`(后者连磁盘缓存都不用, 每次全量重取) # 验证 - `go vet` 干净;`go test ./... -count=1` 全量通过(新增 internal/static 用例) - 部署后线上实测三处响应头: - `/` → `Cache-Control: no-cache` - `/assets/index-DUb2s9Ly.css` → `public, max-age=31536000, immutable` - `/assets/agentmail.svg`(无哈希)→ `no-cache` --- server/cmd/server/main.go | 7 ++- server/internal/static/cache.go | 57 +++++++++++++++++++++ server/internal/static/cache_test.go | 75 ++++++++++++++++++++++++++++ 3 files changed, 138 insertions(+), 1 deletion(-) create mode 100644 server/internal/static/cache.go create mode 100644 server/internal/static/cache_test.go diff --git a/server/cmd/server/main.go b/server/cmd/server/main.go index ed62e10..ac9cffd 100644 --- a/server/cmd/server/main.go +++ b/server/cmd/server/main.go @@ -265,6 +265,10 @@ func main() { }) // 静态前端 + // + // 缓存策略见 static/cache.go:入口页必须每次回源校验(否则换了新前端、 + // 用户手里的 HTML 仍指向旧一代资源,而且没有任何报错), + // 带内容哈希的 /assets/* 可以永久缓存。 staticRoot := os.Getenv("STATIC_DIR") var staticMux http.Handler if staticRoot != "" { @@ -272,9 +276,10 @@ func main() { } else { staticMux = static.Handler() } - r.Handle("/assets/*", staticMux) + r.Handle("/assets/*", static.CacheControl(staticMux)) r.Get("/", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html; charset=utf-8") + static.SetIndexCacheControl(w) w.Write(static.GetIndex()) }) diff --git a/server/internal/static/cache.go b/server/internal/static/cache.go new file mode 100644 index 0000000..21d1569 --- /dev/null +++ b/server/internal/static/cache.go @@ -0,0 +1,57 @@ +package static + +import ( + "net/http" + "regexp" + "strings" +) + +// hashedAsset 匹配 Vite 产出的内容哈希文件名,例如 index-DUb2s9Ly.js、CalendarView-BQ1MXzlA.css。 +// +// 判据是「主名-哈希.扩展名」,哈希为 8 位以上字母数字(含 - 与 _, +// Vite 用 URL-safe base64 变体)。**故意不宽松匹配**:只有真正带内容哈希的 +// 文件才敢配 immutable,配错了会让更新永远拿不到。 +var hashedAsset = regexp.MustCompile(`-[A-Za-z0-9_-]{8,}\.[a-z0-9]+$`) + +// CacheControl 给静态前端加缓存策略。 +// +// # 为什么必须区分两类 +// +// 「换了新前端但用户看到的还是旧界面」几乎总是因为入口页被缓存:Vite 给每个 +// 资源按内容加哈希,新构建会生成**新的文件名**,但 `index.html` 里引用的是哪个 +// 文件名,取决于用户拿到的是哪一代 HTML。给 HTML 加了长缓存,等于把整站钉死在 +// 那一代资源上,而且**没有任何报错** —— 只有人肉硬刷新才能发现。 +// +// 所以: +// - **入口页每次回源校验**(no-cache,不是 no-store:允许落盘但要先确认)。 +// 它只有 ~2KB,回源代价可忽略,而它决定了用户拿到哪一代资源。 +// - **带内容哈希的资源永久缓存**(immutable)。内容变了文件名就变, +// 因此不存在「缓存了旧内容」的问题,连回源都不需要。 +// - **不带哈希的资源不缓存**。`STATIC_DIR` 指向开发目录时文件名可能没有哈希, +// 这时给 immutable 会让改动永远不生效 —— 比缓存旧资源更难查。 +func CacheControl(h http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if IsImmutableAsset(r.URL.Path) { + w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") + } else { + w.Header().Set("Cache-Control", "no-cache") + } + h.ServeHTTP(w, r) + }) +} + +// IsImmutableAsset 判断一个静态路径能否安全地永久缓存。 +func IsImmutableAsset(path string) bool { + if !strings.HasPrefix(path, "/assets/") { + return false + } + return hashedAsset.MatchString(path) +} + +// SetIndexCacheControl 给入口页写缓存策略。 +// +// 与 CacheControl 分开是因为入口页走的是独立的 handler(不在 /assets/ 前缀下), +// 而它的策略是固定的:必须每次回源校验。 +func SetIndexCacheControl(w http.ResponseWriter) { + w.Header().Set("Cache-Control", "no-cache") +} diff --git a/server/internal/static/cache_test.go b/server/internal/static/cache_test.go new file mode 100644 index 0000000..9915637 --- /dev/null +++ b/server/internal/static/cache_test.go @@ -0,0 +1,75 @@ +package static + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +// 缓存策略的判据。 +// +// 这套规则守的是一个**没有报错、只有肉眼可见**的故障:换了新前端,用户却长期 +// 看到旧界面。原因是入口页被缓存 —— Vite 的资源名带内容哈希,新构建生成新文件名, +// 而用户拿到的 HTML 里引用的是旧文件名,于是整站钉死在那一代资源上。 +func TestIsImmutableAsset(t *testing.T) { + cases := []struct { + path string + want bool + why string + }{ + {"/assets/index-DUb2s9Ly.css", true, "Vite 产出的 CSS(主名-8 位哈希.扩展名)"}, + {"/assets/index-ziu1EtZt.js", true, "Vite 产出的 JS"}, + {"/assets/CalendarView-BQ1MXzlA.js", true, "懒加载 chunk"}, + {"/assets/agentmail.svg", false, "没有内容哈希:改名不变,缓存了就更新不了"}, + {"/assets/favicon.ico", false, "同上(public/ 直接拷贝的资源)"}, + {"/assets/foo.css", false, "无哈希"}, + {"/assets/short-ab12.css", false, "哈希长度不足 8 位:更像版本号或缩写,不当作内容哈希"}, + {"/index.html", false, "入口页必须每次回源校验"}, + {"/", false, "根路径"}, + {"/api/v1/me", false, "API 不该被静态缓存规则碰到"}, + } + for _, c := range cases { + if got := IsImmutableAsset(c.path); got != c.want { + t.Errorf("%s:IsImmutableAsset(%q) = %v,应为 %v —— %s", + c.path, c.path, got, c.want, c.why) + } + } +} + +func TestCacheControlHeaders(t *testing.T) { + inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok")) + }) + h := CacheControl(inner) + + get := func(path string) string { + req := httptest.NewRequest(http.MethodGet, path, nil) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + return rec.Header().Get("Cache-Control") + } + + // 带哈希 → 永久缓存 + if got := get("/assets/index-DUb2s9Ly.css"); got != "public, max-age=31536000, immutable" { + t.Errorf("带哈希资源应 immutable,得到 %q", got) + } + // 不带哈希 → 必须回源校验(给它 immutable 会让改动永远不生效) + if got := get("/assets/agentmail.svg"); got != "no-cache" { + t.Errorf("无哈希资源应 no-cache,得到 %q", got) + } + // 其他路径(含入口页可能的回退)→ 同样回源校验 + if got := get("/"); got != "no-cache" { + t.Errorf("根路径应 no-cache,得到 %q", got) + } +} + +func TestSetIndexCacheControl(t *testing.T) { + rec := httptest.NewRecorder() + SetIndexCacheControl(rec) + // 不能是 no-store:那会连磁盘缓存都不用,每次全量重取; + // no-cache 的语义是「可以存,但每次必须先回源确认」 + if got := rec.Header().Get("Cache-Control"); got != "no-cache" { + t.Errorf("入口页应 no-cache,得到 %q", got) + } +}