// 缩略图懒生成:GET /uploads/thumb/{key}?w=960 // // 首次请求从存储端读一次原图,缩放编码后落盘缓存(DataDir/.thumbnail_cache), // 之后直接供缓存——时间线首屏不再拉原图。非图片 / 解码失败 / 冷却期内 // 一律 302 回原图:前端 拿 302 是无感的。 package api import ( "errors" "net/http" "os" "strconv" "strings" "oneblog/internal/httpx" "oneblog/internal/model" "oneblog/internal/storage" "oneblog/internal/store" "oneblog/internal/thumbs" ) const ( thumbDefaultW = 960 thumbMinW = 64 thumbMaxW = 1600 ) func (a *API) ThumbHandler() http.Handler { return http.HandlerFunc(a.thumb) } func (a *API) thumb(w http.ResponseWriter, r *http.Request) { if a.Thumbs == nil { httpx.NotFound(w) return } key := strings.TrimPrefix(r.URL.Path, "/uploads/thumb/") if key == "" || strings.Contains(key, "..") { httpx.NotFound(w) return } width := thumbDefaultW if s := r.URL.Query().Get("w"); s != "" { if n, err := strconv.Atoi(s); err == nil && n >= thumbMinW && n <= thumbMaxW { width = n } } f, err := a.Store.GetFileByKey(key) if errors.Is(err, store.ErrNotFound) { httpx.NotFound(w) return } if err != nil { httpx.ServerError(w, err) return } if !thumbs.Supported(f.Mime) || f.SHA256 == "" { a.thumbFallback(w, r, f) return } // 快路径:缓存命中直接供(锁外) if p := a.Thumbs.FindCached(f.SHA256, width); p != "" { a.serveThumbFile(w, r, p, f) return } // 冷却期:近期失败过,不再尝试 if a.Thumbs.FailedRecently(f.SHA256) { a.thumbFallback(w, r, f) return } // 慢路径:同 key+宽度并发只生成一次,后来者等锁后读缓存 lk := a.Thumbs.Lock(f.SHA256, width) lk.Lock() defer lk.Unlock() if p := a.Thumbs.FindCached(f.SHA256, width); p != "" { a.serveThumbFile(w, r, p, f) return } rc, _, err := a.Blobs.Open(r.Context(), f.Key) if err != nil { a.Thumbs.MarkFailed(f.SHA256) a.thumbFallback(w, r, f) return } src, err := thumbs.ReadAllLimited(rc, thumbs.MaxSrcBytes) rc.Close() if err != nil { a.Thumbs.MarkFailed(f.SHA256) a.thumbFallback(w, r, f) return } out, outMime, _, err := thumbs.Generate(src, f.Mime, width) if err != nil { a.Thumbs.MarkFailed(f.SHA256) a.thumbFallback(w, r, f) return } p, err := a.Thumbs.Put(f.SHA256, width, out, outMime) if err != nil { // 写缓存失败不拖累本次响应:产物就在内存里 a.serveThumbBytes(w, outMime, out, f) return } a.serveThumbFile(w, r, p, f) } // thumbFallback 回原图:R2 + 公开域名是 302 直链,本地/未配域名回 /uploads/ 路由。 func (a *API) thumbFallback(w http.ResponseWriter, r *http.Request, f model.File) { http.Redirect(w, r, storage.FileURL(f.Store, f.Key, a.Cfg.UploadsPublicBase), http.StatusFound) } // serveThumbFile 用 ServeContent 供缓存文件:自带 Range/Last-Modified/304。 func (a *API) serveThumbFile(w http.ResponseWriter, r *http.Request, path string, f model.File) { fp, err := os.Open(path) if err != nil { a.thumbFallback(w, r, f) return } defer fp.Close() fi, err := fp.Stat() if err != nil { httpx.NotFound(w) return } a.Thumbs.Touch(path) // 记一次「最近用过」,供容量闸按 LRU 淘汰 w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") http.ServeContent(w, r, path, fi.ModTime(), fp) } func (a *API) serveThumbBytes(w http.ResponseWriter, mime string, data []byte, f model.File) { w.Header().Set("Content-Type", mime) w.Header().Set("Content-Length", strconv.Itoa(len(data))) w.Header().Set("ETag", `"`+f.SHA256+`"`) w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") w.WriteHeader(http.StatusOK) _, _ = w.Write(data) }