The Go module name has been changed from `carrot_bbs` to `with_you`, which requires all import paths to be updated throughout the codebase and by external consumers. BREAKING CHANGE: Module name changed from carrot_bbs to with_you. All imports and dependencies must be updated from carrot_bbs/* to with_you/*.
388 lines
11 KiB
Go
388 lines
11 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/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
|
||
}
|
||
|
||
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
|
||
}
|