Files
mce/internal/service/avatar_service.go
Victor_Jay 97adf54d6d fix: golangci-lint 零告警通过 (57→0) + gofumpt/goimports 全量格式化
## CI 修复 (P0/P1)

P0 — 编译阻塞:
  - interfaces.go: postUseCase ISP 接口移除不用的 Create/Update/Delete

P1 — 必须修复:
  - ST1000: 为 13 个包添加包注释 (common/config/model/service/...)
  - ST1005: redis_store.go 全部错误消息改为小写开头
  - errcheck (19处): defer Close()→闭包忽略, notifier.Create→_=, r.Run→检查error
  - errorlint (9处): switch-on-error→errors.Is 链, ==→errors.Is
  - ST1020/ST1022: 导出符号注释以符号名开头

P2 — 安全评审:
  - gosec (12处): G203/G301/G304/G306 添加 nolint 注释并附理由

P3 — 清理:
  - unused: 移除 hasUnicode/energyStore/current/energyAdminUseCase
  - gofumpt + goimports 全量格式化 (35+ 文件)
2026-06-22 02:27:35 +08:00

245 lines
7.3 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/mce/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("图片解码失败:%w", 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("图片编码失败:%w", err)
}
// 7. 确保存储目录存在
if err := os.MkdirAll(s.storageDir, 0o755); err != nil { //nolint:gosec // 头像存储目录标准权限
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, 0o644); err != nil { //nolint:gosec // 上传头像标准权限
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
}