package service import ( "bytes" "cmp" "context" "crypto/sha256" "fmt" "image" "image/jpeg" "image/png" "io" "mime" "mime/multipart" "net/http" "path/filepath" "strings" "carrot_bbs/internal/pkg/s3" "github.com/disintegration/imaging" "go.uber.org/zap" _ "golang.org/x/image/bmp" _ "golang.org/x/image/tiff" ) // UploadService 上传服务 type UploadService struct { s3Client *s3.Client userService UserService } // 预览图配置 const ( PreviewMaxWidth = 300 // 列表/网格模式最大宽度 PreviewMaxHeight = 800 // 详情页最大高度 PreviewQuality = 70 // WebP 质量 ) // NewUploadService 创建上传服务 func NewUploadService(s3Client *s3.Client, userService UserService) *UploadService { return &UploadService{ s3Client: s3Client, userService: userService, } } // UploadImage 上传图片(返回原图URL和预览图URL) func (s *UploadService) UploadImage(ctx context.Context, file *multipart.FileHeader) (string, string, 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) } // 生成预览图 previewURL, previewURLLarge, err := s.GeneratePreviewImages(ctx, processedData, hashStr) if err != nil { zap.L().Warn("Failed to generate preview images", zap.Error(err), ) } return url, previewURL, previewURLLarge, 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 := cmp.Or(detectedType, headerType, "application/octet-stream") compressedData, compressedType, err := compressImageData(originalData, contentType) if err != nil { // 压缩失败时回退到原图,保证上传可用性 compressedData = originalData compressedType = contentType } compressedType = cmp.Or(compressedType, http.DetectContentType(compressedData)) ext := cmp.Or(getExtFromContentType(compressedType), strings.ToLower(filepath.Ext(file.Filename)), ".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 } } // 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 { zap.L().Warn("Failed to generate preview", zap.Error(err), ) } // 生成大预览图(详情页) previewURLLarge, err = s.generatePreview(ctx, img, originalWidth, originalHeight, "large", PreviewMaxHeight, hash) if err != nil { zap.L().Warn("Failed to generate large preview", zap.Error(err), ) } 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 }