# 起因
用户问「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`
76 lines
2.8 KiB
Go
76 lines
2.8 KiB
Go
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)
|
||
}
|
||
}
|