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