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:
@ -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())
|
||||
})
|
||||
|
||||
|
||||
57
server/internal/static/cache.go
Normal file
57
server/internal/static/cache.go
Normal 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")
|
||||
}
|
||||
75
server/internal/static/cache_test.go
Normal file
75
server/internal/static/cache_test.go
Normal 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("%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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user