Files
backend/internal/service/upload_service.go
lan 4d8f2ec997 Initial backend repository commit.
Set up project files and add .gitignore to exclude local build/runtime artifacts.

Made-with: Cursor
2026-03-09 21:28:58 +08:00

274 lines
7.0 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"
"context"
"crypto/sha256"
"fmt"
"image"
"image/jpeg"
"image/png"
"io"
"mime"
"mime/multipart"
"net/http"
"path/filepath"
"strings"
"carrot_bbs/internal/pkg/s3"
_ "golang.org/x/image/bmp"
_ "golang.org/x/image/tiff"
)
// UploadService 上传服务
type UploadService struct {
s3Client *s3.Client
userService *UserService
}
// NewUploadService 创建上传服务
func NewUploadService(s3Client *s3.Client, userService *UserService) *UploadService {
return &UploadService{
s3Client: s3Client,
userService: userService,
}
}
// UploadImage 上传图片
func (s *UploadService) UploadImage(ctx context.Context, 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("images/%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)
}
return url, nil
}
// 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"))
contentType := detectedType
if contentType == "" || contentType == "application/octet-stream" {
contentType = headerType
}
compressedData, compressedType, err := compressImageData(originalData, contentType)
if err != nil {
// 压缩失败时回退到原图,保证上传可用性
compressedData = originalData
compressedType = contentType
}
if compressedType == "" {
compressedType = contentType
}
if compressedType == "" {
compressedType = http.DetectContentType(compressedData)
}
ext := getExtFromContentType(compressedType)
if ext == "" {
ext = strings.ToLower(filepath.Ext(file.Filename))
}
if ext == "" {
// 最终兜底,避免对象名无扩展名导致 URL 语义不明确
ext = ".jpg"
}
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
}
}