feat: 书库导入导出/图片变体/书籍搜索/小组件运行时等

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

其它:
- 后端 handlers/services 全量调整
- 前端页面、组件、库函数与配置更新
This commit is contained in:
2026-10-01 03:06:05 +08:00
parent 7fbd6ba699
commit ff2ab286fb
141 changed files with 7527 additions and 1407 deletions

View 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
}