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`
This commit is contained in:
2026-09-12 09:13:56 +08:00
parent 0997d441af
commit 0f379a2ca0
3 changed files with 138 additions and 1 deletions

View File

@ -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())
})

View File

@ -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")
}

View File

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