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 } }