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:
@@ -67,3 +67,8 @@ ENTRYPOINT ["./server"]
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 == "*" {
|
||||||
|
|||||||
@@ -24,3 +24,8 @@ func (Client) TableName() string {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -20,12 +20,12 @@ 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"`
|
||||||
FavoriteCount int `gorm:"column:favorite_count;type:integer;not null;default:0;index:idx_textures_favorite_count,sort:desc" json:"favorite_count"`
|
FavoriteCount int `gorm:"column:favorite_count;type:integer;not null;default:0;index:idx_textures_favorite_count,sort:desc" json:"favorite_count"`
|
||||||
IsSlim bool `gorm:"column:is_slim;not null;default:false" json:"is_slim"` // Alex(细) or Steve(粗)
|
IsSlim bool `gorm:"column:is_slim;not null;default:false" json:"is_slim"` // Alex(细) or Steve(粗)
|
||||||
Status int16 `gorm:"column:status;type:smallint;not null;default:1;index:idx_textures_public_type_status,priority:3;index:idx_textures_uploader_status,priority:2" json:"status"` // 1:正常, 0:审核中, -1:已删除
|
Status int16 `gorm:"column:status;type:smallint;not null;default:1;index:idx_textures_public_type_status,priority:3;index:idx_textures_uploader_status,priority:2" json:"status"` // 1:正常, 0:审核中, -1:已删除
|
||||||
CreatedAt time.Time `gorm:"column:created_at;type:timestamp;not null;default:CURRENT_TIMESTAMP;index:idx_textures_uploader_created,priority:2,sort:desc;index:idx_textures_created_at,sort:desc" json:"created_at"`
|
CreatedAt time.Time `gorm:"column:created_at;type:timestamp;not null;default:CURRENT_TIMESTAMP;index:idx_textures_uploader_created,priority:2,sort:desc;index:idx_textures_created_at,sort:desc" json:"created_at"`
|
||||||
UpdatedAt time.Time `gorm:"column:updated_at;type:timestamp;not null;default:CURRENT_TIMESTAMP" json:"updated_at"`
|
UpdatedAt time.Time `gorm:"column:updated_at;type:timestamp;not null;default:CURRENT_TIMESTAMP" json:"updated_at"`
|
||||||
|
|||||||
@@ -47,7 +47,8 @@ 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)
|
||||||
FindByIDs(ctx context.Context, ids []int64) ([]*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) // 批量查询
|
||||||
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)
|
||||||
Update(ctx context.Context, texture *model.Texture) error
|
Update(ctx context.Context, texture *model.Texture) error
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user