新增: - 书库导入导出(library_import/library_export)及测试 - 图片变体生成(image_variants)与响应式图片(responsiveImage) - 书籍搜索(bookSearch)+ BookSearch/LibrarySearchGrid 组件 - 小组件运行时(widgetRuntime)与静态检查(widgetLint) - 上传缓存中间件(upload_cache)与字体 CSS 提取脚本 其它: - 后端 handlers/services 全量调整 - 前端页面、组件、库函数与配置更新
277 lines
7.7 KiB
Go
277 lines
7.7 KiB
Go
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
|
||
}
|