package service import ( "bytes" "context" "crypto/sha1" "encoding/hex" "errors" "image" _ "image/gif" _ "image/jpeg" _ "image/png" "io" "os" "path/filepath" "strconv" "strings" "sync" "time" webpenc "github.com/gen2brain/webp" "golang.org/x/image/draw" xwebp "golang.org/x/image/webp" ) // 公开图片变体:GET /api/img?u=<源图URL>&w=<目标宽> // 按宽度白名单实时缩放为 WebP 并持久缓存到 .thumbs/v/,供全站 消费。 // 源内容不可变(上传文件名为随机哈希),变体 URL 可被浏览器/CDN 长期 immutable 缓存。 // GIF/SVG/ICO 与「源宽 ≤ 请求宽」一律原样透传:保动效、保矢量、不放大。 // 本地源按 mtime 失效;远程对象(/api/media/)ID 即内容,缓存永久有效。 // VariantWebPQuality 变体 WebP 有损质量(与 MediaThumb 一致) const VariantWebPQuality = 78 // variantWidths 宽度白名单(与前端 lib/responsiveImage.ts 同值,约 1.5x 步进) var variantWidths = []int{48, 96, 160, 256, 384, 512, 768, 1080, 1536, 1920} // IsVariantWidth 是否白名单精确值(非法尺寸 400,防止被刷任意尺寸) func IsVariantWidth(w int) bool { for _, v := range variantWidths { if v == w { return true } } return false } // variantGenLocks 并发生成去重:key=sha1(url).w → 每变体一把锁 var variantGenLocks sync.Map // extMIME 扩展名 → MIME(含 SVG/ICO 透传类型) func extMIME(ext string) string { switch ext { case ".jpg", ".jpeg": return "image/jpeg" case ".png": return "image/png" case ".webp": return "image/webp" case ".gif": return "image/gif" case ".svg": return "image/svg+xml" case ".ico": return "image/x-icon" } return "" } // variantCacheFile 变体缓存路径:.thumbs/v/.w.webp func (s *UploadService) variantCacheFile(url string, w int) string { sum := sha1.Sum([]byte(url)) name := hex.EncodeToString(sum[:]) + ".w" + strconv.Itoa(w) + ".webp" return filepath.Join(s.dir, ".thumbs", "v", name) } // variantCacheFresh 缓存是否新鲜:本地源按 mtime;远程对象(modTime 零值)只看存在 func variantCacheFresh(cache string, modTime time.Time) bool { ti, err := os.Stat(cache) if err != nil || ti.IsDir() { return false } return modTime.IsZero() || !ti.ModTime().Before(modTime) } // Variant 返回源图指定宽度的变体字节与 MIME。 func (s *UploadService) Variant(rawURL string, width int) ([]byte, string, error) { rawURL = strings.TrimSpace(rawURL) if len(rawURL) == 0 || len(rawURL) > 512 { return nil, "", errors.New("无效地址") } if !IsVariantWidth(width) { return nil, "", errors.New("不支持的尺寸") } var ( srcData []byte // 远程源预读字节(本地源在锁内读取) srcMIME string srcPath string // 本地源绝对路径 modTime time.Time remote bool cache = s.variantCacheFile(rawURL, width) ) switch { case strings.HasPrefix(rawURL, "/uploads/"): rel := filepath.Clean(filepath.FromSlash(strings.TrimPrefix(rawURL, "/uploads/"))) if filepath.IsAbs(rel) || rel == "." || strings.HasPrefix(rel, "..") { return nil, "", errors.New("无效地址") } dir := filepath.Dir(rel) catOK := false for _, cat := range mediaLibraryCategories { if cat.Dir == dir { catOK = true break } } if !catOK { return nil, "", errors.New("无效地址") } ext := strings.ToLower(filepath.Ext(rel)) mime := extMIME(ext) if mime == "" { return nil, "", errors.New("非图片") } srcPath = filepath.Join(s.dir, rel) info, err := os.Stat(srcPath) if err != nil || info.IsDir() { return nil, "", errors.New("文件不存在") } // 动图/矢量/图标:无法或无需光栅缩放,原样透传(不占变体缓存) if ext == ".gif" || ext == ".svg" || ext == ".ico" { data, err := os.ReadFile(srcPath) return data, mime, err } srcMIME = mime modTime = info.ModTime() case strings.HasPrefix(rawURL, "/api/media/"): if s.ops == nil { return nil, "", errors.New("无效地址") } id := strings.TrimPrefix(rawURL, "/api/media/") if id == "" || strings.ContainsAny(id, "/?#") || len(id) > 128 { return nil, "", errors.New("无效地址") } remote = true ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) defer cancel() r, mime, e := s.ops.OpenObject(ctx, id, true) if e != nil { return nil, "", errors.New("文件暂不可用") } defer r.Close() switch mime { case "image/jpeg", "image/png", "image/webp": case "image/gif", "image/svg+xml", "image/x-icon", "image/vnd.microsoft.icon": data, err := io.ReadAll(r) return data, mime, err default: return nil, "", errors.New("非图片") } srcData, e = io.ReadAll(r) if e != nil { return nil, "", e } srcMIME = mime default: return nil, "", errors.New("无效地址") } // 快速路径:缓存已新鲜直接返回(无锁) if variantCacheFresh(cache, modTime) { if data, err := os.ReadFile(cache); err == nil { return data, "image/webp", nil } } // 并发生成去重:同 url+w 只生成一次 sum := sha1.Sum([]byte(rawURL)) key := hex.EncodeToString(sum[:]) + ".w" + strconv.Itoa(width) mu, _ := variantGenLocks.LoadOrStore(key, &sync.Mutex{}) lk := mu.(*sync.Mutex) lk.Lock() defer lk.Unlock() // double-check:等锁期间可能已由他人生成 if variantCacheFresh(cache, modTime) { if data, err := os.ReadFile(cache); err == nil { return data, "image/webp", nil } } if !remote { data, err := os.ReadFile(srcPath) if err != nil { return nil, "", err } srcData = data } out, m, err := variantFromBytes(srcData, srcMIME, width) if err != nil { return nil, "", err // 如动图 WebP,调用方回退原图 } // 仅 WebP 变体积缓存(小图透传不落盘);失败仅影响下次重复生成,不阻断响应 if m == "image/webp" { _ = atomicWriteFile(cache, out) } return out, m, nil } // variantFromBytes 源字节 → 目标变体:解码失败报错(调用方回退原图); // 源宽 ≤ 目标宽 → 原字节透传(不放大);否则按目标宽等比缩放为 WebP。 func variantFromBytes(srcData []byte, srcMIME string, width int) ([]byte, string, error) { img, err := decodeImageBytes(srcData, srcMIME == "image/webp") if err != nil { return nil, "", err } b := img.Bounds() if b.Dx() <= width { return srcData, srcMIME, nil } nh := max(1, b.Dy()*width/b.Dx()) dst := scaleImage(img, width, nh) out, err := encodeLossyWebP(dst, VariantWebPQuality) if err != nil { return nil, "", err } return out, "image/webp", nil } // ---------- 与 MediaThumb 共用的图片处理核心 ---------- // decodeImageBytes 解码图片字节;WebP 走 x/image/webp 解码器 func decodeImageBytes(data []byte, isWebP bool) (image.Image, error) { if isWebP { return xwebp.Decode(bytes.NewReader(data)) } img, _, err := image.Decode(bytes.NewReader(data)) return img, err } // scaleImage CatmullRom 高质量缩放到指定宽高 func scaleImage(img image.Image, nw, nh int) *image.RGBA { dst := image.NewRGBA(image.Rect(0, 0, nw, nh)) draw.CatmullRom.Scale(dst, dst.Bounds(), img, img.Bounds(), draw.Src, nil) return dst } // encodeLossyWebP 有损 WebP 编码 func encodeLossyWebP(img image.Image, quality int) ([]byte, error) { var buf bytes.Buffer if err := webpenc.Encode(&buf, img, webpenc.Options{Quality: quality}); err != nil { return nil, err } return buf.Bytes(), nil } // atomicWriteFile 临时文件 + rename 原子落盘 func atomicWriteFile(path string, data []byte) error { if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { return err } tmp := path + ".partial" if err := os.WriteFile(tmp, data, 0o644); err != nil { return err } if err := os.Rename(tmp, path); err != nil { _ = os.Remove(tmp) return err } return nil }