diff --git a/internal/container/container.go b/internal/container/container.go index cde146e..2677f09 100644 --- a/internal/container/container.go +++ b/internal/container/container.go @@ -2,6 +2,7 @@ package container import ( "carrotskin/internal/repository" + "carrotskin/internal/service" "carrotskin/pkg/auth" "carrotskin/pkg/redis" "carrotskin/pkg/storage" @@ -26,6 +27,12 @@ type Container struct { TextureRepo repository.TextureRepository TokenRepo repository.TokenRepository ConfigRepo repository.SystemConfigRepository + + // Service层 + UserService service.UserService + ProfileService service.ProfileService + TextureService service.TextureService + TokenService service.TokenService } // NewContainer 创建依赖容器 @@ -51,6 +58,12 @@ func NewContainer( c.TokenRepo = repository.NewTokenRepository(db) c.ConfigRepo = repository.NewSystemConfigRepository(db) + // 初始化Service + c.UserService = service.NewUserService(c.UserRepo, c.ConfigRepo, jwtService, redisClient, logger) + c.ProfileService = service.NewProfileService(c.ProfileRepo, c.UserRepo, logger) + c.TextureService = service.NewTextureService(c.TextureRepo, c.UserRepo, logger) + c.TokenService = service.NewTokenService(c.TokenRepo, c.ProfileRepo, logger) + return c } @@ -135,3 +148,31 @@ func WithConfigRepo(repo repository.SystemConfigRepository) Option { c.ConfigRepo = repo } } + +// WithUserService 设置用户服务 +func WithUserService(svc service.UserService) Option { + return func(c *Container) { + c.UserService = svc + } +} + +// WithProfileService 设置档案服务 +func WithProfileService(svc service.ProfileService) Option { + return func(c *Container) { + c.ProfileService = svc + } +} + +// WithTextureService 设置材质服务 +func WithTextureService(svc service.TextureService) Option { + return func(c *Container) { + c.TextureService = svc + } +} + +// WithTokenService 设置令牌服务 +func WithTokenService(svc service.TokenService) Option { + return func(c *Container) { + c.TokenService = svc + } +} diff --git a/internal/handler/profile_handler_di.go b/internal/handler/profile_handler_di.go index 6fdbeb9..d9d8e3b 100644 --- a/internal/handler/profile_handler_di.go +++ b/internal/handler/profile_handler_di.go @@ -244,4 +244,3 @@ func (h *ProfileHandler) SetActive(c *gin.Context) { RespondSuccess(c, gin.H{"message": "设置成功"}) } - diff --git a/internal/service/interfaces.go b/internal/service/interfaces.go new file mode 100644 index 0000000..55a9f1d --- /dev/null +++ b/internal/service/interfaces.go @@ -0,0 +1,144 @@ +// Package service 定义业务逻辑层接口 +package service + +import ( + "carrotskin/internal/model" + "carrotskin/pkg/storage" + "context" + + "go.uber.org/zap" +) + +// UserService 用户服务接口 +type UserService interface { + // 用户认证 + Register(username, password, email, avatar string) (*model.User, string, error) + Login(usernameOrEmail, password, ipAddress, userAgent string) (*model.User, string, error) + + // 用户查询 + GetByID(id int64) (*model.User, error) + GetByEmail(email string) (*model.User, error) + + // 用户更新 + UpdateInfo(user *model.User) error + UpdateAvatar(userID int64, avatarURL string) error + ChangePassword(userID int64, oldPassword, newPassword string) error + ResetPassword(email, newPassword string) error + ChangeEmail(userID int64, newEmail string) error + + // URL验证 + ValidateAvatarURL(avatarURL string) error + + // 配置获取 + GetMaxProfilesPerUser() int + GetMaxTexturesPerUser() int +} + +// ProfileService 档案服务接口 +type ProfileService interface { + // 档案CRUD + Create(userID int64, name string) (*model.Profile, error) + GetByUUID(uuid string) (*model.Profile, error) + GetByUserID(userID int64) ([]*model.Profile, error) + Update(uuid string, userID int64, name *string, skinID, capeID *int64) (*model.Profile, error) + Delete(uuid string, userID int64) error + + // 档案状态 + SetActive(uuid string, userID int64) error + CheckLimit(userID int64, maxProfiles int) error + + // 批量查询 + GetByNames(names []string) ([]*model.Profile, error) + GetByProfileName(name string) (*model.Profile, error) +} + +// TextureService 材质服务接口 +type TextureService interface { + // 材质CRUD + Create(uploaderID int64, name, description, textureType, url, hash string, size int, isPublic, isSlim bool) (*model.Texture, error) + GetByID(id int64) (*model.Texture, error) + GetByUserID(uploaderID int64, page, pageSize int) ([]*model.Texture, int64, error) + Search(keyword string, textureType model.TextureType, publicOnly bool, page, pageSize int) ([]*model.Texture, int64, error) + Update(textureID, uploaderID int64, name, description string, isPublic *bool) (*model.Texture, error) + Delete(textureID, uploaderID int64) error + + // 收藏 + ToggleFavorite(userID, textureID int64) (bool, error) + GetUserFavorites(userID int64, page, pageSize int) ([]*model.Texture, int64, error) + + // 限制检查 + CheckUploadLimit(uploaderID int64, maxTextures int) error +} + +// TokenService 令牌服务接口 +type TokenService interface { + // 令牌管理 + Create(userID int64, uuid, clientToken string) (*model.Profile, []*model.Profile, string, string, error) + Validate(accessToken, clientToken string) bool + Refresh(accessToken, clientToken, selectedProfileID string) (string, string, error) + Invalidate(accessToken string) + InvalidateUserTokens(userID int64) + + // 令牌查询 + GetUUIDByAccessToken(accessToken string) (string, error) + GetUserIDByAccessToken(accessToken string) (int64, error) +} + +// VerificationService 验证码服务接口 +type VerificationService interface { + SendCode(ctx context.Context, email, codeType string) error + VerifyCode(ctx context.Context, email, code, codeType string) error +} + +// CaptchaService 滑动验证码服务接口 +type CaptchaService interface { + Generate(ctx context.Context) (masterImg, tileImg, captchaID string, y int, err error) + 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 { + // 用户认证 + GetUserIDByEmail(email string) (int64, error) + VerifyPassword(password string, userID int64) error + + // 会话管理 + JoinServer(serverID, accessToken, selectedProfile, ip string) error + HasJoinedServer(serverID, username, ip string) error + + // 密码管理 + ResetYggdrasilPassword(userID int64) (string, error) + + // 序列化 + SerializeProfile(profile model.Profile) map[string]interface{} + SerializeUser(user *model.User, uuid string) map[string]interface{} + + // 证书 + GeneratePlayerCertificate(uuid string) (map[string]interface{}, error) + GetPublicKey() (string, error) +} + +// Services 服务集合 +type Services struct { + User UserService + Profile ProfileService + Texture TextureService + Token TokenService + Verification VerificationService + Captcha CaptchaService + Upload UploadService + Yggdrasil YggdrasilService +} + +// ServiceDeps 服务依赖 +type ServiceDeps struct { + Logger *zap.Logger + Storage *storage.StorageClient +} + diff --git a/internal/service/profile_service_impl.go b/internal/service/profile_service_impl.go new file mode 100644 index 0000000..a84dcad --- /dev/null +++ b/internal/service/profile_service_impl.go @@ -0,0 +1,233 @@ +package service + +import ( + "carrotskin/internal/model" + "carrotskin/internal/repository" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "errors" + "fmt" + + "github.com/google/uuid" + "go.uber.org/zap" + "gorm.io/gorm" +) + +// profileServiceImpl ProfileService的实现 +type profileServiceImpl struct { + profileRepo repository.ProfileRepository + userRepo repository.UserRepository + logger *zap.Logger +} + +// NewProfileService 创建ProfileService实例 +func NewProfileService( + profileRepo repository.ProfileRepository, + userRepo repository.UserRepository, + logger *zap.Logger, +) ProfileService { + return &profileServiceImpl{ + profileRepo: profileRepo, + userRepo: userRepo, + logger: logger, + } +} + +func (s *profileServiceImpl) Create(userID int64, name string) (*model.Profile, error) { + // 验证用户存在 + user, err := s.userRepo.FindByID(userID) + if err != nil || user == nil { + return nil, errors.New("用户不存在") + } + if user.Status != 1 { + return nil, errors.New("用户状态异常") + } + + // 检查角色名是否已存在 + existingName, err := s.profileRepo.FindByName(name) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fmt.Errorf("查询角色名失败: %w", err) + } + if existingName != nil { + return nil, errors.New("角色名已被使用") + } + + // 生成UUID和RSA密钥 + profileUUID := uuid.New().String() + privateKey, err := generateRSAPrivateKeyInternal() + if err != nil { + return nil, fmt.Errorf("生成RSA密钥失败: %w", err) + } + + // 创建档案 + profile := &model.Profile{ + UUID: profileUUID, + UserID: userID, + Name: name, + RSAPrivateKey: privateKey, + IsActive: true, + } + + if err := s.profileRepo.Create(profile); err != nil { + return nil, fmt.Errorf("创建档案失败: %w", err) + } + + // 设置活跃状态 + if err := s.profileRepo.SetActive(profileUUID, userID); err != nil { + return nil, fmt.Errorf("设置活跃状态失败: %w", err) + } + + return profile, nil +} + +func (s *profileServiceImpl) GetByUUID(uuid string) (*model.Profile, error) { + profile, err := s.profileRepo.FindByUUID(uuid) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrProfileNotFound + } + return nil, fmt.Errorf("查询档案失败: %w", err) + } + return profile, nil +} + +func (s *profileServiceImpl) GetByUserID(userID int64) ([]*model.Profile, error) { + profiles, err := s.profileRepo.FindByUserID(userID) + if err != nil { + return nil, fmt.Errorf("查询档案列表失败: %w", err) + } + return profiles, nil +} + +func (s *profileServiceImpl) Update(uuid string, userID int64, name *string, skinID, capeID *int64) (*model.Profile, error) { + // 获取档案并验证权限 + profile, err := s.profileRepo.FindByUUID(uuid) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrProfileNotFound + } + return nil, fmt.Errorf("查询档案失败: %w", err) + } + + if profile.UserID != userID { + return nil, ErrProfileNoPermission + } + + // 检查角色名是否重复 + if name != nil && *name != profile.Name { + existingName, err := s.profileRepo.FindByName(*name) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fmt.Errorf("查询角色名失败: %w", err) + } + if existingName != nil { + return nil, errors.New("角色名已被使用") + } + profile.Name = *name + } + + // 更新皮肤和披风 + if skinID != nil { + profile.SkinID = skinID + } + if capeID != nil { + profile.CapeID = capeID + } + + if err := s.profileRepo.Update(profile); err != nil { + return nil, fmt.Errorf("更新档案失败: %w", err) + } + + return s.profileRepo.FindByUUID(uuid) +} + +func (s *profileServiceImpl) Delete(uuid string, userID int64) error { + // 获取档案并验证权限 + profile, err := s.profileRepo.FindByUUID(uuid) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ErrProfileNotFound + } + return fmt.Errorf("查询档案失败: %w", err) + } + + if profile.UserID != userID { + return ErrProfileNoPermission + } + + if err := s.profileRepo.Delete(uuid); err != nil { + return fmt.Errorf("删除档案失败: %w", err) + } + return nil +} + +func (s *profileServiceImpl) SetActive(uuid string, userID int64) error { + // 获取档案并验证权限 + profile, err := s.profileRepo.FindByUUID(uuid) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ErrProfileNotFound + } + return fmt.Errorf("查询档案失败: %w", err) + } + + if profile.UserID != userID { + return ErrProfileNoPermission + } + + if err := s.profileRepo.SetActive(uuid, userID); err != nil { + return fmt.Errorf("设置活跃状态失败: %w", err) + } + + if err := s.profileRepo.UpdateLastUsedAt(uuid); err != nil { + return fmt.Errorf("更新使用时间失败: %w", err) + } + + return nil +} + +func (s *profileServiceImpl) CheckLimit(userID int64, maxProfiles int) error { + count, err := s.profileRepo.CountByUserID(userID) + if err != nil { + return fmt.Errorf("查询档案数量失败: %w", err) + } + + if int(count) >= maxProfiles { + return fmt.Errorf("已达到档案数量上限(%d个)", maxProfiles) + } + return nil +} + +func (s *profileServiceImpl) GetByNames(names []string) ([]*model.Profile, error) { + profiles, err := s.profileRepo.GetByNames(names) + if err != nil { + return nil, fmt.Errorf("查找失败: %w", err) + } + return profiles, nil +} + +func (s *profileServiceImpl) GetByProfileName(name string) (*model.Profile, error) { + profile, err := s.profileRepo.FindByName(name) + if err != nil { + return nil, errors.New("用户角色未创建") + } + return profile, nil +} + +// generateRSAPrivateKeyInternal 生成RSA-2048私钥(PEM格式) +func generateRSAPrivateKeyInternal() (string, error) { + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + return "", err + } + + privateKeyBytes := x509.MarshalPKCS1PrivateKey(privateKey) + privateKeyPEM := pem.EncodeToMemory(&pem.Block{ + Type: "RSA PRIVATE KEY", + Bytes: privateKeyBytes, + }) + + return string(privateKeyPEM), nil +} + diff --git a/internal/service/texture_service_impl.go b/internal/service/texture_service_impl.go new file mode 100644 index 0000000..9a82ac8 --- /dev/null +++ b/internal/service/texture_service_impl.go @@ -0,0 +1,216 @@ +package service + +import ( + "carrotskin/internal/model" + "carrotskin/internal/repository" + "errors" + "fmt" + + "go.uber.org/zap" +) + +// textureServiceImpl TextureService的实现 +type textureServiceImpl struct { + textureRepo repository.TextureRepository + userRepo repository.UserRepository + logger *zap.Logger +} + +// NewTextureService 创建TextureService实例 +func NewTextureService( + textureRepo repository.TextureRepository, + userRepo repository.UserRepository, + logger *zap.Logger, +) TextureService { + return &textureServiceImpl{ + textureRepo: textureRepo, + userRepo: userRepo, + logger: logger, + } +} + +func (s *textureServiceImpl) Create(uploaderID int64, name, description, textureType, url, hash string, size int, isPublic, isSlim bool) (*model.Texture, error) { + // 验证用户存在 + user, err := s.userRepo.FindByID(uploaderID) + if err != nil || user == nil { + return nil, ErrUserNotFound + } + + // 检查Hash是否已存在 + existingTexture, err := s.textureRepo.FindByHash(hash) + if err != nil { + return nil, err + } + if existingTexture != nil { + return nil, errors.New("该材质已存在") + } + + // 转换材质类型 + textureTypeEnum, err := parseTextureTypeInternal(textureType) + if err != nil { + return nil, err + } + + // 创建材质 + texture := &model.Texture{ + UploaderID: uploaderID, + Name: name, + Description: description, + Type: textureTypeEnum, + URL: url, + Hash: hash, + Size: size, + IsPublic: isPublic, + IsSlim: isSlim, + Status: 1, + DownloadCount: 0, + FavoriteCount: 0, + } + + if err := s.textureRepo.Create(texture); err != nil { + return nil, err + } + + return texture, nil +} + +func (s *textureServiceImpl) GetByID(id int64) (*model.Texture, error) { + texture, err := s.textureRepo.FindByID(id) + if err != nil { + return nil, err + } + if texture == nil { + return nil, ErrTextureNotFound + } + if texture.Status == -1 { + return nil, errors.New("材质已删除") + } + return texture, nil +} + +func (s *textureServiceImpl) GetByUserID(uploaderID int64, page, pageSize int) ([]*model.Texture, int64, error) { + page, pageSize = NormalizePagination(page, pageSize) + return s.textureRepo.FindByUploaderID(uploaderID, page, pageSize) +} + +func (s *textureServiceImpl) Search(keyword string, textureType model.TextureType, publicOnly bool, page, pageSize int) ([]*model.Texture, int64, error) { + page, pageSize = NormalizePagination(page, pageSize) + return s.textureRepo.Search(keyword, textureType, publicOnly, page, pageSize) +} + +func (s *textureServiceImpl) Update(textureID, uploaderID int64, name, description string, isPublic *bool) (*model.Texture, error) { + // 获取材质并验证权限 + texture, err := s.textureRepo.FindByID(textureID) + if err != nil { + return nil, err + } + if texture == nil { + return nil, ErrTextureNotFound + } + if texture.UploaderID != uploaderID { + return nil, ErrTextureNoPermission + } + + // 更新字段 + updates := make(map[string]interface{}) + if name != "" { + updates["name"] = name + } + if description != "" { + updates["description"] = description + } + if isPublic != nil { + updates["is_public"] = *isPublic + } + + if len(updates) > 0 { + if err := s.textureRepo.UpdateFields(textureID, updates); err != nil { + return nil, err + } + } + + return s.textureRepo.FindByID(textureID) +} + +func (s *textureServiceImpl) Delete(textureID, uploaderID int64) error { + // 获取材质并验证权限 + texture, err := s.textureRepo.FindByID(textureID) + if err != nil { + return err + } + if texture == nil { + return ErrTextureNotFound + } + if texture.UploaderID != uploaderID { + return ErrTextureNoPermission + } + + return s.textureRepo.Delete(textureID) +} + +func (s *textureServiceImpl) ToggleFavorite(userID, textureID int64) (bool, error) { + // 确保材质存在 + texture, err := s.textureRepo.FindByID(textureID) + if err != nil { + return false, err + } + if texture == nil { + return false, ErrTextureNotFound + } + + isFavorited, err := s.textureRepo.IsFavorited(userID, textureID) + if err != nil { + return false, err + } + + if isFavorited { + // 已收藏 -> 取消收藏 + if err := s.textureRepo.RemoveFavorite(userID, textureID); err != nil { + return false, err + } + if err := s.textureRepo.DecrementFavoriteCount(textureID); err != nil { + return false, err + } + return false, nil + } + + // 未收藏 -> 添加收藏 + if err := s.textureRepo.AddFavorite(userID, textureID); err != nil { + return false, err + } + if err := s.textureRepo.IncrementFavoriteCount(textureID); err != nil { + return false, err + } + return true, nil +} + +func (s *textureServiceImpl) GetUserFavorites(userID int64, page, pageSize int) ([]*model.Texture, int64, error) { + page, pageSize = NormalizePagination(page, pageSize) + return s.textureRepo.GetUserFavorites(userID, page, pageSize) +} + +func (s *textureServiceImpl) CheckUploadLimit(uploaderID int64, maxTextures int) error { + count, err := s.textureRepo.CountByUploaderID(uploaderID) + if err != nil { + return err + } + + if count >= int64(maxTextures) { + return fmt.Errorf("已达到最大上传数量限制(%d)", maxTextures) + } + + return nil +} + +// parseTextureTypeInternal 解析材质类型 +func parseTextureTypeInternal(textureType string) (model.TextureType, error) { + switch textureType { + case "SKIN": + return model.TextureTypeSkin, nil + case "CAPE": + return model.TextureTypeCape, nil + default: + return "", errors.New("无效的材质类型") + } +} + diff --git a/internal/service/token_service_impl.go b/internal/service/token_service_impl.go new file mode 100644 index 0000000..8d49910 --- /dev/null +++ b/internal/service/token_service_impl.go @@ -0,0 +1,278 @@ +package service + +import ( + "carrotskin/internal/model" + "carrotskin/internal/repository" + "context" + "errors" + "fmt" + "strconv" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "go.uber.org/zap" +) + +// tokenServiceImpl TokenService的实现 +type tokenServiceImpl struct { + tokenRepo repository.TokenRepository + profileRepo repository.ProfileRepository + logger *zap.Logger +} + +// NewTokenService 创建TokenService实例 +func NewTokenService( + tokenRepo repository.TokenRepository, + profileRepo repository.ProfileRepository, + logger *zap.Logger, +) TokenService { + return &tokenServiceImpl{ + tokenRepo: tokenRepo, + profileRepo: profileRepo, + logger: logger, + } +} + +const ( + tokenExtendedTimeout = 10 * time.Second + tokensMaxCount = 10 +) + +func (s *tokenServiceImpl) Create(userID int64, UUID string, clientToken string) (*model.Profile, []*model.Profile, string, string, error) { + var ( + selectedProfileID *model.Profile + availableProfiles []*model.Profile + ) + + // 设置超时上下文 + _, cancel := context.WithTimeout(context.Background(), DefaultTimeout) + defer cancel() + + // 验证用户存在 + if UUID != "" { + _, err := s.profileRepo.FindByUUID(UUID) + if err != nil { + return selectedProfileID, availableProfiles, "", "", fmt.Errorf("获取用户信息失败: %w", err) + } + } + + // 生成令牌 + if clientToken == "" { + clientToken = uuid.New().String() + } + + accessToken := uuid.New().String() + token := model.Token{ + AccessToken: accessToken, + ClientToken: clientToken, + UserID: userID, + Usable: true, + IssueDate: time.Now(), + } + + // 获取用户配置文件 + profiles, err := s.profileRepo.FindByUserID(userID) + if err != nil { + return selectedProfileID, availableProfiles, "", "", fmt.Errorf("获取用户配置文件失败: %w", err) + } + + // 如果用户只有一个配置文件,自动选择 + if len(profiles) == 1 { + selectedProfileID = profiles[0] + token.ProfileId = selectedProfileID.UUID + } + availableProfiles = profiles + + // 插入令牌 + err = s.tokenRepo.Create(&token) + if err != nil { + return selectedProfileID, availableProfiles, "", "", fmt.Errorf("创建Token失败: %w", err) + } + + // 清理多余的令牌 + go s.checkAndCleanupExcessTokens(userID) + + return selectedProfileID, availableProfiles, accessToken, clientToken, nil +} + +func (s *tokenServiceImpl) Validate(accessToken, clientToken string) bool { + if accessToken == "" { + return false + } + + token, err := s.tokenRepo.FindByAccessToken(accessToken) + if err != nil { + return false + } + + if !token.Usable { + return false + } + + if clientToken == "" { + return true + } + + return token.ClientToken == clientToken +} + +func (s *tokenServiceImpl) Refresh(accessToken, clientToken, selectedProfileID string) (string, string, error) { + if accessToken == "" { + return "", "", errors.New("accessToken不能为空") + } + + // 查找旧令牌 + oldToken, err := s.tokenRepo.FindByAccessToken(accessToken) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return "", "", errors.New("accessToken无效") + } + s.logger.Error("查询Token失败", zap.Error(err), zap.String("accessToken", accessToken)) + return "", "", fmt.Errorf("查询令牌失败: %w", err) + } + + // 验证profile + if selectedProfileID != "" { + valid, validErr := s.validateProfileByUserID(oldToken.UserID, selectedProfileID) + if validErr != nil { + s.logger.Error("验证Profile失败", + zap.Error(err), + zap.Int64("userId", oldToken.UserID), + zap.String("profileId", selectedProfileID), + ) + return "", "", fmt.Errorf("验证角色失败: %w", err) + } + if !valid { + return "", "", errors.New("角色与用户不匹配") + } + } + + // 检查 clientToken 是否有效 + if clientToken != "" && clientToken != oldToken.ClientToken { + return "", "", errors.New("clientToken无效") + } + + // 检查 selectedProfileID 的逻辑 + if selectedProfileID != "" { + if oldToken.ProfileId != "" && oldToken.ProfileId != selectedProfileID { + return "", "", errors.New("原令牌已绑定角色,无法选择新角色") + } + } else { + selectedProfileID = oldToken.ProfileId + } + + // 生成新令牌 + newAccessToken := uuid.New().String() + newToken := model.Token{ + AccessToken: newAccessToken, + ClientToken: oldToken.ClientToken, + UserID: oldToken.UserID, + Usable: true, + ProfileId: selectedProfileID, + IssueDate: time.Now(), + } + + // 先插入新令牌,再删除旧令牌 + err = s.tokenRepo.Create(&newToken) + if err != nil { + s.logger.Error("创建新Token失败", zap.Error(err), zap.String("accessToken", accessToken)) + return "", "", fmt.Errorf("创建新Token失败: %w", err) + } + + err = s.tokenRepo.DeleteByAccessToken(accessToken) + if err != nil { + s.logger.Warn("删除旧Token失败,但新Token已创建", + zap.Error(err), + zap.String("oldToken", oldToken.AccessToken), + zap.String("newToken", newAccessToken), + ) + } + + s.logger.Info("成功刷新Token", zap.Int64("userId", oldToken.UserID), zap.String("accessToken", newAccessToken)) + return newAccessToken, oldToken.ClientToken, nil +} + +func (s *tokenServiceImpl) Invalidate(accessToken string) { + if accessToken == "" { + return + } + + err := s.tokenRepo.DeleteByAccessToken(accessToken) + if err != nil { + s.logger.Error("删除Token失败", zap.Error(err), zap.String("accessToken", accessToken)) + return + } + s.logger.Info("成功删除Token", zap.String("token", accessToken)) +} + +func (s *tokenServiceImpl) InvalidateUserTokens(userID int64) { + if userID == 0 { + return + } + + err := s.tokenRepo.DeleteByUserID(userID) + if err != nil { + s.logger.Error("删除用户Token失败", zap.Error(err), zap.Int64("userId", userID)) + return + } + + s.logger.Info("成功删除用户Token", zap.Int64("userId", userID)) +} + +func (s *tokenServiceImpl) GetUUIDByAccessToken(accessToken string) (string, error) { + return s.tokenRepo.GetUUIDByAccessToken(accessToken) +} + +func (s *tokenServiceImpl) GetUserIDByAccessToken(accessToken string) (int64, error) { + return s.tokenRepo.GetUserIDByAccessToken(accessToken) +} + +// 私有辅助方法 + +func (s *tokenServiceImpl) checkAndCleanupExcessTokens(userID int64) { + if userID == 0 { + return + } + + tokens, err := s.tokenRepo.GetByUserID(userID) + if err != nil { + s.logger.Error("获取用户Token失败", zap.Error(err), zap.String("userId", strconv.FormatInt(userID, 10))) + return + } + + if len(tokens) <= tokensMaxCount { + return + } + + tokensToDelete := make([]string, 0, len(tokens)-tokensMaxCount) + for i := tokensMaxCount; i < len(tokens); i++ { + tokensToDelete = append(tokensToDelete, tokens[i].AccessToken) + } + + deletedCount, err := s.tokenRepo.BatchDelete(tokensToDelete) + if err != nil { + s.logger.Error("清理用户多余Token失败", zap.Error(err), zap.String("userId", strconv.FormatInt(userID, 10))) + return + } + + if deletedCount > 0 { + s.logger.Info("成功清理用户多余Token", zap.Int64("userId", userID), zap.Int64("count", deletedCount)) + } +} + +func (s *tokenServiceImpl) validateProfileByUserID(userID int64, UUID string) (bool, error) { + if userID == 0 || UUID == "" { + return false, errors.New("用户ID或配置文件ID不能为空") + } + + profile, err := s.profileRepo.FindByUUID(UUID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return false, errors.New("配置文件不存在") + } + return false, fmt.Errorf("验证配置文件失败: %w", err) + } + return profile.UserID == userID, nil +} + diff --git a/internal/service/user_service_impl.go b/internal/service/user_service_impl.go new file mode 100644 index 0000000..2b7250e --- /dev/null +++ b/internal/service/user_service_impl.go @@ -0,0 +1,368 @@ +package service + +import ( + "carrotskin/internal/model" + "carrotskin/internal/repository" + "carrotskin/pkg/auth" + "carrotskin/pkg/config" + "carrotskin/pkg/redis" + "context" + "errors" + "fmt" + "net/url" + "strings" + "time" + + "go.uber.org/zap" +) + +// userServiceImpl UserService的实现 +type userServiceImpl struct { + userRepo repository.UserRepository + configRepo repository.SystemConfigRepository + jwtService *auth.JWTService + redis *redis.Client + logger *zap.Logger +} + +// NewUserService 创建UserService实例 +func NewUserService( + userRepo repository.UserRepository, + configRepo repository.SystemConfigRepository, + jwtService *auth.JWTService, + redisClient *redis.Client, + logger *zap.Logger, +) UserService { + return &userServiceImpl{ + userRepo: userRepo, + configRepo: configRepo, + jwtService: jwtService, + redis: redisClient, + logger: logger, + } +} + +func (s *userServiceImpl) Register(username, password, email, avatar string) (*model.User, string, error) { + // 检查用户名是否已存在 + existingUser, err := s.userRepo.FindByUsername(username) + if err != nil { + return nil, "", err + } + if existingUser != nil { + return nil, "", errors.New("用户名已存在") + } + + // 检查邮箱是否已存在 + existingEmail, err := s.userRepo.FindByEmail(email) + if err != nil { + return nil, "", err + } + if existingEmail != nil { + return nil, "", errors.New("邮箱已被注册") + } + + // 加密密码 + hashedPassword, err := auth.HashPassword(password) + if err != nil { + return nil, "", errors.New("密码加密失败") + } + + // 确定头像URL + avatarURL := avatar + if avatarURL != "" { + if err := s.ValidateAvatarURL(avatarURL); err != nil { + return nil, "", err + } + } else { + avatarURL = s.getDefaultAvatar() + } + + // 创建用户 + user := &model.User{ + Username: username, + Password: hashedPassword, + Email: email, + Avatar: avatarURL, + Role: "user", + Status: 1, + Points: 0, + } + + if err := s.userRepo.Create(user); err != nil { + return nil, "", err + } + + // 生成JWT Token + token, err := s.jwtService.GenerateToken(user.ID, user.Username, user.Role) + if err != nil { + return nil, "", errors.New("生成Token失败") + } + + return user, token, nil +} + +func (s *userServiceImpl) Login(usernameOrEmail, password, ipAddress, userAgent string) (*model.User, string, error) { + ctx := context.Background() + + // 检查账号是否被锁定 + if s.redis != nil { + identifier := usernameOrEmail + ":" + ipAddress + locked, ttl, err := CheckLoginLocked(ctx, s.redis, identifier) + if err == nil && locked { + return nil, "", fmt.Errorf("登录尝试次数过多,请在 %d 分钟后重试", int(ttl.Minutes())+1) + } + } + + // 查找用户 + var user *model.User + var err error + + if strings.Contains(usernameOrEmail, "@") { + user, err = s.userRepo.FindByEmail(usernameOrEmail) + } else { + user, err = s.userRepo.FindByUsername(usernameOrEmail) + } + + if err != nil { + return nil, "", err + } + if user == nil { + s.recordLoginFailure(ctx, usernameOrEmail, ipAddress, userAgent, 0, "用户不存在") + return nil, "", errors.New("用户名/邮箱或密码错误") + } + + // 检查用户状态 + if user.Status != 1 { + s.recordLoginFailure(ctx, usernameOrEmail, ipAddress, userAgent, user.ID, "账号已被禁用") + return nil, "", errors.New("账号已被禁用") + } + + // 验证密码 + if !auth.CheckPassword(user.Password, password) { + s.recordLoginFailure(ctx, usernameOrEmail, ipAddress, userAgent, user.ID, "密码错误") + return nil, "", errors.New("用户名/邮箱或密码错误") + } + + // 登录成功,清除失败计数 + if s.redis != nil { + identifier := usernameOrEmail + ":" + ipAddress + _ = ClearLoginAttempts(ctx, s.redis, identifier) + } + + // 生成JWT Token + token, err := s.jwtService.GenerateToken(user.ID, user.Username, user.Role) + if err != nil { + return nil, "", errors.New("生成Token失败") + } + + // 更新最后登录时间 + now := time.Now() + user.LastLoginAt = &now + _ = s.userRepo.UpdateFields(user.ID, map[string]interface{}{ + "last_login_at": now, + }) + + // 记录成功登录日志 + s.logSuccessLogin(user.ID, ipAddress, userAgent) + + return user, token, nil +} + +func (s *userServiceImpl) GetByID(id int64) (*model.User, error) { + return s.userRepo.FindByID(id) +} + +func (s *userServiceImpl) GetByEmail(email string) (*model.User, error) { + return s.userRepo.FindByEmail(email) +} + +func (s *userServiceImpl) UpdateInfo(user *model.User) error { + return s.userRepo.Update(user) +} + +func (s *userServiceImpl) UpdateAvatar(userID int64, avatarURL string) error { + return s.userRepo.UpdateFields(userID, map[string]interface{}{ + "avatar": avatarURL, + }) +} + +func (s *userServiceImpl) ChangePassword(userID int64, oldPassword, newPassword string) error { + user, err := s.userRepo.FindByID(userID) + if err != nil || user == nil { + return errors.New("用户不存在") + } + + if !auth.CheckPassword(user.Password, oldPassword) { + return errors.New("原密码错误") + } + + hashedPassword, err := auth.HashPassword(newPassword) + if err != nil { + return errors.New("密码加密失败") + } + + return s.userRepo.UpdateFields(userID, map[string]interface{}{ + "password": hashedPassword, + }) +} + +func (s *userServiceImpl) ResetPassword(email, newPassword string) error { + user, err := s.userRepo.FindByEmail(email) + if err != nil || user == nil { + return errors.New("用户不存在") + } + + hashedPassword, err := auth.HashPassword(newPassword) + if err != nil { + return errors.New("密码加密失败") + } + + return s.userRepo.UpdateFields(user.ID, map[string]interface{}{ + "password": hashedPassword, + }) +} + +func (s *userServiceImpl) ChangeEmail(userID int64, newEmail string) error { + existingUser, err := s.userRepo.FindByEmail(newEmail) + if err != nil { + return err + } + if existingUser != nil && existingUser.ID != userID { + return errors.New("邮箱已被其他用户使用") + } + + return s.userRepo.UpdateFields(userID, map[string]interface{}{ + "email": newEmail, + }) +} + +func (s *userServiceImpl) ValidateAvatarURL(avatarURL string) error { + if avatarURL == "" { + return nil + } + + // 允许相对路径 + if strings.HasPrefix(avatarURL, "/") { + return nil + } + + // 解析URL + parsedURL, err := url.Parse(avatarURL) + if err != nil { + return errors.New("无效的URL格式") + } + + // 必须是HTTP或HTTPS协议 + if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" { + return errors.New("URL必须使用http或https协议") + } + + host := parsedURL.Hostname() + if host == "" { + return errors.New("URL缺少主机名") + } + + // 从配置获取允许的域名列表 + cfg, err := config.GetConfig() + if err != nil { + allowedDomains := []string{"localhost", "127.0.0.1"} + return s.checkDomainAllowed(host, allowedDomains) + } + + return s.checkDomainAllowed(host, cfg.Security.AllowedDomains) +} + +func (s *userServiceImpl) GetMaxProfilesPerUser() int { + config, err := s.configRepo.GetByKey("max_profiles_per_user") + if err != nil || config == nil { + return 5 + } + var value int + fmt.Sscanf(config.Value, "%d", &value) + if value <= 0 { + return 5 + } + return value +} + +func (s *userServiceImpl) GetMaxTexturesPerUser() int { + config, err := s.configRepo.GetByKey("max_textures_per_user") + if err != nil || config == nil { + return 50 + } + var value int + fmt.Sscanf(config.Value, "%d", &value) + if value <= 0 { + return 50 + } + return value +} + +// 私有辅助方法 + +func (s *userServiceImpl) getDefaultAvatar() string { + config, err := s.configRepo.GetByKey("default_avatar") + if err != nil || config == nil || config.Value == "" { + return "" + } + return config.Value +} + +func (s *userServiceImpl) checkDomainAllowed(host string, allowedDomains []string) error { + host = strings.ToLower(host) + + for _, allowed := range allowedDomains { + allowed = strings.ToLower(strings.TrimSpace(allowed)) + if allowed == "" { + continue + } + + if host == allowed { + return nil + } + + if strings.HasPrefix(allowed, "*.") { + suffix := allowed[1:] + if strings.HasSuffix(host, suffix) { + return nil + } + } + } + + return errors.New("URL域名不在允许的列表中") +} + +func (s *userServiceImpl) recordLoginFailure(ctx context.Context, usernameOrEmail, ipAddress, userAgent string, userID int64, reason string) { + if s.redis != nil { + identifier := usernameOrEmail + ":" + ipAddress + count, _ := RecordLoginFailure(ctx, s.redis, identifier) + if count >= MaxLoginAttempts { + s.logFailedLogin(userID, ipAddress, userAgent, reason+"-账号已锁定") + return + } + } + s.logFailedLogin(userID, ipAddress, userAgent, reason) +} + +func (s *userServiceImpl) logSuccessLogin(userID int64, ipAddress, userAgent string) { + log := &model.UserLoginLog{ + UserID: userID, + IPAddress: ipAddress, + UserAgent: userAgent, + LoginMethod: "PASSWORD", + IsSuccess: true, + } + _ = s.userRepo.CreateLoginLog(log) +} + +func (s *userServiceImpl) logFailedLogin(userID int64, ipAddress, userAgent, reason string) { + log := &model.UserLoginLog{ + UserID: userID, + IPAddress: ipAddress, + UserAgent: userAgent, + LoginMethod: "PASSWORD", + IsSuccess: false, + FailureReason: reason, + } + _ = s.userRepo.CreateLoginLog(log) +}