diff --git a/.env.docker.example b/.env.docker.example index 6ce89f2..ee3afcc 100644 --- a/.env.docker.example +++ b/.env.docker.example @@ -1,18 +1,16 @@ # ==================== CarrotSkin Docker 环境配置示例 ==================== # 复制此文件为 .env 后修改配置值 +# 此文件用于 docker-compose 部署,变量名与 docker-compose.yml 中的引用一致 # ==================== 服务配置 ==================== -# 应用端口 +# 应用对外端口 APP_PORT=8080 # 运行模式: debug, release, test SERVER_MODE=release -# API 根路径 (用于反向代理,如 /api) -SERVER_BASE_PATH= -# 公开访问地址 (用于生成回调URL、邮件链接等) -PUBLIC_URL=http://localhost:8080 # ==================== 数据库配置 ==================== -DB_PASSWORD=carrotskin123 +# 数据库密码,生产环境务必修改 +DATABASE_PASSWORD=carrotskin123 # ==================== Redis 配置 ==================== # 留空表示不设置密码 @@ -25,23 +23,26 @@ JWT_SECRET=your-super-secret-jwt-key-change-in-production # ==================== 存储配置 (RustFS S3兼容) ==================== # 内部访问地址 (容器间通信) RUSTFS_ENDPOINT=rustfs:9000 +# 公开访问地址 (用于生成文件URL,供外部浏览器访问) +# 示例: 直接访问 http://localhost:9000 或反向代理 https://example.com/storage +RUSTFS_PUBLIC_URL=http://localhost:9000 RUSTFS_ACCESS_KEY=rustfsadmin RUSTFS_SECRET_KEY=rustfsadmin123 RUSTFS_USE_SSL=false - # 存储桶配置 -RUSTFS_BUCKET_TEXTURES=carrotskin -RUSTFS_BUCKET_AVATARS=carrotskin +RUSTFS_BUCKET_TEXTURES=carrot-skin-textures +RUSTFS_BUCKET_AVATARS=carrot-skin-avatars -# 公开访问地址 (用于生成文件URL,供外部浏览器访问) -# 示例: -# 直接访问: http://localhost:9000 -# 反向代理: https://example.com/storage -RUSTFS_PUBLIC_URL=http://localhost:9000 +# ==================== 安全配置 ==================== +# CORS 允许的来源,多个用逗号分隔 +SECURITY_ALLOWED_ORIGINS=* +# 允许的头像/材质URL域名,多个用逗号分隔 +SECURITY_ALLOWED_DOMAINS=localhost,127.0.0.1 -# ==================== 邮件配置 (可选) ==================== -SMTP_HOST= -SMTP_PORT=587 -SMTP_USER= -SMTP_PASSWORD= -SMTP_FROM= +# ==================== 邮件配置 ==================== +EMAIL_ENABLED=false +EMAIL_SMTP_HOST= +EMAIL_SMTP_PORT=587 +EMAIL_USERNAME= +EMAIL_PASSWORD= +EMAIL_FROM_NAME=CarrotSkin diff --git a/.env.example b/.env.example index 1a4698b..99b68ca 100644 --- a/.env.example +++ b/.env.example @@ -23,6 +23,7 @@ DATABASE_TIMEZONE=Asia/Shanghai DATABASE_MAX_IDLE_CONNS=10 DATABASE_MAX_OPEN_CONNS=100 DATABASE_CONN_MAX_LIFETIME=1h +DATABASE_CONN_MAX_IDLE_TIME=10m # ============================================================================= # Redis配置 @@ -37,6 +38,7 @@ REDIS_POOL_SIZE=10 # RustFS对象存储配置 (S3兼容) # ============================================================================= RUSTFS_ENDPOINT=127.0.0.1:9000 +RUSTFS_PUBLIC_URL=http://127.0.0.1:9000 RUSTFS_ACCESS_KEY=your_access_key RUSTFS_SECRET_KEY=your_secret_key RUSTFS_USE_SSL=false @@ -55,26 +57,17 @@ JWT_EXPIRE_HOURS=168 LOG_LEVEL=info LOG_FORMAT=json LOG_OUTPUT=logs/app.log -LOG_MAX_SIZE=100 -LOG_MAX_BACKUPS=3 -LOG_MAX_AGE=28 -LOG_COMPRESS=true - -# ============================================================================= -# 文件上传配置 -# ============================================================================= -UPLOAD_MAX_SIZE=10485760 -UPLOAD_TEXTURE_MAX_SIZE=2097152 -UPLOAD_AVATAR_MAX_SIZE=1048576 # ============================================================================= # 安全配置 # ============================================================================= -MAX_LOGIN_ATTEMPTS=5 -LOGIN_LOCK_DURATION=30m +# CORS 允许的来源,多个用逗号分隔 +SECURITY_ALLOWED_ORIGINS=* +# 允许的头像/材质URL域名,多个用逗号分隔 +SECURITY_ALLOWED_DOMAINS=localhost,127.0.0.1 # ============================================================================= -# 邮件配置(可选) +# 邮件配置 # 腾讯企业邮箱SSL配置示例:smtp.exmail.qq.com, 端口465 # ============================================================================= EMAIL_ENABLED=false diff --git a/docker-compose.yml b/docker-compose.yml index 3795054..55eee7b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,40 +13,43 @@ services: - "${APP_PORT:-8080}:8080" environment: # 服务器配置 - - SERVER_PORT=8080 + - SERVER_PORT=:8080 - SERVER_MODE=${SERVER_MODE:-release} - - SERVER_BASE_PATH=${SERVER_BASE_PATH:-} - # 公开访问地址 (用于生成回调URL、邮件链接等) - - PUBLIC_URL=${PUBLIC_URL:-http://localhost:8080} # 数据库配置 - - DB_HOST=postgres - - DB_PORT=5432 - - DB_USER=carrotskin - - DB_PASSWORD=${DB_PASSWORD:-carrotskin123} - - DB_NAME=carrotskin - - DB_SSLMODE=disable + - DATABASE_DRIVER=postgres + - DATABASE_HOST=postgres + - DATABASE_PORT=5432 + - DATABASE_USERNAME=carrotskin + - DATABASE_PASSWORD=${DATABASE_PASSWORD:-carrotskin123} + - DATABASE_NAME=carrotskin + - DATABASE_SSL_MODE=disable + - DATABASE_TIMEZONE=Asia/Shanghai # Redis 配置 - REDIS_HOST=redis - REDIS_PORT=6379 - REDIS_PASSWORD=${REDIS_PASSWORD:-} - - REDIS_DB=0 + - REDIS_DATABASE=0 # JWT 配置 - JWT_SECRET=${JWT_SECRET:-your-super-secret-jwt-key-change-in-production} - - JWT_EXPIRE_HOURS=24 + - JWT_EXPIRE_HOURS=168 # 存储配置 (RustFS S3兼容) - RUSTFS_ENDPOINT=${RUSTFS_ENDPOINT:-rustfs:9000} - RUSTFS_PUBLIC_URL=${RUSTFS_PUBLIC_URL:-http://localhost:9000} - RUSTFS_ACCESS_KEY=${RUSTFS_ACCESS_KEY:-rustfsadmin} - RUSTFS_SECRET_KEY=${RUSTFS_SECRET_KEY:-rustfsadmin123} - RUSTFS_USE_SSL=${RUSTFS_USE_SSL:-false} - - RUSTFS_BUCKET_TEXTURES=${RUSTFS_BUCKET_TEXTURES:-carrotskin} - - RUSTFS_BUCKET_AVATARS=${RUSTFS_BUCKET_AVATARS:-carrotskin} - # 邮件配置 (可选) - - SMTP_HOST=${SMTP_HOST:-} - - SMTP_PORT=${SMTP_PORT:-587} - - SMTP_USER=${SMTP_USER:-} - - SMTP_PASSWORD=${SMTP_PASSWORD:-} - - SMTP_FROM=${SMTP_FROM:-} + - RUSTFS_BUCKET_TEXTURES=${RUSTFS_BUCKET_TEXTURES:-carrot-skin-textures} + - RUSTFS_BUCKET_AVATARS=${RUSTFS_BUCKET_AVATARS:-carrot-skin-avatars} + # 安全配置 + - SECURITY_ALLOWED_ORIGINS=${SECURITY_ALLOWED_ORIGINS:-*} + - SECURITY_ALLOWED_DOMAINS=${SECURITY_ALLOWED_DOMAINS:-localhost,127.0.0.1} + # 邮件配置 + - EMAIL_ENABLED=${EMAIL_ENABLED:-false} + - EMAIL_SMTP_HOST=${EMAIL_SMTP_HOST:-} + - EMAIL_SMTP_PORT=${EMAIL_SMTP_PORT:-587} + - EMAIL_USERNAME=${EMAIL_USERNAME:-} + - EMAIL_PASSWORD=${EMAIL_PASSWORD:-} + - EMAIL_FROM_NAME=${EMAIL_FROM_NAME:-CarrotSkin} depends_on: postgres: condition: service_healthy @@ -68,7 +71,7 @@ services: restart: unless-stopped environment: - POSTGRES_USER=carrotskin - - POSTGRES_PASSWORD=${DB_PASSWORD:-carrotskin123} + - POSTGRES_PASSWORD=${DATABASE_PASSWORD:-carrotskin123} - POSTGRES_DB=carrotskin - PGDATA=/var/lib/postgresql/data/pgdata volumes: @@ -148,14 +151,19 @@ services: echo '等待 RustFS 启动...'; sleep 5; mc alias set myrustfs http://rustfs:9000 $${RUSTFS_ACCESS_KEY} $${RUSTFS_SECRET_KEY}; - mc mb myrustfs/$${RUSTFS_BUCKET} --ignore-existing; - mc anonymous set download myrustfs/$${RUSTFS_BUCKET}; - echo '存储桶 $${RUSTFS_BUCKET} 创建完成,已设置公开读取权限'; + echo '创建材质存储桶...'; + mc mb myrustfs/$${RUSTFS_BUCKET_TEXTURES} --ignore-existing; + mc anonymous set download myrustfs/$${RUSTFS_BUCKET_TEXTURES}; + echo '创建头像存储桶...'; + mc mb myrustfs/$${RUSTFS_BUCKET_AVATARS} --ignore-existing; + mc anonymous set download myrustfs/$${RUSTFS_BUCKET_AVATARS}; + echo '存储桶创建完成: $${RUSTFS_BUCKET_TEXTURES}, $${RUSTFS_BUCKET_AVATARS}'; " environment: - RUSTFS_ACCESS_KEY=${RUSTFS_ACCESS_KEY:-rustfsadmin} - RUSTFS_SECRET_KEY=${RUSTFS_SECRET_KEY:-rustfsadmin123} - - RUSTFS_BUCKET=${RUSTFS_BUCKET_TEXTURES:-carrotskin} + - RUSTFS_BUCKET_TEXTURES=${RUSTFS_BUCKET_TEXTURES:-carrot-skin-textures} + - RUSTFS_BUCKET_AVATARS=${RUSTFS_BUCKET_AVATARS:-carrot-skin-avatars} networks: - carrotskin-network profiles: diff --git a/internal/container/container.go b/internal/container/container.go index cecd6a7..bc5091f 100644 --- a/internal/container/container.go +++ b/internal/container/container.go @@ -41,7 +41,6 @@ type Container struct { TokenService service.TokenService YggdrasilService service.YggdrasilService VerificationService service.VerificationService - UploadService service.UploadService SecurityService service.SecurityService CaptchaService service.CaptchaService SignatureService *service.SignatureService @@ -87,7 +86,7 @@ func NewContainer( c.SignatureService = service.NewSignatureService(c.ProfileRepo, redisClient, logger) // 初始化Service(注入缓存管理器) - c.UserService = service.NewUserService(c.UserRepo, c.ConfigRepo, jwtService, redisClient, cacheManager, logger) + c.UserService = service.NewUserService(c.UserRepo, c.ConfigRepo, jwtService, redisClient, cacheManager, storageClient, logger) c.ProfileService = service.NewProfileService(c.ProfileRepo, c.UserRepo, cacheManager, logger) c.TextureService = service.NewTextureService(c.TextureRepo, c.UserRepo, storageClient, cacheManager, logger) c.TextureRenderService = service.NewTextureRenderService(c.TextureRepo, storageClient, cacheManager, logger) @@ -107,7 +106,6 @@ func NewContainer( // 初始化其他服务 c.SecurityService = service.NewSecurityService(redisClient) - c.UploadService = service.NewUploadService(storageClient) c.CaptchaService = service.NewCaptchaService(redisClient, logger) // 初始化VerificationService(需要email.Service) @@ -251,13 +249,6 @@ func WithVerificationService(svc service.VerificationService) Option { } } -// WithUploadService 设置上传服务 -func WithUploadService(svc service.UploadService) Option { - return func(c *Container) { - c.UploadService = svc - } -} - // WithSecurityService 设置安全服务 func WithSecurityService(svc service.SecurityService) Option { return func(c *Container) { diff --git a/internal/handler/routes.go b/internal/handler/routes.go index 209e383..15666af 100644 --- a/internal/handler/routes.go +++ b/internal/handler/routes.go @@ -95,8 +95,8 @@ func registerUserRoutes(v1 *gin.RouterGroup, h *UserHandler, jwtService *auth.JW userGroup.PUT("/profile", h.UpdateProfile) // 头像相关 - userGroup.POST("/avatar/upload-url", h.GenerateAvatarUploadURL) - userGroup.PUT("/avatar", h.UpdateAvatar) + userGroup.POST("/avatar/upload", h.UploadAvatar) // 直接上传头像文件 + userGroup.PUT("/avatar", h.UpdateAvatar) // 更新头像URL(外部URL) // 更换邮箱 userGroup.POST("/change-email", h.ChangeEmail) @@ -122,9 +122,7 @@ func registerTextureRoutes(v1 *gin.RouterGroup, h *TextureHandler, jwtService *a textureAuth := textureGroup.Group("") textureAuth.Use(middleware.AuthMiddleware(jwtService)) { - textureAuth.POST("/upload", h.Upload) // 直接上传文件 - textureAuth.POST("/upload-url", h.GenerateUploadURL) // 生成预签名URL(保留兼容性) - textureAuth.POST("", h.Create) // 创建材质记录(配合预签名URL使用) + textureAuth.POST("/upload", h.Upload) // 直接上传文件 textureAuth.PUT("/:id", h.Update) textureAuth.DELETE("/:id", h.Delete) textureAuth.POST("/:id/favorite", h.ToggleFavorite) diff --git a/internal/handler/texture_handler.go b/internal/handler/texture_handler.go index 2c04b7b..208636c 100644 --- a/internal/handler/texture_handler.go +++ b/internal/handler/texture_handler.go @@ -25,93 +25,6 @@ func NewTextureHandler(c *container.Container) *TextureHandler { } } -// GenerateUploadURL 生成材质上传URL -func (h *TextureHandler) GenerateUploadURL(c *gin.Context) { - userID, ok := GetUserIDFromContext(c) - if !ok { - return - } - - var req types.GenerateTextureUploadURLRequest - if err := c.ShouldBindJSON(&req); err != nil { - RespondBadRequest(c, "请求参数错误", err) - return - } - - if h.container.Storage == nil { - RespondServerError(c, "存储服务不可用", nil) - return - } - - result, err := h.container.UploadService.GenerateTextureUploadURL( - c.Request.Context(), - userID, - req.FileName, - string(req.TextureType), - ) - if err != nil { - h.logger.Error("生成材质上传URL失败", - zap.Int64("user_id", userID), - zap.String("file_name", req.FileName), - zap.String("texture_type", string(req.TextureType)), - zap.Error(err), - ) - RespondBadRequest(c, err.Error(), nil) - return - } - - RespondSuccess(c, &types.GenerateTextureUploadURLResponse{ - PostURL: result.PostURL, - FormData: result.FormData, - TextureURL: result.FileURL, - ExpiresIn: 900, - }) -} - -// Create 创建材质记录 -func (h *TextureHandler) Create(c *gin.Context) { - userID, ok := GetUserIDFromContext(c) - if !ok { - return - } - - var req types.CreateTextureRequest - if err := c.ShouldBindJSON(&req); err != nil { - RespondBadRequest(c, "请求参数错误", err) - return - } - - 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.Create( - c.Request.Context(), - userID, - req.Name, - req.Description, - string(req.Type), - req.URL, - req.Hash, - req.Size, - req.IsPublic, - req.IsSlim, - ) - if err != nil { - h.logger.Error("创建材质失败", - zap.Int64("user_id", userID), - zap.String("name", req.Name), - zap.Error(err), - ) - RespondBadRequest(c, err.Error(), nil) - return - } - - RespondSuccess(c, TextureToTextureInfo(texture)) -} - // Get 获取材质详情 func (h *TextureHandler) Get(c *gin.Context) { id, err := strconv.ParseInt(c.Param("id"), 10, 64) diff --git a/internal/handler/user_handler.go b/internal/handler/user_handler.go index 08edcbf..b9f49f1 100644 --- a/internal/handler/user_handler.go +++ b/internal/handler/user_handler.go @@ -102,44 +102,66 @@ func (h *UserHandler) UpdateProfile(c *gin.Context) { RespondSuccess(c, UserToUserInfo(updatedUser)) } -// GenerateAvatarUploadURL 生成头像上传URL -func (h *UserHandler) GenerateAvatarUploadURL(c *gin.Context) { +// UploadAvatar 直接上传头像文件 +func (h *UserHandler) UploadAvatar(c *gin.Context) { userID, ok := GetUserIDFromContext(c) if !ok { return } - var req types.GenerateAvatarUploadURLRequest - if err := c.ShouldBindJSON(&req); err != nil { - RespondBadRequest(c, "请求参数错误", err) + // 解析multipart表单 + if err := c.Request.ParseMultipartForm(10 << 20); err != nil { // 10MB + RespondBadRequest(c, "解析表单失败", err) return } - if h.container.Storage == nil { - RespondServerError(c, "存储服务不可用", nil) - return - } - - result, err := h.container.UploadService.GenerateAvatarUploadURL(c.Request.Context(), userID, req.FileName) + // 获取文件 + file, err := c.FormFile("file") if err != nil { - h.logger.Error("生成头像上传URL失败", + 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 + } + + // 调用服务上传头像 + avatarURL, err := h.container.UserService.UploadAvatar(c.Request.Context(), userID, fileData, file.Filename) + if err != nil { + h.logger.Error("上传头像失败", zap.Int64("user_id", userID), - zap.String("file_name", req.FileName), + zap.String("file_name", file.Filename), zap.Error(err), ) RespondBadRequest(c, err.Error(), nil) return } - RespondSuccess(c, &types.GenerateAvatarUploadURLResponse{ - PostURL: result.PostURL, - FormData: result.FormData, - AvatarURL: result.FileURL, - ExpiresIn: 900, + // 获取更新后的用户信息 + user, err := h.container.UserService.GetByID(c.Request.Context(), userID) + if err != nil || user == nil { + RespondNotFound(c, "用户不存在") + return + } + + RespondSuccess(c, gin.H{ + "avatar_url": avatarURL, + "user": UserToUserInfo(user), }) } -// UpdateAvatar 更新头像URL +// UpdateAvatar 更新头像URL(保留用于外部URL) func (h *UserHandler) UpdateAvatar(c *gin.Context) { userID, ok := GetUserIDFromContext(c) if !ok { diff --git a/internal/service/interfaces.go b/internal/service/interfaces.go index 1e97f7d..444f60d 100644 --- a/internal/service/interfaces.go +++ b/internal/service/interfaces.go @@ -27,6 +27,9 @@ type UserService interface { ResetPassword(ctx context.Context, email, newPassword string) error ChangeEmail(ctx context.Context, userID int64, newEmail string) error + // 头像上传 + UploadAvatar(ctx context.Context, userID int64, fileData []byte, fileName string) (string, error) + // URL验证 ValidateAvatarURL(ctx context.Context, avatarURL string) error @@ -55,8 +58,7 @@ type ProfileService interface { // TextureService 材质服务接口 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) // 直接上传材质文件 + 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) @@ -98,12 +100,6 @@ type CaptchaService interface { Verify(ctx context.Context, dx int, captchaID string) (bool, error) } -// UploadService 上传服务接口 -type UploadService interface { - GenerateAvatarUploadURL(ctx context.Context, userID int64, fileName string) (*storage.PresignedPostPolicyResult, error) - GenerateTextureUploadURL(ctx context.Context, userID int64, fileName, textureType string) (*storage.PresignedPostPolicyResult, error) -} - // YggdrasilService Yggdrasil服务接口 type YggdrasilService interface { // 用户认证 @@ -211,7 +207,6 @@ type Services struct { Token TokenService Verification VerificationService Captcha CaptchaService - Upload UploadService Yggdrasil YggdrasilService Security SecurityService } diff --git a/internal/service/texture_service.go b/internal/service/texture_service.go index 6dcb2fc..f31e3ce 100644 --- a/internal/service/texture_service.go +++ b/internal/service/texture_service.go @@ -48,57 +48,6 @@ func NewTextureService( } } -func (s *textureService) Create(ctx context.Context, uploaderID int64, name, description, textureType, url, hash string, size int, isPublic, isSlim bool) (*model.Texture, error) { - // 验证用户存在 - user, err := s.userRepo.FindByID(ctx, uploaderID) - if err != nil || user == nil { - return nil, ErrUserNotFound - } - - // 检查是否有任何用户上传过相同Hash的皮肤(复用URL,不重复保存文件) - existingTexture, err := s.textureRepo.FindByHash(ctx, hash) - if err != nil { - return nil, err - } - - // 如果已存在相同Hash的皮肤,复用已存在的URL - finalURL := url - if existingTexture != nil { - finalURL = existingTexture.URL - } - - // 转换材质类型 - textureTypeEnum, err := parseTextureTypeInternal(textureType) - if err != nil { - return nil, err - } - - // 创建材质记录(即使Hash相同,也创建新的数据库记录) - texture := &model.Texture{ - UploaderID: uploaderID, - Name: name, - Description: description, - Type: textureTypeEnum, - URL: finalURL, // 复用已存在的URL或使用新URL - Hash: hash, - Size: size, - 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 -} - func (s *textureService) GetByID(ctx context.Context, id int64) (*model.Texture, error) { // 尝试从缓存获取 cacheKey := s.cacheKeys.Texture(id) diff --git a/internal/service/texture_service_test.go b/internal/service/texture_service_test.go index 7e36aff..1163ed0 100644 --- a/internal/service/texture_service_test.go +++ b/internal/service/texture_service_test.go @@ -478,130 +478,6 @@ func boolPtr(b bool) *bool { // 使用 Mock 的集成测试 // ============================================================================ -// TestTextureServiceImpl_Create 测试创建Texture -func TestTextureServiceImpl_Create(t *testing.T) { - textureRepo := NewMockTextureRepository() - userRepo := NewMockUserRepository() - logger := zap.NewNop() - - // 预置用户 - testUser := &model.User{ - ID: 1, - Username: "testuser", - Email: "test@example.com", - Status: 1, - } - _ = userRepo.Create(context.Background(), testUser) - - cacheManager := NewMockCacheManager() - // 在测试中传入nil作为storageClient,因为测试不涉及文件上传 - textureService := NewTextureService(textureRepo, userRepo, nil, cacheManager, logger) - - tests := []struct { - name string - uploaderID int64 - textureName string - textureType string - hash string - wantErr bool - errContains string - setupMocks func() - }{ - { - name: "正常创建SKIN材质", - uploaderID: 1, - textureName: "TestSkin", - textureType: "SKIN", - hash: "unique-hash-1", - wantErr: false, - }, - { - name: "正常创建CAPE材质", - uploaderID: 1, - textureName: "TestCape", - textureType: "CAPE", - hash: "unique-hash-2", - wantErr: false, - }, - { - name: "用户不存在", - uploaderID: 999, - textureName: "TestTexture", - textureType: "SKIN", - hash: "unique-hash-3", - wantErr: true, - }, - { - name: "材质Hash已存在,应该成功创建并复用URL", - uploaderID: 1, - textureName: "DuplicateTexture", - textureType: "SKIN", - hash: "existing-hash", - wantErr: false, // 业务逻辑允许相同Hash存在,只是复用URL - errContains: "", - setupMocks: func() { - _ = textureRepo.Create(context.Background(), &model.Texture{ - ID: 100, - UploaderID: 1, - Name: "ExistingTexture", - Hash: "existing-hash", - }) - }, - }, - { - name: "无效的材质类型", - uploaderID: 1, - textureName: "InvalidTypeTexture", - textureType: "INVALID", - hash: "unique-hash-4", - wantErr: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if tt.setupMocks != nil { - tt.setupMocks() - } - - ctx := context.Background() - texture, err := textureService.Create( - ctx, - tt.uploaderID, - tt.textureName, - "Test description", - tt.textureType, - "http://example.com/texture.png", - tt.hash, - 512, - true, - false, - ) - - if tt.wantErr { - if err == nil { - t.Error("期望返回错误,但实际没有错误") - return - } - if tt.errContains != "" && !containsString(err.Error(), tt.errContains) { - t.Errorf("错误信息应包含 %q, 实际为: %v", tt.errContains, err.Error()) - } - } else { - if err != nil { - t.Errorf("不期望返回错误: %v", err) - return - } - if texture == nil { - t.Error("返回的Texture不应为nil") - } - if texture.Name != tt.textureName { - t.Errorf("Texture名称不匹配: got %v, want %v", texture.Name, tt.textureName) - } - } - }) - } -} - // TestTextureServiceImpl_GetByID 测试获取Texture func TestTextureServiceImpl_GetByID(t *testing.T) { textureRepo := NewMockTextureRepository() diff --git a/internal/service/upload_service.go b/internal/service/upload_service.go deleted file mode 100644 index 457f360..0000000 --- a/internal/service/upload_service.go +++ /dev/null @@ -1,167 +0,0 @@ -package service - -import ( - "carrotskin/pkg/storage" - "context" - "fmt" - "path/filepath" - "strings" - "time" -) - -// FileType 文件类型枚举 -type FileType string - -const ( - FileTypeAvatar FileType = "avatar" - FileTypeTexture FileType = "texture" -) - -// UploadConfig 上传配置 -type UploadConfig struct { - AllowedExts map[string]bool // 允许的文件扩展名 - MinSize int64 // 最小文件大小(字节) - MaxSize int64 // 最大文件大小(字节) - Expires time.Duration // URL过期时间 -} - -// uploadService UploadService的实现 -type uploadService struct { - storage *storage.StorageClient -} - -// NewUploadService 创建UploadService实例 -func NewUploadService(storageClient *storage.StorageClient) UploadService { - return &uploadService{ - storage: storageClient, - } -} - -// GenerateAvatarUploadURL 生成头像上传URL -func (s *uploadService) GenerateAvatarUploadURL(ctx context.Context, userID int64, fileName string) (*storage.PresignedPostPolicyResult, error) { - // 1. 验证文件名 - if err := ValidateFileName(fileName, FileTypeAvatar); err != nil { - return nil, err - } - - // 2. 获取上传配置 - uploadConfig := GetUploadConfig(FileTypeAvatar) - - // 3. 获取存储桶名称 - bucketName, err := s.storage.GetBucket("avatars") - if err != nil { - return nil, fmt.Errorf("获取存储桶失败: %w", err) - } - - // 4. 生成对象名称(路径) - // 格式: user_{userId}/timestamp_{originalFileName} - timestamp := time.Now().Format("20060102150405") - objectName := fmt.Sprintf("user_%d/%s_%s", userID, timestamp, fileName) - - // 5. 生成预签名POST URL (使用存储客户端内置的 PublicURL) - result, err := s.storage.GeneratePresignedPostURL( - ctx, - bucketName, - objectName, - uploadConfig.MinSize, - uploadConfig.MaxSize, - uploadConfig.Expires, - ) - if err != nil { - return nil, fmt.Errorf("生成上传URL失败: %w", err) - } - - return result, nil -} - -// GenerateTextureUploadURL 生成材质上传URL -func (s *uploadService) GenerateTextureUploadURL(ctx context.Context, userID int64, fileName, textureType string) (*storage.PresignedPostPolicyResult, error) { - // 1. 验证文件名 - if err := ValidateFileName(fileName, FileTypeTexture); err != nil { - return nil, err - } - - // 2. 验证材质类型 - if textureType != "SKIN" && textureType != "CAPE" { - return nil, fmt.Errorf("无效的材质类型: %s", textureType) - } - - // 3. 获取上传配置 - uploadConfig := GetUploadConfig(FileTypeTexture) - - // 4. 获取存储桶名称 - bucketName, err := s.storage.GetBucket("textures") - if err != nil { - return nil, fmt.Errorf("获取存储桶失败: %w", err) - } - - // 5. 生成对象名称(路径) - // 格式: user_{userId}/{textureType}/timestamp_{originalFileName} - timestamp := time.Now().Format("20060102150405") - textureTypeFolder := strings.ToLower(textureType) - objectName := fmt.Sprintf("user_%d/%s/%s_%s", userID, textureTypeFolder, timestamp, fileName) - - // 6. 生成预签名POST URL (使用存储客户端内置的 PublicURL) - result, err := s.storage.GeneratePresignedPostURL( - ctx, - bucketName, - objectName, - uploadConfig.MinSize, - uploadConfig.MaxSize, - uploadConfig.Expires, - ) - if err != nil { - return nil, fmt.Errorf("生成上传URL失败: %w", err) - } - - return result, nil -} - -// GetUploadConfig 根据文件类型获取上传配置 -func GetUploadConfig(fileType FileType) *UploadConfig { - switch fileType { - case FileTypeAvatar: - return &UploadConfig{ - AllowedExts: map[string]bool{ - ".jpg": true, - ".jpeg": true, - ".png": true, - ".gif": true, - ".webp": true, - }, - MinSize: 512, // 512B - MaxSize: 5 * 1024 * 1024, // 5MB - Expires: 15 * time.Minute, - } - case FileTypeTexture: - return &UploadConfig{ - AllowedExts: map[string]bool{ - ".png": true, - }, - MinSize: 512, // 512B - MaxSize: 10 * 1024 * 1024, // 10MB - Expires: 15 * time.Minute, - } - default: - return nil - } -} - -// ValidateFileName 验证文件名 -func ValidateFileName(fileName string, fileType FileType) error { - if fileName == "" { - return fmt.Errorf("文件名不能为空") - } - - uploadConfig := GetUploadConfig(fileType) - if uploadConfig == nil { - return fmt.Errorf("不支持的文件类型") - } - - ext := strings.ToLower(filepath.Ext(fileName)) - if !uploadConfig.AllowedExts[ext] { - return fmt.Errorf("不支持的文件格式: %s", ext) - } - - return nil -} diff --git a/internal/service/upload_service_test.go b/internal/service/upload_service_test.go deleted file mode 100644 index 9d57b36..0000000 --- a/internal/service/upload_service_test.go +++ /dev/null @@ -1,389 +0,0 @@ -package service - -import ( - "context" - "errors" - "strings" - "testing" - "time" - - "carrotskin/pkg/storage" -) - -// TestUploadService_FileTypes 测试文件类型常量 -func TestUploadService_FileTypes(t *testing.T) { - if FileTypeAvatar == "" { - t.Error("FileTypeAvatar should not be empty") - } - - if FileTypeTexture == "" { - t.Error("FileTypeTexture should not be empty") - } - - if FileTypeAvatar == FileTypeTexture { - t.Error("FileTypeAvatar and FileTypeTexture should be different") - } -} - -// TestGetUploadConfig 测试获取上传配置 -func TestGetUploadConfig(t *testing.T) { - tests := []struct { - name string - fileType FileType - wantConfig bool - }{ - { - name: "头像类型返回配置", - fileType: FileTypeAvatar, - wantConfig: true, - }, - { - name: "材质类型返回配置", - fileType: FileTypeTexture, - wantConfig: true, - }, - { - name: "无效类型返回nil", - fileType: FileType("invalid"), - wantConfig: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - config := GetUploadConfig(tt.fileType) - hasConfig := config != nil - if hasConfig != tt.wantConfig { - t.Errorf("GetUploadConfig() = %v, want %v", hasConfig, tt.wantConfig) - } - - if config != nil { - // 验证配置字段 - if config.MinSize <= 0 { - t.Error("MinSize should be greater than 0") - } - if config.MaxSize <= 0 { - t.Error("MaxSize should be greater than 0") - } - if config.MaxSize < config.MinSize { - t.Error("MaxSize should be greater than or equal to MinSize") - } - if config.Expires <= 0 { - t.Error("Expires should be greater than 0") - } - if len(config.AllowedExts) == 0 { - t.Error("AllowedExts should not be empty") - } - } - }) - } -} - -// TestGetUploadConfig_AvatarConfig 测试头像配置详情 -func TestGetUploadConfig_AvatarConfig(t *testing.T) { - config := GetUploadConfig(FileTypeAvatar) - if config == nil { - t.Fatal("Avatar config should not be nil") - } - - // 验证允许的扩展名 - expectedExts := []string{".jpg", ".jpeg", ".png", ".gif", ".webp"} - for _, ext := range expectedExts { - if !config.AllowedExts[ext] { - t.Errorf("Avatar config should allow %s extension", ext) - } - } - - // 验证文件大小限制 - if config.MinSize != 512 { - t.Errorf("Avatar MinSize = %d, want 512", config.MinSize) - } - - if config.MaxSize != 5*1024*1024 { - t.Errorf("Avatar MaxSize = %d, want 5MB", config.MaxSize) - } - - // 验证过期时间 - if config.Expires != 15*time.Minute { - t.Errorf("Avatar Expires = %v, want 15 minutes", config.Expires) - } -} - -// TestGetUploadConfig_TextureConfig 测试材质配置详情 -func TestGetUploadConfig_TextureConfig(t *testing.T) { - config := GetUploadConfig(FileTypeTexture) - if config == nil { - t.Fatal("Texture config should not be nil") - } - - // 验证允许的扩展名(材质只允许PNG) - if !config.AllowedExts[".png"] { - t.Error("Texture config should allow .png extension") - } - - // 验证文件大小限制 - if config.MinSize != 512 { - t.Errorf("Texture MinSize = %d, want 512", config.MinSize) - } - - if config.MaxSize != 10*1024*1024 { - t.Errorf("Texture MaxSize = %d, want 10MB", config.MaxSize) - } - - // 验证过期时间 - if config.Expires != 15*time.Minute { - t.Errorf("Texture Expires = %v, want 15 minutes", config.Expires) - } -} - -// TestValidateFileName 测试文件名验证 -func TestValidateFileName(t *testing.T) { - tests := []struct { - name string - fileName string - fileType FileType - wantErr bool - errContains string - }{ - { - name: "有效的头像文件名", - fileName: "avatar.png", - fileType: FileTypeAvatar, - wantErr: false, - }, - { - name: "有效的材质文件名", - fileName: "texture.png", - fileType: FileTypeTexture, - wantErr: false, - }, - { - name: "文件名为空", - fileName: "", - fileType: FileTypeAvatar, - wantErr: true, - errContains: "文件名不能为空", - }, - { - name: "不支持的文件扩展名", - fileName: "file.txt", - fileType: FileTypeAvatar, - wantErr: true, - errContains: "不支持的文件格式", - }, - { - name: "无效的文件类型", - fileName: "file.png", - fileType: FileType("invalid"), - wantErr: true, - errContains: "不支持的文件类型", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := ValidateFileName(tt.fileName, tt.fileType) - if (err != nil) != tt.wantErr { - t.Errorf("ValidateFileName() error = %v, wantErr %v", err, tt.wantErr) - return - } - if tt.wantErr && tt.errContains != "" { - if err == nil || !strings.Contains(err.Error(), tt.errContains) { - t.Errorf("ValidateFileName() error = %v, should contain %s", err, tt.errContains) - } - } - }) - } -} - -// TestValidateFileName_Extensions 测试各种扩展名 -func TestValidateFileName_Extensions(t *testing.T) { - avatarExts := []string{".jpg", ".jpeg", ".png", ".gif", ".webp"} - for _, ext := range avatarExts { - fileName := "test" + ext - err := ValidateFileName(fileName, FileTypeAvatar) - if err != nil { - t.Errorf("Avatar file with %s extension should be valid, got error: %v", ext, err) - } - } - - // 材质只支持PNG - textureExts := []string{".png"} - for _, ext := range textureExts { - fileName := "test" + ext - err := ValidateFileName(fileName, FileTypeTexture) - if err != nil { - t.Errorf("Texture file with %s extension should be valid, got error: %v", ext, err) - } - } - - // 测试不支持的扩展名 - invalidExts := []string{".txt", ".pdf", ".doc"} - for _, ext := range invalidExts { - fileName := "test" + ext - err := ValidateFileName(fileName, FileTypeAvatar) - if err == nil { - t.Errorf("Avatar file with %s extension should be invalid", ext) - } - } -} - -// TestValidateFileName_CaseInsensitive 测试扩展名大小写不敏感 -func TestValidateFileName_CaseInsensitive(t *testing.T) { - testCases := []struct { - fileName string - fileType FileType - wantErr bool - }{ - {"test.PNG", FileTypeAvatar, false}, - {"test.JPG", FileTypeAvatar, false}, - {"test.JPEG", FileTypeAvatar, false}, - {"test.GIF", FileTypeAvatar, false}, - {"test.WEBP", FileTypeAvatar, false}, - {"test.PnG", FileTypeTexture, false}, - } - - for _, tc := range testCases { - t.Run(tc.fileName, func(t *testing.T) { - err := ValidateFileName(tc.fileName, tc.fileType) - if (err != nil) != tc.wantErr { - t.Errorf("ValidateFileName(%s, %s) error = %v, wantErr %v", tc.fileName, tc.fileType, err, tc.wantErr) - } - }) - } -} - -// TestUploadConfig_Structure 测试UploadConfig结构 -func TestUploadConfig_Structure(t *testing.T) { - config := &UploadConfig{ - AllowedExts: map[string]bool{ - ".png": true, - }, - MinSize: 512, - MaxSize: 5 * 1024 * 1024, - Expires: 15 * time.Minute, - } - - if config.AllowedExts == nil { - t.Error("AllowedExts should not be nil") - } - - if config.MinSize <= 0 { - t.Error("MinSize should be greater than 0") - } - - if config.MaxSize <= config.MinSize { - t.Error("MaxSize should be greater than MinSize") - } - - if config.Expires <= 0 { - t.Error("Expires should be greater than 0") - } -} - -// mockStorageClient 用于单元测试的简单存储客户端假实现 -// 注意:这里只声明与 upload_service 使用到的方法,避免依赖真实 MinIO 客户端 -type mockStorageClient struct { - getBucketFn func(name string) (string, error) - generatePresignedPostURLFn func(ctx context.Context, bucketName, objectName string, minSize, maxSize int64, expires time.Duration) (*storage.PresignedPostPolicyResult, error) -} - -func (m *mockStorageClient) GetBucket(name string) (string, error) { - if m.getBucketFn != nil { - return m.getBucketFn(name) - } - return "", errors.New("GetBucket not implemented") -} - -func (m *mockStorageClient) GeneratePresignedPostURL(ctx context.Context, bucketName, objectName string, minSize, maxSize int64, expires time.Duration) (*storage.PresignedPostPolicyResult, error) { - if m.generatePresignedPostURLFn != nil { - return m.generatePresignedPostURLFn(ctx, bucketName, objectName, minSize, maxSize, expires) - } - return nil, errors.New("GeneratePresignedPostURL not implemented") -} - -// TestGenerateAvatarUploadURL_Success 测试头像上传URL生成成功 -func TestGenerateAvatarUploadURL_Success(t *testing.T) { - // 由于 mockStorageClient 类型不匹配,跳过该测试 - t.Skip("This test requires refactoring to work with the new service architecture") - - _ = &mockStorageClient{ - getBucketFn: func(name string) (string, error) { - if name != "avatars" { - t.Fatalf("unexpected bucket name: %s", name) - } - return "avatars-bucket", nil - }, - generatePresignedPostURLFn: func(ctx context.Context, bucketName, objectName string, minSize, maxSize int64, expires time.Duration) (*storage.PresignedPostPolicyResult, error) { - if bucketName != "avatars-bucket" { - t.Fatalf("unexpected bucketName: %s", bucketName) - } - if !strings.Contains(objectName, "user_") { - t.Fatalf("objectName should contain user_ prefix, got: %s", objectName) - } - if !strings.Contains(objectName, "avatar.png") { - t.Fatalf("objectName should contain original file name, got: %s", objectName) - } - // 检查大小与过期时间传递 - if minSize != 512 { - t.Fatalf("minSize = %d, want 512", minSize) - } - if maxSize != 5*1024*1024 { - t.Fatalf("maxSize = %d, want 5MB", maxSize) - } - if expires != 15*time.Minute { - t.Fatalf("expires = %v, want 15m", expires) - } - return &storage.PresignedPostPolicyResult{ - PostURL: "http://example.com/upload", - FormData: map[string]string{"key": objectName}, - FileURL: "http://example.com/file/" + objectName, - }, nil - }, - } - -} - -// TestGenerateTextureUploadURL_Success 测试材质上传URL生成成功(SKIN/CAPE) -func TestGenerateTextureUploadURL_Success(t *testing.T) { - // 由于 mockStorageClient 类型不匹配,跳过该测试 - t.Skip("This test requires refactoring to work with the new service architecture") - - tests := []struct { - name string - textureType string - }{ - {"SKIN 材质", "SKIN"}, - {"CAPE 材质", "CAPE"}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - _ = &mockStorageClient{ - getBucketFn: func(name string) (string, error) { - if name != "textures" { - t.Fatalf("unexpected bucket name: %s", name) - } - return "textures-bucket", nil - }, - generatePresignedPostURLFn: func(ctx context.Context, bucketName, objectName string, minSize, maxSize int64, expires time.Duration) (*storage.PresignedPostPolicyResult, error) { - if bucketName != "textures-bucket" { - t.Fatalf("unexpected bucketName: %s", bucketName) - } - if !strings.Contains(objectName, "texture.png") { - t.Fatalf("objectName should contain original file name, got: %s", objectName) - } - if !strings.Contains(objectName, "/"+strings.ToLower(tt.textureType)+"/") { - t.Fatalf("objectName should contain texture type folder, got: %s", objectName) - } - return &storage.PresignedPostPolicyResult{ - PostURL: "http://example.com/upload", - FormData: map[string]string{"key": objectName}, - FileURL: "http://example.com/file/" + objectName, - }, nil - }, - } - - }) - } -} diff --git a/internal/service/user_service.go b/internal/service/user_service.go index 4a556b8..1575e43 100644 --- a/internal/service/user_service.go +++ b/internal/service/user_service.go @@ -1,10 +1,14 @@ package service import ( + "bytes" "context" + "crypto/sha256" + "encoding/hex" "errors" "fmt" "net/url" + "path/filepath" "strings" "time" @@ -15,6 +19,7 @@ import ( "carrotskin/pkg/config" "carrotskin/pkg/database" "carrotskin/pkg/redis" + "carrotskin/pkg/storage" "go.uber.org/zap" ) @@ -28,6 +33,7 @@ type userService struct { cache *database.CacheManager cacheKeys *database.CacheKeyBuilder cacheInv *database.CacheInvalidator + storage *storage.StorageClient logger *zap.Logger } @@ -38,6 +44,7 @@ func NewUserService( jwtService *auth.JWTService, redisClient *redis.Client, cacheManager *database.CacheManager, + storageClient *storage.StorageClient, logger *zap.Logger, ) UserService { // CacheKeyBuilder 使用空前缀,因为 CacheManager 已经处理了前缀 @@ -50,6 +57,7 @@ func NewUserService( cache: cacheManager, cacheKeys: database.NewCacheKeyBuilder(""), cacheInv: database.NewCacheInvalidator(cacheManager), + storage: storageClient, logger: logger, } } @@ -347,6 +355,67 @@ func (s *userService) ValidateAvatarURL(ctx context.Context, avatarURL string) e return s.checkDomainAllowed(host, cfg.Security.AllowedDomains) } +func (s *userService) UploadAvatar(ctx context.Context, userID int64, fileData []byte, fileName string) (string, error) { + // 验证文件大小 + fileSize := len(fileData) + const minSize = 512 // 512B + const maxSize = 5 * 1024 * 1024 // 5MB + if int64(fileSize) < minSize || int64(fileSize) > maxSize { + return "", fmt.Errorf("文件大小必须在 %d 到 %d 字节之间", minSize, maxSize) + } + + // 验证文件扩展名 + ext := strings.ToLower(filepath.Ext(fileName)) + allowedExts := map[string]bool{".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".webp": true} + if !allowedExts[ext] { + return "", fmt.Errorf("不支持的文件格式: %s,仅支持 jpg/jpeg/png/gif/webp", ext) + } + + // 检查存储服务 + if s.storage == nil { + return "", errors.New("存储服务不可用") + } + + // 计算文件哈希 + hashBytes := sha256.Sum256(fileData) + hash := hex.EncodeToString(hashBytes[:]) + + // 获取存储桶 + bucketName, err := s.storage.GetBucket("avatars") + if err != nil { + return "", fmt.Errorf("获取存储桶失败: %w", err) + } + + // 生成对象路径: avatars/{hash[:2]}/{hash[2:4]}/{hash}{ext} + objectName := fmt.Sprintf("%s/%s/%s%s", hash[:2], hash[2:4], hash, ext) + + // 上传文件 + reader := bytes.NewReader(fileData) + contentType := "image/" + strings.TrimPrefix(ext, ".") + if ext == ".jpg" { + contentType = "image/jpeg" + } + if err := s.storage.UploadObject(ctx, bucketName, objectName, reader, int64(fileSize), contentType); err != nil { + return "", fmt.Errorf("上传文件失败: %w", err) + } + + // 构建文件URL + avatarURL := s.storage.BuildFileURL(bucketName, objectName) + + // 更新用户头像 + if err := s.UpdateAvatar(ctx, userID, avatarURL); err != nil { + return "", fmt.Errorf("更新用户头像失败: %w", err) + } + + s.logger.Info("上传头像成功", + zap.Int64("user_id", userID), + zap.String("hash", hash), + zap.String("url", avatarURL), + ) + + return avatarURL, nil +} + func (s *userService) GetMaxProfilesPerUser() int { config, err := s.configRepo.GetByKey(context.Background(), "max_profiles_per_user") if err != nil || config == nil { diff --git a/internal/service/user_service_test.go b/internal/service/user_service_test.go index 5ca4abf..daad043 100644 --- a/internal/service/user_service_test.go +++ b/internal/service/user_service_test.go @@ -19,7 +19,7 @@ func TestUserServiceImpl_Register(t *testing.T) { // 初始化Service // 注意:redisClient 和 cacheManager 传入 nil,因为 Register 方法中没有使用它们 cacheManager := NewMockCacheManager() - userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, logger) + userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, nil, logger) ctx := context.Background() @@ -130,7 +130,7 @@ func TestUserServiceImpl_Login(t *testing.T) { _ = userRepo.Create(context.Background(), testUser) cacheManager := NewMockCacheManager() - userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, logger) + userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, nil, logger) ctx := context.Background() @@ -211,7 +211,7 @@ func TestUserServiceImpl_BasicGettersAndUpdates(t *testing.T) { _ = userRepo.Create(context.Background(), user) cacheManager := NewMockCacheManager() - userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, logger) + userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, nil, logger) ctx := context.Background() @@ -259,7 +259,7 @@ func TestUserServiceImpl_ChangePassword(t *testing.T) { _ = userRepo.Create(context.Background(), user) cacheManager := NewMockCacheManager() - userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, logger) + userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, nil, logger) ctx := context.Background() @@ -294,7 +294,7 @@ func TestUserServiceImpl_ResetPassword(t *testing.T) { _ = userRepo.Create(context.Background(), user) cacheManager := NewMockCacheManager() - userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, logger) + userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, nil, logger) ctx := context.Background() @@ -322,7 +322,7 @@ func TestUserServiceImpl_ChangeEmail(t *testing.T) { _ = userRepo.Create(context.Background(), user2) cacheManager := NewMockCacheManager() - userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, logger) + userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, nil, logger) ctx := context.Background() @@ -345,7 +345,7 @@ func TestUserServiceImpl_ValidateAvatarURL(t *testing.T) { logger := zap.NewNop() cacheManager := NewMockCacheManager() - userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, logger) + userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, nil, logger) ctx := context.Background() @@ -381,7 +381,7 @@ func TestUserServiceImpl_MaxLimits(t *testing.T) { // 未配置时走默认值 cacheManager := NewMockCacheManager() - userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, logger) + userService := NewUserService(userRepo, configRepo, jwtService, nil, cacheManager, nil, logger) if got := userService.GetMaxProfilesPerUser(); got != 5 { t.Fatalf("GetMaxProfilesPerUser 默认值错误, got=%d", got) } diff --git a/internal/types/common.go b/internal/types/common.go index eab20a8..3e85f97 100644 --- a/internal/types/common.go +++ b/internal/types/common.go @@ -65,19 +65,6 @@ type ChangeEmailRequest struct { VerificationCode string `json:"verification_code" binding:"required,len=6" example:"123456"` } -// GenerateAvatarUploadURLRequest 生成头像上传URL请求 -type GenerateAvatarUploadURLRequest struct { - FileName string `json:"file_name" binding:"required" example:"avatar.png"` -} - -// GenerateAvatarUploadURLResponse 生成头像上传URL响应 -type GenerateAvatarUploadURLResponse struct { - PostURL string `json:"post_url" example:"https://rustfs.example.com/avatars"` - FormData map[string]string `json:"form_data"` - AvatarURL string `json:"avatar_url" example:"https://rustfs.example.com/avatars/user_1/xxx.png"` - ExpiresIn int `json:"expires_in" example:"900"` // 秒 -} - // CreateProfileRequest 创建档案请求 type CreateProfileRequest struct { Name string `json:"name" binding:"required,min=1,max=16" example:"PlayerName"` @@ -90,20 +77,6 @@ type UpdateTextureRequest struct { IsPublic *bool `json:"is_public" example:"true"` } -// GenerateTextureUploadURLRequest 生成材质上传URL请求 -type GenerateTextureUploadURLRequest struct { - FileName string `json:"file_name" binding:"required" example:"skin.png"` - TextureType TextureType `json:"texture_type" binding:"required,oneof=SKIN CAPE" example:"SKIN"` -} - -// GenerateTextureUploadURLResponse 生成材质上传URL响应 -type GenerateTextureUploadURLResponse struct { - PostURL string `json:"post_url" example:"https://rustfs.example.com/textures"` - FormData map[string]string `json:"form_data"` - TextureURL string `json:"texture_url" example:"https://rustfs.example.com/textures/user_1/skin/xxx.png"` - ExpiresIn int `json:"expires_in" example:"900"` // 秒 -} - // LoginResponse 登录响应 type LoginResponse struct { Token string `json:"token"` @@ -177,18 +150,6 @@ type UploadURLResponse struct { ExpiresIn int `json:"expires_in"` } -// CreateTextureRequest 创建材质请求 -type CreateTextureRequest struct { - Name string `json:"name" binding:"required,min=1,max=100" example:"My Cool Skin"` - Description string `json:"description" binding:"max=500" example:"A very cool skin"` - Type TextureType `json:"type" binding:"required,oneof=SKIN CAPE" example:"SKIN"` - URL string `json:"url" binding:"required,url" example:"https://rustfs.example.com/textures/user_1/skin/xxx.png"` - Hash string `json:"hash" binding:"required,len=64" example:"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"` - Size int `json:"size" binding:"required,min=1" example:"2048"` - IsPublic bool `json:"is_public" example:"true"` - IsSlim bool `json:"is_slim" example:"false"` // Alex模型(细臂)为true,Steve模型(粗臂)为false -} - // SearchTextureRequest 搜索材质请求 type SearchTextureRequest struct { PaginationRequest diff --git a/pkg/config/config.go b/pkg/config/config.go index 7871d94..21258ad 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -21,7 +21,6 @@ type Config struct { JWT JWTConfig `mapstructure:"jwt"` Casbin CasbinConfig `mapstructure:"casbin"` Log LogConfig `mapstructure:"log"` - Upload UploadConfig `mapstructure:"upload"` Email EmailConfig `mapstructure:"email"` Security SecurityConfig `mapstructure:"security"` } @@ -99,14 +98,6 @@ type LogConfig struct { Compress bool `mapstructure:"compress"` } -// UploadConfig 文件上传配置 -type UploadConfig struct { - MaxSize int64 `mapstructure:"max_size"` - AllowedTypes []string `mapstructure:"allowed_types"` - TextureMaxSize int64 `mapstructure:"texture_max_size"` - AvatarMaxSize int64 `mapstructure:"avatar_max_size"` -} - // EmailConfig 邮件配置 type EmailConfig struct { Enabled bool `mapstructure:"enabled"` @@ -203,12 +194,6 @@ func setDefaults() { viper.SetDefault("log.max_age", 28) viper.SetDefault("log.compress", true) - // 文件上传默认配置 - viper.SetDefault("upload.max_size", 10485760) - viper.SetDefault("upload.texture_max_size", 2097152) - viper.SetDefault("upload.avatar_max_size", 1048576) - viper.SetDefault("upload.allowed_types", []string{"image/png", "image/jpeg"}) - // 邮件默认配置 viper.SetDefault("email.enabled", false) viper.SetDefault("email.smtp_port", 587) @@ -370,25 +355,6 @@ func overrideFromEnv(config *Config) { } } - // 处理文件上传配置 - if maxSize := os.Getenv("UPLOAD_MAX_SIZE"); maxSize != "" { - if val, err := strconv.ParseInt(maxSize, 10, 64); err == nil { - config.Upload.MaxSize = val - } - } - - if textureMaxSize := os.Getenv("UPLOAD_TEXTURE_MAX_SIZE"); textureMaxSize != "" { - if val, err := strconv.ParseInt(textureMaxSize, 10, 64); err == nil { - config.Upload.TextureMaxSize = val - } - } - - if avatarMaxSize := os.Getenv("UPLOAD_AVATAR_MAX_SIZE"); avatarMaxSize != "" { - if val, err := strconv.ParseInt(avatarMaxSize, 10, 64); err == nil { - config.Upload.AvatarMaxSize = val - } - } - // 处理邮件配置 if emailEnabled := os.Getenv("EMAIL_ENABLED"); emailEnabled != "" { config.Email.Enabled = emailEnabled == "true" || emailEnabled == "True" || emailEnabled == "TRUE" || emailEnabled == "1" diff --git a/scripts/check-env.sh b/scripts/check-env.sh index 8bb8808..87e0796 100644 --- a/scripts/check-env.sh +++ b/scripts/check-env.sh @@ -10,9 +10,12 @@ REQUIRED_VARS=( "DATABASE_USERNAME" "DATABASE_PASSWORD" "DATABASE_NAME" + "REDIS_HOST" "RUSTFS_ENDPOINT" "RUSTFS_ACCESS_KEY" "RUSTFS_SECRET_KEY" + "RUSTFS_BUCKET_TEXTURES" + "RUSTFS_BUCKET_AVATARS" "JWT_SECRET" ) @@ -26,7 +29,9 @@ fi echo "✅ .env 文件存在" # 加载.env文件 +set -a source .env 2>/dev/null +set +a # 检查必需的环境变量 missing_vars=() @@ -51,8 +56,10 @@ echo "✅ 所有必需的环境变量都已设置" # 检查关键配置的合理性 echo "" echo "📋 当前配置概览:" -echo " 数据库: $DATABASE_USERNAME@$DATABASE_HOST:$DATABASE_PORT/$DATABASE_NAME" +echo " 数据库: $DATABASE_USERNAME@$DATABASE_HOST:${DATABASE_PORT:-5432}/$DATABASE_NAME" +echo " Redis: $REDIS_HOST:${REDIS_PORT:-6379}" echo " RustFS: $RUSTFS_ENDPOINT" +echo " 存储桶: $RUSTFS_BUCKET_TEXTURES, $RUSTFS_BUCKET_AVATARS" echo " JWT密钥长度: ${#JWT_SECRET} 字符" # 检查JWT密钥长度 @@ -65,11 +72,11 @@ if [ "$JWT_SECRET" = "your-jwt-secret-key-change-this-in-production" ]; then echo "⚠️ 使用的是默认JWT密钥,生产环境中请更改" fi -if [ "$DATABASE_PASSWORD" = "123456" ] || [ "$DATABASE_PASSWORD" = "your_password_here" ]; then +if [ "$DATABASE_PASSWORD" = "123456" ] || [ "$DATABASE_PASSWORD" = "your_password_here" ] || [ "$DATABASE_PASSWORD" = "carrotskin123" ]; then echo "⚠️ 使用的是默认数据库密码,生产环境中请更改" fi -if [ "$RUSTFS_ACCESS_KEY" = "your_access_key" ] || [ "$RUSTFS_SECRET_KEY" = "your_secret_key" ]; then +if [ "$RUSTFS_ACCESS_KEY" = "your_access_key" ] || [ "$RUSTFS_SECRET_KEY" = "your_secret_key" ] || [ "$RUSTFS_ACCESS_KEY" = "rustfsadmin" ]; then echo "⚠️ 使用的是默认RustFS凭证,生产环境中请更改" fi diff --git a/scripts/dev.sh b/scripts/dev.sh deleted file mode 100644 index e3bef2f..0000000 --- a/scripts/dev.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/bin/bash - -# CarrotSkin 开发环境启动脚本 - -echo "🚀 启动 CarrotSkin 开发环境..." - -# 检查配置文件 -if [ ! -f "configs/config.yaml" ]; then - echo "📝 复制配置文件..." - cp configs/config.yaml.example configs/config.yaml - echo "⚠️ 请编辑 configs/config.yaml 文件配置数据库和其他服务连接信息" -fi - -# 检查依赖 -echo "📦 检查依赖..." -go mod tidy - -# 生成Swagger文档 -echo "📚 生成Swagger文档..." -if command -v swag &> /dev/null; then - swag init -g cmd/server/main.go -o docs --parseDependency --parseInternal -else - echo "⚠️ swag工具未安装,请运行: go install github.com/swaggo/swag/cmd/swag@latest" -fi - -# 启动应用 -echo "🎯 启动应用..." -go run cmd/server/main.go