feat: Enhance texture upload functionality and API response format

- Introduced a new upload endpoint for direct texture file uploads, allowing users to upload textures with validation for size and format.
- Updated existing texture-related API responses to a standardized format, improving consistency across the application.
- Refactored texture service methods to handle file uploads and reuse existing texture URLs based on hash checks.
- Cleaned up Dockerfile and other files by removing unnecessary whitespace.
This commit is contained in:
lan
2025-12-04 20:07:30 +08:00
parent 0bcd9336c4
commit 8858fd1ede
16 changed files with 295 additions and 24 deletions

View File

@@ -67,3 +67,8 @@ ENTRYPOINT ["./server"]

View File

@@ -98,6 +98,10 @@ func main() {
// 创建路由 // 创建路由
router := gin.New() router := gin.New()
// 禁用自动重定向允许API路径带或不带/结尾都能正常访问
router.RedirectTrailingSlash = false
router.RedirectFixedPath = false
// 添加中间件 // 添加中间件
router.Use(middleware.Logger(loggerInstance)) router.Use(middleware.Logger(loggerInstance))
router.Use(middleware.Recovery(loggerInstance)) router.Use(middleware.Recovery(loggerInstance))

View File

@@ -88,7 +88,7 @@ func NewContainer(
// 初始化Service注入缓存管理器 // 初始化Service注入缓存管理器
c.UserService = service.NewUserService(c.UserRepo, c.ConfigRepo, jwtService, redisClient, cacheManager, logger) c.UserService = service.NewUserService(c.UserRepo, c.ConfigRepo, jwtService, redisClient, cacheManager, logger)
c.ProfileService = service.NewProfileService(c.ProfileRepo, c.UserRepo, cacheManager, logger) c.ProfileService = service.NewProfileService(c.ProfileRepo, c.UserRepo, cacheManager, logger)
c.TextureService = service.NewTextureService(c.TextureRepo, c.UserRepo, cacheManager, logger) c.TextureService = service.NewTextureService(c.TextureRepo, c.UserRepo, storageClient, cacheManager, logger)
// 获取Yggdrasil私钥并创建JWT服务TokenService需要 // 获取Yggdrasil私钥并创建JWT服务TokenService需要
// 注意这里仍然需要预先初始化因为TokenService在创建时需要YggdrasilJWT // 注意这里仍然需要预先初始化因为TokenService在创建时需要YggdrasilJWT

View File

@@ -113,8 +113,9 @@ func registerTextureRoutes(v1 *gin.RouterGroup, h *TextureHandler, jwtService *a
textureAuth := textureGroup.Group("") textureAuth := textureGroup.Group("")
textureAuth.Use(middleware.AuthMiddleware(jwtService)) textureAuth.Use(middleware.AuthMiddleware(jwtService))
{ {
textureAuth.POST("/upload-url", h.GenerateUploadURL) textureAuth.POST("/upload", h.Upload) // 直接上传文件
textureAuth.POST("", h.Create) textureAuth.POST("/upload-url", h.GenerateUploadURL) // 生成预签名URL保留兼容性
textureAuth.POST("", h.Create) // 创建材质记录配合预签名URL使用
textureAuth.PUT("/:id", h.Update) textureAuth.PUT("/:id", h.Update)
textureAuth.DELETE("/:id", h.Delete) textureAuth.DELETE("/:id", h.Delete)
textureAuth.POST("/:id/favorite", h.ToggleFavorite) textureAuth.POST("/:id/favorite", h.ToggleFavorite)
@@ -135,6 +136,9 @@ func registerProfileRoutesWithDI(v1 *gin.RouterGroup, h *ProfileHandler, jwtServ
profileAuth := profileGroup.Group("") profileAuth := profileGroup.Group("")
profileAuth.Use(middleware.AuthMiddleware(jwtService)) profileAuth.Use(middleware.AuthMiddleware(jwtService))
{ {
// 同时支持 /api/v1/profile 和 /api/v1/profile/ 两种形式返回列表与创建
profileAuth.GET("", h.List)
profileAuth.POST("", h.Create)
profileAuth.POST("/", h.Create) profileAuth.POST("/", h.Create)
profileAuth.GET("/", h.List) profileAuth.GET("/", h.List)
profileAuth.PUT("/:uuid", h.Update) profileAuth.PUT("/:uuid", h.Update)

View File

@@ -152,7 +152,23 @@ func (h *TextureHandler) Search(c *gin.Context) {
return return
} }
c.JSON(200, model.NewPaginationResponse(TexturesToTextureInfos(textures), total, page, pageSize)) // 返回格式:
// {
// "code": 200,
// "message": "操作成功",
// "data": {
// "list": [...],
// "total": 1,
// "page": 1,
// "per_page": 5
// }
// }
RespondSuccess(c, gin.H{
"list": TexturesToTextureInfos(textures),
"total": total,
"page": page,
"per_page": pageSize,
})
} }
// Update 更新材质 // Update 更新材质
@@ -258,7 +274,12 @@ func (h *TextureHandler) GetUserTextures(c *gin.Context) {
return return
} }
c.JSON(200, model.NewPaginationResponse(TexturesToTextureInfos(textures), total, page, pageSize)) RespondSuccess(c, gin.H{
"list": TexturesToTextureInfos(textures),
"total": total,
"page": page,
"per_page": pageSize,
})
} }
// GetUserFavorites 获取用户收藏的材质列表 // GetUserFavorites 获取用户收藏的材质列表
@@ -278,5 +299,92 @@ func (h *TextureHandler) GetUserFavorites(c *gin.Context) {
return return
} }
c.JSON(200, model.NewPaginationResponse(TexturesToTextureInfos(textures), total, page, pageSize)) RespondSuccess(c, gin.H{
"list": TexturesToTextureInfos(textures),
"total": total,
"page": page,
"per_page": pageSize,
})
}
// Upload 直接上传材质文件
func (h *TextureHandler) Upload(c *gin.Context) {
userID, ok := GetUserIDFromContext(c)
if !ok {
return
}
// 解析multipart表单
if err := c.Request.ParseMultipartForm(32 << 20); err != nil { // 32MB
RespondBadRequest(c, "解析表单失败", err)
return
}
// 获取文件
file, err := c.FormFile("file")
if err != nil {
RespondBadRequest(c, "获取文件失败", err)
return
}
// 读取文件内容
src, err := file.Open()
if err != nil {
RespondBadRequest(c, "打开文件失败", err)
return
}
defer src.Close()
fileData := make([]byte, file.Size)
if _, err := src.Read(fileData); err != nil {
RespondBadRequest(c, "读取文件失败", err)
return
}
// 获取表单字段
name := c.PostForm("name")
if name == "" {
RespondBadRequest(c, "名称不能为空", nil)
return
}
description := c.PostForm("description")
textureType := c.PostForm("type")
if textureType == "" {
textureType = "SKIN" // 默认值
}
isPublic := c.PostForm("is_public") == "true"
isSlim := c.PostForm("is_slim") == "true"
// 检查上传限制
maxTextures := h.container.UserService.GetMaxTexturesPerUser()
if err := h.container.TextureService.CheckUploadLimit(c.Request.Context(), userID, maxTextures); err != nil {
RespondBadRequest(c, err.Error(), nil)
return
}
// 调用服务上传
texture, err := h.container.TextureService.UploadTexture(
c.Request.Context(),
userID,
name,
description,
textureType,
fileData,
file.Filename,
isPublic,
isSlim,
)
if err != nil {
h.logger.Error("上传材质失败",
zap.Int64("user_id", userID),
zap.String("file_name", file.Filename),
zap.Error(err),
)
RespondBadRequest(c, err.Error(), nil)
return
}
RespondSuccess(c, TextureToTextureInfo(texture))
} }

View File

@@ -10,11 +10,14 @@ import (
func CORS() gin.HandlerFunc { func CORS() gin.HandlerFunc {
// 获取配置,如果配置未初始化则使用默认值 // 获取配置,如果配置未初始化则使用默认值
var allowedOrigins []string var allowedOrigins []string
var isTestEnv bool
if cfg, err := config.GetConfig(); err == nil { if cfg, err := config.GetConfig(); err == nil {
allowedOrigins = cfg.Security.AllowedOrigins allowedOrigins = cfg.Security.AllowedOrigins
isTestEnv = cfg.IsTestEnvironment()
} else { } else {
// 默认允许所有来源(向后兼容) // 默认允许所有来源(向后兼容)
allowedOrigins = []string{"*"} allowedOrigins = []string{"*"}
isTestEnv = false
} }
return gin.HandlerFunc(func(c *gin.Context) { return gin.HandlerFunc(func(c *gin.Context) {
@@ -22,7 +25,8 @@ func CORS() gin.HandlerFunc {
// 检查是否允许该来源 // 检查是否允许该来源
allowOrigin := "*" allowOrigin := "*"
if len(allowedOrigins) > 0 && allowedOrigins[0] != "*" { // 测试环境下强制使用 *,否则按配置处理
if !isTestEnv && len(allowedOrigins) > 0 && allowedOrigins[0] != "*" {
allowOrigin = "" allowOrigin = ""
for _, allowed := range allowedOrigins { for _, allowed := range allowedOrigins {
if allowed == origin || allowed == "*" { if allowed == origin || allowed == "*" {

View File

@@ -24,3 +24,8 @@ func (Client) TableName() string {

View File

@@ -20,7 +20,7 @@ type Texture struct {
Description string `gorm:"column:description;type:text" json:"description,omitempty"` Description string `gorm:"column:description;type:text" json:"description,omitempty"`
Type TextureType `gorm:"column:type;type:varchar(50);not null;index:idx_textures_public_type_status,priority:2" json:"type"` // SKIN, CAPE Type TextureType `gorm:"column:type;type:varchar(50);not null;index:idx_textures_public_type_status,priority:2" json:"type"` // SKIN, CAPE
URL string `gorm:"column:url;type:varchar(255);not null" json:"url"` URL string `gorm:"column:url;type:varchar(255);not null" json:"url"`
Hash string `gorm:"column:hash;type:varchar(64);not null;uniqueIndex:idx_textures_hash" json:"hash"` // SHA-256 Hash string `gorm:"column:hash;type:varchar(64);not null;index:idx_textures_hash" json:"hash"` // SHA-256
Size int `gorm:"column:size;type:integer;not null;default:0" json:"size"` Size int `gorm:"column:size;type:integer;not null;default:0" json:"size"`
IsPublic bool `gorm:"column:is_public;not null;default:false;index:idx_textures_public_type_status,priority:1" json:"is_public"` IsPublic bool `gorm:"column:is_public;not null;default:false;index:idx_textures_public_type_status,priority:1" json:"is_public"`
DownloadCount int `gorm:"column:download_count;type:integer;not null;default:0;index:idx_textures_download_count,sort:desc" json:"download_count"` DownloadCount int `gorm:"column:download_count;type:integer;not null;default:0;index:idx_textures_download_count,sort:desc" json:"download_count"`

View File

@@ -47,6 +47,7 @@ type TextureRepository interface {
Create(ctx context.Context, texture *model.Texture) error Create(ctx context.Context, texture *model.Texture) error
FindByID(ctx context.Context, id int64) (*model.Texture, error) FindByID(ctx context.Context, id int64) (*model.Texture, error)
FindByHash(ctx context.Context, hash string) (*model.Texture, error) FindByHash(ctx context.Context, hash string) (*model.Texture, error)
FindByHashAndUploaderID(ctx context.Context, hash string, uploaderID int64) (*model.Texture, error) // 根据Hash和上传者ID查找
FindByIDs(ctx context.Context, ids []int64) ([]*model.Texture, error) // 批量查询 FindByIDs(ctx context.Context, ids []int64) ([]*model.Texture, error) // 批量查询
FindByUploaderID(ctx context.Context, uploaderID int64, page, pageSize int) ([]*model.Texture, int64, error) FindByUploaderID(ctx context.Context, uploaderID int64, page, pageSize int) ([]*model.Texture, int64, error)
Search(ctx context.Context, keyword string, textureType model.TextureType, publicOnly bool, page, pageSize int) ([]*model.Texture, int64, error) Search(ctx context.Context, keyword string, textureType model.TextureType, publicOnly bool, page, pageSize int) ([]*model.Texture, int64, error)

View File

@@ -33,6 +33,12 @@ func (r *textureRepository) FindByHash(ctx context.Context, hash string) (*model
return handleNotFoundResult(&texture, err) return handleNotFoundResult(&texture, err)
} }
func (r *textureRepository) FindByHashAndUploaderID(ctx context.Context, hash string, uploaderID int64) (*model.Texture, error) {
var texture model.Texture
err := r.db.WithContext(ctx).Where("hash = ? AND uploader_id = ?", hash, uploaderID).First(&texture).Error
return handleNotFoundResult(&texture, err)
}
func (r *textureRepository) FindByIDs(ctx context.Context, ids []int64) ([]*model.Texture, error) { func (r *textureRepository) FindByIDs(ctx context.Context, ids []int64) ([]*model.Texture, error) {
if len(ids) == 0 { if len(ids) == 0 {
return []*model.Texture{}, nil return []*model.Texture{}, nil

View File

@@ -57,6 +57,7 @@ type ProfileService interface {
type TextureService interface { type TextureService interface {
// 材质CRUD // 材质CRUD
Create(ctx context.Context, uploaderID int64, name, description, textureType, url, hash string, size int, isPublic, isSlim bool) (*model.Texture, error) Create(ctx context.Context, uploaderID int64, name, description, textureType, url, hash string, size int, isPublic, isSlim bool) (*model.Texture, error)
UploadTexture(ctx context.Context, uploaderID int64, name, description, textureType string, fileData []byte, fileName string, isPublic, isSlim bool) (*model.Texture, error) // 直接上传材质文件
GetByID(ctx context.Context, id int64) (*model.Texture, error) GetByID(ctx context.Context, id int64) (*model.Texture, error)
GetByHash(ctx context.Context, hash string) (*model.Texture, error) GetByHash(ctx context.Context, hash string) (*model.Texture, error)
GetByUserID(ctx context.Context, uploaderID int64, page, pageSize int) ([]*model.Texture, int64, error) GetByUserID(ctx context.Context, uploaderID int64, page, pageSize int) ([]*model.Texture, int64, error)

View File

@@ -1,12 +1,18 @@
package service package service
import ( import (
"bytes"
"carrotskin/internal/model" "carrotskin/internal/model"
"carrotskin/internal/repository" "carrotskin/internal/repository"
"carrotskin/pkg/database" "carrotskin/pkg/database"
"carrotskin/pkg/storage"
"context" "context"
"crypto/sha256"
"encoding/hex"
"errors" "errors"
"fmt" "fmt"
"path/filepath"
"strings"
"time" "time"
"go.uber.org/zap" "go.uber.org/zap"
@@ -16,6 +22,7 @@ import (
type textureService struct { type textureService struct {
textureRepo repository.TextureRepository textureRepo repository.TextureRepository
userRepo repository.UserRepository userRepo repository.UserRepository
storage *storage.StorageClient
cache *database.CacheManager cache *database.CacheManager
cacheKeys *database.CacheKeyBuilder cacheKeys *database.CacheKeyBuilder
cacheInv *database.CacheInvalidator cacheInv *database.CacheInvalidator
@@ -26,12 +33,14 @@ type textureService struct {
func NewTextureService( func NewTextureService(
textureRepo repository.TextureRepository, textureRepo repository.TextureRepository,
userRepo repository.UserRepository, userRepo repository.UserRepository,
storageClient *storage.StorageClient,
cacheManager *database.CacheManager, cacheManager *database.CacheManager,
logger *zap.Logger, logger *zap.Logger,
) TextureService { ) TextureService {
return &textureService{ return &textureService{
textureRepo: textureRepo, textureRepo: textureRepo,
userRepo: userRepo, userRepo: userRepo,
storage: storageClient,
cache: cacheManager, cache: cacheManager,
cacheKeys: database.NewCacheKeyBuilder(""), cacheKeys: database.NewCacheKeyBuilder(""),
cacheInv: database.NewCacheInvalidator(cacheManager), cacheInv: database.NewCacheInvalidator(cacheManager),
@@ -46,13 +55,16 @@ func (s *textureService) Create(ctx context.Context, uploaderID int64, name, des
return nil, ErrUserNotFound return nil, ErrUserNotFound
} }
// 检查Hash是否已存在 // 检查是否有任何用户上传过相同Hash的皮肤复用URL不重复保存文件
existingTexture, err := s.textureRepo.FindByHash(ctx, hash) existingTexture, err := s.textureRepo.FindByHash(ctx, hash)
if err != nil { if err != nil {
return nil, err return nil, err
} }
// 如果已存在相同Hash的皮肤复用已存在的URL
finalURL := url
if existingTexture != nil { if existingTexture != nil {
return nil, errors.New("该材质已存在") finalURL = existingTexture.URL
} }
// 转换材质类型 // 转换材质类型
@@ -61,13 +73,13 @@ func (s *textureService) Create(ctx context.Context, uploaderID int64, name, des
return nil, err return nil, err
} }
// 创建材质 // 创建材质记录即使Hash相同也创建新的数据库记录
texture := &model.Texture{ texture := &model.Texture{
UploaderID: uploaderID, UploaderID: uploaderID,
Name: name, Name: name,
Description: description, Description: description,
Type: textureTypeEnum, Type: textureTypeEnum,
URL: url, URL: finalURL, // 复用已存在的URL或使用新URL
Hash: hash, Hash: hash,
Size: size, Size: size,
IsPublic: isPublic, IsPublic: isPublic,
@@ -304,6 +316,116 @@ func (s *textureService) CheckUploadLimit(ctx context.Context, uploaderID int64,
return nil return nil
} }
// UploadTexture 直接上传材质文件
func (s *textureService) UploadTexture(ctx context.Context, uploaderID int64, name, description, textureType string, fileData []byte, fileName string, isPublic, isSlim bool) (*model.Texture, error) {
// 验证用户存在
user, err := s.userRepo.FindByID(ctx, uploaderID)
if err != nil || user == nil {
return nil, ErrUserNotFound
}
// 验证文件大小和扩展名
fileSize := len(fileData)
const minSize = 512 // 512B
const maxSize = 10 * 1024 * 1024 // 10MB
if int64(fileSize) < minSize || int64(fileSize) > maxSize {
return nil, fmt.Errorf("文件大小必须在 %d 到 %d 字节之间", minSize, maxSize)
}
// 验证文件扩展名只支持PNG
ext := strings.ToLower(filepath.Ext(fileName))
if ext != ".png" {
return nil, fmt.Errorf("不支持的文件格式: %s仅支持PNG格式", ext)
}
// 验证材质类型
if textureType != "SKIN" && textureType != "CAPE" {
return nil, errors.New("无效的材质类型")
}
// 计算文件SHA256哈希
hashBytes := sha256.Sum256(fileData)
hash := hex.EncodeToString(hashBytes[:])
// 检查是否有任何用户上传过相同Hash的皮肤复用URL不重复保存文件
existingTexture, err := s.textureRepo.FindByHash(ctx, hash)
if err != nil {
return nil, err
}
var finalURL string
if existingTexture != nil {
// 如果已存在相同Hash的皮肤复用已存在的URL不重复上传
finalURL = existingTexture.URL
s.logger.Info("复用已存在的材质文件",
zap.String("hash", hash),
zap.String("url", finalURL),
)
} else {
// 如果不存在,上传到对象存储
if s.storage == nil {
return nil, errors.New("存储服务不可用")
}
// 获取存储桶名称
bucketName, err := s.storage.GetBucket("textures")
if err != nil {
return nil, fmt.Errorf("获取存储桶失败: %w", err)
}
// 生成对象名称(路径)
// 格式: hash/{hash[:2]}/{hash[2:4]}/{hash}.png
// 使用哈希值作为路径,避免重复存储相同文件
textureTypeFolder := strings.ToLower(textureType)
objectName := fmt.Sprintf("%s/%s/%s/%s/%s%s", textureTypeFolder, hash[:2], hash[2:4], hash, hash, ext)
// 上传文件
reader := bytes.NewReader(fileData)
contentType := "image/png"
if err := s.storage.UploadObject(ctx, bucketName, objectName, reader, int64(fileSize), contentType); err != nil {
return nil, fmt.Errorf("上传文件失败: %w", err)
}
// 构建文件URL
finalURL = s.storage.BuildFileURL(bucketName, objectName)
s.logger.Info("上传新的材质文件",
zap.String("hash", hash),
zap.String("url", finalURL),
)
}
// 转换材质类型
textureTypeEnum, err := parseTextureTypeInternal(textureType)
if err != nil {
return nil, err
}
// 创建材质记录即使Hash相同也创建新的数据库记录
texture := &model.Texture{
UploaderID: uploaderID,
Name: name,
Description: description,
Type: textureTypeEnum,
URL: finalURL,
Hash: hash,
Size: fileSize,
IsPublic: isPublic,
IsSlim: isSlim,
Status: 1,
DownloadCount: 0,
FavoriteCount: 0,
}
if err := s.textureRepo.Create(ctx, texture); err != nil {
return nil, err
}
// 清除用户的 texture 列表缓存(所有分页)
s.cacheInv.BatchInvalidate(ctx, fmt.Sprintf("texture:user:%d:*", uploaderID))
return texture, nil
}
// parseTextureTypeInternal 解析材质类型 // parseTextureTypeInternal 解析材质类型
func parseTextureTypeInternal(textureType string) (model.TextureType, error) { func parseTextureTypeInternal(textureType string) (model.TextureType, error) {
switch textureType { switch textureType {

View File

@@ -572,7 +572,7 @@ func TestTextureServiceImpl_Create(t *testing.T) {
tt.textureType, tt.textureType,
"http://example.com/texture.png", "http://example.com/texture.png",
tt.hash, tt.hash,
1024, 512,
true, true,
false, false,
) )

View File

@@ -129,7 +129,7 @@ func GetUploadConfig(fileType FileType) *UploadConfig {
".gif": true, ".gif": true,
".webp": true, ".webp": true,
}, },
MinSize: 1024, // 1KB MinSize: 512, // 512B
MaxSize: 5 * 1024 * 1024, // 5MB MaxSize: 5 * 1024 * 1024, // 5MB
Expires: 15 * time.Minute, Expires: 15 * time.Minute,
} }
@@ -138,7 +138,7 @@ func GetUploadConfig(fileType FileType) *UploadConfig {
AllowedExts: map[string]bool{ AllowedExts: map[string]bool{
".png": true, ".png": true,
}, },
MinSize: 1024, // 1KB MinSize: 512, // 512B
MaxSize: 10 * 1024 * 1024, // 10MB MaxSize: 10 * 1024 * 1024, // 10MB
Expires: 15 * time.Minute, Expires: 15 * time.Minute,
} }

View File

@@ -95,8 +95,8 @@ func TestGetUploadConfig_AvatarConfig(t *testing.T) {
} }
// 验证文件大小限制 // 验证文件大小限制
if config.MinSize != 1024 { if config.MinSize != 512 {
t.Errorf("Avatar MinSize = %d, want 1024", config.MinSize) t.Errorf("Avatar MinSize = %d, want 512", config.MinSize)
} }
if config.MaxSize != 5*1024*1024 { if config.MaxSize != 5*1024*1024 {
@@ -122,8 +122,8 @@ func TestGetUploadConfig_TextureConfig(t *testing.T) {
} }
// 验证文件大小限制 // 验证文件大小限制
if config.MinSize != 1024 { if config.MinSize != 512 {
t.Errorf("Texture MinSize = %d, want 1024", config.MinSize) t.Errorf("Texture MinSize = %d, want 512", config.MinSize)
} }
if config.MaxSize != 10*1024*1024 { if config.MaxSize != 10*1024*1024 {
@@ -259,7 +259,7 @@ func TestUploadConfig_Structure(t *testing.T) {
AllowedExts: map[string]bool{ AllowedExts: map[string]bool{
".png": true, ".png": true,
}, },
MinSize: 1024, MinSize: 512,
MaxSize: 5 * 1024 * 1024, MaxSize: 5 * 1024 * 1024,
Expires: 15 * time.Minute, Expires: 15 * time.Minute,
} }
@@ -325,8 +325,8 @@ func TestGenerateAvatarUploadURL_Success(t *testing.T) {
t.Fatalf("objectName should contain original file name, got: %s", objectName) t.Fatalf("objectName should contain original file name, got: %s", objectName)
} }
// 检查大小与过期时间传递 // 检查大小与过期时间传递
if minSize != 1024 { if minSize != 512 {
t.Fatalf("minSize = %d, want 1024", minSize) t.Fatalf("minSize = %d, want 512", minSize)
} }
if maxSize != 5*1024*1024 { if maxSize != 5*1024*1024 {
t.Fatalf("maxSize = %d, want 5MB", maxSize) t.Fatalf("maxSize = %d, want 5MB", maxSize)

View File

@@ -201,3 +201,14 @@ func (s *StorageClient) ParseFileURL(fileURL string) (bucket, objectName string,
return bucket, objectName, nil return bucket, objectName, nil
} }
// UploadObject 上传对象到存储
func (s *StorageClient) UploadObject(ctx context.Context, bucketName, objectName string, reader io.Reader, size int64, contentType string) error {
_, err := s.client.PutObject(ctx, bucketName, objectName, reader, size, minio.PutObjectOptions{
ContentType: contentType,
})
if err != nil {
return fmt.Errorf("上传对象失败: %w", err)
}
return nil
}