refactor: Gitea 式目录改组,移除本分支 SPA 与杂项产物
将 model/service/handler/middleware 迁至 models/services/routers/api/modules/auth,并删除 frontend、embed_static、scripts 及误入库缓存/二进制。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
173
services/thumb.go
Normal file
173
services/thumb.go
Normal file
@@ -0,0 +1,173 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user