diff --git a/src/main/java/club/muimi/backend/controller/admin/AdminUserController.java b/src/main/java/club/muimi/backend/controller/admin/AdminUserController.java index cbd89b4..038b7fc 100644 --- a/src/main/java/club/muimi/backend/controller/admin/AdminUserController.java +++ b/src/main/java/club/muimi/backend/controller/admin/AdminUserController.java @@ -6,6 +6,8 @@ import club.muimi.backend.common.enums.UserStatus; import club.muimi.backend.dto.admin.UpdateUserRoleRequest; import club.muimi.backend.dto.admin.UpdateUserStatusRequest; +import club.muimi.backend.dto.admin.CreateUserRequest; +import club.muimi.backend.dto.admin.UpdateUserRequest; import club.muimi.backend.service.admin.AdminUserService; import club.muimi.backend.vo.admin.AdminUserDetailVo; import club.muimi.backend.vo.admin.AdminUserSummaryVo; @@ -49,6 +51,25 @@ public ApiResponse getUserDetail(@PathVariable Long userId) { return ApiResponse.success(adminUserService.getUserDetail(userId), "ok"); } + @PostMapping + public ApiResponse createUser(@Valid @RequestBody CreateUserRequest request) { + return ApiResponse.success(adminUserService.createUser(request), "用户创建成功"); + } + + @RequestMapping(value = "/{userId}", method = {RequestMethod.PUT, RequestMethod.PATCH}) + public ApiResponse updateUser( + @PathVariable Long userId, + @Valid @RequestBody UpdateUserRequest request + ) { + return ApiResponse.success(adminUserService.updateUser(userId, request), "用户信息更新成功"); + } + + @DeleteMapping("/{userId}") + public ApiResponse deleteUser(@PathVariable Long userId) { + adminUserService.deleteUser(userId); + return ApiResponse.success(null, "用户删除成功"); + } + @PatchMapping("/{userId}/status") public ApiResponse updateUserStatus( @PathVariable Long userId, diff --git a/src/main/java/club/muimi/backend/dto/admin/CreateUserRequest.java b/src/main/java/club/muimi/backend/dto/admin/CreateUserRequest.java new file mode 100644 index 0000000..8b2d014 --- /dev/null +++ b/src/main/java/club/muimi/backend/dto/admin/CreateUserRequest.java @@ -0,0 +1,29 @@ +package club.muimi.backend.dto.admin; + +import club.muimi.backend.common.enums.Role; +import club.muimi.backend.common.enums.UserStatus; +import jakarta.validation.constraints.*; + +public record CreateUserRequest( + @NotBlank(message = "用户名不能为空") + @Size(min = 3, max = 32, message = "用户名长度必须在 3 到 32 位之间") + @Pattern(regexp = "^[A-Za-z0-9_]+$", message = "用户名只能包含字母、数字和下划线") + String username, + @NotBlank(message = "邮箱不能为空") + @Email(message = "邮箱格式不正确") + String email, + @NotBlank(message = "密码不能为空") + String password, + @NotBlank(message = "确认密码不能为空") + String confirmPassword, + @NotNull(message = "用户角色不能为空") + Role role, + @NotNull(message = "用户状态不能为空") + UserStatus status, + Boolean emailVerified +) { + public CreateUserRequest(String username, String email, String password, + Role role, UserStatus status, Boolean emailVerified) { + this(username, email, password, password, role, status, emailVerified); + } +} diff --git a/src/main/java/club/muimi/backend/dto/admin/UpdateUserRequest.java b/src/main/java/club/muimi/backend/dto/admin/UpdateUserRequest.java new file mode 100644 index 0000000..c8f1cbf --- /dev/null +++ b/src/main/java/club/muimi/backend/dto/admin/UpdateUserRequest.java @@ -0,0 +1,28 @@ +package club.muimi.backend.dto.admin; + +import club.muimi.backend.common.enums.Role; +import club.muimi.backend.common.enums.UserStatus; +import jakarta.validation.constraints.*; + +public record UpdateUserRequest( + @NotBlank(message = "用户名不能为空") + @Size(min = 3, max = 32, message = "用户名长度必须在 3 到 32 位之间") + @Pattern(regexp = "^[A-Za-z0-9_]+$", message = "用户名只能包含字母、数字和下划线") + String username, + @NotBlank(message = "邮箱不能为空") + @Email(message = "邮箱格式不正确") + String email, + @Size(min = 8, message = "密码至少 8 位") + String password, + String confirmPassword, + @NotNull(message = "用户角色不能为空") + Role role, + @NotNull(message = "用户状态不能为空") + UserStatus status, + Boolean emailVerified +) { + public UpdateUserRequest(String username, String email, String password, + Role role, UserStatus status, Boolean emailVerified) { + this(username, email, password, password, role, status, emailVerified); + } +} diff --git a/src/main/java/club/muimi/backend/repository/UserRepository.java b/src/main/java/club/muimi/backend/repository/UserRepository.java index 8a98bdd..be6ccf6 100644 --- a/src/main/java/club/muimi/backend/repository/UserRepository.java +++ b/src/main/java/club/muimi/backend/repository/UserRepository.java @@ -21,6 +21,8 @@ public interface UserRepository extends JpaRepository { Optional findByEmail(String email); + Optional findByUsername(String username); + @Query(""" select u from User u diff --git a/src/main/java/club/muimi/backend/service/admin/AdminUserService.java b/src/main/java/club/muimi/backend/service/admin/AdminUserService.java index a0055e4..da930b8 100644 --- a/src/main/java/club/muimi/backend/service/admin/AdminUserService.java +++ b/src/main/java/club/muimi/backend/service/admin/AdminUserService.java @@ -7,6 +7,8 @@ import club.muimi.backend.common.enums.UserStatus; import club.muimi.backend.dto.admin.UpdateUserRoleRequest; import club.muimi.backend.dto.admin.UpdateUserStatusRequest; +import club.muimi.backend.dto.admin.CreateUserRequest; +import club.muimi.backend.dto.admin.UpdateUserRequest; import club.muimi.backend.entity.Application; import club.muimi.backend.entity.GroupMember; import club.muimi.backend.entity.RecruitmentGroup; @@ -29,8 +31,11 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Sort; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.security.crypto.password.PasswordEncoder; import java.util.*; import java.util.stream.Collectors; @@ -44,14 +49,17 @@ public class AdminUserService { private final RecruitmentGroupRepository recruitmentGroupRepository; private final CurrentUserService currentUserService; private final AuditLogService auditLogService; + private final PasswordEncoder passwordEncoder; + @Autowired public AdminUserService( UserRepository userRepository, ApplicationRepository applicationRepository, GroupMemberRepository groupMemberRepository, RecruitmentGroupRepository recruitmentGroupRepository, CurrentUserService currentUserService, - AuditLogService auditLogService + AuditLogService auditLogService, + PasswordEncoder passwordEncoder ) { this.userRepository = userRepository; this.applicationRepository = applicationRepository; @@ -59,6 +67,20 @@ public AdminUserService( this.recruitmentGroupRepository = recruitmentGroupRepository; this.currentUserService = currentUserService; this.auditLogService = auditLogService; + this.passwordEncoder = passwordEncoder; + } + + /** Constructor retained for existing unit tests that exercise legacy methods. */ + public AdminUserService( + UserRepository userRepository, + ApplicationRepository applicationRepository, + GroupMemberRepository groupMemberRepository, + RecruitmentGroupRepository recruitmentGroupRepository, + CurrentUserService currentUserService, + AuditLogService auditLogService + ) { + this(userRepository, applicationRepository, groupMemberRepository, recruitmentGroupRepository, + currentUserService, auditLogService, null); } @Transactional(readOnly = true) @@ -172,6 +194,129 @@ public AdminUserDetailVo updateUserRole(Long userId, UpdateUserRoleRequest reque return toDetailVo(user); } + @Transactional + public AdminUserDetailVo createUser(CreateUserRequest request) { + LoginUser currentUser = requireHighestAdmin(); + validatePasswordPair(request.password(), request.confirmPassword()); + validatePasswordStrength(request.password()); + ensureUsernameAndEmailAvailable(request.username(), request.email(), null); + + User user = User.builder() + .username(request.username().trim()) + .email(request.email().trim()) + .passwordHash(requirePasswordEncoder().encode(request.password())) + .emailVerified(Boolean.TRUE.equals(request.emailVerified())) + .role(request.role()) + .status(request.status()) + .tokenVersion(0L) + .build(); + try { + user = userRepository.save(user); + } catch (DataIntegrityViolationException exception) { + throw new ConflictException("用户名或邮箱已存在"); + } + auditLogService.record(AuditLogCommand.builder(AuditModule.AUTH, "ADMIN_CREATE_USER", + AuditSeverity.IMPORTANT, "管理员创建用户") + .actor(currentUser).target("USER", user.getId()) + .detail(Map.of("role", user.getRole(), "status", user.getStatus())).build()); + return toDetailVo(user); + } + + @Transactional + public AdminUserDetailVo updateUser(Long userId, UpdateUserRequest request) { + LoginUser currentUser = requireHighestAdmin(); + if (currentUser.getUserId().equals(userId) + && (request.role() != Role.ADMIN || request.status() != UserStatus.ACTIVE)) { + throw new ForbiddenException("管理员不能修改自己的角色或状态"); + } + User user = getUserOrThrow(userId); + ensureUsernameAndEmailAvailable(request.username(), request.email(), userId); + if (request.password() != null && !request.password().isBlank()) { + validatePasswordPair(request.password(), request.confirmPassword()); + validatePasswordStrength(request.password()); + user.setPasswordHash(requirePasswordEncoder().encode(request.password())); + } + user.setUsername(request.username().trim()); + user.setEmail(request.email().trim()); + user.setRole(request.role()); + user.setStatus(request.status()); + user.setEmailVerified(Boolean.TRUE.equals(request.emailVerified())); + user.setTokenVersion(user.getTokenVersion() + 1); + try { + userRepository.save(user); + } catch (DataIntegrityViolationException exception) { + throw new ConflictException("用户名或邮箱已存在"); + } + auditLogService.record(AuditLogCommand.builder(AuditModule.AUTH, "ADMIN_UPDATE_USER", + AuditSeverity.IMPORTANT, "管理员修改用户信息") + .actor(currentUser).target("USER", userId) + .detail(Map.of("role", user.getRole(), "status", user.getStatus())).build()); + return toDetailVo(user); + } + + @Transactional + public void deleteUser(Long userId) { + LoginUser currentUser = requireHighestAdmin(); + if (currentUser.getUserId().equals(userId)) { + throw new ForbiddenException("管理员不能删除自己的账号"); + } + User user = getUserOrThrow(userId); + try { + userRepository.delete(user); + userRepository.flush(); + } catch (DataIntegrityViolationException exception) { + throw new ConflictException("该用户仍有关联业务数据,无法删除"); + } + auditLogService.record(AuditLogCommand.builder(AuditModule.AUTH, "ADMIN_DELETE_USER", + AuditSeverity.MAJOR, "管理员删除用户") + .actor(currentUser).target("USER", userId) + .detail(Map.of("username", user.getUsername(), "email", user.getEmail())).build()); + } + + private LoginUser requireHighestAdmin() { + LoginUser currentUser = currentUserService.requireCurrentUser(); + if (currentUser.getRole() != Role.ADMIN) { + throw new ForbiddenException("仅最高管理员可以执行此操作"); + } + return currentUser; + } + + private PasswordEncoder requirePasswordEncoder() { + if (passwordEncoder == null) { + throw new IllegalStateException("PasswordEncoder 未配置"); + } + return passwordEncoder; + } + + private void ensureUsernameAndEmailAvailable(String username, String email, Long excludedUserId) { + userRepository.findByEmail(email.trim()).ifPresent(existing -> { + if (!Objects.equals(existing.getId(), excludedUserId)) { + throw new ConflictException("邮箱已被注册"); + } + }); + if (userRepository.existsByUsername(username.trim())) { + User existing = userRepository.findByUsername(username.trim()).orElse(null); + if (existing == null || !Objects.equals(existing.getId(), excludedUserId)) { + throw new ConflictException("用户名已存在"); + } + } + } + + private void validatePasswordPair(String password, String confirmPassword) { + if (password == null || !password.equals(confirmPassword)) { + throw new ValidationException("两次输入的密码不一致"); + } + } + + private void validatePasswordStrength(String password) { + boolean valid = password != null && password.length() >= 8 + && password.chars().anyMatch(Character::isLetter) + && password.chars().anyMatch(Character::isDigit); + if (!valid) { + throw new ValidationException("密码至少 8 位,且必须同时包含字母和数字"); + } + } + private AdminUserDetailVo toDetailVo(User user) { List groupMembers = groupMemberRepository.findAllByUserId(user.getId()); Set groupIds = groupMembers.stream() diff --git a/src/test/java/club/muimi/backend/service/admin/AdminUserServiceTest.java b/src/test/java/club/muimi/backend/service/admin/AdminUserServiceTest.java index 89e0657..b2105b8 100644 --- a/src/test/java/club/muimi/backend/service/admin/AdminUserServiceTest.java +++ b/src/test/java/club/muimi/backend/service/admin/AdminUserServiceTest.java @@ -4,6 +4,8 @@ import club.muimi.backend.common.enums.UserStatus; import club.muimi.backend.dto.admin.UpdateUserRoleRequest; import club.muimi.backend.dto.admin.UpdateUserStatusRequest; +import club.muimi.backend.dto.admin.CreateUserRequest; +import club.muimi.backend.dto.admin.UpdateUserRequest; import club.muimi.backend.entity.RecruitmentGroup; import club.muimi.backend.entity.User; import club.muimi.backend.exception.ConflictException; @@ -30,6 +32,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.Mockito.when; +import static org.mockito.Mockito.verify; @ExtendWith(MockitoExtension.class) class AdminUserServiceTest { @@ -46,6 +49,8 @@ class AdminUserServiceTest { private CurrentUserService currentUserService; @Mock private AuditLogService auditLogService; + @Mock + private org.springframework.security.crypto.password.PasswordEncoder passwordEncoder; private AdminUserService adminUserService; @@ -57,7 +62,8 @@ void setUp() { groupMemberRepository, recruitmentGroupRepository, currentUserService, - auditLogService + auditLogService, + passwordEncoder ); } @@ -234,4 +240,57 @@ void getUserDetailShouldTolerateLeaderOwningMultipleGroups() { .extracting(club.muimi.backend.vo.auth.GroupSimpleVo::id) .containsExactly(20L, 10L); } + + @Test + void createUserShouldRequireHighestAdminAndHashPassword() { + LoginUser admin = new LoginUser(1L, "admin", "admin@example.com", "hashed", Role.ADMIN, UserStatus.ACTIVE, 0L, "jti-admin"); + when(currentUserService.requireCurrentUser()).thenReturn(admin); + when(userRepository.existsByUsername("new_user")).thenReturn(false); + when(userRepository.findByEmail("new_user@example.com")).thenReturn(Optional.empty()); + when(passwordEncoder.encode("NewPass123")).thenReturn("encoded"); + User created = User.builder().id(8L).username("new_user").email("new_user@example.com") + .passwordHash("encoded").role(Role.LEADER).status(UserStatus.ACTIVE).emailVerified(true).tokenVersion(0L).build(); + when(userRepository.save(org.mockito.ArgumentMatchers.any(User.class))).thenReturn(created); + when(groupMemberRepository.findAllByUserId(8L)).thenReturn(List.of()); + when(recruitmentGroupRepository.findAllByLeaderUserIdOrderByCreatedAtDesc(8L)).thenReturn(List.of()); + when(applicationRepository.countByUserId(8L)).thenReturn(0L); + + var result = adminUserService.createUser(new CreateUserRequest("new_user", "new_user@example.com", "NewPass123", "NewPass123", Role.LEADER, UserStatus.ACTIVE, true)); + + assertThat(result.id()).isEqualTo(8L); + verify(passwordEncoder).encode("NewPass123"); + } + + @Test + void createUserShouldRejectNonAdmin() { + when(currentUserService.requireCurrentUser()).thenReturn(new LoginUser(2L, "leader", "leader@example.com", "hashed", Role.LEADER, UserStatus.ACTIVE, 0L, "jti")); + assertThatThrownBy(() -> adminUserService.createUser(new CreateUserRequest("new_user", "new@example.com", "NewPass123", "NewPass123", Role.FRESHMAN, UserStatus.ACTIVE, true))) + .isInstanceOf(ForbiddenException.class) + .hasMessage("仅最高管理员可以执行此操作"); + } + + @Test + void deleteUserShouldRejectReferencedUser() { + LoginUser admin = new LoginUser(1L, "admin", "admin@example.com", "hashed", Role.ADMIN, UserStatus.ACTIVE, 0L, "jti-admin"); + User target = User.builder().id(2L).username("target").email("target@example.com").passwordHash("hash").role(Role.FRESHMAN).status(UserStatus.ACTIVE).build(); + when(currentUserService.requireCurrentUser()).thenReturn(admin); + when(userRepository.findById(2L)).thenReturn(Optional.of(target)); + org.mockito.Mockito.doThrow(new org.springframework.dao.DataIntegrityViolationException("fk")) + .when(userRepository).flush(); + + assertThatThrownBy(() -> adminUserService.deleteUser(2L)) + .isInstanceOf(ConflictException.class) + .hasMessage("该用户仍有关联业务数据,无法删除"); + } + + @Test + void updateUserShouldNotAllowAdminToChangeOwnRoleOrStatus() { + LoginUser admin = new LoginUser(1L, "admin", "admin@example.com", "hashed", Role.ADMIN, UserStatus.ACTIVE, 0L, "jti-admin"); + when(currentUserService.requireCurrentUser()).thenReturn(admin); + + assertThatThrownBy(() -> adminUserService.updateUser(1L, + new UpdateUserRequest("admin", "admin@example.com", null, null, Role.FRESHMAN, UserStatus.ACTIVE, true))) + .isInstanceOf(ForbiddenException.class) + .hasMessage("管理员不能修改自己的角色或状态"); + } }