package service import ( "crypto/sha1" "encoding/hex" "errors" "os" "path/filepath" "strings" ) // 媒体库缩略图:管理后台网格不再直连原图(全量图片一次加载网络压力大)。 // 服务端按需生成最长边 480px 的 WebP 缩略图,落盘 .thumbs/(按 URL 哈希命名)缓存, // 源文件更新后(mtime 更新)自动重建。仅覆盖本地 /uploads/ 图片; // 远程存储对象与解码失败(如动图 WebP)由调用方回退原图。 // 解码/缩放/编码/原子落盘共用 image_variants.go 的图片处理核心。 // 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 } img, err := decodeImageBytes(data, ext == ".webp") 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 := scaleImage(img, nw, nh) out, err := encodeLossyWebP(dst, VariantWebPQuality) if err != nil { return nil, err } // 原子落缓存(失败仅影响下次重复生成,不阻断响应) _ = atomicWriteFile(thumbPath, out) return out, nil }