feat: 书库导入导出/图片变体/书籍搜索/小组件运行时等
新增: - 书库导入导出(library_import/library_export)及测试 - 图片变体生成(image_variants)与响应式图片(responsiveImage) - 书籍搜索(bookSearch)+ BookSearch/LibrarySearchGrid 组件 - 小组件运行时(widgetRuntime)与静态检查(widgetLint) - 上传缓存中间件(upload_cache)与字体 CSS 提取脚本 其它: - 后端 handlers/services 全量调整 - 前端页面、组件、库函数与配置更新
This commit is contained in:
276
backend/service/image_variants.go
Normal file
276
backend/service/image_variants.go
Normal file
@@ -0,0 +1,276 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user