Files
backend/internal/service/user_service.go
Wu XiangYu 44f007936e Merge remote-tracking branch 'origin/feature/redis-auth-integration' into dev
# Conflicts:
#	go.mod
#	go.sum
#	internal/container/container.go
#	internal/repository/interfaces.go
#	internal/service/mocks_test.go
#	internal/service/texture_service_test.go
#	internal/service/token_service_test.go
#	pkg/redis/manager.go
2025-12-25 22:45:58 +08:00

500 lines
13 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package service
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"net/url"
"path/filepath"
"strings"
"time"
apperrors "carrotskin/internal/errors"
"carrotskin/internal/model"
"carrotskin/internal/repository"
"carrotskin/pkg/auth"
"carrotskin/pkg/config"
"carrotskin/pkg/database"
"carrotskin/pkg/redis"
"carrotskin/pkg/storage"
"go.uber.org/zap"
)
// userService UserService的实现
type userService struct {
userRepo repository.UserRepository
jwtService *auth.JWTService
redis *redis.Client
cache *database.CacheManager
cacheKeys *database.CacheKeyBuilder
cacheInv *database.CacheInvalidator
storage *storage.StorageClient
logger *zap.Logger
}
// NewUserService 创建UserService实例
func NewUserService(
userRepo repository.UserRepository,
jwtService *auth.JWTService,
redisClient *redis.Client,
cacheManager *database.CacheManager,
storageClient *storage.StorageClient,
logger *zap.Logger,
) UserService {
// CacheKeyBuilder 使用空前缀,因为 CacheManager 已经处理了前缀
// 这样缓存键的格式为: CacheManager前缀 + CacheKeyBuilder生成的键
return &userService{
userRepo: userRepo,
jwtService: jwtService,
redis: redisClient,
cache: cacheManager,
cacheKeys: database.NewCacheKeyBuilder(""),
cacheInv: database.NewCacheInvalidator(cacheManager),
storage: storageClient,
logger: logger,
}
}
func (s *userService) Register(ctx context.Context, username, password, email, avatar string) (*model.User, string, error) {
// 检查用户名是否已存在
existingUser, err := s.userRepo.FindByUsername(ctx, username)
if err != nil {
return nil, "", err
}
if existingUser != nil {
return nil, "", apperrors.ErrUserAlreadyExists
}
// 检查邮箱是否已存在
existingEmail, err := s.userRepo.FindByEmail(ctx, email)
if err != nil {
return nil, "", err
}
if existingEmail != nil {
return nil, "", apperrors.ErrEmailAlreadyExists
}
// 加密密码
hashedPassword, err := auth.HashPassword(password)
if err != nil {
return nil, "", errors.New("密码加密失败")
}
// 确定头像URL
avatarURL := avatar
if avatarURL != "" {
if err := s.ValidateAvatarURL(ctx, 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(ctx, 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 *userService) Login(ctx context.Context, usernameOrEmail, password, ipAddress, userAgent string) (*model.User, string, error) {
// 检查账号是否被锁定
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(ctx, usernameOrEmail)
} else {
user, err = s.userRepo.FindByUsername(ctx, 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(ctx, user.ID, map[string]interface{}{
"last_login_at": now,
})
// 记录成功登录日志
s.logSuccessLogin(ctx, user.ID, ipAddress, userAgent)
return user, token, nil
}
func (s *userService) GetByID(ctx context.Context, id int64) (*model.User, error) {
// 使用 Cached 装饰器自动处理缓存
cacheKey := s.cacheKeys.User(id)
return database.Cached(ctx, s.cache, cacheKey, func() (*model.User, error) {
return s.userRepo.FindByID(ctx, id)
}, s.cache.Policy.UserTTL)
}
func (s *userService) GetByEmail(ctx context.Context, email string) (*model.User, error) {
// 使用 Cached 装饰器自动处理缓存
cacheKey := s.cacheKeys.UserByEmail(email)
return database.Cached(ctx, s.cache, cacheKey, func() (*model.User, error) {
return s.userRepo.FindByEmail(ctx, email)
}, s.cache.Policy.UserEmailTTL)
}
func (s *userService) UpdateInfo(ctx context.Context, user *model.User) error {
err := s.userRepo.Update(ctx, user)
if err != nil {
return err
}
// 清除缓存
s.cacheInv.OnUpdate(ctx,
s.cacheKeys.User(user.ID),
s.cacheKeys.UserByEmail(user.Email),
s.cacheKeys.UserByUsername(user.Username),
)
return nil
}
func (s *userService) UpdateAvatar(ctx context.Context, userID int64, avatarURL string) error {
err := s.userRepo.UpdateFields(ctx, userID, map[string]interface{}{
"avatar": avatarURL,
})
if err != nil {
return err
}
// 清除用户缓存
s.cacheInv.OnUpdate(ctx, s.cacheKeys.User(userID))
return nil
}
func (s *userService) ChangePassword(ctx context.Context, userID int64, oldPassword, newPassword string) error {
user, err := s.userRepo.FindByID(ctx, 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("密码加密失败")
}
err = s.userRepo.UpdateFields(ctx, userID, map[string]interface{}{
"password": hashedPassword,
})
if err != nil {
return err
}
// 清除用户缓存
s.cacheInv.OnUpdate(ctx, s.cacheKeys.User(userID))
return nil
}
func (s *userService) ResetPassword(ctx context.Context, email, newPassword string) error {
user, err := s.userRepo.FindByEmail(ctx, email)
if err != nil || user == nil {
return errors.New("用户不存在")
}
hashedPassword, err := auth.HashPassword(newPassword)
if err != nil {
return errors.New("密码加密失败")
}
err = s.userRepo.UpdateFields(ctx, user.ID, map[string]interface{}{
"password": hashedPassword,
})
if err != nil {
return err
}
// 清除用户缓存
s.cacheInv.OnUpdate(ctx,
s.cacheKeys.User(user.ID),
s.cacheKeys.UserByEmail(email),
)
return nil
}
func (s *userService) ChangeEmail(ctx context.Context, userID int64, newEmail string) error {
// 获取旧邮箱
oldUser, _ := s.userRepo.FindByID(ctx, userID)
existingUser, err := s.userRepo.FindByEmail(ctx, newEmail)
if err != nil {
return err
}
if existingUser != nil && existingUser.ID != userID {
return apperrors.ErrEmailAlreadyExists
}
err = s.userRepo.UpdateFields(ctx, userID, map[string]interface{}{
"email": newEmail,
})
if err != nil {
return err
}
// 清除旧邮箱和用户ID的缓存
keysToInvalidate := []string{
s.cacheKeys.User(userID),
s.cacheKeys.UserByEmail(newEmail),
}
if oldUser != nil {
keysToInvalidate = append(keysToInvalidate, s.cacheKeys.UserByEmail(oldUser.Email))
}
s.cacheInv.OnUpdate(ctx, keysToInvalidate...)
return nil
}
func (s *userService) ValidateAvatarURL(ctx context.Context, 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 *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 {
cfg, err := config.GetConfig()
if err != nil || cfg.Site.MaxProfilesPerUser <= 0 {
return 5
}
return cfg.Site.MaxProfilesPerUser
}
func (s *userService) GetMaxTexturesPerUser() int {
cfg, err := config.GetConfig()
if err != nil || cfg.Site.MaxTexturesPerUser <= 0 {
return 50
}
return cfg.Site.MaxTexturesPerUser
}
// 私有辅助方法
func (s *userService) getDefaultAvatar() string {
cfg, err := config.GetConfig()
if err != nil {
return ""
}
return cfg.Site.DefaultAvatar
}
func (s *userService) 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 *userService) 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(ctx, userID, ipAddress, userAgent, reason+"-账号已锁定")
return
}
}
s.logFailedLogin(ctx, userID, ipAddress, userAgent, reason)
}
func (s *userService) logSuccessLogin(ctx context.Context, userID int64, ipAddress, userAgent string) {
log := &model.UserLoginLog{
UserID: userID,
IPAddress: ipAddress,
UserAgent: userAgent,
LoginMethod: "PASSWORD",
IsSuccess: true,
}
_ = s.userRepo.CreateLoginLog(ctx, log)
}
func (s *userService) logFailedLogin(ctx context.Context, userID int64, ipAddress, userAgent, reason string) {
log := &model.UserLoginLog{
UserID: userID,
IPAddress: ipAddress,
UserAgent: userAgent,
LoginMethod: "PASSWORD",
IsSuccess: false,
FailureReason: reason,
}
_ = s.userRepo.CreateLoginLog(ctx, log)
}