Files
mce/internal/service/avatar_service.go
Victor_Jay ca21ae7216 fix: 修复头像审核旧图裂开问题 + CSP 配置修复
- 头像提交审核不再删除旧文件,审核通过后才删除旧头像文件
- 头像审核被拒时删除已上传的新头像文件,保留旧头像
- CSP style-src 移除 nonce 以启用 unsafe-inline(JS 动态样式需要)
- CSP script-src 移除 nonce 改用 unsafe-inline + unsafe-eval(Vditor 编辑器和动态样式需要)
2026-06-02 22:26:01 +08:00

246 lines
7.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"
"fmt"
"image"
_ "image/gif"
_ "image/jpeg"
_ "image/png"
"io"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"metazone.cc/metalab/internal/model"
"github.com/chai2010/webp"
"golang.org/x/image/draw"
)
// 头像处理常量
const (
avatarMaxFileSize = 5 * 1024 * 1024 // 5MB
avatarMinWidth = 128 // 最小宽高,防止马赛克图被拉升
avatarMinHeight = 128
avatarMaxWidth = 3840
avatarMaxHeight = 3840
avatarOutputSize = 512
avatarTargetKB = 100
avatarStorageDir = "storage/uploads/avatars"
)
// userAvatarStore 头像服务所需的最小仓储接口
type userAvatarStore interface {
FindByID(id uint) (*model.User, error)
Update(user *model.User) error
FindByIDUnscoped(userID uint) (*model.User, error)
}
// AvatarService 头像上传处理服务
type AvatarService struct {
userRepo userAvatarStore
storageDir string
}
// NewAvatarService 构造函数
func NewAvatarService(userRepo userAvatarStore) *AvatarService {
return &AvatarService{userRepo: userRepo, storageDir: avatarStorageDir}
}
// ProcessAvatar 处理头像上传流程(完整流程:处理图片 + 更新 User
func (s *AvatarService) ProcessAvatar(userID uint, file io.Reader, contentType string, cropX, cropY, cropSize int) (string, error) {
avatarURL, err := s.ProcessImage(userID, file, contentType, cropX, cropY, cropSize)
if err != nil {
return "", err
}
// 直接更新场景:删除旧头像文件,仅保留新文件
s.CleanOldAvatars(userID)
user, err := s.userRepo.FindByID(userID)
if err != nil {
return "", fmt.Errorf("用户不存在")
}
user.Avatar = avatarURL
if err := s.userRepo.Update(user); err != nil {
return "", fmt.Errorf("更新头像失败")
}
return avatarURL, nil
}
// ProcessImage 处理头像上传流程(仅处理图片 + 保存文件,不更新 User审核场景用
func (s *AvatarService) ProcessImage(userID uint, file io.Reader, contentType string, cropX, cropY, cropSize int) (string, error) {
// 1. 检查文件类型(不支持 GIF裁切后无法保持动画
if contentType != "image/jpeg" && contentType != "image/png" && contentType != "image/webp" {
return "", fmt.Errorf("仅支持 JPEG、PNG、WebP 格式")
}
// 2. 读取全部字节并校验大小
data, err := io.ReadAll(io.LimitReader(file, avatarMaxFileSize+1))
if err != nil {
return "", fmt.Errorf("读取文件失败")
}
if len(data) > avatarMaxFileSize {
return "", fmt.Errorf("文件大小不能超过 5MB")
}
// 3. 解码图片WebP 需显式调用 webp.DecodeGo 标准库不含 WebP 解码器)
var img image.Image
switch contentType {
case "image/webp":
img, err = webp.Decode(bytes.NewReader(data))
default:
img, _, err = image.Decode(bytes.NewReader(data))
}
if err != nil {
return "", fmt.Errorf("图片解码失败:%v", err)
}
bounds := img.Bounds()
w, h := bounds.Dx(), bounds.Dy()
// 原图只检查最小尺寸,确保有足够像素供上采样
if w < avatarMinWidth || h < avatarMinHeight {
return "", fmt.Errorf("图片分辨率不能低于 128x128")
}
// 4. 裁切为正方形(优先使用自定义裁切参数)
var cropped image.Image
if cropSize > 0 && cropX >= 0 && cropY >= 0 &&
cropX+cropSize <= w && cropY+cropSize <= h {
cropped = subImage(img, cropX, cropY, cropSize)
} else {
cropped = centerCrop(img)
}
// 裁切后检查最大尺寸(用户实际选区不应超过此限制)
cb := cropped.Bounds()
cw, ch := cb.Dx(), cb.Dy()
if cw > avatarMaxWidth || ch > avatarMaxHeight {
return "", fmt.Errorf("裁切区域分辨率不能超过 3840x3840")
}
// 5. 缩放至 512x512CatmullRom 高质量)
resized := image.NewNRGBA(image.Rect(0, 0, avatarOutputSize, avatarOutputSize))
draw.CatmullRom.Scale(resized, resized.Bounds(), cropped, cropped.Bounds(), draw.Over, nil)
// 6. 编码为 WebP目标 ≤100KB
webpData, err := encodeWebP(resized, avatarTargetKB*1024)
if err != nil {
return "", fmt.Errorf("图片编码失败:%v", err)
}
// 7. 确保存储目录存在
if err := os.MkdirAll(s.storageDir, 0755); err != nil {
return "", fmt.Errorf("创建存储目录失败")
}
// 8. 原子写入:先写临时文件,再 rename 到最终路径
// 注意:此处不删除旧头像文件,审核场景下审核通过前旧头像仍需正常显示
ts := time.Now().Unix()
filename := fmt.Sprintf("%d_%d.webp", userID, ts)
tmpPath := filepath.Join(s.storageDir, filename+".tmp")
finalPath := filepath.Join(s.storageDir, filename)
if err := os.WriteFile(tmpPath, webpData, 0644); err != nil {
return "", fmt.Errorf("写入临时文件失败")
}
if err := os.Rename(tmpPath, finalPath); err != nil {
os.Remove(tmpPath)
return "", fmt.Errorf("保存文件失败")
}
return fmt.Sprintf("/uploads/avatars/%d_%d.webp", userID, ts), nil
}
// CleanOldAvatars 删除指定用户的所有旧头像文件,调用方负责确保新文件不受影响
func (s *AvatarService) CleanOldAvatars(userID uint) {
prefix := strconv.FormatUint(uint64(userID), 10) + "_"
entries, err := os.ReadDir(s.storageDir)
if err != nil {
return
}
for _, e := range entries {
if !e.IsDir() && strings.HasPrefix(e.Name(), prefix) && strings.HasSuffix(e.Name(), ".webp") {
os.Remove(filepath.Join(s.storageDir, e.Name()))
}
}
}
// DeleteAvatarFile 根据头像 URL 删除对应的磁盘文件
func (s *AvatarService) DeleteAvatarFile(url string) error {
if url == "" {
return nil
}
filename := filepath.Base(url)
if filename == "." || filename == "/" {
return nil
}
fullPath := filepath.Join(s.storageDir, filename)
return os.Remove(fullPath)
}
// subImage 从原图中裁切指定正方形区域
func subImage(img image.Image, x, y, size int) image.Image {
cropped := image.NewNRGBA(image.Rect(0, 0, size, size))
draw.Draw(cropped, cropped.Bounds(), img, image.Point{x, y}, draw.Src)
return cropped
}
// centerCrop 中心裁切为正方形(取 min(width, height)
func centerCrop(img image.Image) image.Image {
b := img.Bounds()
w, h := b.Dx(), b.Dy()
size := w
if h < size {
size = h
}
ox := (w - size) / 2
oy := (h - size) / 2
cropped := image.NewNRGBA(image.Rect(0, 0, size, size))
draw.Draw(cropped, cropped.Bounds(), img, image.Point{ox, oy}, draw.Src)
return cropped
}
// encodeWebP 编码为 WebP通过二分查找质量参数使文件大小接近 targetBytes
func encodeWebP(img image.Image, targetBytes int) ([]byte, error) {
buf := &bytes.Buffer{}
// 如果图片非常简单(如纯色),即使 quality=1 也可能很小
if err := webp.Encode(buf, img, &webp.Options{Quality: 80}); err != nil {
return nil, err
}
if buf.Len() <= targetBytes {
return buf.Bytes(), nil
}
// 二分查找合适的质量参数
low, high := 1, 80
var best []byte
for low <= high {
mid := (low + high) / 2
buf.Reset()
if err := webp.Encode(buf, img, &webp.Options{Quality: float32(mid)}); err != nil {
return nil, err
}
if buf.Len() <= targetBytes {
best = make([]byte, buf.Len())
copy(best, buf.Bytes())
low = mid + 1 // 尝试更高画质
} else {
high = mid - 1
}
}
if best != nil {
return best, nil
}
// 降到底仍超限,返回最低质量
buf.Reset()
if err := webp.Encode(buf, img, &webp.Options{Quality: 1}); err != nil {
return nil, err
}
return buf.Bytes(), nil
}