package service import ( "bytes" "cmp" "context" "crypto/sha256" "fmt" "image" "image/jpeg" "image/png" "io" "mime" "mime/multipart" "net/http" "path/filepath" "strings" "with_you/internal/model" "with_you/internal/pkg/s3" "github.com/disintegration/imaging" "go.uber.org/zap" _ "golang.org/x/image/bmp" _ "golang.org/x/image/tiff" ) // UploadService 上传服务接口 type UploadService interface { UploadImage(ctx context.Context, file *multipart.FileHeader) (string, string, string, error) UploadImageBytes(ctx context.Context, raw []byte, filename string) (url, previewURL, previewURLLarge string, err error) UploadAvatar(ctx context.Context, userID string, file *multipart.FileHeader) (string, error) UploadCover(ctx context.Context, userID string, file *multipart.FileHeader) (string, error) GetURL(ctx context.Context, objectName string) (string, error) Delete(ctx context.Context, objectName string) error GeneratePreviewImages(ctx context.Context, originalData []byte, hash string) (previewURL, previewURLLarge string, err error) ValidateChatMessageImageSegments(segments model.MessageSegments) error } // 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 } const maxUploadImageBytesFromBuffer = 15 << 20 // 与聊天内联解码上限一致,略大于 upload.max_file_size 时可接受 // UploadImageBytes 从内存字节上传图片(与 multipart 上传走同一套压缩与存储逻辑)。 func (s *uploadService) UploadImageBytes(ctx context.Context, raw []byte, filename string) (url, previewURL, previewURLLarge string, err error) { if len(raw) == 0 { return "", "", "", fmt.Errorf("empty image data") } if len(raw) > maxUploadImageBytesFromBuffer { return "", "", "", fmt.Errorf("image data too large") } detectedType := normalizeImageContentType(http.DetectContentType(raw)) compressedData, compressedType, cerr := compressImageData(raw, detectedType) if cerr != nil { compressedData = raw compressedType = detectedType } compressedType = cmp.Or(compressedType, http.DetectContentType(compressedData)) ext := cmp.Or(getExtFromContentType(compressedType), strings.ToLower(filepath.Ext(filename)), ".jpg") hash := sha256.Sum256(compressedData) hashStr := fmt.Sprintf("%x", hash) objectName := fmt.Sprintf("images/%s%s", hashStr, ext) url, err = s.s3Client.UploadData(ctx, objectName, compressedData, compressedType) if err != nil { return "", "", "", fmt.Errorf("failed to upload to S3: %w", err) } previewURL, previewURLLarge, prevErr := s.GeneratePreviewImages(ctx, compressedData, hashStr) if prevErr != nil { zap.L().Warn("UploadImageBytes: preview generation failed", zap.Error(prevErr)) } 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 }