- 新增媒体库:media_library/media_thumbs 服务(WebP 缩略图)、管理端 media 页面 - 移动端底部导航 MobileTabBar 替换 MobilePostBar - 旧数据导入增强与测试、路由与登录会话/板块侧边栏测试补强 - site-doc 组件精简(移除 Breadcrumb),文档新增迁移公告说明
115 lines
3.2 KiB
Go
115 lines
3.2 KiB
Go
package service
|
||
|
||
import (
|
||
"bytes"
|
||
"crypto/sha1"
|
||
"encoding/hex"
|
||
"errors"
|
||
"image"
|
||
_ "image/gif"
|
||
_ "image/jpeg"
|
||
_ "image/png"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
|
||
webpenc "github.com/gen2brain/webp"
|
||
"golang.org/x/image/draw"
|
||
xwebp "golang.org/x/image/webp"
|
||
)
|
||
|
||
// 媒体库缩略图:管理后台网格不再直连原图(全量图片一次加载网络压力大)。
|
||
// 服务端按需生成最长边 480px 的 WebP 缩略图,落盘 .thumbs/(按 URL 哈希命名)缓存,
|
||
// 源文件更新后(mtime 更新)自动重建。仅覆盖本地 /uploads/ 图片;
|
||
// 远程存储对象与解码失败(如动图 WebP)由调用方回退原图。
|
||
|
||
// 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
|
||
}
|
||
var img image.Image
|
||
if ext == ".webp" {
|
||
img, err = xwebp.Decode(bytes.NewReader(data))
|
||
} else {
|
||
img, _, err = image.Decode(bytes.NewReader(data))
|
||
}
|
||
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 := image.NewRGBA(image.Rect(0, 0, nw, nh))
|
||
draw.CatmullRom.Scale(dst, dst.Bounds(), img, b, draw.Src, nil)
|
||
|
||
var buf bytes.Buffer
|
||
if err := webpenc.Encode(&buf, dst, webpenc.Options{Quality: 78}); err != nil {
|
||
return nil, err
|
||
}
|
||
// 原子落缓存(失败仅影响下次重复生成,不阻断响应)
|
||
if err := os.MkdirAll(filepath.Dir(thumbPath), 0o755); err == nil {
|
||
tmp := thumbPath + ".partial"
|
||
if err := os.WriteFile(tmp, buf.Bytes(), 0o644); err == nil {
|
||
if err := os.Rename(tmp, thumbPath); err != nil {
|
||
_ = os.Remove(tmp)
|
||
}
|
||
}
|
||
}
|
||
return buf.Bytes(), nil
|
||
}
|