package service import ( "bytes" "crypto/sha1" "encoding/hex" "errors" "image" _ "image/gif" _ "image/jpeg" _ "image/png" "os" "path/filepath" "strings" webpenc "github.com/gen2brain/webp" "golang.org/x/image/draw" xwebp "golang.org/x/image/webp" ) // 媒体库缩略图:管理后台网格不再直连原图(全量图片一次加载网络压力大)。 // 服务端按需生成最长边 480px 的 WebP 缩略图,落盘 .thumbs/(按 URL 哈希命名)缓存, // 源文件更新后(mtime 更新)自动重建。仅覆盖本地 /uploads/ 图片; // 远程存储对象与解码失败(如动图 WebP)由调用方回退原图。 // MediaThumbMaxSide 缩略图最长边(网格单元 ~200px,2x DPR 足够) const MediaThumbMaxSide = 480 // MediaThumb 返回指定 /uploads/ 图片的缩略图字节。 func (s *UploadService) MediaThumb(url string) ([]byte, error) { if !strings.HasPrefix(url, "/uploads/") || len(url) > 512 { return nil, errors.New("无效地址") } rel := filepath.Clean(filepath.FromSlash(strings.TrimPrefix(url, "/uploads/"))) if filepath.IsAbs(rel) || rel == "." || strings.HasPrefix(rel, "..") { return nil, errors.New("无效地址") } dir := filepath.Dir(rel) ok := false for _, cat := range mediaLibraryCategories { if cat.Dir == dir { ok = true break } } if !ok { return nil, errors.New("无效地址") } ext := strings.ToLower(filepath.Ext(rel)) switch ext { case ".jpg", ".jpeg", ".png", ".webp", ".gif": default: return nil, errors.New("非图片") } src := filepath.Join(s.dir, rel) info, err := os.Stat(src) if err != nil || info.IsDir() { return nil, errors.New("文件不存在") } // 磁盘缓存:哈希命名防跨分类文件名冲突;mtime 旧于源文件则重建 sum := sha1.Sum([]byte(url)) thumbPath := filepath.Join(s.dir, ".thumbs", hex.EncodeToString(sum[:])+".webp") if ti, err := os.Stat(thumbPath); err == nil && !ti.IsDir() && !ti.ModTime().Before(info.ModTime()) { return os.ReadFile(thumbPath) } data, err := os.ReadFile(src) if err != nil { return nil, err } var img image.Image if ext == ".webp" { img, err = xwebp.Decode(bytes.NewReader(data)) } else { img, _, err = image.Decode(bytes.NewReader(data)) } if err != nil { return nil, err // 如动图 WebP,调用方回退原图 } b := img.Bounds() w, h := b.Dx(), b.Dy() if w <= MediaThumbMaxSide && h <= MediaThumbMaxSide { // 小图无需缩放,直接回原图省 CPU return data, nil } nw, nh := w, h if w >= h { nw = MediaThumbMaxSide nh = max(1, h*MediaThumbMaxSide/w) } else { nh = MediaThumbMaxSide nw = max(1, w*MediaThumbMaxSide/h) } dst := image.NewRGBA(image.Rect(0, 0, nw, nh)) draw.CatmullRom.Scale(dst, dst.Bounds(), img, b, draw.Src, nil) var buf bytes.Buffer if err := webpenc.Encode(&buf, dst, webpenc.Options{Quality: 78}); err != nil { return nil, err } // 原子落缓存(失败仅影响下次重复生成,不阻断响应) if err := os.MkdirAll(filepath.Dir(thumbPath), 0o755); err == nil { tmp := thumbPath + ".partial" if err := os.WriteFile(tmp, buf.Bytes(), 0o644); err == nil { if err := os.Rename(tmp, thumbPath); err != nil { _ = os.Remove(tmp) } } } return buf.Bytes(), nil }