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 }