Files
jiang13-bbs/backend/service/media_thumbs.go
freefire 1d862a0dd3 feat: 站点媒体库与移动端底部导航,服务测试补强
- 新增媒体库:media_library/media_thumbs 服务(WebP 缩略图)、管理端 media 页面
- 移动端底部导航 MobileTabBar 替换 MobilePostBar
- 旧数据导入增强与测试、路由与登录会话/板块侧边栏测试补强
- site-doc 组件精简(移除 Breadcrumb),文档新增迁移公告说明
2026-09-27 02:36:11 +08:00

115 lines
3.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}