Commit e0bf3066667b02cbb51b6b6d82331f3636b8633b

Authored by zichun
1 parent 6cebb6d1

feat(usr): login service + account locked handling REQ-USR-004

backend/src/main/java/com/xly/erp/common/exception/AccountLockedException.java 0 → 100644
  1 +package com.xly.erp.common.exception;
  2 +
  3 +import com.xly.erp.common.response.ErrorCode;
  4 +import lombok.Getter;
  5 +
  6 +/**
  7 + * REQ-USR-004 账号被临时锁定时抛出。携带剩余 cooldownSeconds,
  8 + * GlobalExceptionHandler 会把它转换成 ApiResponse.data 的 cooldownSeconds 字段。
  9 + */
  10 +@Getter
  11 +public class AccountLockedException extends BizException {
  12 +
  13 + private final long cooldownSeconds;
  14 +
  15 + public AccountLockedException(long cooldownSeconds) {
  16 + super(ErrorCode.LOGIN_ACCOUNT_LOCKED);
  17 + this.cooldownSeconds = cooldownSeconds;
  18 + }
  19 +}
... ...
backend/src/main/java/com/xly/erp/common/exception/GlobalExceptionHandler.java
... ... @@ -12,6 +12,14 @@ import org.springframework.web.bind.annotation.RestControllerAdvice;
12 12 @RestControllerAdvice
13 13 public class GlobalExceptionHandler {
14 14  
  15 + /** REQ-USR-004 账号锁定专用:把 cooldownSeconds 暴露在 data.cooldownSeconds */
  16 + @ExceptionHandler(AccountLockedException.class)
  17 + public ApiResponse<java.util.Map<String, Object>> handleAccountLocked(AccountLockedException e) {
  18 + log.warn("AccountLocked code={} cooldown={}s", e.getCode(), e.getCooldownSeconds());
  19 + java.util.Map<String, Object> data = java.util.Map.of("cooldownSeconds", e.getCooldownSeconds());
  20 + return new ApiResponse<>(e.getCode(), e.getMessage(), data, System.currentTimeMillis());
  21 + }
  22 +
15 23 @ExceptionHandler(BizException.class)
16 24 public ApiResponse<Void> handleBiz(BizException e) {
17 25 log.warn("BizException code={} message={}", e.getCode(), e.getMessage());
... ...
backend/src/main/java/com/xly/erp/module/usr/service/LoginService.java 0 → 100644
  1 +package com.xly.erp.module.usr.service;
  2 +
  3 +import com.xly.erp.module.usr.dto.LoginDTO;
  4 +import com.xly.erp.module.usr.vo.LoginResultVO;
  5 +
  6 +public interface LoginService {
  7 + /** REQ-USR-004 用户登录 */
  8 + LoginResultVO login(LoginDTO dto);
  9 +}
... ...
backend/src/main/java/com/xly/erp/module/usr/service/impl/LoginServiceImpl.java 0 → 100644
  1 +package com.xly.erp.module.usr.service.impl;
  2 +
  3 +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
  4 +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
  5 +import com.xly.erp.common.exception.AccountLockedException;
  6 +import com.xly.erp.common.exception.BizException;
  7 +import com.xly.erp.common.response.ErrorCode;
  8 +import com.xly.erp.module.usr.dto.LoginDTO;
  9 +import com.xly.erp.module.usr.entity.UserEntity;
  10 +import com.xly.erp.module.usr.mapper.UserMapper;
  11 +import com.xly.erp.module.usr.security.JwtTokenProvider;
  12 +import com.xly.erp.module.usr.security.LoginAttemptStore;
  13 +import com.xly.erp.module.usr.service.LoginService;
  14 +import com.xly.erp.module.usr.vo.LoginResultVO;
  15 +import lombok.RequiredArgsConstructor;
  16 +import lombok.extern.slf4j.Slf4j;
  17 +import org.springframework.security.crypto.password.PasswordEncoder;
  18 +import org.springframework.stereotype.Service;
  19 +import org.springframework.transaction.annotation.Transactional;
  20 +
  21 +import java.time.LocalDateTime;
  22 +
  23 +/** REQ-USR-004 用户登录 service */
  24 +@Slf4j
  25 +@Service
  26 +@RequiredArgsConstructor
  27 +public class LoginServiceImpl implements LoginService {
  28 +
  29 + private final UserMapper userMapper;
  30 + private final PasswordEncoder passwordEncoder;
  31 + private final LoginAttemptStore attemptStore;
  32 + private final JwtTokenProvider jwtTokenProvider;
  33 +
  34 + @Override
  35 + @Transactional(rollbackFor = Exception.class)
  36 + public LoginResultVO login(LoginDTO dto) {
  37 + String username = dto.getSUserName();
  38 +
  39 + // 1. 锁定检查
  40 + long cooldown = attemptStore.cooldownSeconds(username);
  41 + if (cooldown > 0L) {
  42 + log.info("Login locked username={} cooldown={}s", username, cooldown);
  43 + throw new AccountLockedException(cooldown);
  44 + }
  45 +
  46 + // 2. 查用户
  47 + UserEntity user = userMapper.selectOne(
  48 + new LambdaQueryWrapper<UserEntity>()
  49 + .eq(UserEntity::getSUserName, username)
  50 + .eq(UserEntity::getBDeleted, false));
  51 + if (user == null) {
  52 + log.info("Login user-not-found username={}", username);
  53 + attemptStore.recordFailure(username);
  54 + // 检查 record 后是否触发锁定(边界:第 5 次失败,下一次再调时已锁)
  55 + long cd = attemptStore.cooldownSeconds(username);
  56 + if (cd > 0L) {
  57 + throw new AccountLockedException(cd);
  58 + }
  59 + throw new BizException(ErrorCode.LOGIN_INVALID_CREDENTIALS);
  60 + }
  61 +
  62 + // 3. BCrypt 校验
  63 + if (!passwordEncoder.matches(dto.getSPassword(), user.getSPasswordHash())) {
  64 + log.info("Login bad-password username={}", username);
  65 + attemptStore.recordFailure(username);
  66 + long cd = attemptStore.cooldownSeconds(username);
  67 + if (cd > 0L) {
  68 + throw new AccountLockedException(cd);
  69 + }
  70 + throw new BizException(ErrorCode.LOGIN_INVALID_CREDENTIALS);
  71 + }
  72 +
  73 + // 4. 成功:清失败计数 + 签发 token + 更新 tLastLoginDate
  74 + attemptStore.clear(username);
  75 + String token = jwtTokenProvider.sign(user.getIIncrement(), user.getSUserName(), user.getSUserType());
  76 + userMapper.update(null,
  77 + new LambdaUpdateWrapper<UserEntity>()
  78 + .eq(UserEntity::getIIncrement, user.getIIncrement())
  79 + .set(UserEntity::getTLastLoginDate, LocalDateTime.now()));
  80 + log.info("Login success username={} uid={}", username, user.getIIncrement());
  81 +
  82 + LoginResultVO.LoginUserInfo info = new LoginResultVO.LoginUserInfo(
  83 + user.getIIncrement(), user.getSUserNo(), user.getSUserName(),
  84 + user.getSUserType(), user.getSLanguage());
  85 + return new LoginResultVO(token, jwtTokenProvider.getExpiresInSeconds(), info);
  86 + }
  87 +}
... ...
backend/src/test/java/com/xly/erp/module/usr/service/LoginServiceImplTest.java 0 → 100644
  1 +package com.xly.erp.module.usr.service;
  2 +
  3 +import com.baomidou.mybatisplus.core.conditions.Wrapper;
  4 +import com.xly.erp.common.exception.AccountLockedException;
  5 +import com.xly.erp.common.exception.BizException;
  6 +import com.xly.erp.common.response.ErrorCode;
  7 +import com.xly.erp.module.usr.dto.LoginDTO;
  8 +import com.xly.erp.module.usr.entity.UserEntity;
  9 +import com.xly.erp.module.usr.mapper.UserMapper;
  10 +import com.xly.erp.module.usr.security.JwtTokenProvider;
  11 +import com.xly.erp.module.usr.security.LoginAttemptStore;
  12 +import com.xly.erp.module.usr.service.impl.LoginServiceImpl;
  13 +import com.xly.erp.module.usr.vo.LoginResultVO;
  14 +import org.junit.jupiter.api.Test;
  15 +import org.junit.jupiter.api.extension.ExtendWith;
  16 +import org.mockito.ArgumentCaptor;
  17 +import org.mockito.InjectMocks;
  18 +import org.mockito.Mock;
  19 +import org.mockito.junit.jupiter.MockitoExtension;
  20 +import org.springframework.security.crypto.password.PasswordEncoder;
  21 +
  22 +import static org.assertj.core.api.Assertions.assertThat;
  23 +import static org.assertj.core.api.Assertions.assertThatThrownBy;
  24 +import static org.mockito.ArgumentMatchers.any;
  25 +import static org.mockito.ArgumentMatchers.anyInt;
  26 +import static org.mockito.ArgumentMatchers.anyString;
  27 +import static org.mockito.ArgumentMatchers.eq;
  28 +import static org.mockito.ArgumentMatchers.isNull;
  29 +import static org.mockito.Mockito.never;
  30 +import static org.mockito.Mockito.times;
  31 +import static org.mockito.Mockito.verify;
  32 +import static org.mockito.Mockito.when;
  33 +
  34 +@ExtendWith(MockitoExtension.class)
  35 +class LoginServiceImplTest {
  36 +
  37 + @Mock UserMapper userMapper;
  38 + @Mock PasswordEncoder passwordEncoder;
  39 + @Mock LoginAttemptStore attemptStore;
  40 + @Mock JwtTokenProvider jwtTokenProvider;
  41 +
  42 + @InjectMocks LoginServiceImpl service;
  43 +
  44 + private LoginDTO dto() {
  45 + LoginDTO d = new LoginDTO();
  46 + d.setSUserName("alice");
  47 + d.setSPassword("666666");
  48 + d.setSVersion("standard");
  49 + return d;
  50 + }
  51 +
  52 + private UserEntity userEntity() {
  53 + UserEntity u = new UserEntity();
  54 + u.setIIncrement(42);
  55 + u.setSUserNo("u001");
  56 + u.setSUserName("alice");
  57 + u.setSUserType("普通用户");
  58 + u.setSLanguage("zh");
  59 + u.setSPasswordHash("$2a$10$hash");
  60 + u.setBDeleted(false);
  61 + return u;
  62 + }
  63 +
  64 + @Test
  65 + void login_validCredentials_returnsTokenAndClearsFailCount() {
  66 + when(attemptStore.cooldownSeconds("alice")).thenReturn(0L);
  67 + when(userMapper.selectOne(any(Wrapper.class))).thenReturn(userEntity());
  68 + when(passwordEncoder.matches("666666", "$2a$10$hash")).thenReturn(true);
  69 + when(jwtTokenProvider.sign(42, "alice", "普通用户")).thenReturn("jwt.token.value");
  70 + when(jwtTokenProvider.getExpiresInSeconds()).thenReturn(7200L);
  71 +
  72 + LoginResultVO vo = service.login(dto());
  73 +
  74 + assertThat(vo.getAccessToken()).isEqualTo("jwt.token.value");
  75 + assertThat(vo.getExpiresIn()).isEqualTo(7200L);
  76 + assertThat(vo.getUser().getIIncrement()).isEqualTo(42);
  77 + assertThat(vo.getUser().getSUserName()).isEqualTo("alice");
  78 + assertThat(vo.getUser().getSUserType()).isEqualTo("普通用户");
  79 + verify(attemptStore).clear("alice");
  80 + verify(userMapper).update(isNull(), any(Wrapper.class));
  81 + }
  82 +
  83 + @Test
  84 + void login_userNotFound_returns40101_recordsFailure() {
  85 + when(attemptStore.cooldownSeconds("alice")).thenReturn(0L);
  86 + when(userMapper.selectOne(any(Wrapper.class))).thenReturn(null);
  87 +
  88 + assertThatThrownBy(() -> service.login(dto()))
  89 + .isInstanceOf(BizException.class)
  90 + .extracting(e -> ((BizException) e).getCode())
  91 + .isEqualTo(ErrorCode.LOGIN_INVALID_CREDENTIALS.getCode());
  92 +
  93 + verify(attemptStore).recordFailure("alice");
  94 + verify(userMapper, never()).update(any(), any(Wrapper.class));
  95 + }
  96 +
  97 + @Test
  98 + void login_passwordMismatch_returns40101_recordsFailure() {
  99 + when(attemptStore.cooldownSeconds("alice")).thenReturn(0L);
  100 + when(userMapper.selectOne(any(Wrapper.class))).thenReturn(userEntity());
  101 + when(passwordEncoder.matches("666666", "$2a$10$hash")).thenReturn(false);
  102 +
  103 + assertThatThrownBy(() -> service.login(dto()))
  104 + .isInstanceOf(BizException.class)
  105 + .extracting(e -> ((BizException) e).getCode())
  106 + .isEqualTo(ErrorCode.LOGIN_INVALID_CREDENTIALS.getCode());
  107 +
  108 + verify(attemptStore).recordFailure("alice");
  109 + verify(userMapper, never()).update(any(), any(Wrapper.class));
  110 + }
  111 +
  112 + @Test
  113 + void login_accountLocked_throwsAccountLockedException_withCooldown() {
  114 + when(attemptStore.cooldownSeconds("alice")).thenReturn(540L);
  115 +
  116 + assertThatThrownBy(() -> service.login(dto()))
  117 + .isInstanceOf(AccountLockedException.class)
  118 + .extracting(e -> ((AccountLockedException) e).getCooldownSeconds())
  119 + .isEqualTo(540L);
  120 +
  121 + verify(userMapper, never()).selectOne(any(Wrapper.class));
  122 + }
  123 +
  124 + @Test
  125 + void login_5thFailureTriggersLock_throwsAccountLockedException() {
  126 + when(attemptStore.cooldownSeconds("alice"))
  127 + .thenReturn(0L) // 入口检查通过
  128 + .thenReturn(900L); // recordFailure 后再查时已锁定
  129 + when(userMapper.selectOne(any(Wrapper.class))).thenReturn(userEntity());
  130 + when(passwordEncoder.matches("666666", "$2a$10$hash")).thenReturn(false);
  131 +
  132 + assertThatThrownBy(() -> service.login(dto()))
  133 + .isInstanceOf(AccountLockedException.class)
  134 + .extracting(e -> ((AccountLockedException) e).getCooldownSeconds())
  135 + .isEqualTo(900L);
  136 +
  137 + verify(attemptStore).recordFailure("alice");
  138 + }
  139 +
  140 + @Test
  141 + void login_successUpdatesTLastLoginDate_viaSetClause() {
  142 + when(attemptStore.cooldownSeconds("alice")).thenReturn(0L);
  143 + when(userMapper.selectOne(any(Wrapper.class))).thenReturn(userEntity());
  144 + when(passwordEncoder.matches(anyString(), anyString())).thenReturn(true);
  145 + when(jwtTokenProvider.sign(anyInt(), anyString(), anyString())).thenReturn("token");
  146 + when(jwtTokenProvider.getExpiresInSeconds()).thenReturn(7200L);
  147 +
  148 + service.login(dto());
  149 +
  150 + // 验证 update(entity=null, wrapper=non-null),对应 LambdaUpdateWrapper.set(tLastLoginDate, now)
  151 + verify(userMapper, times(1)).update(isNull(), any(Wrapper.class));
  152 + }
  153 +
  154 + @Test
  155 + void login_userSoftDeleted_returns40101() {
  156 + // selectOne 已过滤 bDeleted=0 → 软删用户返回 null(与 not found 等效路径)
  157 + when(attemptStore.cooldownSeconds("alice")).thenReturn(0L);
  158 + when(userMapper.selectOne(any(Wrapper.class))).thenReturn(null);
  159 +
  160 + assertThatThrownBy(() -> service.login(dto()))
  161 + .isInstanceOf(BizException.class)
  162 + .extracting(e -> ((BizException) e).getCode())
  163 + .isEqualTo(ErrorCode.LOGIN_INVALID_CREDENTIALS.getCode());
  164 + }
  165 +}
... ...