Files
user-system/internal/service/user_service.go
long-agent 2ecd1fef1e refactor: 提取 service 层 best-effort 超时常量
- 新增 defaultBETimeout = 5 * time.Second
- 替换 auth/auth_runtime/password_reset/user_service/webhook 中 6 处硬编码 5*time.Second
2026-05-08 12:44:05 +08:00

578 lines
17 KiB
Go
Raw 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 (
"context"
"errors"
"fmt"
"log"
"strings"
"time"
"unicode/utf8"
"github.com/user-management-system/internal/auth"
"github.com/user-management-system/internal/domain"
"github.com/user-management-system/internal/pagination"
"github.com/user-management-system/internal/repository"
"gorm.io/gorm"
)
// Repository interfaces for dependency inversion (DIP) — service layer depends on these abstractions, not concrete types.
type userRepository interface {
GetByID(ctx context.Context, id int64) (*domain.User, error)
GetByUsername(ctx context.Context, username string) (*domain.User, error)
GetByEmail(ctx context.Context, email string) (*domain.User, error)
Create(ctx context.Context, user *domain.User) error
Update(ctx context.Context, user *domain.User) error
Delete(ctx context.Context, id int64) error
List(ctx context.Context, offset, limit int) ([]*domain.User, int64, error)
ListCursor(ctx context.Context, filter *repository.AdvancedFilter, limit int, cursor *pagination.Cursor) ([]*domain.User, bool, error)
GetByIDs(ctx context.Context, ids []int64) ([]*domain.User, error)
UpdateStatus(ctx context.Context, id int64, status domain.UserStatus) error
BatchUpdateStatus(ctx context.Context, ids []int64, status domain.UserStatus) error
BatchDelete(ctx context.Context, ids []int64) error
DB() *gorm.DB
}
type userRoleRepository interface {
GetByUserID(ctx context.Context, userID int64) ([]*domain.UserRole, error)
DeleteByUserID(ctx context.Context, userID int64) error
DeleteByUserAndRole(ctx context.Context, userID, roleID int64) error
GetByRoleID(ctx context.Context, roleID int64) ([]*domain.UserRole, error)
GetUserIDByRoleID(ctx context.Context, roleID int64) ([]int64, error)
BatchCreate(ctx context.Context, userRoles []*domain.UserRole) error
ReplaceUserRoles(ctx context.Context, userID int64, roleIDs []int64) error
DB() *gorm.DB
}
type roleRepository interface {
GetByCode(ctx context.Context, code string) (*domain.Role, error)
GetByID(ctx context.Context, id int64) (*domain.Role, error)
GetByIDs(ctx context.Context, ids []int64) ([]*domain.Role, error)
}
type passwordHistoryRepository interface {
GetByUserID(ctx context.Context, userID int64, limit int) ([]*domain.PasswordHistory, error)
Create(ctx context.Context, history *domain.PasswordHistory) error
DeleteOldRecords(ctx context.Context, userID int64, keep int) error
}
// UserService 用户服务
type UserService struct {
userRepo userRepository
userRoleRepo userRoleRepository
roleRepo roleRepository
passwordHistoryRepo passwordHistoryRepository
}
const passwordHistoryLimit = 5 // 保留最近5条密码历史
// NewUserService 创建用户服务实例
func NewUserService(
userRepo userRepository,
userRoleRepo userRoleRepository,
roleRepo roleRepository,
passwordHistoryRepo passwordHistoryRepository,
) *UserService {
return &UserService{
userRepo: userRepo,
userRoleRepo: userRoleRepo,
roleRepo: roleRepo,
passwordHistoryRepo: passwordHistoryRepo,
}
}
// ChangePassword 修改用户密码(含历史记录检查)
func (s *UserService) ChangePassword(ctx context.Context, userID int64, oldPassword, newPassword string) error {
if s.userRepo == nil {
return errors.New("user repository is not configured")
}
user, err := s.userRepo.GetByID(ctx, userID)
if err != nil {
return errors.New("用户不存在")
}
// 验证旧密码
if strings.TrimSpace(oldPassword) == "" {
return errors.New("请输入当前密码")
}
if !auth.VerifyPassword(user.Password, oldPassword) {
return errors.New("当前密码不正确")
}
// 检查新密码强度
if strings.TrimSpace(newPassword) == "" {
return errors.New("新密码不能为空")
}
return s.applyNewPassword(ctx, user, newPassword)
/*
if err := validatePasswordStrength(newPassword, 8, false); err != nil {
return err
}
// 检查密码历史(需要明文密码比对,必须在哈希之前)
if s.passwordHistoryRepo != nil {
histories, err := s.passwordHistoryRepo.GetByUserID(ctx, userID, passwordHistoryLimit)
if err == nil && len(histories) > 0 {
for _, h := range histories {
if auth.VerifyPassword(h.PasswordHash, newPassword) {
return errors.New("新密码不能与最近5次密码相同")
}
}
}
}
// 计算一次哈希,用于更新密码和保存历史(避免 Argon2id 重复计算的高成本)
newHashedPassword, hashErr := auth.HashPassword(newPassword)
if hashErr != nil {
return errors.New("密码哈希失败")
}
// 保存新密码到历史记录(异步,不阻塞密码更新)
if s.passwordHistoryRepo != nil {
// #nosec G118 - 使用带超时的独立 context不能使用请求 ctx该 goroutine 在请求完成后仍可能运行)
go func(hashedPw string) { // #nosec G118
bgCtx, cancel := context.WithTimeout(context.Background(), defaultBETimeout)
defer cancel()
_ = s.passwordHistoryRepo.Create(bgCtx, &domain.PasswordHistory{
UserID: userID,
PasswordHash: hashedPw,
})
_ = s.passwordHistoryRepo.DeleteOldRecords(bgCtx, userID, passwordHistoryLimit)
}(newHashedPassword)
}
// 更新密码(使用同一哈希值)
user.Password = newHashedPassword
user.PasswordChangedAt = time.Now()
return s.userRepo.Update(ctx, user)
*/
}
// GetByID 根据ID获取用户
// AdminResetPassword resets a user's password without requiring the old password.
func (s *UserService) AdminResetPassword(ctx context.Context, userID int64, newPassword string) error {
if s.userRepo == nil {
return errors.New("user repository is not configured")
}
user, err := s.userRepo.GetByID(ctx, userID)
if err != nil {
return errors.New("user not found")
}
return s.applyNewPassword(ctx, user, newPassword)
}
func (s *UserService) applyNewPassword(ctx context.Context, user *domain.User, newPassword string) error {
if user == nil {
return errors.New("user not found")
}
if strings.TrimSpace(newPassword) == "" {
return errors.New("new password is required")
}
if err := validatePasswordStrength(newPassword, 8, false); err != nil {
return err
}
if s.passwordHistoryRepo != nil {
histories, err := s.passwordHistoryRepo.GetByUserID(ctx, user.ID, passwordHistoryLimit)
if err == nil && len(histories) > 0 {
for _, h := range histories {
if auth.VerifyPassword(h.PasswordHash, newPassword) {
return errors.New("new password cannot reuse recent password history")
}
}
}
}
newHashedPassword, hashErr := auth.HashPassword(newPassword)
if hashErr != nil {
return errors.New("password hashing failed")
}
if s.passwordHistoryRepo != nil {
go func(userID int64, hashedPw string) { // #nosec G118
defer func() {
if r := recover(); r != nil {
log.Printf("user_service: password history save panic recovered, user_id=%d err=%v", userID, r)
}
}()
bgCtx, cancel := context.WithTimeout(context.Background(), defaultBETimeout)
defer cancel()
_ = s.passwordHistoryRepo.Create(bgCtx, &domain.PasswordHistory{
UserID: userID,
PasswordHash: hashedPw,
})
_ = s.passwordHistoryRepo.DeleteOldRecords(bgCtx, userID, passwordHistoryLimit)
}(user.ID, newHashedPassword)
}
user.Password = newHashedPassword
user.PasswordChangedAt = time.Now()
return s.userRepo.Update(ctx, user)
}
func (s *UserService) GetByID(ctx context.Context, id int64) (*domain.User, error) {
return s.userRepo.GetByID(ctx, id)
}
// GetByEmail 根据邮箱获取用户
func (s *UserService) GetByEmail(ctx context.Context, email string) (*domain.User, error) {
return s.userRepo.GetByEmail(ctx, email)
}
// Create 创建用户
func (s *UserService) Create(ctx context.Context, user *domain.User) error {
// 验证用户名
if strings.TrimSpace(user.Username) == "" {
return errors.New("用户名不能为空")
}
if len(user.Username) > 50 {
return errors.New("用户名长度超过限制")
}
// 验证邮箱格式
if user.Email != nil && *user.Email != "" {
if !isValidEmail(*user.Email) {
return errors.New("邮箱格式不正确")
}
if len(*user.Email) > 100 {
return errors.New("邮箱长度超过限制")
}
}
// 验证昵称长度(按字符数计算)
if utf8.RuneCountInString(user.Nickname) > 50 {
return errors.New("昵称长度超过限制")
}
// 验证简介长度(按字符数计算)
if utf8.RuneCountInString(user.Bio) > 500 {
return errors.New("简介长度超过限制")
}
return s.userRepo.Create(ctx, user)
}
// isValidEmail 验证邮箱格式
func isValidEmail(email string) bool {
if email == "" {
return true
}
// 基本格式验证:必须包含@且@前后都有内容
atIndex := strings.Index(email, "@")
if atIndex <= 0 || atIndex >= len(email)-1 {
return false
}
// 检查是否包含空格
if strings.Contains(email, " ") {
return false
}
// 检查是否只有一个@
if strings.Count(email, "@") != 1 {
return false
}
return true
}
// Update 更新用户
func (s *UserService) Update(ctx context.Context, user *domain.User) error {
return s.userRepo.Update(ctx, user)
}
// Delete 删除用户
func (s *UserService) Delete(ctx context.Context, id int64) error {
return s.userRepo.Delete(ctx, id)
}
// List 获取用户列表
func (s *UserService) List(ctx context.Context, offset, limit int) ([]*domain.User, int64, error) {
// 处理无效的分页参数
if limit <= 0 {
limit = 10 // 默认页面大小
}
if offset < 0 {
offset = 0
}
return s.userRepo.List(ctx, offset, limit)
}
// ListCursorRequest 用户游标分页请求
type ListCursorRequest struct {
Keyword string `form:"keyword"`
Status int `form:"status"` // -1=全部
RoleIDs []int64
CreatedFrom *time.Time
CreatedTo *time.Time
SortBy string // created_at, last_login_time, username
SortOrder string // asc, desc
Cursor string `form:"cursor"`
Size int `form:"size"`
}
// UserCursorResult wraps cursor-based pagination response for users
type UserCursorResult struct {
Items []*domain.User `json:"items"`
NextCursor string `json:"next_cursor"`
HasMore bool `json:"has_more"`
PageSize int `json:"page_size"`
}
// ListCursor 游标分页获取用户列表(推荐使用)
func (s *UserService) ListCursor(ctx context.Context, req *ListCursorRequest) (*UserCursorResult, error) {
size := pagination.ClampPageSize(req.Size)
cursor, err := pagination.Decode(req.Cursor)
if err != nil {
return nil, fmt.Errorf("invalid cursor: %w", err)
}
filter := &repository.AdvancedFilter{
Keyword: req.Keyword,
Status: req.Status,
RoleIDs: req.RoleIDs,
CreatedFrom: req.CreatedFrom,
CreatedTo: req.CreatedTo,
SortBy: req.SortBy,
SortOrder: req.SortOrder,
}
users, hasMore, err := s.userRepo.ListCursor(ctx, filter, size, cursor)
if err != nil {
return nil, err
}
nextCursor := ""
if len(users) > 0 {
last := users[len(users)-1]
nextCursor = pagination.BuildNextCursor(last.ID, last.CreatedAt)
}
return &UserCursorResult{
Items: users,
NextCursor: nextCursor,
HasMore: hasMore,
PageSize: size,
}, nil
}
// UpdateStatus 更新用户状态
func (s *UserService) UpdateStatus(ctx context.Context, id int64, status domain.UserStatus) error {
return s.userRepo.UpdateStatus(ctx, id, status)
}
// BatchUpdateStatusRequest 批量更新状态请求
type BatchUpdateStatusRequest struct {
IDs []int64 `json:"ids" binding:"required,min=1"`
Status domain.UserStatus `json:"status" binding:"required"`
}
// BatchDeleteRequest 批量删除请求
type BatchDeleteRequest struct {
IDs []int64 `json:"ids" binding:"required,min=1"`
}
// BatchUpdateStatus 批量更新用户状态
func (s *UserService) BatchUpdateStatus(ctx context.Context, req *BatchUpdateStatusRequest) (int64, error) {
err := s.userRepo.BatchUpdateStatus(ctx, req.IDs, req.Status)
return int64(len(req.IDs)), err
}
// BatchDelete 批量删除用户
func (s *UserService) BatchDelete(ctx context.Context, req *BatchDeleteRequest) (int64, error) {
err := s.userRepo.BatchDelete(ctx, req.IDs)
return int64(len(req.IDs)), err
}
// GetUserRoles 获取用户的所有角色
func (s *UserService) GetUserRoles(ctx context.Context, userID int64) ([]*domain.Role, error) {
// 检查用户是否存在
if _, err := s.userRepo.GetByID(ctx, userID); err != nil {
return nil, err
}
// 获取用户角色关联
userRoles, err := s.userRoleRepo.GetByUserID(ctx, userID)
if err != nil {
return nil, err
}
if len(userRoles) == 0 {
return []*domain.Role{}, nil
}
// 获取角色ID列表
roleIDs := make([]int64, len(userRoles))
for i, ur := range userRoles {
roleIDs[i] = ur.RoleID
}
// 批量获取角色详情(消除 N+1 查询)
roles, err := s.roleRepo.GetByIDs(ctx, roleIDs)
if err != nil {
return nil, fmt.Errorf("failed to fetch roles: %w", err)
}
return roles, nil
}
// AssignRoles 分配用户角色
func (s *UserService) AssignRoles(ctx context.Context, userID int64, roleIDs []int64) error {
// 检查用户是否存在
if _, err := s.userRepo.GetByID(ctx, userID); err != nil {
return err
}
// 验证所有角色存在(批量查询消除 N+1
if len(roleIDs) > 0 {
foundRoles, err := s.roleRepo.GetByIDs(ctx, roleIDs)
if err != nil {
return fmt.Errorf("验证角色失败: %w", err)
}
if len(foundRoles) != len(roleIDs) {
// 找出缺失的角色ID
foundMap := make(map[int64]bool, len(foundRoles))
for _, r := range foundRoles {
foundMap[r.ID] = true
}
for _, id := range roleIDs {
if !foundMap[id] {
return fmt.Errorf("角色 %d 不存在", id)
}
}
}
}
// 使用 Repository 层的事务方法替换用户角色(原子操作)
return s.userRoleRepo.ReplaceUserRoles(ctx, userID, roleIDs)
}
// getAdminRoleID looks up the admin role ID by code to avoid hardcoded magic numbers.
func (s *UserService) getAdminRoleID(ctx context.Context) (int64, error) {
adminRole, err := s.roleRepo.GetByCode(ctx, "admin")
if err != nil {
return 0, fmt.Errorf("failed to find admin role: %w", err)
}
return adminRole.ID, nil
}
// ListAdmins 获取所有管理员
func (s *UserService) ListAdmins(ctx context.Context) ([]*domain.User, error) {
// 获取管理员角色ID列表
adminRoleID, err := s.getAdminRoleID(ctx)
if err != nil {
return nil, err
}
adminUserIDs, err := s.userRoleRepo.GetUserIDByRoleID(ctx, adminRoleID)
if err != nil {
return nil, err
}
if len(adminUserIDs) == 0 {
return []*domain.User{}, nil
}
// 批量获取所有管理员用户(消除 N+1 查询)
admins, err := s.userRepo.GetByIDs(ctx, adminUserIDs)
if err != nil {
return nil, fmt.Errorf("failed to fetch admin users: %w", err)
}
return admins, nil
}
// CreateAdmin 创建管理员(事务性)
func (s *UserService) CreateAdmin(ctx context.Context, req *CreateAdminRequest) (*domain.User, error) {
// 检查用户名是否已存在
existingUser, err := s.userRepo.GetByUsername(ctx, req.Username)
if err == nil && existingUser != nil {
return nil, errors.New("用户名已存在")
}
// 预先查询管理员角色 ID避免在事务中使用 roleRepo
adminRoleID, err := s.getAdminRoleID(ctx)
if err != nil {
return nil, err
}
// 创建用户
hashedPassword, err := auth.HashPassword(req.Password)
if err != nil {
return nil, errors.New("密码哈希失败")
}
user := &domain.User{
Username: req.Username,
Password: hashedPassword,
Status: domain.UserStatusActive,
}
if req.Email != "" {
user.Email = &req.Email
}
if req.Nickname != "" {
user.Nickname = req.Nickname
}
// 使用事务创建用户和分配角色
err = s.userRepo.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Create(user).Error; err != nil {
return err
}
// 分配管理员角色
userRole := &domain.UserRole{
UserID: user.ID,
RoleID: adminRoleID,
}
if err := tx.Create(userRole).Error; err != nil {
return err
}
return nil
})
if err != nil {
return nil, err
}
return user, nil
}
// DeleteAdmin 删除管理员(移除管理员角色)
func (s *UserService) DeleteAdmin(ctx context.Context, userID int64, currentUserID int64) error {
// 检查用户是否存在
if _, err := s.userRepo.GetByID(ctx, userID); err != nil {
return err
}
// 不能删除自己
if currentUserID == userID {
return errors.New("不能删除自己")
}
// 检查是否是最后一个管理员(保护)
adminRoleID, err := s.getAdminRoleID(ctx)
if err != nil {
return err
}
adminUserRoles, err := s.userRoleRepo.GetByRoleID(ctx, adminRoleID)
if err != nil {
return err
}
if len(adminUserRoles) <= 1 {
return errors.New("不能删除最后一个管理员")
}
// 删除用户的管理员角色
return s.userRoleRepo.DeleteByUserAndRole(ctx, userID, adminRoleID)
}
// CreateAdminRequest 创建管理员请求
type CreateAdminRequest struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
Email string `json:"email"`
Nickname string `json:"nickname"`
}