- Store 增 MaxBytes(默认 256MB):写入后节流扫描(10 分钟一次、同时只跑一个), 超限按 mtime 从旧到新删到 3/4 水位(滞回,避免卡在边界反复删),顺带清掉 进程被杀留下的 .tmp - 命中缓存时 Touch 更新 mtime,淘汰因此是 LRU 而非「最早生成的先死」, 热图不会被误删后反复回源拉存储 - 新增 Purge(sha):后台删除上传文件时连带清掉该图所有宽度的产物与 .failed 标记,否则已删图片会永久占着缓存
143 lines
3.7 KiB
Go
143 lines
3.7 KiB
Go
// 缩略图懒生成:GET /uploads/thumb/{key}?w=960
|
|
//
|
|
// 首次请求从存储端读一次原图,缩放编码后落盘缓存(DataDir/.thumbnail_cache),
|
|
// 之后直接供缓存——时间线首屏不再拉原图。非图片 / 解码失败 / 冷却期内
|
|
// 一律 302 回原图:前端 <img> 拿 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)
|
|
}
|