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" "with_you/internal/repository" "github.com/disintegration/imaging" "go.uber.org/zap" _ "golang.org/x/image/bmp" _ "golang.org/x/image/tiff" ) // UploadFileResult 通用文件上传结果 type UploadFileResult struct { URL string `json:"url"` Name string `json:"name"` Size int64 `json:"size"` MimeType string `json:"mime_type"` } // 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) // UploadFile 上传聊天文件。 // displayName 为前端显式传入的真实文件名(用于展示); // 当为空时回退到 multipart header 中的 Filename。 // 注意:原生端 expo-file-system 的 File.upload() 无法自定义 multipart filename, // 文件常被复制到缓存目录并以 UUID 命名,导致 Filename 丢失真实名,因此需要显式回传。 UploadFile(ctx context.Context, file *multipart.FileHeader, folder string, uploaderID string, displayName string) (*UploadFileResult, 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 // GetExpiredURLSet 返回所有已从 S3 清理的文件 URL 集合,供 DTO 层标记失效 GetExpiredURLSet(ctx context.Context) (map[string]struct{}, error) } // uploadService 上传服务实现 type uploadService struct { s3Client *s3.Client userService UserService uploadedRepo repository.UploadedFileRepository } // 预览图配置 const ( PreviewMaxWidth = 300 // 列表/网格模式最大宽度 PreviewMaxHeight = 800 // 详情页最大高度 PreviewQuality = 70 // WebP 质量 ) // NewUploadService 创建上传服务 func NewUploadService(s3Client *s3.Client, userService UserService, uploadedRepo repository.UploadedFileRepository) UploadService { return &uploadService{ s3Client: s3Client, userService: userService, uploadedRepo: uploadedRepo, } } // 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 } // 聊天文件上传限制 const ( MaxChatFileSize = 50 << 20 // 50MB chatFileFolderLimit = 32 // folder 名长度上限,避免路径注入 ) // chatFileAllowedMime 允许上传的文件 MIME 白名单(按类型前缀匹配)。 // 不在白名单内的类型一律拒绝,防止上传可执行脚本等高危文件。 var chatFileAllowedMime = map[string]bool{ // 文档 "application/pdf": true, "application/msword": true, "application/vnd.openxmlformats-officedocument.wordprocessingml.document": true, "application/vnd.ms-excel": true, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": true, "application/vnd.ms-powerpoint": true, "application/vnd.openxmlformats-officedocument.presentationml.presentation": true, "application/rtf": true, "text/plain": true, "text/csv": true, "text/markdown": true, "application/json": true, "application/xml": true, "text/xml": true, "application/zip": true, "application/x-zip-compressed": true, "application/x-rar-compressed": true, "application/x-7z-compressed": true, "application/gzip": true, "application/x-tar": true, // 音视频/图片(聊天文件入口) "audio/mpeg": true, "audio/mp3": true, "audio/wav": true, "audio/x-wav": true, "audio/aac": true, "audio/x-m4a": true, "audio/flac": true, "video/mp4": true, "video/quicktime": true, "video/x-msvideo": true, "video/webm": true, "image/jpeg": true, "image/png": true, "image/gif": true, "image/webp": true, "image/bmp": true, // 其他常用类型 "application/octet-stream": true, // 部分客户端(如 Windows)上传无明确类型,需结合扩展名二次校验 } // chatFileExtFallback 当 MIME 为 octet-stream 时,按扩展名推断的真实类型 var chatFileExtFallback = map[string]string{ ".pdf": "application/pdf", ".doc": "application/msword", ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", ".xls": "application/vnd.ms-excel", ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", ".ppt": "application/vnd.ms-powerpoint", ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation", ".zip": "application/zip", ".rar": "application/x-rar-compressed", ".7z": "application/x-7z-compressed", ".gz": "application/gzip", ".tar": "application/x-tar", ".mp3": "audio/mpeg", ".mp4": "video/mp4", ".mov": "video/quicktime", ".avi": "video/x-msvideo", ".json": "application/json", ".csv": "text/csv", ".md": "text/markdown", ".txt": "text/plain", } // normalizeChatFileMime 校正文件 MIME 类型: // 1. 解析 multipart header 的 Content-Type; // 2. 若为通用 octet-stream,依据文件名扩展名回退到更具体的类型; // 3. 全部兜底为 application/octet-stream。 func normalizeChatFileMime(headerType, filename string) string { baseType, _, err := mime.ParseMediaType(strings.TrimSpace(headerType)) if err != nil || baseType == "" { baseType = "application/octet-stream" } baseType = strings.ToLower(baseType) if baseType == "application/octet-stream" { ext := strings.ToLower(filepath.Ext(filename)) if real, ok := chatFileExtFallback[ext]; ok { return real } } return baseType } // validateChatFile 校验聊天文件大小与 MIME 白名单 func validateChatFile(size int64, mimeType string) error { if size <= 0 { return fmt.Errorf("文件为空") } if size > MaxChatFileSize { return fmt.Errorf("文件大小超过限制(最大 %d MB)", MaxChatFileSize>>20) } if !chatFileAllowedMime[mimeType] { return fmt.Errorf("不支持的文件类型: %s", mimeType) } return nil } // sanitizeFileName 清洗用于展示的文件名: // - 去除首尾空白与两侧的路径分隔符/点号,避免展示绝对路径或隐藏文件名; // - 去掉任何目录成分(防止 "a/b.pdf" 这类注入展示层); // - 限制长度,避免超长名影响存储与列表展示。 // // 注意:仅用于"展示名",不影响 S3 对象名(对象名始终为内容哈希)。 func sanitizeFileName(name string) string { name = strings.TrimSpace(name) if name == "" { return "" } // 统一分隔符后只保留文件名部分(basename) name = filepath.Base(strings.ReplaceAll(name, "\\", "/")) // 去除开头多余的 "."(避免 ".file" 之外出现 "..file" 之类异常),但保留正常的隐藏文件名 name = strings.TrimLeft(name, ".") name = strings.TrimSpace(name) if len(name) > 255 { // 过长时保留扩展名,截断主名 ext := strings.ToLower(filepath.Ext(name)) cut := 255 - len(ext) if cut < 1 { cut = 1 } name = name[:cut] + ext } return name } // UploadFile 上传聊天文件(非图片,按文件类型原样存储到 S3) // folder 用于隔离不同来源(如 chat/post),仅允许字母数字与下划线。 // uploaderID 为上传者用户 ID,用于审计。 // displayName 为前端显式传入的真实文件名(用于展示), // 为空时回退到 multipart header 中的 Filename。 func (s *uploadService) UploadFile(ctx context.Context, file *multipart.FileHeader, folder string, uploaderID string, displayName string) (*UploadFileResult, error) { if file == nil { return nil, fmt.Errorf("文件为空") } if file.Size > MaxChatFileSize { return nil, fmt.Errorf("文件大小超过限制(最大 %d MB)", MaxChatFileSize>>20) } // 选择用于展示的真实文件名:优先用前端显式回传的 displayName, // 它最贴近用户原始文件名(multipart header 的 Filename 在原生端常被 UUID 覆盖)。 displayName = sanitizeFileName(displayName) headerName := sanitizeFileName(file.Filename) fileName := cmp.Or(displayName, headerName) // 校验与规范化 folder,避免路径穿越 folder = strings.Map(func(r rune) rune { if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' { return r } return -1 }, folder) if folder == "" || len(folder) > chatFileFolderLimit { folder = "chat" } // 校验 MIME 类型 mimeType := normalizeChatFileMime(file.Header.Get("Content-Type"), file.Filename) if err := validateChatFile(file.Size, mimeType); err != nil { return nil, err } // 读取文件内容 f, err := file.Open() if err != nil { return nil, fmt.Errorf("打开文件失败: %w", err) } defer f.Close() data, err := io.ReadAll(f) if err != nil { return nil, fmt.Errorf("读取文件失败: %w", err) } if err := validateChatFile(int64(len(data)), mimeType); err != nil { return nil, err } // 以内容哈希命名,避免重复存储;扩展名优先取真实文件名(便于浏览器识别) hash := sha256.Sum256(data) hashStr := fmt.Sprintf("%x", hash) // header Filename 在原生端常为 UUID,因此优先从真实展示名推导扩展名, // 两者都没有时再从 MIME 推断 ext := strings.ToLower(filepath.Ext(fileName)) if ext == "" { ext = strings.ToLower(filepath.Ext(file.Filename)) } if ext == "" { // 从 MIME 推断扩展名(mime.ExtensionsByType 在新版本返回 (exts, err)) if es, _ := mime.ExtensionsByType(mimeType); len(es) > 0 { ext = es[0] } else { ext = ".bin" } } objectName := fmt.Sprintf("files/%s/%s%s", folder, hashStr, ext) url, err := s.s3Client.UploadData(ctx, objectName, data, mimeType) if err != nil { return nil, fmt.Errorf("上传到 S3 失败: %w", err) } // 写入上传记录,用于 7 天 TTL 过期清理(失败仅记日志,不影响上传) if s.uploadedRepo != nil { record := &model.UploadedFile{ URL: url, ObjectName: objectName, Name: fileName, Size: file.Size, MimeType: mimeType, Folder: folder, UploaderID: uploaderID, } if rerr := s.uploadedRepo.Create(ctx, record); rerr != nil { zap.L().Warn("写入文件上传记录失败", zap.String("url", url), zap.Error(rerr)) } } return &UploadFileResult{ URL: url, Name: fileName, Size: file.Size, MimeType: mimeType, }, nil } // GetExpiredURLSet 返回所有已从 S3 清理的文件 URL 集合,供 DTO 层标记失效 func (s *uploadService) GetExpiredURLSet(ctx context.Context) (map[string]struct{}, error) { if s.uploadedRepo == nil { return nil, nil } return s.uploadedRepo.GetExpiredURLSet(ctx) } // 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 }