Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -49,6 +51,25 @@ public ApiResponse<AdminUserDetailVo> getUserDetail(@PathVariable Long userId) {
return ApiResponse.success(adminUserService.getUserDetail(userId), "ok");
}

@PostMapping
public ApiResponse<AdminUserDetailVo> createUser(@Valid @RequestBody CreateUserRequest request) {
return ApiResponse.success(adminUserService.createUser(request), "用户创建成功");
}

@RequestMapping(value = "/{userId}", method = {RequestMethod.PUT, RequestMethod.PATCH})
public ApiResponse<AdminUserDetailVo> updateUser(
@PathVariable Long userId,
@Valid @RequestBody UpdateUserRequest request
) {
return ApiResponse.success(adminUserService.updateUser(userId, request), "用户信息更新成功");
}
Comment on lines +59 to +65

@DeleteMapping("/{userId}")
public ApiResponse<Void> deleteUser(@PathVariable Long userId) {
adminUserService.deleteUser(userId);
return ApiResponse.success(null, "用户删除成功");
}

@PatchMapping("/{userId}/status")
public ApiResponse<AdminUserDetailVo> updateUserStatus(
@PathVariable Long userId,
Expand Down
29 changes: 29 additions & 0 deletions src/main/java/club/muimi/backend/dto/admin/CreateUserRequest.java
Original file line number Diff line number Diff line change
@@ -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);
}
}
28 changes: 28 additions & 0 deletions src/main/java/club/muimi/backend/dto/admin/UpdateUserRequest.java
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ public interface UserRepository extends JpaRepository<User, Long> {

Optional<User> findByEmail(String email);

Optional<User> findByUsername(String username);

@Query("""
select u
from User u
Expand Down
147 changes: 146 additions & 1 deletion src/main/java/club/muimi/backend/service/admin/AdminUserService.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -44,21 +49,38 @@ 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;
this.groupMemberRepository = groupMemberRepository;
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)
Expand Down Expand Up @@ -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 {
Comment on lines +232 to +245
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("用户名已存在");
}
}
}
Comment on lines +291 to +303

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<GroupMember> groupMembers = groupMemberRepository.findAllByUserId(user.getId());
Set<Long> groupIds = groupMembers.stream()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 {
Expand All @@ -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;

Expand All @@ -57,7 +62,8 @@ void setUp() {
groupMemberRepository,
recruitmentGroupRepository,
currentUserService,
auditLogService
auditLogService,
passwordEncoder
);
}

Expand Down Expand Up @@ -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("管理员不能修改自己的角色或状态");
}
}