2026-03-09 21:28:58 +08:00
|
|
|
|
package service
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
|
"bytes"
|
2026-03-17 00:47:17 +08:00
|
|
|
|
"cmp"
|
2026-03-09 21:28:58 +08:00
|
|
|
|
"context"
|
|
|
|
|
|
"crypto/sha256"
|
|
|
|
|
|
"fmt"
|
|
|
|
|
|
"image"
|
|
|
|
|
|
"image/jpeg"
|
|
|
|
|
|
"image/png"
|
|
|
|
|
|
"io"
|
|
|
|
|
|
"mime"
|
|
|
|
|
|
"mime/multipart"
|
|
|
|
|
|
"net/http"
|
|
|
|
|
|
"path/filepath"
|
|
|
|
|
|
"strings"
|
|
|
|
|
|
|
|
|
|
|
|
"carrot_bbs/internal/pkg/s3"
|
2026-03-17 00:47:17 +08:00
|
|
|
|
|
2026-03-15 02:25:10 +08:00
|
|
|
|
"github.com/disintegration/imaging"
|
2026-03-17 00:47:17 +08:00
|
|
|
|
"go.uber.org/zap"
|
2026-03-13 09:38:18 +08:00
|
|
|
|
|
2026-03-09 21:28:58 +08:00
|
|
|
|
_ "golang.org/x/image/bmp"
|
|
|
|
|
|
_ "golang.org/x/image/tiff"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
// UploadService 上传服务
|
|
|
|
|
|
type UploadService struct {
|
|
|
|
|
|
s3Client *s3.Client
|
2026-03-13 09:38:18 +08:00
|
|
|
|
userService UserService
|
2026-03-09 21:28:58 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-15 02:25:10 +08:00
|
|
|
|
// 预览图配置
|
|
|
|
|
|
const (
|
|
|
|
|
|
PreviewMaxWidth = 300 // 列表/网格模式最大宽度
|
|
|
|
|
|
PreviewMaxHeight = 800 // 详情页最大高度
|
|
|
|
|
|
PreviewQuality = 70 // WebP 质量
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-03-09 21:28:58 +08:00
|
|
|
|
// NewUploadService 创建上传服务
|
2026-03-13 09:38:18 +08:00
|
|
|
|
func NewUploadService(s3Client *s3.Client, userService UserService) *UploadService {
|
2026-03-09 21:28:58 +08:00
|
|
|
|
return &UploadService{
|
|
|
|
|
|
s3Client: s3Client,
|
|
|
|
|
|
userService: userService,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-15 02:25:10 +08:00
|
|
|
|
// UploadImage 上传图片(返回原图URL和预览图URL)
|
|
|
|
|
|
func (s *UploadService) UploadImage(ctx context.Context, file *multipart.FileHeader) (string, string, string, error) {
|
2026-03-09 21:28:58 +08:00
|
|
|
|
processedData, contentType, ext, err := prepareImageForUpload(file)
|
|
|
|
|
|
if err != nil {
|
2026-03-15 02:25:10 +08:00
|
|
|
|
return "", "", "", err
|
2026-03-09 21:28:58 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 压缩后再计算哈希,确保同一压缩结果映射同一对象名
|
|
|
|
|
|
hash := sha256.Sum256(processedData)
|
|
|
|
|
|
hashStr := fmt.Sprintf("%x", hash)
|
|
|
|
|
|
|
2026-03-15 02:25:10 +08:00
|
|
|
|
// 上传原图
|
2026-03-09 21:28:58 +08:00
|
|
|
|
objectName := fmt.Sprintf("images/%s%s", hashStr, ext)
|
|
|
|
|
|
url, err := s.s3Client.UploadData(ctx, objectName, processedData, contentType)
|
|
|
|
|
|
if err != nil {
|
2026-03-15 02:25:10 +08:00
|
|
|
|
return "", "", "", fmt.Errorf("failed to upload to S3: %w", err)
|
2026-03-09 21:28:58 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-15 02:25:10 +08:00
|
|
|
|
// 生成预览图
|
|
|
|
|
|
previewURL, previewURLLarge, err := s.GeneratePreviewImages(ctx, processedData, hashStr)
|
|
|
|
|
|
if err != nil {
|
2026-03-17 00:47:17 +08:00
|
|
|
|
zap.L().Warn("Failed to generate preview images",
|
|
|
|
|
|
zap.Error(err),
|
|
|
|
|
|
)
|
2026-03-15 02:25:10 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return url, previewURL, previewURLLarge, nil
|
2026-03-09 21:28:58 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// getExtFromContentType 根据Content-Type获取文件扩展名
|
|
|
|
|
|
func getExtFromContentType(contentType string) string {
|
|
|
|
|
|
baseType, _, err := mime.ParseMediaType(contentType)
|
|
|
|
|
|
if err == nil && baseType != "" {
|
|
|
|
|
|
contentType = baseType
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
switch contentType {
|
|
|
|
|
|
case "image/jpg", "image/jpeg":
|
|
|
|
|
|
return ".jpg"
|
|
|
|
|
|
case "image/png":
|
|
|
|
|
|
return ".png"
|
|
|
|
|
|
case "image/gif":
|
|
|
|
|
|
return ".gif"
|
|
|
|
|
|
case "image/webp":
|
|
|
|
|
|
return ".webp"
|
|
|
|
|
|
case "image/bmp", "image/x-ms-bmp":
|
|
|
|
|
|
return ".bmp"
|
|
|
|
|
|
case "image/tiff":
|
|
|
|
|
|
return ".tiff"
|
|
|
|
|
|
default:
|
|
|
|
|
|
return ""
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// UploadAvatar 上传头像
|
|
|
|
|
|
func (s *UploadService) UploadAvatar(ctx context.Context, userID string, file *multipart.FileHeader) (string, error) {
|
|
|
|
|
|
processedData, contentType, ext, err := prepareImageForUpload(file)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return "", err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 压缩后再计算哈希
|
|
|
|
|
|
hash := sha256.Sum256(processedData)
|
|
|
|
|
|
hashStr := fmt.Sprintf("%x", hash)
|
|
|
|
|
|
|
|
|
|
|
|
objectName := fmt.Sprintf("avatars/%s%s", hashStr, ext)
|
|
|
|
|
|
|
|
|
|
|
|
url, err := s.s3Client.UploadData(ctx, objectName, processedData, contentType)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return "", fmt.Errorf("failed to upload to S3: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 更新用户头像
|
|
|
|
|
|
if s.userService != nil {
|
|
|
|
|
|
user, err := s.userService.GetUserByID(ctx, userID)
|
|
|
|
|
|
if err == nil && user != nil {
|
|
|
|
|
|
user.Avatar = url
|
|
|
|
|
|
err = s.userService.UpdateUser(ctx, user)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
// 更新失败不影响上传结果,只记录日志
|
|
|
|
|
|
fmt.Printf("[UploadAvatar] failed to update user avatar: %v\n", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return url, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// UploadCover 上传头图(个人主页封面)
|
|
|
|
|
|
func (s *UploadService) UploadCover(ctx context.Context, userID string, file *multipart.FileHeader) (string, error) {
|
|
|
|
|
|
processedData, contentType, ext, err := prepareImageForUpload(file)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return "", err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 压缩后再计算哈希
|
|
|
|
|
|
hash := sha256.Sum256(processedData)
|
|
|
|
|
|
hashStr := fmt.Sprintf("%x", hash)
|
|
|
|
|
|
|
|
|
|
|
|
objectName := fmt.Sprintf("covers/%s%s", hashStr, ext)
|
|
|
|
|
|
|
|
|
|
|
|
url, err := s.s3Client.UploadData(ctx, objectName, processedData, contentType)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return "", fmt.Errorf("failed to upload to S3: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 更新用户头图
|
|
|
|
|
|
if s.userService != nil {
|
|
|
|
|
|
user, err := s.userService.GetUserByID(ctx, userID)
|
|
|
|
|
|
if err == nil && user != nil {
|
|
|
|
|
|
user.CoverURL = url
|
|
|
|
|
|
err = s.userService.UpdateUser(ctx, user)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
// 更新失败不影响上传结果,只记录日志
|
|
|
|
|
|
fmt.Printf("[UploadCover] failed to update user cover: %v\n", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return url, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// GetURL 获取文件URL
|
|
|
|
|
|
func (s *UploadService) GetURL(ctx context.Context, objectName string) (string, error) {
|
|
|
|
|
|
return s.s3Client.GetURL(ctx, objectName)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Delete 删除文件
|
|
|
|
|
|
func (s *UploadService) Delete(ctx context.Context, objectName string) error {
|
|
|
|
|
|
return s.s3Client.Delete(ctx, objectName)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func prepareImageForUpload(file *multipart.FileHeader) ([]byte, string, string, error) {
|
|
|
|
|
|
f, err := file.Open()
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, "", "", fmt.Errorf("failed to open file: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
defer f.Close()
|
|
|
|
|
|
|
|
|
|
|
|
originalData, err := io.ReadAll(f)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, "", "", fmt.Errorf("failed to read file: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 优先从文件字节探测真实类型,避免前端压缩/转码后 header 与实际格式不一致
|
|
|
|
|
|
detectedType := normalizeImageContentType(http.DetectContentType(originalData))
|
|
|
|
|
|
headerType := normalizeImageContentType(file.Header.Get("Content-Type"))
|
2026-03-17 00:47:17 +08:00
|
|
|
|
contentType := cmp.Or(detectedType, headerType, "application/octet-stream")
|
2026-03-09 21:28:58 +08:00
|
|
|
|
|
|
|
|
|
|
compressedData, compressedType, err := compressImageData(originalData, contentType)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
// 压缩失败时回退到原图,保证上传可用性
|
|
|
|
|
|
compressedData = originalData
|
|
|
|
|
|
compressedType = contentType
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-17 00:47:17 +08:00
|
|
|
|
compressedType = cmp.Or(compressedType, http.DetectContentType(compressedData))
|
2026-03-09 21:28:58 +08:00
|
|
|
|
|
2026-03-17 00:47:17 +08:00
|
|
|
|
ext := cmp.Or(getExtFromContentType(compressedType), strings.ToLower(filepath.Ext(file.Filename)), ".jpg")
|
2026-03-09 21:28:58 +08:00
|
|
|
|
|
|
|
|
|
|
return compressedData, compressedType, ext, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func compressImageData(data []byte, contentType string) ([]byte, string, error) {
|
|
|
|
|
|
contentType = normalizeImageContentType(contentType)
|
|
|
|
|
|
|
|
|
|
|
|
// GIF/WebP 等格式先保留原图,避免动画和透明通道丢失
|
|
|
|
|
|
if contentType == "image/gif" || contentType == "image/webp" {
|
|
|
|
|
|
return data, contentType, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if contentType != "image/jpeg" &&
|
|
|
|
|
|
contentType != "image/png" &&
|
|
|
|
|
|
contentType != "image/bmp" &&
|
|
|
|
|
|
contentType != "image/x-ms-bmp" &&
|
|
|
|
|
|
contentType != "image/tiff" {
|
|
|
|
|
|
return data, contentType, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
img, _, err := image.Decode(bytes.NewReader(data))
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, "", fmt.Errorf("failed to decode image: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var buf bytes.Buffer
|
|
|
|
|
|
switch contentType {
|
|
|
|
|
|
case "image/png":
|
|
|
|
|
|
encoder := png.Encoder{CompressionLevel: png.BestCompression}
|
|
|
|
|
|
if err := encoder.Encode(&buf, img); err != nil {
|
|
|
|
|
|
return nil, "", fmt.Errorf("failed to encode png: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
return buf.Bytes(), "image/png", nil
|
|
|
|
|
|
default:
|
|
|
|
|
|
// BMP/TIFF 等无损大图统一压缩为 JPEG,控制体积
|
|
|
|
|
|
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 82}); err != nil {
|
|
|
|
|
|
return nil, "", fmt.Errorf("failed to encode jpeg: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
return buf.Bytes(), "image/jpeg", nil
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func normalizeImageContentType(contentType string) string {
|
|
|
|
|
|
if contentType == "" {
|
|
|
|
|
|
return ""
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
baseType, _, err := mime.ParseMediaType(contentType)
|
|
|
|
|
|
if err == nil && baseType != "" {
|
|
|
|
|
|
contentType = baseType
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
switch strings.ToLower(contentType) {
|
|
|
|
|
|
case "image/jpg":
|
|
|
|
|
|
return "image/jpeg"
|
|
|
|
|
|
case "image/jpeg":
|
|
|
|
|
|
return "image/jpeg"
|
|
|
|
|
|
case "image/png":
|
|
|
|
|
|
return "image/png"
|
|
|
|
|
|
case "image/gif":
|
|
|
|
|
|
return "image/gif"
|
|
|
|
|
|
case "image/webp":
|
|
|
|
|
|
return "image/webp"
|
|
|
|
|
|
case "image/bmp", "image/x-ms-bmp":
|
|
|
|
|
|
return "image/bmp"
|
|
|
|
|
|
case "image/tiff":
|
|
|
|
|
|
return "image/tiff"
|
|
|
|
|
|
default:
|
|
|
|
|
|
return contentType
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-15 02:25:10 +08:00
|
|
|
|
|
|
|
|
|
|
// GeneratePreviewImages 生成预览图
|
|
|
|
|
|
func (s *UploadService) GeneratePreviewImages(ctx context.Context, originalData []byte, hash string) (previewURL, previewURLLarge string, err error) {
|
|
|
|
|
|
img, _, err := image.Decode(bytes.NewReader(originalData))
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return "", "", fmt.Errorf("failed to decode image: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
bounds := img.Bounds()
|
|
|
|
|
|
originalWidth := bounds.Dx()
|
|
|
|
|
|
originalHeight := bounds.Dy()
|
|
|
|
|
|
|
|
|
|
|
|
// 生成普通预览图(列表/网格模式)
|
|
|
|
|
|
previewURL, err = s.generatePreview(ctx, img, originalWidth, originalHeight, "preview", PreviewMaxWidth, hash)
|
|
|
|
|
|
if err != nil {
|
2026-03-17 00:47:17 +08:00
|
|
|
|
zap.L().Warn("Failed to generate preview",
|
|
|
|
|
|
zap.Error(err),
|
|
|
|
|
|
)
|
2026-03-15 02:25:10 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 生成大预览图(详情页)
|
|
|
|
|
|
previewURLLarge, err = s.generatePreview(ctx, img, originalWidth, originalHeight, "large", PreviewMaxHeight, hash)
|
|
|
|
|
|
if err != nil {
|
2026-03-17 00:47:17 +08:00
|
|
|
|
zap.L().Warn("Failed to generate large preview",
|
|
|
|
|
|
zap.Error(err),
|
|
|
|
|
|
)
|
2026-03-15 02:25:10 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return previewURL, previewURLLarge, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// generatePreview 生成单个预览图
|
|
|
|
|
|
func (s *UploadService) generatePreview(ctx context.Context, img image.Image, originalWidth, originalHeight int, mode string, maxDim int, hash string) (string, error) {
|
|
|
|
|
|
var targetWidth, targetHeight int
|
|
|
|
|
|
|
|
|
|
|
|
// 计算目标尺寸
|
|
|
|
|
|
if originalWidth > originalHeight {
|
|
|
|
|
|
// 宽图:按宽度缩放
|
|
|
|
|
|
if originalWidth > maxDim {
|
|
|
|
|
|
targetWidth = maxDim
|
|
|
|
|
|
targetHeight = int(float64(originalHeight) * float64(maxDim) / float64(originalWidth))
|
|
|
|
|
|
} else {
|
|
|
|
|
|
return "", nil // 图片足够小,不需要生成预览图
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// 高图:按高度缩放
|
|
|
|
|
|
if originalHeight > maxDim {
|
|
|
|
|
|
targetHeight = maxDim
|
|
|
|
|
|
targetWidth = int(float64(originalWidth) * float64(maxDim) / float64(originalHeight))
|
|
|
|
|
|
} else {
|
|
|
|
|
|
return "", nil // 图片足够小,不需要生成预览图
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 缩放图片(使用高质量 Lanczos 算法)
|
|
|
|
|
|
scaledImg := imaging.Resize(img, targetWidth, targetHeight, imaging.Lanczos)
|
|
|
|
|
|
|
|
|
|
|
|
// 编码为 JPEG(WebP 在某些设备上可能不支持,使用 JPEG 保证兼容性)
|
|
|
|
|
|
var buf bytes.Buffer
|
|
|
|
|
|
if err := jpeg.Encode(&buf, scaledImg, &jpeg.Options{Quality: PreviewQuality}); err != nil {
|
|
|
|
|
|
return "", fmt.Errorf("failed to encode jpeg: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 构建对象名:thumbnails/{hash}_{mode}.jpg
|
|
|
|
|
|
objectName := fmt.Sprintf("thumbnails/%s_%s.jpg", hash, mode)
|
|
|
|
|
|
|
|
|
|
|
|
// 上传到 S3
|
|
|
|
|
|
url, err := s.s3Client.UploadData(ctx, objectName, buf.Bytes(), "image/jpeg")
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return "", fmt.Errorf("failed to upload preview: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return url, nil
|
|
|
|
|
|
}
|