Initial backend repository commit.
Set up project files and add .gitignore to exclude local build/runtime artifacts. Made-with: Cursor
This commit is contained in:
273
internal/service/upload_service.go
Normal file
273
internal/service/upload_service.go
Normal file
@@ -0,0 +1,273 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"io"
|
||||
"mime"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"carrot_bbs/internal/pkg/s3"
|
||||
_ "golang.org/x/image/bmp"
|
||||
_ "golang.org/x/image/tiff"
|
||||
)
|
||||
|
||||
// UploadService 上传服务
|
||||
type UploadService struct {
|
||||
s3Client *s3.Client
|
||||
userService *UserService
|
||||
}
|
||||
|
||||
// NewUploadService 创建上传服务
|
||||
func NewUploadService(s3Client *s3.Client, userService *UserService) *UploadService {
|
||||
return &UploadService{
|
||||
s3Client: s3Client,
|
||||
userService: userService,
|
||||
}
|
||||
}
|
||||
|
||||
// UploadImage 上传图片
|
||||
func (s *UploadService) UploadImage(ctx context.Context, 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("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)
|
||||
}
|
||||
|
||||
return url, 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 := detectedType
|
||||
if contentType == "" || contentType == "application/octet-stream" {
|
||||
contentType = headerType
|
||||
}
|
||||
|
||||
compressedData, compressedType, err := compressImageData(originalData, contentType)
|
||||
if err != nil {
|
||||
// 压缩失败时回退到原图,保证上传可用性
|
||||
compressedData = originalData
|
||||
compressedType = contentType
|
||||
}
|
||||
|
||||
if compressedType == "" {
|
||||
compressedType = contentType
|
||||
}
|
||||
if compressedType == "" {
|
||||
compressedType = http.DetectContentType(compressedData)
|
||||
}
|
||||
|
||||
ext := getExtFromContentType(compressedType)
|
||||
if ext == "" {
|
||||
ext = strings.ToLower(filepath.Ext(file.Filename))
|
||||
}
|
||||
if ext == "" {
|
||||
// 最终兜底,避免对象名无扩展名导致 URL 语义不明确
|
||||
ext = ".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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user