UserServiceImpl.java
package org.darkroomlibrary.service.impl;
import org.darkroomlibrary.context.CurrentUserContext;
import org.darkroomlibrary.mapper.BookMapper;
import org.darkroomlibrary.mapper.BookReservationMapper;
import org.darkroomlibrary.mapper.BorrowRecordMapper;
import org.darkroomlibrary.mapper.ProcurementOrderMapper;
import org.darkroomlibrary.mapper.UserMapper;
import org.darkroomlibrary.web.response.ApiResponse;
import org.darkroomlibrary.web.response.PageResponse;
import org.darkroomlibrary.web.dto.query.PageQuery;
import org.darkroomlibrary.web.dto.query.UserPageQuery;
import org.darkroomlibrary.web.dto.command.UserAdminUpdateDto;
import org.darkroomlibrary.web.dto.command.UserLoginDto;
import org.darkroomlibrary.web.dto.command.UserRegisterDto;
import org.darkroomlibrary.web.dto.command.UserUpdateDto;
import org.darkroomlibrary.web.dto.command.PasswordResetDto;
import org.darkroomlibrary.web.dto.command.PasswordUpdateDto;
import org.darkroomlibrary.domain.type.AccountStatus;
import org.darkroomlibrary.domain.type.FileReferenceType;
import org.darkroomlibrary.domain.type.LoginStatus;
import org.darkroomlibrary.domain.type.UserRole;
import org.darkroomlibrary.domain.type.VerificationCodePurpose;
import org.darkroomlibrary.domain.type.MuteStatus;
import org.darkroomlibrary.domain.model.User;
import org.darkroomlibrary.web.view.MetricPoint;
import org.darkroomlibrary.web.view.UserProfileView;
import org.darkroomlibrary.service.OperationAuditService;
import org.darkroomlibrary.service.CaptchaService;
import org.darkroomlibrary.service.FileStorageService;
import org.darkroomlibrary.service.LoginAttemptService;
import org.darkroomlibrary.service.ReservationWorkflowService;
import org.darkroomlibrary.service.UserService;
import org.darkroomlibrary.service.VerificationCodeService;
import org.darkroomlibrary.service.support.RecommendationSourceVersionService;
import org.darkroomlibrary.utils.AnalyticsTimeline;
import org.darkroomlibrary.utils.IdListUtils;
import org.darkroomlibrary.utils.JwtUtil;
import org.darkroomlibrary.utils.PasswordValidator;
import org.darkroomlibrary.utils.TransactionCallbacks;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.BeanUtils;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.interceptor.TransactionAspectSupport;
import jakarta.annotation.Resource;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.stream.Collectors;
@Service
@Slf4j
public class UserServiceImpl implements UserService {
private static final BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();
private static final String LOGIN_FAILURE_MESSAGE = "登录失败,请检查账号凭据或联系管理员";
private static final String DUMMY_PASSWORD_HASH =
"$2a$10$Maz5r60mNdcsdNhhjbskuekg5Z.C5WKhbFXtuTidGR/NAO/qki4uq";
@Resource
private UserMapper userMapper;
@Resource
private UserEmailQuotaService userEmailQuotaService;
@Resource
private VerificationCodeService verificationCodeService;
@Resource
private LoginAttemptService loginAttemptService;
@Resource
private CaptchaService captchaService;
@Resource
private BorrowRecordMapper borrowRecordMapper;
@Resource
private BookReservationMapper bookReservationMapper;
@Resource
private BookMapper bookMapper;
@Resource
private ProcurementOrderMapper procurementOrderMapper;
@Resource
private OperationAuditService operationAuditService;
@Resource
private FileStorageService fileStorageService;
@Resource
private ReservationWorkflowService reservationWorkflowService;
@Resource
private RecommendationSourceVersionService recommendationSourceVersionService;
@Resource
private JwtUtil jwtUtil;
@Override
@Transactional
public ApiResponse<String> register(UserRegisterDto userRegisterDTO) {
if (userRegisterDTO.getVerificationCode() == null || userRegisterDTO.getVerificationCode().isEmpty()) {
return ApiResponse.error("请输入邮箱验证码");
}
String invalidInput = validateRegistrationInput(userRegisterDTO);
if (invalidInput != null) {
return ApiResponse.error(invalidInput);
}
if (!verificationCodeService.verify(
userRegisterDTO.getUserEmail(),
VerificationCodePurpose.REGISTER.name(),
userRegisterDTO.getVerificationCode())) {
return ApiResponse.error("验证码错误或已过期");
}
return createReader(userRegisterDTO, "注册成功", true);
}
@Override
public ApiResponse<Object> login(UserLoginDto userLoginDTO) {
if (!captchaService.verify(userLoginDTO.getCaptchaId(), userLoginDTO.getCaptchaAnswer())) {
return ApiResponse.error("验证码错误或已过期");
}
String account = userLoginDTO.getUserAccount();
if (loginAttemptService.isBlocked(account)) {
long seconds = loginAttemptService.getRemainingLockSeconds(account);
long minutes = Math.max(1, seconds / 60);
return ApiResponse.error("登录尝试过于频繁,请" + minutes + "分钟后再试");
}
User user = userMapper.getByActive(User.builder().userAccount(account).build());
String storedPwd = user == null ? null : user.getUserPwd();
boolean validPasswordHash = storedPwd != null && storedPwd.startsWith("$2");
boolean passwordMatches = encoder.matches(
userLoginDTO.getUserPwd(),
validPasswordHash ? storedPwd : DUMMY_PASSWORD_HASH);
boolean unavailable = user == null
|| Objects.equals(user.getAccountStatus(), AccountStatus.CANCELLED.code())
|| Objects.equals(user.getAccountStatus(), AccountStatus.FROZEN.code())
|| Boolean.TRUE.equals(user.getIsLogin())
|| !validPasswordHash
|| !passwordMatches;
if (unavailable) {
loginAttemptService.loginFailed(account);
return ApiResponse.error(LOGIN_FAILURE_MESSAGE);
}
loginAttemptService.loginSucceeded(account);
Map<String, Object> data = new HashMap<>();
data.put("token", jwtUtil.toToken(
user.getId(), user.getUserRole(), currentAuthVersion(user)));
data.put("role", user.getUserRole());
return ApiResponse.success("登录成功", data);
}
@Override
public ApiResponse<UserProfileView> auth() {
Integer userId = CurrentUserContext.userId();
User user = userMapper.getByActive(User.builder().id(userId).build());
if (user == null) {
return ApiResponse.error("用户不存在");
}
return ApiResponse.success(toView(user));
}
@Override
public ApiResponse<List<User>> query(UserPageQuery userPageQuery) {
if (!isAdminOrSuper(CurrentUserContext.roleCode())) {
return ApiResponse.error("无查询权限");
}
List<User> users = userMapper.query(userPageQuery);
Integer count = userMapper.queryCount(userPageQuery);
minimizeUserList(users);
return PageResponse.success(users, count);
}
@Override
@Transactional
public ApiResponse<String> update(UserUpdateDto userUpdateDTO) {
Integer userId = CurrentUserContext.userId();
User existing = userMapper.findByIdForUpdate(userId);
if (existing == null) {
return ApiResponse.error("用户不存在");
}
String invalidProfile = validateOptionalProfileFields(
userUpdateDTO.getUserName(), userUpdateDTO.getUserEmail());
if (invalidProfile != null) {
return ApiResponse.error(invalidProfile);
}
String conflict = findUserFieldConflict(
userId, userUpdateDTO.getUserAccount(), userUpdateDTO.getUserName());
if (conflict != null) {
return ApiResponse.error(conflict);
}
String requestedEmail = userUpdateDTO.getUserEmail() == null
? null
: userEmailQuotaService.normalize(userUpdateDTO.getUserEmail());
boolean emailChanged = requestedEmail != null
&& !Objects.equals(
userEmailQuotaService.normalize(existing.getUserEmail()), requestedEmail);
if (emailChanged) {
if (userUpdateDTO.getVerificationCode() == null
|| userUpdateDTO.getVerificationCode().isBlank()) {
return ApiResponse.error("请输入新邮箱验证码");
}
if (!verificationCodeService.verify(
requestedEmail,
VerificationCodePurpose.CHANGE_EMAIL.name(),
userUpdateDTO.getVerificationCode())) {
return ApiResponse.error("验证码错误或已过期");
}
if (!userEmailQuotaService.moveAccount(existing.getUserEmail(), requestedEmail)) {
return ApiResponse.error(UserEmailQuotaService.LIMIT_MESSAGE);
}
}
User updateEntity = User.builder()
.id(userId)
.userName(trimToNull(userUpdateDTO.getUserName()))
.userAccount(userUpdateDTO.getUserAccount())
.userAvatar(userUpdateDTO.getUserAvatar())
.userEmail(requestedEmail)
.build();
try {
if (userMapper.update(updateEntity) != 1) {
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
return ApiResponse.error("用户状态已变化,请刷新后重试");
}
} catch (DuplicateKeyException e) {
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
return ApiResponse.error(duplicateUserMessage(e));
}
if (userUpdateDTO.getUserAvatar() != null
&& !Objects.equals(userUpdateDTO.getUserAvatar(), existing.getUserAvatar())
&& !fileStorageService.bindSingle(
userUpdateDTO.getUserAvatar(), FileReferenceType.USER_AVATAR, userId)) {
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
return ApiResponse.error("头像文件无效或不属于当前用户");
}
return ApiResponse.success("保存成功");
}
@Override
@Transactional
public ApiResponse<String> batchDelete(List<Integer> ids) {
if (ids == null || ids.isEmpty()) {
return ApiResponse.error("请选择要删除的用户");
}
List<Integer> normalizedIds = ids.stream()
.filter(Objects::nonNull)
.distinct()
.sorted()
.collect(Collectors.toList());
if (normalizedIds.isEmpty()) {
return ApiResponse.error("请选择要删除的用户");
}
if (IdListUtils.exceedsBatchLimit(normalizedIds)) {
return ApiResponse.error("单次最多删除" + IdListUtils.MAX_BATCH_SIZE + "个用户");
}
Integer currentUserId = CurrentUserContext.userId();
Integer currentRoleId = CurrentUserContext.roleCode();
boolean currentIsSuperAdmin = Objects.equals(currentRoleId, UserRole.SUPER_ADMIN.code());
List<User> targets = new ArrayList<>(normalizedIds.size());
for (Integer id : normalizedIds) {
if (Objects.equals(id, currentUserId)) {
return ApiResponse.error("不能删除自己");
}
User target = userMapper.findByIdForUpdate(id);
if (target == null) {
return ApiResponse.error("用户不存在");
}
if (!currentIsSuperAdmin
&& !Objects.equals(target.getUserRole(), UserRole.READER.code())) {
return ApiResponse.error("普通管理员只能删除读者账号");
}
targets.add(target);
}
if (borrowRecordMapper.countByUserIds(normalizedIds) > 0) {
return ApiResponse.error("存在借阅历史,不能删除用户;可改为冻结账号");
}
if (bookReservationMapper.countActiveByUserIds(normalizedIds) > 0) {
return ApiResponse.error("存在进行中的预约,不能删除用户;请先处理预约");
}
if (procurementOrderMapper.countActiveByUserIds(normalizedIds) > 0) {
return ApiResponse.error("用户仍参与进行中的采购单,不能删除;可改为冻结账号");
}
userEmailQuotaService.releaseAccounts(
targets.stream().map(User::getUserEmail).collect(Collectors.toList()));
fileStorageService.releaseUserBusinessFiles(normalizedIds);
if (userMapper.batchDelete(normalizedIds) != normalizedIds.size()) {
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
return ApiResponse.error("部分用户状态已变化,请刷新后重试");
}
recommendationSourceVersionService.invalidateGlobalAfterCommit();
return ApiResponse.success("删除成功");
}
@Override
@Transactional
public ApiResponse<String> updatePwd(PasswordUpdateDto dto) {
String oldPwd = dto.getOldPwd();
String newPwd = dto.getNewPwd();
String againPwd = dto.getAgainPwd();
if (oldPwd == null || oldPwd.isEmpty()) {
return ApiResponse.error("原始密码不能为空");
}
if (newPwd == null || newPwd.isEmpty()) {
return ApiResponse.error("请输入新密码");
}
if (againPwd == null || againPwd.isEmpty()) {
return ApiResponse.error("请确认新密码");
}
if (!newPwd.equals(againPwd)) {
return ApiResponse.error("两次密码输入不一致");
}
if (!PasswordValidator.isValid(newPwd)) {
return ApiResponse.error(PasswordValidator.getRequirement());
}
User user = userMapper.findByIdForUpdate(CurrentUserContext.userId());
if (user == null) {
return ApiResponse.error("用户不存在");
}
String storedPwd = user.getUserPwd();
if (storedPwd == null || !storedPwd.startsWith("$2")) {
return ApiResponse.error("账号密码格式异常,请使用忘记密码功能重置");
}
if (!encoder.matches(oldPwd, storedPwd)) {
return ApiResponse.error("原始密码验证失败");
}
if (userMapper.update(User.builder()
.id(user.getId())
.userPwd(encoder.encode(newPwd))
.authVersion(nextAuthVersion(user))
.build()) != 1) {
return ApiResponse.error("用户状态已变化,请刷新后重试");
}
return ApiResponse.success("密码修改成功");
}
@Override
public ApiResponse<UserProfileView> getById(Integer id) {
Integer currentUserId = CurrentUserContext.userId();
if (!CurrentUserContext.isAdministrator() && !Objects.equals(currentUserId, id)) {
return ApiResponse.error("无操作权限");
}
User user = userMapper.getByActive(User.builder().id(id).build());
if (user == null) {
return ApiResponse.error("用户不存在");
}
return ApiResponse.success(toView(user));
}
@Override
@Transactional
public ApiResponse<String> insert(UserRegisterDto userRegisterDTO) {
Integer currentRoleId = CurrentUserContext.roleCode();
if (!isAdminOrSuper(currentRoleId)) {
return ApiResponse.error("无操作权限");
}
Integer requestedRole = userRegisterDTO.getUserRole() != null
? userRegisterDTO.getUserRole()
: UserRole.READER.code();
if (!isKnownRole(requestedRole)) {
return ApiResponse.error("角色无效");
}
if (!isSuperAdminRole(currentRoleId) && !isReaderRole(requestedRole)) {
return ApiResponse.error("普通管理员只能新增读者账号");
}
boolean coordinatorAdmin = Boolean.TRUE.equals(userRegisterDTO.getIsCoordinatorAdmin());
if (coordinatorAdmin && !isSuperAdminRole(currentRoleId)) {
return ApiResponse.error("只有超级管理员可以任命馆务协调员");
}
if (coordinatorAdmin && !isAdminRole(requestedRole)) {
return ApiResponse.error("只有管理员可以被任命为馆务协调员");
}
return createUser(userRegisterDTO, "新增成功", requestedRole, coordinatorAdmin);
}
@Override
@Transactional
public ApiResponse<String> backUpdate(UserAdminUpdateDto dto) {
Integer currentUserId = CurrentUserContext.userId();
Integer currentRoleId = CurrentUserContext.roleCode();
if (!isAdminOrSuper(currentRoleId)) {
return ApiResponse.error("无操作权限");
}
User target = userMapper.findByIdForUpdate(dto.getId());
if (target == null) {
return ApiResponse.error("用户不存在");
}
boolean currentIsSuperAdmin = Objects.equals(currentRoleId, UserRole.SUPER_ADMIN.code());
boolean currentIsAdmin = Objects.equals(currentRoleId, UserRole.ADMIN.code());
boolean targetIsSuperAdmin = Objects.equals(target.getUserRole(), UserRole.SUPER_ADMIN.code());
boolean targetIsAdmin = Objects.equals(target.getUserRole(), UserRole.ADMIN.code());
boolean targetCancelled = Objects.equals(target.getAccountStatus(), AccountStatus.CANCELLED.code());
Integer requestedRole = dto.getUserRole();
if (targetCancelled) {
return ApiResponse.error("已注销账号不能修改");
}
if (targetIsSuperAdmin && !currentIsSuperAdmin) {
return ApiResponse.error("普通管理员不能修改超级管理员");
}
if (currentIsAdmin && !Objects.equals(dto.getId(), currentUserId) && !isReaderRole(target.getUserRole())) {
return ApiResponse.error("普通管理员只能修改读者账号");
}
if (currentIsAdmin && targetIsAdmin && !Objects.equals(dto.getId(), currentUserId)) {
return ApiResponse.error("不能修改同级管理员");
}
if (requestedRole != null && !isKnownRole(requestedRole)) {
return ApiResponse.error("角色无效");
}
Integer roleAfterUpdate = requestedRole != null ? requestedRole : target.getUserRole();
ApiResponse<Boolean> coordinatorAdminResult = resolveCoordinatorAdminUpdate(dto, target, currentIsSuperAdmin, roleAfterUpdate);
if (coordinatorAdminResult.getCode() != 200) {
return ApiResponse.error(coordinatorAdminResult.getMsg());
}
Boolean coordinatorAdminUpdate = coordinatorAdminResult.getData();
boolean loginStatusChanged = dto.getIsLogin() != null
&& !Objects.equals(dto.getIsLogin(), target.getIsLogin());
boolean wordStatusChanged = dto.getIsWord() != null
&& !Objects.equals(dto.getIsWord(), target.getIsWord());
boolean roleChanged = dto.getUserRole() != null
&& !Objects.equals(dto.getUserRole(), target.getUserRole());
boolean coordinatorAdminChanged = coordinatorAdminUpdate != null
&& !Objects.equals(coordinatorAdminUpdate, target.getIsCoordinatorAdmin());
boolean passwordResetRequested = dto.getUserPwd() != null && !dto.getUserPwd().isBlank();
boolean authenticationChanged = loginStatusChanged
|| roleChanged
|| coordinatorAdminChanged
|| passwordResetRequested;
if (passwordResetRequested && !currentIsSuperAdmin) {
return ApiResponse.error("只有超级管理员可以重置其他用户密码");
}
if (passwordResetRequested && Objects.equals(dto.getId(), currentUserId)) {
return ApiResponse.error("修改自己的密码必须验证原密码");
}
if (passwordResetRequested && !PasswordValidator.isValid(dto.getUserPwd())) {
return ApiResponse.error(PasswordValidator.getRequirement());
}
if (!currentIsSuperAdmin
&& requestedRole != null
&& !Objects.equals(requestedRole, target.getUserRole())) {
return ApiResponse.error("普通管理员不能变更用户角色");
}
if (Objects.equals(dto.getId(), currentUserId)
&& requestedRole != null
&& !Objects.equals(requestedRole, target.getUserRole())) {
return ApiResponse.error("不能修改自己的角色");
}
if (Boolean.TRUE.equals(dto.getIsLogin()) && Objects.equals(dto.getId(), currentUserId)) {
return ApiResponse.error("不能禁用自己");
}
if (Boolean.FALSE.equals(dto.getIsLogin()) && Objects.equals(dto.getId(), currentUserId)) {
return ApiResponse.error("不能解禁自己");
}
if (Boolean.TRUE.equals(dto.getIsLogin()) && targetIsSuperAdmin) {
return ApiResponse.error("不能禁用超级管理员");
}
String roleTransitionError = validateRoleTransition(target, roleAfterUpdate, roleChanged);
if (roleTransitionError != null) {
return ApiResponse.error(roleTransitionError);
}
String invalidProfile = validateOptionalProfileFields(dto.getUserName(), dto.getUserEmail());
if (invalidProfile != null) {
return ApiResponse.error(invalidProfile);
}
String conflict = findUserFieldConflict(dto.getId(), dto.getUserAccount(), dto.getUserName());
if (conflict != null) {
return ApiResponse.error(conflict);
}
String requestedEmail = dto.getUserEmail() == null
? null
: userEmailQuotaService.normalize(dto.getUserEmail());
if (requestedEmail != null
&& !userEmailQuotaService.moveAccount(target.getUserEmail(), requestedEmail)) {
return ApiResponse.error(UserEmailQuotaService.LIMIT_MESSAGE);
}
User updateEntity = User.builder()
.id(dto.getId())
.userName(trimToNull(dto.getUserName()))
.userAccount(dto.getUserAccount())
.userPwd(passwordResetRequested ? encoder.encode(dto.getUserPwd()) : null)
.userAvatar(dto.getUserAvatar())
.userEmail(requestedEmail)
.isLogin(dto.getIsLogin())
.accountStatus(resolveAccountStatusByLoginFlag(dto.getIsLogin()))
.isWord(dto.getIsWord())
.userRole(dto.getUserRole())
.isCoordinatorAdmin(coordinatorAdminUpdate)
.authVersion(authenticationChanged ? nextAuthVersion(target) : null)
.build();
try {
if (userMapper.update(updateEntity) != 1) {
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
return ApiResponse.error("用户状态已变化,请刷新后重试");
}
} catch (DuplicateKeyException e) {
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
return ApiResponse.error(duplicateUserMessage(e));
}
if (dto.getUserAvatar() != null
&& !Objects.equals(dto.getUserAvatar(), target.getUserAvatar())
&& !fileStorageService.bindSingle(
dto.getUserAvatar(), FileReferenceType.USER_AVATAR, dto.getId())) {
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
return ApiResponse.error("头像文件无效或不属于当前用户");
}
if (isReaderRole(target.getUserRole())
&& (Boolean.TRUE.equals(dto.getIsLogin()) || !isReaderRole(roleAfterUpdate))) {
releaseReaderReservations(target.getId());
}
if (passwordResetRequested) {
clearLoginAttemptsAfterCommit(target.getUserAccount(), dto.getUserAccount());
operationAuditService.record("重置", "用户密码",
userIdentity(target) + ",密码已由超级管理员重置");
}
recordUserUpdateAudits(
target, dto, loginStatusChanged, wordStatusChanged, roleChanged,
coordinatorAdminChanged, coordinatorAdminUpdate);
if (loginStatusChanged || roleChanged) {
recommendationSourceVersionService.invalidateGlobalAfterCommit();
}
return ApiResponse.success(passwordResetRequested ? "保存成功,密码已重置" : "保存成功");
}
@Override
public ApiResponse<List<MetricPoint>> queryByDays(Integer day) {
PageQuery queryDto = AnalyticsTimeline.queryWindow(day);
return ApiResponse.success(AnalyticsTimeline.toDailyMetrics(
day,
userMapper.dailyCreateStats(queryDto.getStartTime(), queryDto.getEndTime())
));
}
@Override
@Transactional
public ApiResponse<String> resetPwd(PasswordResetDto dto) {
String account = dto.getAccount();
String email = dto.getEmail();
String code = dto.getCode();
String newPwd = dto.getNewPwd();
if (account == null || account.isEmpty()) {
return ApiResponse.error("请输入账号");
}
if (email == null || email.isEmpty()) {
return ApiResponse.error("请输入邮箱");
}
if (code == null || code.isEmpty()) {
return ApiResponse.error("请输入验证码");
}
if (newPwd == null || newPwd.isEmpty()) {
return ApiResponse.error("请输入新密码");
}
if (!PasswordValidator.isValid(newPwd)) {
return ApiResponse.error(PasswordValidator.getRequirement());
}
User snapshot = userMapper.getByActive(User.builder().userAccount(account).build());
if (snapshot == null) {
return ApiResponse.error("账号不存在");
}
User user = userMapper.findByIdForUpdate(snapshot.getId());
if (user == null || !Objects.equals(user.getUserAccount(), account)) {
return ApiResponse.error("账号状态已变化,请重新提交");
}
if (user.getUserEmail() == null || !user.getUserEmail().equalsIgnoreCase(email)) {
return ApiResponse.error("邮箱与账号不匹配");
}
if (!verificationCodeService.verify(email, VerificationCodePurpose.RESET_PASSWORD.name(), code)) {
return ApiResponse.error("验证码错误或已过期");
}
if (userMapper.update(User.builder()
.id(user.getId())
.userPwd(encoder.encode(newPwd))
.authVersion(nextAuthVersion(user))
.build()) != 1) {
return ApiResponse.error("用户状态已变化,请重新提交");
}
clearLoginAttemptsAfterCommit(user.getUserAccount());
return ApiResponse.success("密码重置成功,请使用新密码登录");
}
@Override
public ApiResponse<String> sendVerifyCode(String email) {
return sendVerifyCode(email, VerificationCodePurpose.REGISTER.name());
}
@Override
public ApiResponse<String> sendVerifyCode(String email, String purpose) {
if (email == null || email.isEmpty()) {
return ApiResponse.error("请输入邮箱");
}
String codePurpose = (purpose == null || purpose.trim().isEmpty())
? VerificationCodePurpose.REGISTER.name()
: purpose;
if (VerificationCodePurpose.from(codePurpose)
.filter(value -> value == VerificationCodePurpose.CHANGE_EMAIL)
.isPresent()
&& CurrentUserContext.userId() == null) {
return ApiResponse.error("身份认证异常,请先登录");
}
return verificationCodeService.sendCode(email, codePurpose);
}
@Override
@Transactional
public ApiResponse<String> freezeUser(Integer userId) {
ApiResponse<User> checkResult = checkStatusChangePermission(userId, "冻结", true);
if (checkResult.getCode() != 200) {
return ApiResponse.error(checkResult.getMsg());
}
User target = checkResult.getData();
boolean changed = !Objects.equals(target.getAccountStatus(), AccountStatus.FROZEN.code())
|| !Boolean.TRUE.equals(target.getIsLogin());
if (userMapper.update(User.builder()
.id(userId)
.isLogin(true)
.accountStatus(AccountStatus.FROZEN.code())
.authVersion(changed ? nextAuthVersion(target) : null)
.build()) != 1) {
return ApiResponse.error("用户状态已变化,请刷新后重试");
}
if (isReaderRole(target.getUserRole())) {
releaseReaderReservations(target.getId());
}
if (changed) {
operationAuditService.record("修改", "用户状态",
userIdentity(target) + ",账号状态:"
+ accountStatusName(target.getAccountStatus()) + " -> 冻结");
}
recommendationSourceVersionService.invalidateGlobalAfterCommit();
return ApiResponse.success("用户已冻结");
}
@Override
@Transactional
public ApiResponse<String> unfreezeUser(Integer userId) {
ApiResponse<User> checkResult = checkStatusChangePermission(userId, "解冻", false);
if (checkResult.getCode() != 200) {
return ApiResponse.error(checkResult.getMsg());
}
User target = checkResult.getData();
if (Objects.equals(target.getAccountStatus(), AccountStatus.CANCELLED.code())) {
return ApiResponse.error("已注销账号不能解冻");
}
boolean changed = !Objects.equals(target.getAccountStatus(), AccountStatus.NORMAL.code())
|| !Boolean.FALSE.equals(target.getIsLogin());
if (userMapper.update(User.builder()
.id(userId)
.isLogin(false)
.accountStatus(AccountStatus.NORMAL.code())
.authVersion(changed ? nextAuthVersion(target) : null)
.build()) != 1) {
return ApiResponse.error("用户状态已变化,请刷新后重试");
}
if (!Objects.equals(target.getAccountStatus(), AccountStatus.NORMAL.code())
|| !Boolean.FALSE.equals(target.getIsLogin())) {
operationAuditService.record("修改", "用户状态",
userIdentity(target) + ",账号状态:"
+ accountStatusName(target.getAccountStatus()) + " -> 正常");
}
recommendationSourceVersionService.invalidateGlobalAfterCommit();
return ApiResponse.success("用户已解冻");
}
@Override
@Transactional
public ApiResponse<String> cancelAccount() {
Integer userId = CurrentUserContext.userId();
User user = userMapper.findByIdForUpdate(userId);
if (user == null) {
return ApiResponse.error("用户不存在");
}
if (!isReaderRole(user.getUserRole())) {
return ApiResponse.error("只有读者账号可以自助注销");
}
if (Objects.equals(user.getAccountStatus(), AccountStatus.CANCELLED.code())) {
return ApiResponse.error("账号已注销");
}
if (Objects.equals(user.getAccountStatus(), AccountStatus.FROZEN.code())
|| Boolean.TRUE.equals(user.getIsLogin())) {
return ApiResponse.error("账号处于冻结状态,请联系管理员处理");
}
if (borrowRecordMapper.getActiveCountByUserId(userId) > 0) {
return ApiResponse.error("存在未归还图书,不能注销账号");
}
BigDecimal fineAmount = borrowRecordMapper.sumFineAmountByUserId(userId);
if (fineAmount != null && fineAmount.compareTo(BigDecimal.ZERO) > 0) {
return ApiResponse.error("存在未处理罚款,不能注销账号");
}
if (bookReservationMapper.countActiveByUserId(userId) > 0) {
return ApiResponse.error("存在进行中的预约,不能注销账号");
}
if (userMapper.update(User.builder()
.id(userId)
.isLogin(true)
.accountStatus(AccountStatus.CANCELLED.code())
.authVersion(nextAuthVersion(user))
.build()) != 1) {
return ApiResponse.error("用户状态已变化,请刷新后重试");
}
recommendationSourceVersionService.invalidateGlobalAfterCommit();
return ApiResponse.success("账号已注销");
}
@Override
public ApiResponse<List<UserProfileView>> queryCollaborationUsers(Integer role) {
Integer currentRole = CurrentUserContext.roleCode();
boolean targetPurchaser = Objects.equals(role, UserRole.ACQUISITIONS.code());
boolean targetLogistics = Objects.equals(role, UserRole.LOGISTICS.code());
if (!targetPurchaser && !targetLogistics) {
return ApiResponse.error("协作角色不正确");
}
if (Objects.equals(currentRole, UserRole.ACQUISITIONS.code()) && !targetLogistics) {
return ApiResponse.error("采购员只能查询物流人员");
}
if (!isAdminOrSuper(currentRole)
&& !Objects.equals(currentRole, UserRole.ACQUISITIONS.code())) {
return ApiResponse.error("无查询权限");
}
UserPageQuery query = new UserPageQuery();
query.setRole(role);
query.setAccountStatus(AccountStatus.NORMAL.code());
query.setIsLogin(false);
List<UserProfileView> users = userMapper.query(query).stream().map(user -> {
UserProfileView vo = new UserProfileView();
vo.setId(user.getId());
vo.setUserName(user.getUserName());
vo.setUserRole(user.getUserRole());
return vo;
}).collect(Collectors.toList());
return ApiResponse.success(users);
}
private ApiResponse<String> createReader(UserRegisterDto dto, String successMessage, boolean requireEmailCode) {
if (requireEmailCode && (dto.getVerificationCode() == null || dto.getVerificationCode().isEmpty())) {
return ApiResponse.error("请输入邮箱验证码");
}
return createUser(dto, successMessage, UserRole.READER.code(), false);
}
private ApiResponse<String> createUser(UserRegisterDto dto, String successMessage, Integer role, boolean coordinatorAdmin) {
String conflict = validateNewUser(dto);
if (conflict != null) {
return ApiResponse.error(conflict);
}
String normalizedEmail = userEmailQuotaService.normalize(dto.getUserEmail());
User saveEntity = User.builder()
.userRole(role)
.isCoordinatorAdmin(coordinatorAdmin && isAdminRole(role))
.userName(dto.getUserName().trim())
.userAccount(dto.getUserAccount())
.userAvatar(dto.getUserAvatar())
.userPwd(encoder.encode(dto.getUserPwd()))
.userEmail(normalizedEmail)
.createTime(LocalDateTime.now())
.accountStatus(AccountStatus.NORMAL.code())
.isLogin(LoginStatus.ACTIVE.disabled())
.isWord(MuteStatus.ACTIVE.muted())
.build();
if (!userEmailQuotaService.reserveNewAccount(normalizedEmail)) {
return ApiResponse.error(UserEmailQuotaService.LIMIT_MESSAGE);
}
try {
if (userMapper.insert(saveEntity) != 1) {
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
return ApiResponse.error("用户创建失败,请重试");
}
} catch (DuplicateKeyException e) {
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
return ApiResponse.error(duplicateUserMessage(e));
}
if (!fileStorageService.bindSingle(
dto.getUserAvatar(), FileReferenceType.USER_AVATAR, saveEntity.getId())) {
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
return ApiResponse.error("头像文件无效或不属于当前用户");
}
return ApiResponse.success(successMessage);
}
private String validateNewUser(UserRegisterDto dto) {
if (!PasswordValidator.isValid(dto.getUserPwd())) {
return PasswordValidator.getRequirement();
}
String normalizedName = dto.getUserName().trim();
if (userMapper.getByActive(User.builder().userName(normalizedName).build()) != null) {
return "用户名已经被使用,请换一个";
}
if (userMapper.getByActive(User.builder().userAccount(dto.getUserAccount()).build()) != null) {
return "账号不可用";
}
if (userEmailQuotaService.normalize(dto.getUserEmail()) == null) {
return "邮箱不能为空";
}
return null;
}
private String validateRegistrationInput(UserRegisterDto dto) {
if (!PasswordValidator.isValid(dto.getUserPwd())) {
return PasswordValidator.getRequirement();
}
if (userEmailQuotaService.normalize(dto.getUserEmail()) == null) {
return "邮箱不能为空";
}
return null;
}
private ApiResponse<Boolean> resolveCoordinatorAdminUpdate(UserAdminUpdateDto dto,
User target,
boolean currentIsSuperAdmin,
Integer roleAfterUpdate) {
Boolean requestedCoordinatorAdmin = dto.getIsCoordinatorAdmin();
boolean currentCoordinatorAdmin = Boolean.TRUE.equals(target.getIsCoordinatorAdmin());
if (requestedCoordinatorAdmin != null) {
boolean targetCoordinatorAdmin = Boolean.TRUE.equals(requestedCoordinatorAdmin);
if (!currentIsSuperAdmin && targetCoordinatorAdmin != currentCoordinatorAdmin) {
return ApiResponse.error("只有超级管理员可以任免馆务协调员");
}
if (targetCoordinatorAdmin && !isAdminRole(roleAfterUpdate)) {
return ApiResponse.error("只有管理员可以被任命为馆务协调员");
}
return ApiResponse.success(targetCoordinatorAdmin);
}
if (!isAdminRole(roleAfterUpdate) && currentCoordinatorAdmin) {
return ApiResponse.success(false);
}
return ApiResponse.success((Boolean) null);
}
private Integer resolveAccountStatusByLoginFlag(Boolean isLogin) {
if (isLogin == null) {
return null;
}
return Boolean.TRUE.equals(isLogin)
? AccountStatus.FROZEN.code()
: AccountStatus.NORMAL.code();
}
private String validateRoleTransition(User target, Integer roleAfterUpdate, boolean roleChanged) {
if (!roleChanged) {
return null;
}
if (isReaderRole(target.getUserRole()) && !isReaderRole(roleAfterUpdate)) {
if (borrowRecordMapper.getActiveCountByUserId(target.getId()) > 0) {
return "用户仍有未归还图书,不能变更为非读者角色";
}
BigDecimal fineAmount = borrowRecordMapper.sumFineAmountByUserId(target.getId());
if (fineAmount != null && fineAmount.compareTo(BigDecimal.ZERO) > 0) {
return "用户仍有未处理罚款,不能变更为非读者角色";
}
}
if (procurementOrderMapper.countActiveByUserIds(List.of(target.getId())) > 0) {
return "用户仍参与进行中的采购单,不能变更角色";
}
return null;
}
private void releaseReaderReservations(Integer userId) {
List<Integer> bookIds = bookReservationMapper.findActiveBookIdsByUserId(userId);
if (bookIds.isEmpty()) {
return;
}
bookMapper.findByIdsForUpdate(bookIds);
if (bookReservationMapper.releaseActiveByUserId(userId) == 0) {
return;
}
TransactionCallbacks.afterCommit(() -> bookIds.forEach(bookId -> {
try {
reservationWorkflowService.onBookReturned(bookId);
} catch (Exception e) {
log.warn("用户状态变化后的预约递补失败,等待定时对账: userId={}, bookId={}, error={}",
userId, bookId, e.getMessage());
}
}));
}
private void recordUserUpdateAudits(User target,
UserAdminUpdateDto dto,
boolean loginStatusChanged,
boolean wordStatusChanged,
boolean roleChanged,
boolean coordinatorAdminChanged,
Boolean coordinatorAdminUpdate) {
String identity = userIdentity(target);
if (loginStatusChanged) {
String before = Boolean.TRUE.equals(target.getIsLogin()) ? "冻结" : "正常";
String after = Boolean.TRUE.equals(dto.getIsLogin()) ? "冻结" : "正常";
operationAuditService.record("修改", "用户状态",
identity + ",账号状态:" + before + " -> " + after);
}
if (wordStatusChanged) {
String before = Boolean.TRUE.equals(target.getIsWord()) ? "禁言" : "可发言";
String after = Boolean.TRUE.equals(dto.getIsWord()) ? "禁言" : "可发言";
operationAuditService.record("修改", "用户发言状态",
identity + ",发言状态:" + before + " -> " + after);
}
if (roleChanged) {
operationAuditService.record("修改", "用户角色",
identity + ",角色:" + roleName(target.getUserRole())
+ " -> " + roleName(dto.getUserRole()));
}
if (coordinatorAdminChanged) {
String before = Boolean.TRUE.equals(target.getIsCoordinatorAdmin()) ? "馆务协调员" : "普通管理员";
String after = Boolean.TRUE.equals(coordinatorAdminUpdate) ? "馆务协调员" : "普通管理员";
operationAuditService.record("修改", "用户角色",
identity + ",管理级别:" + before + " -> " + after);
}
}
private String userIdentity(User user) {
return "用户ID=" + user.getId()
+ ",账号=" + user.getUserAccount()
+ ",姓名=" + user.getUserName();
}
private String accountStatusName(Integer status) {
if (Objects.equals(status, AccountStatus.FROZEN.code())) {
return "冻结";
}
if (Objects.equals(status, AccountStatus.CANCELLED.code())) {
return "注销";
}
return "正常";
}
private String roleName(Integer roleId) {
String roleName = UserRole.displayNameOf(roleId);
return roleName == null ? "未知角色(" + roleId + ")" : roleName;
}
private ApiResponse<User> checkStatusChangePermission(Integer targetUserId, String actionName, boolean blockSuperTarget) {
Integer currentUserId = CurrentUserContext.userId();
Integer currentRoleId = CurrentUserContext.roleCode();
if (!isAdminOrSuper(currentRoleId)) {
return ApiResponse.error("无操作权限");
}
if (Objects.equals(targetUserId, currentUserId)) {
return ApiResponse.error("不能" + actionName + "自己");
}
User target = userMapper.findByIdForUpdate(targetUserId);
if (target == null) {
return ApiResponse.error("用户不存在");
}
if (blockSuperTarget && isSuperAdminRole(target.getUserRole())) {
return ApiResponse.error("不能冻结超级管理员");
}
if (Objects.equals(target.getAccountStatus(), AccountStatus.CANCELLED.code())) {
return ApiResponse.error("已注销账号不能" + actionName);
}
if (!isSuperAdminRole(currentRoleId) && !isReaderRole(target.getUserRole())) {
return ApiResponse.error("普通管理员只能" + actionName + "读者账号");
}
return ApiResponse.success(target);
}
private void minimizeUserList(List<User> users) {
if (isSuperAdminRole(CurrentUserContext.roleCode()) || users == null) {
return;
}
for (User user : users) {
if (!isReaderRole(user.getUserRole())) {
user.setUserEmail(maskEmail(user.getUserEmail()));
}
}
}
private boolean isAdminOrSuper(Integer roleId) {
return Objects.equals(roleId, UserRole.ADMIN.code()) || isSuperAdminRole(roleId);
}
private boolean isAdminRole(Integer roleId) {
return Objects.equals(roleId, UserRole.ADMIN.code());
}
private boolean isSuperAdminRole(Integer roleId) {
return Objects.equals(roleId, UserRole.SUPER_ADMIN.code());
}
private boolean isReaderRole(Integer roleId) {
return Objects.equals(roleId, UserRole.READER.code());
}
private boolean isKnownRole(Integer roleId) {
return UserRole.displayNameOf(roleId) != null;
}
private String maskEmail(String email) {
if (email == null || email.isBlank()) {
return "";
}
int atIndex = email.indexOf('@');
if (atIndex <= 0) {
return "***";
}
String localPart = email.substring(0, atIndex);
String domain = email.substring(atIndex);
if (localPart.length() == 1) {
return localPart + "***" + domain;
}
int keepLength = Math.min(3, localPart.length());
return localPart.substring(0, keepLength) + "***" + domain;
}
private String findUserFieldConflict(Integer currentId, String account, String userName) {
if (account != null && !account.isBlank()) {
User accountOwner = userMapper.getByActive(User.builder().userAccount(account).build());
if (accountOwner != null && !Objects.equals(accountOwner.getId(), currentId)) {
return "账号不可用";
}
}
if (userName != null && !userName.isBlank()) {
User nameOwner = userMapper.getByActive(User.builder().userName(userName.trim()).build());
if (nameOwner != null && !Objects.equals(nameOwner.getId(), currentId)) {
return "用户名已经被使用,请换一个";
}
}
return null;
}
private String validateOptionalProfileFields(String userName, String email) {
if (userName != null && userName.trim().isEmpty()) {
return "用户名不能为空";
}
if (email != null && email.trim().isEmpty()) {
return "邮箱不能为空";
}
return null;
}
private String duplicateUserMessage(DuplicateKeyException e) {
String message = e.getMostSpecificCause() == null
? e.getMessage()
: e.getMostSpecificCause().getMessage();
String normalized = message == null ? "" : message.toLowerCase();
if (normalized.contains("uk_user_name") || normalized.contains("user_name")) {
return "用户名已经被使用,请换一个";
}
return "账号不可用";
}
private void clearLoginAttemptsAfterCommit(String... accounts) {
List<String> normalizedAccounts = java.util.Arrays.stream(accounts)
.filter(Objects::nonNull)
.map(String::trim)
.filter(account -> !account.isEmpty())
.distinct()
.collect(Collectors.toList());
if (normalizedAccounts.isEmpty()) {
return;
}
TransactionCallbacks.afterCommit(() ->
normalizedAccounts.forEach(loginAttemptService::loginSucceeded));
}
private String trimToNull(String value) {
return value == null ? null : value.trim();
}
private int currentAuthVersion(User user) {
return user.getAuthVersion() == null ? 1 : user.getAuthVersion();
}
private int nextAuthVersion(User user) {
return currentAuthVersion(user) + 1;
}
private UserProfileView toView(User user) {
UserProfileView userView = new UserProfileView();
BeanUtils.copyProperties(user, userView);
return userView;
}
}