Files
MailUI4Agents/server/internal/static/cache_test.go
JianFeeeee 0f379a2ca0 fix(static): 给前端加缓存策略,修掉「换了新前端但用户仍看到旧界面」
# 起因

用户问「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`
2026-09-12 09:13:56 +08:00

76 lines
2.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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("%sIsImmutableAsset(%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)
}
}