Files
jiang13-bbs/backend/service/image_variants.go
freefire ff2ab286fb feat: 书库导入导出/图片变体/书籍搜索/小组件运行时等
新增:
- 书库导入导出(library_import/library_export)及测试
- 图片变体生成(image_variants)与响应式图片(responsiveImage)
- 书籍搜索(bookSearch)+ BookSearch/LibrarySearchGrid 组件
- 小组件运行时(widgetRuntime)与静态检查(widgetLint)
- 上传缓存中间件(upload_cache)与字体 CSS 提取脚本

其它:
- 后端 handlers/services 全量调整
- 前端页面、组件、库函数与配置更新
2026-10-01 03:06:05 +08:00

277 lines
7.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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/,供全站 <img srcset> 消费。
// 源内容不可变(上传文件名为随机哈希),变体 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/<sha1(url)>.w<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
}