新增: - 书库导入导出(library_import/library_export)及测试 - 图片变体生成(image_variants)与响应式图片(responsiveImage) - 书籍搜索(bookSearch)+ BookSearch/LibrarySearchGrid 组件 - 小组件运行时(widgetRuntime)与静态检查(widgetLint) - 上传缓存中间件(upload_cache)与字体 CSS 提取脚本 其它: - 后端 handlers/services 全量调整 - 前端页面、组件、库函数与配置更新
94 lines
2.7 KiB
Go
94 lines
2.7 KiB
Go
package service
|
||
|
||
import (
|
||
"crypto/sha1"
|
||
"encoding/hex"
|
||
"errors"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
)
|
||
|
||
// 媒体库缩略图:管理后台网格不再直连原图(全量图片一次加载网络压力大)。
|
||
// 服务端按需生成最长边 480px 的 WebP 缩略图,落盘 .thumbs/(按 URL 哈希命名)缓存,
|
||
// 源文件更新后(mtime 更新)自动重建。仅覆盖本地 /uploads/ 图片;
|
||
// 远程存储对象与解码失败(如动图 WebP)由调用方回退原图。
|
||
// 解码/缩放/编码/原子落盘共用 image_variants.go 的图片处理核心。
|
||
|
||
// MediaThumbMaxSide 缩略图最长边(网格单元 ~200px,2x DPR 足够)
|
||
const MediaThumbMaxSide = 480
|
||
|
||
// MediaThumb 返回指定 /uploads/ 图片的缩略图字节。
|
||
func (s *UploadService) MediaThumb(url string) ([]byte, error) {
|
||
if !strings.HasPrefix(url, "/uploads/") || len(url) > 512 {
|
||
return nil, errors.New("无效地址")
|
||
}
|
||
rel := filepath.Clean(filepath.FromSlash(strings.TrimPrefix(url, "/uploads/")))
|
||
if filepath.IsAbs(rel) || rel == "." || strings.HasPrefix(rel, "..") {
|
||
return nil, errors.New("无效地址")
|
||
}
|
||
dir := filepath.Dir(rel)
|
||
ok := false
|
||
for _, cat := range mediaLibraryCategories {
|
||
if cat.Dir == dir {
|
||
ok = true
|
||
break
|
||
}
|
||
}
|
||
if !ok {
|
||
return nil, errors.New("无效地址")
|
||
}
|
||
ext := strings.ToLower(filepath.Ext(rel))
|
||
switch ext {
|
||
case ".jpg", ".jpeg", ".png", ".webp", ".gif":
|
||
default:
|
||
return nil, errors.New("非图片")
|
||
}
|
||
|
||
src := filepath.Join(s.dir, rel)
|
||
info, err := os.Stat(src)
|
||
if err != nil || info.IsDir() {
|
||
return nil, errors.New("文件不存在")
|
||
}
|
||
|
||
// 磁盘缓存:哈希命名防跨分类文件名冲突;mtime 旧于源文件则重建
|
||
sum := sha1.Sum([]byte(url))
|
||
thumbPath := filepath.Join(s.dir, ".thumbs", hex.EncodeToString(sum[:])+".webp")
|
||
if ti, err := os.Stat(thumbPath); err == nil && !ti.IsDir() && !ti.ModTime().Before(info.ModTime()) {
|
||
return os.ReadFile(thumbPath)
|
||
}
|
||
|
||
data, err := os.ReadFile(src)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
img, err := decodeImageBytes(data, ext == ".webp")
|
||
if err != nil {
|
||
return nil, err // 如动图 WebP,调用方回退原图
|
||
}
|
||
|
||
b := img.Bounds()
|
||
w, h := b.Dx(), b.Dy()
|
||
if w <= MediaThumbMaxSide && h <= MediaThumbMaxSide {
|
||
// 小图无需缩放,直接回原图省 CPU
|
||
return data, nil
|
||
}
|
||
nw, nh := w, h
|
||
if w >= h {
|
||
nw = MediaThumbMaxSide
|
||
nh = max(1, h*MediaThumbMaxSide/w)
|
||
} else {
|
||
nh = MediaThumbMaxSide
|
||
nw = max(1, w*MediaThumbMaxSide/h)
|
||
}
|
||
dst := scaleImage(img, nw, nh)
|
||
|
||
out, err := encodeLossyWebP(dst, VariantWebPQuality)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
// 原子落缓存(失败仅影响下次重复生成,不阻断响应)
|
||
_ = atomicWriteFile(thumbPath, out)
|
||
return out, nil
|
||
}
|