Add GET /api/v1/messages/:id endpoint to retrieve quoted/replied messages for preview fill and navigation purposes. The endpoint includes authorization checks ensuring only conversation participants can access the message. Additionally improve file upload handling for native clients (expo-file-system) by accepting explicit displayName from frontend, with sanitization to prevent path traversal and 255-character length limit. File extension detection now prioritizes the real display name over the multipart header filename.
665 lines
22 KiB
Go
665 lines
22 KiB
Go
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
|
||
}
|