diff --git a/Dockerfile b/Dockerfile index 6dd5d6e..077006c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -67,3 +67,8 @@ ENTRYPOINT ["./server"] + + + + + diff --git a/cmd/server/main.go b/cmd/server/main.go index 87c8114..8abc2fa 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -98,6 +98,10 @@ func main() { // 创建路由 router := gin.New() + // 禁用自动重定向,允许API路径带或不带/结尾都能正常访问 + router.RedirectTrailingSlash = false + router.RedirectFixedPath = false + // 添加中间件 router.Use(middleware.Logger(loggerInstance)) router.Use(middleware.Recovery(loggerInstance)) diff --git a/internal/container/container.go b/internal/container/container.go index 70edc4d..b3f0ef8 100644 --- a/internal/container/container.go +++ b/internal/container/container.go @@ -88,7 +88,7 @@ func NewContainer( // 初始化Service(注入缓存管理器) c.UserService = service.NewUserService(c.UserRepo, c.ConfigRepo, jwtService, redisClient, 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需要) // 注意:这里仍然需要预先初始化,因为TokenService在创建时需要YggdrasilJWT diff --git a/internal/handler/routes.go b/internal/handler/routes.go index 4d62899..852de64 100644 --- a/internal/handler/routes.go +++ b/internal/handler/routes.go @@ -113,8 +113,9 @@ func registerTextureRoutes(v1 *gin.RouterGroup, h *TextureHandler, jwtService *a textureAuth := textureGroup.Group("") textureAuth.Use(middleware.AuthMiddleware(jwtService)) { - textureAuth.POST("/upload-url", h.GenerateUploadURL) - textureAuth.POST("", h.Create) + textureAuth.POST("/upload", h.Upload) // 直接上传文件 + textureAuth.POST("/upload-url", h.GenerateUploadURL) // 生成预签名URL(保留兼容性) + textureAuth.POST("", h.Create) // 创建材质记录(配合预签名URL使用) textureAuth.PUT("/:id", h.Update) textureAuth.DELETE("/:id", h.Delete) textureAuth.POST("/:id/favorite", h.ToggleFavorite) @@ -135,6 +136,9 @@ func registerProfileRoutesWithDI(v1 *gin.RouterGroup, h *ProfileHandler, jwtServ profileAuth := profileGroup.Group("") profileAuth.Use(middleware.AuthMiddleware(jwtService)) { + // 同时支持 /api/v1/profile 和 /api/v1/profile/ 两种形式返回列表与创建 + profileAuth.GET("", h.List) + profileAuth.POST("", h.Create) profileAuth.POST("/", h.Create) profileAuth.GET("/", h.List) profileAuth.PUT("/:uuid", h.Update) diff --git a/internal/handler/texture_handler.go b/internal/handler/texture_handler.go index c412915..e798b0f 100644 --- a/internal/handler/texture_handler.go +++ b/internal/handler/texture_handler.go @@ -152,7 +152,23 @@ func (h *TextureHandler) Search(c *gin.Context) { 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 更新材质 @@ -258,7 +274,12 @@ func (h *TextureHandler) GetUserTextures(c *gin.Context) { 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 获取用户收藏的材质列表 @@ -278,5 +299,92 @@ func (h *TextureHandler) GetUserFavorites(c *gin.Context) { 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)) } diff --git a/internal/middleware/cors.go b/internal/middleware/cors.go index a806368..296adad 100644 --- a/internal/middleware/cors.go +++ b/internal/middleware/cors.go @@ -10,11 +10,14 @@ import ( func CORS() gin.HandlerFunc { // 获取配置,如果配置未初始化则使用默认值 var allowedOrigins []string + var isTestEnv bool if cfg, err := config.GetConfig(); err == nil { allowedOrigins = cfg.Security.AllowedOrigins + isTestEnv = cfg.IsTestEnvironment() } else { // 默认允许所有来源(向后兼容) allowedOrigins = []string{"*"} + isTestEnv = false } return gin.HandlerFunc(func(c *gin.Context) { @@ -22,7 +25,8 @@ func CORS() gin.HandlerFunc { // 检查是否允许该来源 allowOrigin := "*" - if len(allowedOrigins) > 0 && allowedOrigins[0] != "*" { + // 测试环境下强制使用 *,否则按配置处理 + if !isTestEnv && len(allowedOrigins) > 0 && allowedOrigins[0] != "*" { allowOrigin = "" for _, allowed := range allowedOrigins { if allowed == origin || allowed == "*" { diff --git a/internal/model/client.go b/internal/model/client.go index a71dc2d..35faf0b 100644 --- a/internal/model/client.go +++ b/internal/model/client.go @@ -24,3 +24,8 @@ func (Client) TableName() string { + + + + + diff --git a/internal/model/texture.go b/internal/model/texture.go index 24b2d4a..bf90b26 100644 --- a/internal/model/texture.go +++ b/internal/model/texture.go @@ -20,12 +20,12 @@ type Texture struct { 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 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"` 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"` 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:已删除 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"` diff --git a/internal/repository/interfaces.go b/internal/repository/interfaces.go index f2ec4a8..1faa028 100644 --- a/internal/repository/interfaces.go +++ b/internal/repository/interfaces.go @@ -47,7 +47,8 @@ type TextureRepository interface { Create(ctx context.Context, texture *model.Texture) error FindByID(ctx context.Context, id int64) (*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) 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 diff --git a/internal/repository/texture_repository.go b/internal/repository/texture_repository.go index a2b9827..d062d50 100644 --- a/internal/repository/texture_repository.go +++ b/internal/repository/texture_repository.go @@ -33,6 +33,12 @@ func (r *textureRepository) FindByHash(ctx context.Context, hash string) (*model 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) { if len(ids) == 0 { return []*model.Texture{}, nil diff --git a/internal/service/interfaces.go b/internal/service/interfaces.go index 327ce37..9634bf9 100644 --- a/internal/service/interfaces.go +++ b/internal/service/interfaces.go @@ -57,6 +57,7 @@ type ProfileService interface { type TextureService interface { // 材质CRUD 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) GetByHash(ctx context.Context, hash string) (*model.Texture, error) GetByUserID(ctx context.Context, uploaderID int64, page, pageSize int) ([]*model.Texture, int64, error) diff --git a/internal/service/texture_service.go b/internal/service/texture_service.go index 146fe6f..6dcb2fc 100644 --- a/internal/service/texture_service.go +++ b/internal/service/texture_service.go @@ -1,12 +1,18 @@ package service import ( + "bytes" "carrotskin/internal/model" "carrotskin/internal/repository" "carrotskin/pkg/database" + "carrotskin/pkg/storage" "context" + "crypto/sha256" + "encoding/hex" "errors" "fmt" + "path/filepath" + "strings" "time" "go.uber.org/zap" @@ -16,6 +22,7 @@ import ( type textureService struct { textureRepo repository.TextureRepository userRepo repository.UserRepository + storage *storage.StorageClient cache *database.CacheManager cacheKeys *database.CacheKeyBuilder cacheInv *database.CacheInvalidator @@ -26,12 +33,14 @@ type textureService struct { func NewTextureService( textureRepo repository.TextureRepository, userRepo repository.UserRepository, + storageClient *storage.StorageClient, cacheManager *database.CacheManager, logger *zap.Logger, ) TextureService { return &textureService{ textureRepo: textureRepo, userRepo: userRepo, + storage: storageClient, cache: cacheManager, cacheKeys: database.NewCacheKeyBuilder(""), cacheInv: database.NewCacheInvalidator(cacheManager), @@ -46,13 +55,16 @@ func (s *textureService) Create(ctx context.Context, uploaderID int64, name, des return nil, ErrUserNotFound } - // 检查Hash是否已存在 + // 检查是否有任何用户上传过相同Hash的皮肤(复用URL,不重复保存文件) existingTexture, err := s.textureRepo.FindByHash(ctx, hash) if err != nil { return nil, err } + + // 如果已存在相同Hash的皮肤,复用已存在的URL + finalURL := url 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 } - // 创建材质 + // 创建材质记录(即使Hash相同,也创建新的数据库记录) texture := &model.Texture{ UploaderID: uploaderID, Name: name, Description: description, Type: textureTypeEnum, - URL: url, + URL: finalURL, // 复用已存在的URL或使用新URL Hash: hash, Size: size, IsPublic: isPublic, @@ -304,6 +316,116 @@ func (s *textureService) CheckUploadLimit(ctx context.Context, uploaderID int64, 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 解析材质类型 func parseTextureTypeInternal(textureType string) (model.TextureType, error) { switch textureType { diff --git a/internal/service/texture_service_test.go b/internal/service/texture_service_test.go index 990d2c6..baa96f5 100644 --- a/internal/service/texture_service_test.go +++ b/internal/service/texture_service_test.go @@ -572,7 +572,7 @@ func TestTextureServiceImpl_Create(t *testing.T) { tt.textureType, "http://example.com/texture.png", tt.hash, - 1024, + 512, true, false, ) diff --git a/internal/service/upload_service.go b/internal/service/upload_service.go index 4be2acc..457f360 100644 --- a/internal/service/upload_service.go +++ b/internal/service/upload_service.go @@ -129,7 +129,7 @@ func GetUploadConfig(fileType FileType) *UploadConfig { ".gif": true, ".webp": true, }, - MinSize: 1024, // 1KB + MinSize: 512, // 512B MaxSize: 5 * 1024 * 1024, // 5MB Expires: 15 * time.Minute, } @@ -138,7 +138,7 @@ func GetUploadConfig(fileType FileType) *UploadConfig { AllowedExts: map[string]bool{ ".png": true, }, - MinSize: 1024, // 1KB + MinSize: 512, // 512B MaxSize: 10 * 1024 * 1024, // 10MB Expires: 15 * time.Minute, } diff --git a/internal/service/upload_service_test.go b/internal/service/upload_service_test.go index ebf72a7..9d57b36 100644 --- a/internal/service/upload_service_test.go +++ b/internal/service/upload_service_test.go @@ -95,8 +95,8 @@ func TestGetUploadConfig_AvatarConfig(t *testing.T) { } // 验证文件大小限制 - if config.MinSize != 1024 { - t.Errorf("Avatar MinSize = %d, want 1024", config.MinSize) + if config.MinSize != 512 { + t.Errorf("Avatar MinSize = %d, want 512", config.MinSize) } if config.MaxSize != 5*1024*1024 { @@ -122,8 +122,8 @@ func TestGetUploadConfig_TextureConfig(t *testing.T) { } // 验证文件大小限制 - if config.MinSize != 1024 { - t.Errorf("Texture MinSize = %d, want 1024", config.MinSize) + if config.MinSize != 512 { + t.Errorf("Texture MinSize = %d, want 512", config.MinSize) } if config.MaxSize != 10*1024*1024 { @@ -259,7 +259,7 @@ func TestUploadConfig_Structure(t *testing.T) { AllowedExts: map[string]bool{ ".png": true, }, - MinSize: 1024, + MinSize: 512, MaxSize: 5 * 1024 * 1024, 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) } // 检查大小与过期时间传递 - if minSize != 1024 { - t.Fatalf("minSize = %d, want 1024", minSize) + if minSize != 512 { + t.Fatalf("minSize = %d, want 512", minSize) } if maxSize != 5*1024*1024 { t.Fatalf("maxSize = %d, want 5MB", maxSize) diff --git a/pkg/storage/minio.go b/pkg/storage/minio.go index d58f999..671438f 100644 --- a/pkg/storage/minio.go +++ b/pkg/storage/minio.go @@ -201,3 +201,14 @@ func (s *StorageClient) ParseFileURL(fileURL string) (bucket, objectName string, 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 +}