Files
jiang13-forum/service/thumb.go

174 lines
4.0 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 (
"errors"
"fmt"
"image"
"os"
"path/filepath"
"strings"
"sync"
"time"
"golang.org/x/image/draw"
// 注册解码器
_ "image/gif"
_ "image/jpeg"
_ "image/png"
_ "golang.org/x/image/webp"
)
const (
// PostThumbMaxSide 正文预览图最长边(像素)
PostThumbMaxSide = 1280
)
var thumbLocks sync.Map // 同一原图并发生成时串行化
// ThumbURLFromUpload 将 /uploads/posts/xxx.webp 转为 /media/thumb/posts/xxx.webp
func ThumbURLFromUpload(uploadURL string) string {
u := strings.TrimSpace(uploadURL)
if u == "" {
return ""
}
if strings.HasPrefix(u, "/media/thumb/") {
return u
}
if strings.HasPrefix(u, "/uploads/") {
return "/media/thumb/" + strings.TrimPrefix(u, "/uploads/")
}
return ""
}
// WarmPostImageThumb 上传后预热缩略图(失败忽略,首次访问仍会生成)
func WarmPostImageThumb(uploadsRoot, relativePath string) {
_, _ = EnsureUploadThumb(uploadsRoot, relativePath)
}
// EnsureUploadThumb 确保缩略图存在,返回磁盘路径
// relativePath 形如 posts/1_123.webp相对 uploads 根目录)
func EnsureUploadThumb(uploadsRoot, relativePath string) (string, error) {
rel, err := sanitizeUploadRel(relativePath)
if err != nil {
return "", err
}
// 仅处理帖子正文图
if !strings.HasPrefix(rel, "posts/") {
return "", errors.New("仅支持帖子图片缩略图")
}
origPath := filepath.Join(uploadsRoot, filepath.FromSlash(rel))
if st, err := os.Stat(origPath); err != nil || st.IsDir() {
return "", errors.New("原图不存在")
}
thumbPath := filepath.Join(uploadsRoot, ".thumbs", filepath.FromSlash(rel)+".webp")
if fresh, err := thumbFresherThan(thumbPath, origPath); err == nil && fresh {
return thumbPath, nil
}
lockKey := rel
muIface, _ := thumbLocks.LoadOrStore(lockKey, &sync.Mutex{})
mu := muIface.(*sync.Mutex)
mu.Lock()
defer mu.Unlock()
// 双检
if fresh, err := thumbFresherThan(thumbPath, origPath); err == nil && fresh {
return thumbPath, nil
}
if err := generateWebPThumb(origPath, thumbPath, PostThumbMaxSide); err != nil {
return "", err
}
return thumbPath, nil
}
func thumbFresherThan(thumbPath, origPath string) (bool, error) {
ts, err := os.Stat(thumbPath)
if err != nil {
return false, err
}
os_, err := os.Stat(origPath)
if err != nil {
return false, err
}
return !ts.ModTime().Before(os_.ModTime()), nil
}
func sanitizeUploadRel(relativePath string) (string, error) {
rel := strings.TrimSpace(relativePath)
rel = strings.TrimPrefix(rel, "/")
rel = strings.ReplaceAll(rel, "\\", "/")
if rel == "" || strings.Contains(rel, "..") {
return "", errors.New("非法路径")
}
cleaned := filepath.Clean(filepath.FromSlash(rel))
if cleaned == "." || strings.HasPrefix(cleaned, "..") {
return "", errors.New("非法路径")
}
return filepath.ToSlash(cleaned), nil
}
func generateWebPThumb(srcPath, dstPath string, maxSide int) error {
f, err := os.Open(srcPath)
if err != nil {
return err
}
defer f.Close()
img, _, err := image.Decode(f)
if err != nil {
return fmt.Errorf("解码图片失败: %w", err)
}
out := resizeToMax(img, maxSide)
data, err := encodeWebPBytes(out, ThumbWebPQuality, UploadWebPMethod)
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(dstPath), 0755); err != nil {
return err
}
tmp := fmt.Sprintf("%s.%d.tmp", dstPath, time.Now().UnixNano())
if err := os.WriteFile(tmp, data, 0644); err != nil {
return err
}
if err := os.Rename(tmp, dstPath); err != nil {
_ = os.Remove(tmp)
return err
}
return nil
}
func resizeToMax(src image.Image, maxSide int) image.Image {
b := src.Bounds()
w, h := b.Dx(), b.Dy()
if w <= 0 || h <= 0 {
return src
}
if w <= maxSide && h <= maxSide {
return src
}
var nw, nh int
if w >= h {
nw = maxSide
nh = int(float64(h) * float64(maxSide) / float64(w))
} else {
nh = maxSide
nw = int(float64(w) * float64(maxSide) / float64(h))
}
if nw < 1 {
nw = 1
}
if nh < 1 {
nh = 1
}
dst := image.NewRGBA(image.Rect(0, 0, nw, nh))
draw.CatmullRom.Scale(dst, dst.Bounds(), src, b, draw.Over, nil)
return dst
}