Set up project files and add .gitignore to exclude local build/runtime artifacts. Made-with: Cursor
274 lines
7.0 KiB
Go
274 lines
7.0 KiB
Go
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
|
||
}
|
||
}
|