Files
MailUI4Agents/server/internal/static/cache.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

58 lines
2.4 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"
"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")
}