feat: 站点媒体库与移动端底部导航,服务测试补强
- 新增媒体库:media_library/media_thumbs 服务(WebP 缩略图)、管理端 media 页面 - 移动端底部导航 MobileTabBar 替换 MobilePostBar - 旧数据导入增强与测试、路由与登录会话/板块侧边栏测试补强 - site-doc 组件精简(移除 Breadcrumb),文档新增迁移公告说明
This commit is contained in:
114
backend/service/media_thumbs.go
Normal file
114
backend/service/media_thumbs.go
Normal file
@@ -0,0 +1,114 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user