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) + } +}