From e0bf3066667b02cbb51b6b6d82331f3636b8633b Mon Sep 17 00:00:00 2001 From: zichun Date: Thu, 7 May 2026 09:16:29 +0800 Subject: [PATCH] feat(usr): login service + account locked handling REQ-USR-004 --- backend/src/main/java/com/xly/erp/common/exception/AccountLockedException.java | 19 +++++++++++++++++++ backend/src/main/java/com/xly/erp/common/exception/GlobalExceptionHandler.java | 8 ++++++++ backend/src/main/java/com/xly/erp/module/usr/service/LoginService.java | 9 +++++++++ backend/src/main/java/com/xly/erp/module/usr/service/impl/LoginServiceImpl.java | 87 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ backend/src/test/java/com/xly/erp/module/usr/service/LoginServiceImplTest.java | 165 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 288 insertions(+), 0 deletions(-) create mode 100644 backend/src/main/java/com/xly/erp/common/exception/AccountLockedException.java create mode 100644 backend/src/main/java/com/xly/erp/module/usr/service/LoginService.java create mode 100644 backend/src/main/java/com/xly/erp/module/usr/service/impl/LoginServiceImpl.java create mode 100644 backend/src/test/java/com/xly/erp/module/usr/service/LoginServiceImplTest.java diff --git a/backend/src/main/java/com/xly/erp/common/exception/AccountLockedException.java b/backend/src/main/java/com/xly/erp/common/exception/AccountLockedException.java new file mode 100644 index 0000000..6d5d099 --- /dev/null +++ b/backend/src/main/java/com/xly/erp/common/exception/AccountLockedException.java @@ -0,0 +1,19 @@ +package com.xly.erp.common.exception; + +import com.xly.erp.common.response.ErrorCode; +import lombok.Getter; + +/** + * REQ-USR-004 账号被临时锁定时抛出。携带剩余 cooldownSeconds, + * GlobalExceptionHandler 会把它转换成 ApiResponse.data 的 cooldownSeconds 字段。 + */ +@Getter +public class AccountLockedException extends BizException { + + private final long cooldownSeconds; + + public AccountLockedException(long cooldownSeconds) { + super(ErrorCode.LOGIN_ACCOUNT_LOCKED); + this.cooldownSeconds = cooldownSeconds; + } +} diff --git a/backend/src/main/java/com/xly/erp/common/exception/GlobalExceptionHandler.java b/backend/src/main/java/com/xly/erp/common/exception/GlobalExceptionHandler.java index 1281b9d..ff2fdd1 100644 --- a/backend/src/main/java/com/xly/erp/common/exception/GlobalExceptionHandler.java +++ b/backend/src/main/java/com/xly/erp/common/exception/GlobalExceptionHandler.java @@ -12,6 +12,14 @@ import org.springframework.web.bind.annotation.RestControllerAdvice; @RestControllerAdvice public class GlobalExceptionHandler { + /** REQ-USR-004 账号锁定专用:把 cooldownSeconds 暴露在 data.cooldownSeconds */ + @ExceptionHandler(AccountLockedException.class) + public ApiResponse> handleAccountLocked(AccountLockedException e) { + log.warn("AccountLocked code={} cooldown={}s", e.getCode(), e.getCooldownSeconds()); + java.util.Map data = java.util.Map.of("cooldownSeconds", e.getCooldownSeconds()); + return new ApiResponse<>(e.getCode(), e.getMessage(), data, System.currentTimeMillis()); + } + @ExceptionHandler(BizException.class) public ApiResponse handleBiz(BizException e) { log.warn("BizException code={} message={}", e.getCode(), e.getMessage()); diff --git a/backend/src/main/java/com/xly/erp/module/usr/service/LoginService.java b/backend/src/main/java/com/xly/erp/module/usr/service/LoginService.java new file mode 100644 index 0000000..3edf345 --- /dev/null +++ b/backend/src/main/java/com/xly/erp/module/usr/service/LoginService.java @@ -0,0 +1,9 @@ +package com.xly.erp.module.usr.service; + +import com.xly.erp.module.usr.dto.LoginDTO; +import com.xly.erp.module.usr.vo.LoginResultVO; + +public interface LoginService { + /** REQ-USR-004 用户登录 */ + LoginResultVO login(LoginDTO dto); +} diff --git a/backend/src/main/java/com/xly/erp/module/usr/service/impl/LoginServiceImpl.java b/backend/src/main/java/com/xly/erp/module/usr/service/impl/LoginServiceImpl.java new file mode 100644 index 0000000..665c530 --- /dev/null +++ b/backend/src/main/java/com/xly/erp/module/usr/service/impl/LoginServiceImpl.java @@ -0,0 +1,87 @@ +package com.xly.erp.module.usr.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.xly.erp.common.exception.AccountLockedException; +import com.xly.erp.common.exception.BizException; +import com.xly.erp.common.response.ErrorCode; +import com.xly.erp.module.usr.dto.LoginDTO; +import com.xly.erp.module.usr.entity.UserEntity; +import com.xly.erp.module.usr.mapper.UserMapper; +import com.xly.erp.module.usr.security.JwtTokenProvider; +import com.xly.erp.module.usr.security.LoginAttemptStore; +import com.xly.erp.module.usr.service.LoginService; +import com.xly.erp.module.usr.vo.LoginResultVO; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; + +/** REQ-USR-004 用户登录 service */ +@Slf4j +@Service +@RequiredArgsConstructor +public class LoginServiceImpl implements LoginService { + + private final UserMapper userMapper; + private final PasswordEncoder passwordEncoder; + private final LoginAttemptStore attemptStore; + private final JwtTokenProvider jwtTokenProvider; + + @Override + @Transactional(rollbackFor = Exception.class) + public LoginResultVO login(LoginDTO dto) { + String username = dto.getSUserName(); + + // 1. 锁定检查 + long cooldown = attemptStore.cooldownSeconds(username); + if (cooldown > 0L) { + log.info("Login locked username={} cooldown={}s", username, cooldown); + throw new AccountLockedException(cooldown); + } + + // 2. 查用户 + UserEntity user = userMapper.selectOne( + new LambdaQueryWrapper() + .eq(UserEntity::getSUserName, username) + .eq(UserEntity::getBDeleted, false)); + if (user == null) { + log.info("Login user-not-found username={}", username); + attemptStore.recordFailure(username); + // 检查 record 后是否触发锁定(边界:第 5 次失败,下一次再调时已锁) + long cd = attemptStore.cooldownSeconds(username); + if (cd > 0L) { + throw new AccountLockedException(cd); + } + throw new BizException(ErrorCode.LOGIN_INVALID_CREDENTIALS); + } + + // 3. BCrypt 校验 + if (!passwordEncoder.matches(dto.getSPassword(), user.getSPasswordHash())) { + log.info("Login bad-password username={}", username); + attemptStore.recordFailure(username); + long cd = attemptStore.cooldownSeconds(username); + if (cd > 0L) { + throw new AccountLockedException(cd); + } + throw new BizException(ErrorCode.LOGIN_INVALID_CREDENTIALS); + } + + // 4. 成功:清失败计数 + 签发 token + 更新 tLastLoginDate + attemptStore.clear(username); + String token = jwtTokenProvider.sign(user.getIIncrement(), user.getSUserName(), user.getSUserType()); + userMapper.update(null, + new LambdaUpdateWrapper() + .eq(UserEntity::getIIncrement, user.getIIncrement()) + .set(UserEntity::getTLastLoginDate, LocalDateTime.now())); + log.info("Login success username={} uid={}", username, user.getIIncrement()); + + LoginResultVO.LoginUserInfo info = new LoginResultVO.LoginUserInfo( + user.getIIncrement(), user.getSUserNo(), user.getSUserName(), + user.getSUserType(), user.getSLanguage()); + return new LoginResultVO(token, jwtTokenProvider.getExpiresInSeconds(), info); + } +} diff --git a/backend/src/test/java/com/xly/erp/module/usr/service/LoginServiceImplTest.java b/backend/src/test/java/com/xly/erp/module/usr/service/LoginServiceImplTest.java new file mode 100644 index 0000000..1704398 --- /dev/null +++ b/backend/src/test/java/com/xly/erp/module/usr/service/LoginServiceImplTest.java @@ -0,0 +1,165 @@ +package com.xly.erp.module.usr.service; + +import com.baomidou.mybatisplus.core.conditions.Wrapper; +import com.xly.erp.common.exception.AccountLockedException; +import com.xly.erp.common.exception.BizException; +import com.xly.erp.common.response.ErrorCode; +import com.xly.erp.module.usr.dto.LoginDTO; +import com.xly.erp.module.usr.entity.UserEntity; +import com.xly.erp.module.usr.mapper.UserMapper; +import com.xly.erp.module.usr.security.JwtTokenProvider; +import com.xly.erp.module.usr.security.LoginAttemptStore; +import com.xly.erp.module.usr.service.impl.LoginServiceImpl; +import com.xly.erp.module.usr.vo.LoginResultVO; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.crypto.password.PasswordEncoder; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class LoginServiceImplTest { + + @Mock UserMapper userMapper; + @Mock PasswordEncoder passwordEncoder; + @Mock LoginAttemptStore attemptStore; + @Mock JwtTokenProvider jwtTokenProvider; + + @InjectMocks LoginServiceImpl service; + + private LoginDTO dto() { + LoginDTO d = new LoginDTO(); + d.setSUserName("alice"); + d.setSPassword("666666"); + d.setSVersion("standard"); + return d; + } + + private UserEntity userEntity() { + UserEntity u = new UserEntity(); + u.setIIncrement(42); + u.setSUserNo("u001"); + u.setSUserName("alice"); + u.setSUserType("普通用户"); + u.setSLanguage("zh"); + u.setSPasswordHash("$2a$10$hash"); + u.setBDeleted(false); + return u; + } + + @Test + void login_validCredentials_returnsTokenAndClearsFailCount() { + when(attemptStore.cooldownSeconds("alice")).thenReturn(0L); + when(userMapper.selectOne(any(Wrapper.class))).thenReturn(userEntity()); + when(passwordEncoder.matches("666666", "$2a$10$hash")).thenReturn(true); + when(jwtTokenProvider.sign(42, "alice", "普通用户")).thenReturn("jwt.token.value"); + when(jwtTokenProvider.getExpiresInSeconds()).thenReturn(7200L); + + LoginResultVO vo = service.login(dto()); + + assertThat(vo.getAccessToken()).isEqualTo("jwt.token.value"); + assertThat(vo.getExpiresIn()).isEqualTo(7200L); + assertThat(vo.getUser().getIIncrement()).isEqualTo(42); + assertThat(vo.getUser().getSUserName()).isEqualTo("alice"); + assertThat(vo.getUser().getSUserType()).isEqualTo("普通用户"); + verify(attemptStore).clear("alice"); + verify(userMapper).update(isNull(), any(Wrapper.class)); + } + + @Test + void login_userNotFound_returns40101_recordsFailure() { + when(attemptStore.cooldownSeconds("alice")).thenReturn(0L); + when(userMapper.selectOne(any(Wrapper.class))).thenReturn(null); + + assertThatThrownBy(() -> service.login(dto())) + .isInstanceOf(BizException.class) + .extracting(e -> ((BizException) e).getCode()) + .isEqualTo(ErrorCode.LOGIN_INVALID_CREDENTIALS.getCode()); + + verify(attemptStore).recordFailure("alice"); + verify(userMapper, never()).update(any(), any(Wrapper.class)); + } + + @Test + void login_passwordMismatch_returns40101_recordsFailure() { + when(attemptStore.cooldownSeconds("alice")).thenReturn(0L); + when(userMapper.selectOne(any(Wrapper.class))).thenReturn(userEntity()); + when(passwordEncoder.matches("666666", "$2a$10$hash")).thenReturn(false); + + assertThatThrownBy(() -> service.login(dto())) + .isInstanceOf(BizException.class) + .extracting(e -> ((BizException) e).getCode()) + .isEqualTo(ErrorCode.LOGIN_INVALID_CREDENTIALS.getCode()); + + verify(attemptStore).recordFailure("alice"); + verify(userMapper, never()).update(any(), any(Wrapper.class)); + } + + @Test + void login_accountLocked_throwsAccountLockedException_withCooldown() { + when(attemptStore.cooldownSeconds("alice")).thenReturn(540L); + + assertThatThrownBy(() -> service.login(dto())) + .isInstanceOf(AccountLockedException.class) + .extracting(e -> ((AccountLockedException) e).getCooldownSeconds()) + .isEqualTo(540L); + + verify(userMapper, never()).selectOne(any(Wrapper.class)); + } + + @Test + void login_5thFailureTriggersLock_throwsAccountLockedException() { + when(attemptStore.cooldownSeconds("alice")) + .thenReturn(0L) // 入口检查通过 + .thenReturn(900L); // recordFailure 后再查时已锁定 + when(userMapper.selectOne(any(Wrapper.class))).thenReturn(userEntity()); + when(passwordEncoder.matches("666666", "$2a$10$hash")).thenReturn(false); + + assertThatThrownBy(() -> service.login(dto())) + .isInstanceOf(AccountLockedException.class) + .extracting(e -> ((AccountLockedException) e).getCooldownSeconds()) + .isEqualTo(900L); + + verify(attemptStore).recordFailure("alice"); + } + + @Test + void login_successUpdatesTLastLoginDate_viaSetClause() { + when(attemptStore.cooldownSeconds("alice")).thenReturn(0L); + when(userMapper.selectOne(any(Wrapper.class))).thenReturn(userEntity()); + when(passwordEncoder.matches(anyString(), anyString())).thenReturn(true); + when(jwtTokenProvider.sign(anyInt(), anyString(), anyString())).thenReturn("token"); + when(jwtTokenProvider.getExpiresInSeconds()).thenReturn(7200L); + + service.login(dto()); + + // 验证 update(entity=null, wrapper=non-null),对应 LambdaUpdateWrapper.set(tLastLoginDate, now) + verify(userMapper, times(1)).update(isNull(), any(Wrapper.class)); + } + + @Test + void login_userSoftDeleted_returns40101() { + // selectOne 已过滤 bDeleted=0 → 软删用户返回 null(与 not found 等效路径) + when(attemptStore.cooldownSeconds("alice")).thenReturn(0L); + when(userMapper.selectOne(any(Wrapper.class))).thenReturn(null); + + assertThatThrownBy(() -> service.login(dto())) + .isInstanceOf(BizException.class) + .extracting(e -> ((BizException) e).getCode()) + .isEqualTo(ErrorCode.LOGIN_INVALID_CREDENTIALS.getCode()); + } +} -- libgit2 0.22.2