Commit 293118bc68c866d68782026c083145ed3cae7d87
1 parent
a8075798
feat(usr): 登录认证 Service 与公司列表 REQ-USR-004
Showing
3 changed files
with
474 additions
and
0 deletions
backend/src/main/java/com/xly/erp/modules/usr/service/UsrAuthService.java
0 → 100644
| 1 | +package com.xly.erp.modules.usr.service; | ||
| 2 | + | ||
| 3 | +import com.xly.erp.modules.usr.dto.LoginDTO; | ||
| 4 | +import com.xly.erp.modules.usr.vo.CompanyOptionVO; | ||
| 5 | +import com.xly.erp.modules.usr.vo.LoginVO; | ||
| 6 | +import java.util.List; | ||
| 7 | + | ||
| 8 | +/** | ||
| 9 | + * 认证服务(spec § 3)。REQ-USR-004 T4。 | ||
| 10 | + */ | ||
| 11 | +public interface UsrAuthService { | ||
| 12 | + | ||
| 13 | + /** | ||
| 14 | + * 登录认证:基础参数校验 → 查用户 → BCrypt 比对 → 判禁用 → companyId 存在性 → | ||
| 15 | + * 签发 JWT + 更新登录时间;含连续失败限流。 | ||
| 16 | + * | ||
| 17 | + * @param dto 登录入参 | ||
| 18 | + * @return 登录输出(token + 用户基础信息,绝不含密码) | ||
| 19 | + */ | ||
| 20 | + LoginVO login(LoginDTO dto); | ||
| 21 | + | ||
| 22 | + /** | ||
| 23 | + * 公司下拉列表(登录页「版本」选项),只读无副作用。 | ||
| 24 | + * | ||
| 25 | + * @return 全部公司下拉项 | ||
| 26 | + */ | ||
| 27 | + List<CompanyOptionVO> listCompanies(); | ||
| 28 | +} |
backend/src/main/java/com/xly/erp/modules/usr/service/impl/UsrAuthServiceImpl.java
0 → 100644
| 1 | +package com.xly.erp.modules.usr.service.impl; | ||
| 2 | + | ||
| 3 | +import com.baomidou.mybatisplus.core.toolkit.Wrappers; | ||
| 4 | +import com.xly.erp.common.exception.BusinessException; | ||
| 5 | +import com.xly.erp.common.response.ResultCode; | ||
| 6 | +import com.xly.erp.common.security.JwtUtil; | ||
| 7 | +import com.xly.erp.modules.usr.dto.LoginDTO; | ||
| 8 | +import com.xly.erp.modules.usr.entity.UsrCompany; | ||
| 9 | +import com.xly.erp.modules.usr.entity.UsrUser; | ||
| 10 | +import com.xly.erp.modules.usr.mapper.UsrCompanyMapper; | ||
| 11 | +import com.xly.erp.modules.usr.mapper.UsrUserMapper; | ||
| 12 | +import com.xly.erp.modules.usr.service.UsrAuthService; | ||
| 13 | +import com.xly.erp.modules.usr.vo.CompanyOptionVO; | ||
| 14 | +import com.xly.erp.modules.usr.vo.LoginVO; | ||
| 15 | +import java.time.LocalDateTime; | ||
| 16 | +import java.util.ArrayList; | ||
| 17 | +import java.util.List; | ||
| 18 | +import java.util.concurrent.ConcurrentHashMap; | ||
| 19 | +import org.springframework.beans.factory.annotation.Value; | ||
| 20 | +import org.springframework.security.crypto.password.PasswordEncoder; | ||
| 21 | +import org.springframework.stereotype.Service; | ||
| 22 | +import org.springframework.transaction.annotation.Transactional; | ||
| 23 | +import org.springframework.util.StringUtils; | ||
| 24 | + | ||
| 25 | +/** | ||
| 26 | + * 认证业务实现(spec § 3)。REQ-USR-004 T4。 | ||
| 27 | + * | ||
| 28 | + * <p>认证判定顺序(spec § 3 规则 1 / § 8 D3,固定): | ||
| 29 | + * ①限流窗判定(命中锁定 → 42901,置于最前)→ ②按 sUserName(trim) 查 usr_user,null → 40101(记一次失败) | ||
| 30 | + * → ③BCrypt 比对密码,不匹配 → 40101(记一次失败,与②同码同 message 防枚举)→ ④iIsVoid=1 → 40302 | ||
| 31 | + * (先验密码再判禁用,禁用不计入失败计数避免锁死)→ ⑤companyId 存在性校验,不存在 → 40001 | ||
| 32 | + * → ⑥签发 JWT + 仅更新 tLastLoginDate + 清零失败计数 + 返回 LoginVO。</p> | ||
| 33 | + * | ||
| 34 | + * <p>限流为进程内(内存)按用户名计数(spec § 8 D7),不依赖 Redis;阈值 / 窗口来自 | ||
| 35 | + * application.yml auth.login.max-fail / auth.login.lock-seconds。密码明文绝不进日志 / 异常 message。</p> | ||
| 36 | + */ | ||
| 37 | +@Service | ||
| 38 | +public class UsrAuthServiceImpl implements UsrAuthService { | ||
| 39 | + | ||
| 40 | + private final UsrUserMapper usrUserMapper; | ||
| 41 | + private final UsrCompanyMapper usrCompanyMapper; | ||
| 42 | + private final PasswordEncoder passwordEncoder; | ||
| 43 | + private final JwtUtil jwtUtil; | ||
| 44 | + | ||
| 45 | + /** 达到该连续失败次数后锁定。 */ | ||
| 46 | + private final int maxFail; | ||
| 47 | + /** 锁定窗秒数。 */ | ||
| 48 | + private final long lockSeconds; | ||
| 49 | + | ||
| 50 | + /** 进程内按用户名的失败计数 / 锁定状态(线程安全)。 */ | ||
| 51 | + private final ConcurrentHashMap<String, LoginAttempt> attempts = new ConcurrentHashMap<>(); | ||
| 52 | + | ||
| 53 | + public UsrAuthServiceImpl(UsrUserMapper usrUserMapper, | ||
| 54 | + UsrCompanyMapper usrCompanyMapper, | ||
| 55 | + PasswordEncoder passwordEncoder, | ||
| 56 | + JwtUtil jwtUtil, | ||
| 57 | + @Value("${auth.login.max-fail:5}") int maxFail, | ||
| 58 | + @Value("${auth.login.lock-seconds:300}") long lockSeconds) { | ||
| 59 | + this.usrUserMapper = usrUserMapper; | ||
| 60 | + this.usrCompanyMapper = usrCompanyMapper; | ||
| 61 | + this.passwordEncoder = passwordEncoder; | ||
| 62 | + this.jwtUtil = jwtUtil; | ||
| 63 | + this.maxFail = maxFail; | ||
| 64 | + this.lockSeconds = lockSeconds; | ||
| 65 | + } | ||
| 66 | + | ||
| 67 | + @Override | ||
| 68 | + @Transactional(rollbackFor = Exception.class) | ||
| 69 | + public LoginVO login(LoginDTO dto) { | ||
| 70 | + String userName = dto.getSUserName() == null ? null : dto.getSUserName().trim(); | ||
| 71 | + | ||
| 72 | + // ① 限流窗判定(命中锁定 → 42901,置于最前)。 | ||
| 73 | + if (isLocked(userName)) { | ||
| 74 | + throw new BusinessException(ResultCode.LOGIN_RATE_LIMITED); | ||
| 75 | + } | ||
| 76 | + | ||
| 77 | + // ② 按用户名查用户,未查到 → 40101(防枚举,统一文案)。 | ||
| 78 | + UsrUser user = usrUserMapper.selectOne( | ||
| 79 | + Wrappers.<UsrUser>lambdaQuery().eq(UsrUser::getSUserName, userName)); | ||
| 80 | + if (user == null) { | ||
| 81 | + recordFailure(userName); | ||
| 82 | + throw new BusinessException(ResultCode.UNAUTHORIZED); | ||
| 83 | + } | ||
| 84 | + | ||
| 85 | + // ③ 先 BCrypt 比对密码,不匹配 → 40101(与②同码同 message)。 | ||
| 86 | + if (!passwordEncoder.matches(dto.getPassword(), user.getSPassword())) { | ||
| 87 | + recordFailure(userName); | ||
| 88 | + throw new BusinessException(ResultCode.UNAUTHORIZED); | ||
| 89 | + } | ||
| 90 | + | ||
| 91 | + // ④ 再判禁用 → 40302(先验密码再返禁用码;禁用不计入失败计数避免锁死禁用账号)。 | ||
| 92 | + if (user.getIIsVoid() != null && user.getIIsVoid() == 1) { | ||
| 93 | + throw new BusinessException(ResultCode.ACCOUNT_DISABLED); | ||
| 94 | + } | ||
| 95 | + | ||
| 96 | + // ⑤ companyId 存在性校验(不参与认证绑定),不存在 → 40001(中性提示,不含枚举信息)。 | ||
| 97 | + if (usrCompanyMapper.selectById(dto.getCompanyId()) == null) { | ||
| 98 | + throw new BusinessException(ResultCode.PARAM_INVALID); | ||
| 99 | + } | ||
| 100 | + | ||
| 101 | + // ⑥ 签发 JWT + 仅更新 tLastLoginDate + 清零失败计数 + 装配 LoginVO。 | ||
| 102 | + String token = jwtUtil.generateToken(user.getSUserName(), user.getSUserType()); | ||
| 103 | + | ||
| 104 | + UsrUser loginTimeUpdate = new UsrUser(); | ||
| 105 | + loginTimeUpdate.setIIncrement(user.getIIncrement()); | ||
| 106 | + loginTimeUpdate.setTLastLoginDate(LocalDateTime.now()); | ||
| 107 | + usrUserMapper.updateById(loginTimeUpdate); | ||
| 108 | + | ||
| 109 | + attempts.remove(userName); | ||
| 110 | + | ||
| 111 | + return toLoginVO(token, user); | ||
| 112 | + } | ||
| 113 | + | ||
| 114 | + @Override | ||
| 115 | + @Transactional(readOnly = true) | ||
| 116 | + public List<CompanyOptionVO> listCompanies() { | ||
| 117 | + List<UsrCompany> companies = usrCompanyMapper.selectList(null); | ||
| 118 | + List<CompanyOptionVO> result = new ArrayList<>(); | ||
| 119 | + for (UsrCompany c : companies) { | ||
| 120 | + CompanyOptionVO vo = new CompanyOptionVO(); | ||
| 121 | + vo.setId(c.getIIncrement()); | ||
| 122 | + vo.setSCompanyName(c.getSCompanyName()); | ||
| 123 | + vo.setSVersion(c.getSVersion()); | ||
| 124 | + result.add(vo); | ||
| 125 | + } | ||
| 126 | + return result; | ||
| 127 | + } | ||
| 128 | + | ||
| 129 | + private LoginVO toLoginVO(String token, UsrUser user) { | ||
| 130 | + LoginVO.UserInfo info = new LoginVO.UserInfo(); | ||
| 131 | + info.setId(user.getIIncrement()); | ||
| 132 | + info.setSUserName(user.getSUserName()); | ||
| 133 | + info.setSUserType(user.getSUserType()); | ||
| 134 | + info.setSLanguage(user.getSLanguage()); | ||
| 135 | + | ||
| 136 | + LoginVO vo = new LoginVO(); | ||
| 137 | + vo.setToken(token); | ||
| 138 | + vo.setUser(info); | ||
| 139 | + return vo; | ||
| 140 | + } | ||
| 141 | + | ||
| 142 | + /** 当前用户名是否处于锁定窗内(连续失败已达阈值且未过期)。 */ | ||
| 143 | + private boolean isLocked(String userName) { | ||
| 144 | + if (!StringUtils.hasText(userName)) { | ||
| 145 | + return false; | ||
| 146 | + } | ||
| 147 | + LoginAttempt attempt = attempts.get(userName); | ||
| 148 | + if (attempt == null) { | ||
| 149 | + return false; | ||
| 150 | + } | ||
| 151 | + if (attempt.failCount >= maxFail && attempt.lockUntilEpochSec > nowEpochSec()) { | ||
| 152 | + return true; | ||
| 153 | + } | ||
| 154 | + // 锁定窗已过:重置计数,允许再次尝试。 | ||
| 155 | + if (attempt.lockUntilEpochSec > 0 && attempt.lockUntilEpochSec <= nowEpochSec()) { | ||
| 156 | + attempts.remove(userName); | ||
| 157 | + } | ||
| 158 | + return false; | ||
| 159 | + } | ||
| 160 | + | ||
| 161 | + /** 记一次失败:累计计数,达阈值则设置锁定到期时间。 */ | ||
| 162 | + private void recordFailure(String userName) { | ||
| 163 | + if (!StringUtils.hasText(userName)) { | ||
| 164 | + return; | ||
| 165 | + } | ||
| 166 | + attempts.compute(userName, (k, prev) -> { | ||
| 167 | + LoginAttempt a = prev == null ? new LoginAttempt() : prev; | ||
| 168 | + a.failCount++; | ||
| 169 | + if (a.failCount >= maxFail) { | ||
| 170 | + a.lockUntilEpochSec = nowEpochSec() + lockSeconds; | ||
| 171 | + } | ||
| 172 | + return a; | ||
| 173 | + }); | ||
| 174 | + } | ||
| 175 | + | ||
| 176 | + private long nowEpochSec() { | ||
| 177 | + return System.currentTimeMillis() / 1000L; | ||
| 178 | + } | ||
| 179 | + | ||
| 180 | + /** 单个用户名的失败计数与锁定到期(epoch 秒)。 */ | ||
| 181 | + private static final class LoginAttempt { | ||
| 182 | + private int failCount; | ||
| 183 | + private long lockUntilEpochSec; | ||
| 184 | + } | ||
| 185 | +} |
backend/src/test/java/com/xly/erp/modules/usr/service/UsrAuthServiceImplTest.java
0 → 100644
| 1 | +package com.xly.erp.modules.usr.service; | ||
| 2 | + | ||
| 3 | +import static org.assertj.core.api.Assertions.assertThat; | ||
| 4 | +import static org.assertj.core.api.Assertions.assertThatThrownBy; | ||
| 5 | +import static org.mockito.ArgumentMatchers.any; | ||
| 6 | +import static org.mockito.ArgumentMatchers.anyString; | ||
| 7 | +import static org.mockito.ArgumentMatchers.eq; | ||
| 8 | +import static org.mockito.Mockito.atLeastOnce; | ||
| 9 | +import static org.mockito.Mockito.never; | ||
| 10 | +import static org.mockito.Mockito.times; | ||
| 11 | +import static org.mockito.Mockito.verify; | ||
| 12 | +import static org.mockito.Mockito.when; | ||
| 13 | + | ||
| 14 | +import com.xly.erp.common.exception.BusinessException; | ||
| 15 | +import com.xly.erp.common.response.ResultCode; | ||
| 16 | +import com.xly.erp.common.security.JwtUtil; | ||
| 17 | +import com.xly.erp.modules.usr.dto.LoginDTO; | ||
| 18 | +import com.xly.erp.modules.usr.entity.UsrCompany; | ||
| 19 | +import com.xly.erp.modules.usr.entity.UsrUser; | ||
| 20 | +import com.xly.erp.modules.usr.mapper.UsrCompanyMapper; | ||
| 21 | +import com.xly.erp.modules.usr.mapper.UsrUserMapper; | ||
| 22 | +import com.xly.erp.modules.usr.service.impl.UsrAuthServiceImpl; | ||
| 23 | +import com.xly.erp.modules.usr.vo.CompanyOptionVO; | ||
| 24 | +import com.xly.erp.modules.usr.vo.LoginVO; | ||
| 25 | +import java.util.List; | ||
| 26 | +import org.junit.jupiter.api.BeforeEach; | ||
| 27 | +import org.junit.jupiter.api.Test; | ||
| 28 | +import org.mockito.ArgumentCaptor; | ||
| 29 | +import org.mockito.Mockito; | ||
| 30 | + | ||
| 31 | +/** | ||
| 32 | + * REQ-USR-004 T4:UsrAuthServiceImpl 认证核心(纯单元,Mockito)。 | ||
| 33 | + * | ||
| 34 | + * <p>桩 UsrUserMapper/UsrCompanyMapper/PasswordEncoder/JwtUtil;限流阈值经测试构造器注入 | ||
| 35 | + * maxFail=3、lockSeconds=300,便于断言锁定行为。覆盖认证判定顺序、防枚举同码同文案、 | ||
| 36 | + * 限流锁定与成功清零、公司列表映射。</p> | ||
| 37 | + */ | ||
| 38 | +class UsrAuthServiceImplTest { | ||
| 39 | + | ||
| 40 | + private UsrUserMapper usrUserMapper; | ||
| 41 | + private UsrCompanyMapper usrCompanyMapper; | ||
| 42 | + private org.springframework.security.crypto.password.PasswordEncoder passwordEncoder; | ||
| 43 | + private JwtUtil jwtUtil; | ||
| 44 | + private UsrAuthServiceImpl service; | ||
| 45 | + | ||
| 46 | + private static final int MAX_FAIL = 3; | ||
| 47 | + | ||
| 48 | + @BeforeEach | ||
| 49 | + void setUp() { | ||
| 50 | + usrUserMapper = Mockito.mock(UsrUserMapper.class); | ||
| 51 | + usrCompanyMapper = Mockito.mock(UsrCompanyMapper.class); | ||
| 52 | + passwordEncoder = Mockito.mock( | ||
| 53 | + org.springframework.security.crypto.password.PasswordEncoder.class); | ||
| 54 | + jwtUtil = Mockito.mock(JwtUtil.class); | ||
| 55 | + service = new UsrAuthServiceImpl(usrUserMapper, usrCompanyMapper, passwordEncoder, jwtUtil, | ||
| 56 | + MAX_FAIL, 300); | ||
| 57 | + } | ||
| 58 | + | ||
| 59 | + private LoginDTO dto(String userName) { | ||
| 60 | + LoginDTO d = new LoginDTO(); | ||
| 61 | + d.setSUserName(userName); | ||
| 62 | + d.setPassword("666666"); | ||
| 63 | + d.setCompanyId(1); | ||
| 64 | + return d; | ||
| 65 | + } | ||
| 66 | + | ||
| 67 | + private UsrUser activeUser(String userName) { | ||
| 68 | + UsrUser u = new UsrUser(); | ||
| 69 | + u.setIIncrement(42); | ||
| 70 | + u.setSUserName(userName); | ||
| 71 | + u.setSPassword("$2a$hash"); | ||
| 72 | + u.setSUserType("超级管理员"); | ||
| 73 | + u.setSLanguage("中文"); | ||
| 74 | + u.setIIsVoid(0); | ||
| 75 | + return u; | ||
| 76 | + } | ||
| 77 | + | ||
| 78 | + private void stubUserFound(String userName) { | ||
| 79 | + when(usrUserMapper.selectOne(any())).thenReturn(activeUser(userName)); | ||
| 80 | + } | ||
| 81 | + | ||
| 82 | + @Test | ||
| 83 | + void successReturnsTokenAndUpdatesLoginTime() { | ||
| 84 | + stubUserFound("admin"); | ||
| 85 | + when(passwordEncoder.matches(eq("666666"), anyString())).thenReturn(true); | ||
| 86 | + when(usrCompanyMapper.selectById(1)).thenReturn(new UsrCompany()); | ||
| 87 | + when(jwtUtil.generateToken("admin", "超级管理员")).thenReturn("jwt.token"); | ||
| 88 | + | ||
| 89 | + LoginVO vo = service.login(dto("admin")); | ||
| 90 | + | ||
| 91 | + assertThat(vo.getToken()).isEqualTo("jwt.token"); | ||
| 92 | + assertThat(vo.getUser().getId()).isEqualTo(42); | ||
| 93 | + assertThat(vo.getUser().getSUserName()).isEqualTo("admin"); | ||
| 94 | + assertThat(vo.getUser().getSUserType()).isEqualTo("超级管理员"); | ||
| 95 | + assertThat(vo.getUser().getSLanguage()).isEqualTo("中文"); | ||
| 96 | + | ||
| 97 | + ArgumentCaptor<UsrUser> captor = ArgumentCaptor.forClass(UsrUser.class); | ||
| 98 | + verify(usrUserMapper, times(1)).updateById(captor.capture()); | ||
| 99 | + UsrUser updated = captor.getValue(); | ||
| 100 | + assertThat(updated.getTLastLoginDate()).isNotNull(); | ||
| 101 | + assertThat(updated.getIIncrement()).isEqualTo(42); | ||
| 102 | + // 仅更新主键 + tLastLoginDate,未覆写其它列(用户名/类型/语言/密码留 null)。 | ||
| 103 | + assertThat(updated.getSUserName()).isNull(); | ||
| 104 | + assertThat(updated.getSPassword()).isNull(); | ||
| 105 | + } | ||
| 106 | + | ||
| 107 | + @Test | ||
| 108 | + void userNotFoundThrows40101() { | ||
| 109 | + when(usrUserMapper.selectOne(any())).thenReturn(null); | ||
| 110 | + | ||
| 111 | + assertThatThrownBy(() -> service.login(dto("ghost"))) | ||
| 112 | + .isInstanceOf(BusinessException.class) | ||
| 113 | + .extracting(e -> ((BusinessException) e).getResultCode()) | ||
| 114 | + .isEqualTo(ResultCode.UNAUTHORIZED); | ||
| 115 | + | ||
| 116 | + verify(passwordEncoder, never()).matches(anyString(), anyString()); | ||
| 117 | + verify(usrUserMapper, never()).updateById(any(UsrUser.class)); | ||
| 118 | + verify(jwtUtil, never()).generateToken(anyString(), anyString()); | ||
| 119 | + } | ||
| 120 | + | ||
| 121 | + @Test | ||
| 122 | + void wrongPasswordThrows40101() { | ||
| 123 | + stubUserFound("admin"); | ||
| 124 | + when(passwordEncoder.matches(eq("666666"), anyString())).thenReturn(false); | ||
| 125 | + | ||
| 126 | + assertThatThrownBy(() -> service.login(dto("admin"))) | ||
| 127 | + .isInstanceOf(BusinessException.class) | ||
| 128 | + .extracting(e -> ((BusinessException) e).getResultCode()) | ||
| 129 | + .isEqualTo(ResultCode.UNAUTHORIZED); | ||
| 130 | + | ||
| 131 | + verify(usrUserMapper, never()).updateById(any(UsrUser.class)); | ||
| 132 | + verify(jwtUtil, never()).generateToken(anyString(), anyString()); | ||
| 133 | + } | ||
| 134 | + | ||
| 135 | + @Test | ||
| 136 | + void notFoundAndWrongPasswordSameCodeAndMessage() { | ||
| 137 | + // 用户不存在路径 | ||
| 138 | + when(usrUserMapper.selectOne(any())).thenReturn(null); | ||
| 139 | + BusinessException notFound = | ||
| 140 | + (BusinessException) catchEx(() -> service.login(dto("ghost"))); | ||
| 141 | + | ||
| 142 | + // 密码错误路径 | ||
| 143 | + when(usrUserMapper.selectOne(any())).thenReturn(activeUser("admin")); | ||
| 144 | + when(passwordEncoder.matches(eq("666666"), anyString())).thenReturn(false); | ||
| 145 | + BusinessException wrongPwd = | ||
| 146 | + (BusinessException) catchEx(() -> service.login(dto("admin"))); | ||
| 147 | + | ||
| 148 | + assertThat(notFound.getResultCode()).isEqualTo(ResultCode.UNAUTHORIZED); | ||
| 149 | + assertThat(wrongPwd.getResultCode()).isEqualTo(ResultCode.UNAUTHORIZED); | ||
| 150 | + assertThat(notFound.getMessage()).isEqualTo(wrongPwd.getMessage()); | ||
| 151 | + } | ||
| 152 | + | ||
| 153 | + @Test | ||
| 154 | + void disabledUserThrows40302AfterPasswordOk() { | ||
| 155 | + UsrUser disabled = activeUser("admin"); | ||
| 156 | + disabled.setIIsVoid(1); | ||
| 157 | + when(usrUserMapper.selectOne(any())).thenReturn(disabled); | ||
| 158 | + when(passwordEncoder.matches(eq("666666"), anyString())).thenReturn(true); | ||
| 159 | + | ||
| 160 | + assertThatThrownBy(() -> service.login(dto("admin"))) | ||
| 161 | + .isInstanceOf(BusinessException.class) | ||
| 162 | + .extracting(e -> ((BusinessException) e).getResultCode()) | ||
| 163 | + .isEqualTo(ResultCode.ACCOUNT_DISABLED); | ||
| 164 | + | ||
| 165 | + // 先验密码再判禁用:matches 被调用过。 | ||
| 166 | + verify(passwordEncoder, atLeastOnce()).matches(eq("666666"), anyString()); | ||
| 167 | + verify(jwtUtil, never()).generateToken(anyString(), anyString()); | ||
| 168 | + verify(usrUserMapper, never()).updateById(any(UsrUser.class)); | ||
| 169 | + } | ||
| 170 | + | ||
| 171 | + @Test | ||
| 172 | + void illegalCompanyIdThrows40001() { | ||
| 173 | + stubUserFound("admin"); | ||
| 174 | + when(passwordEncoder.matches(eq("666666"), anyString())).thenReturn(true); | ||
| 175 | + when(usrCompanyMapper.selectById(1)).thenReturn(null); | ||
| 176 | + | ||
| 177 | + assertThatThrownBy(() -> service.login(dto("admin"))) | ||
| 178 | + .isInstanceOf(BusinessException.class) | ||
| 179 | + .extracting(e -> ((BusinessException) e).getResultCode()) | ||
| 180 | + .isEqualTo(ResultCode.PARAM_INVALID); | ||
| 181 | + | ||
| 182 | + verify(jwtUtil, never()).generateToken(anyString(), anyString()); | ||
| 183 | + verify(usrUserMapper, never()).updateById(any(UsrUser.class)); | ||
| 184 | + } | ||
| 185 | + | ||
| 186 | + @Test | ||
| 187 | + void rateLimitAfterMaxFailThrows42901() { | ||
| 188 | + String user = "ratelimited"; | ||
| 189 | + when(usrUserMapper.selectOne(any())).thenReturn(activeUser(user)); | ||
| 190 | + when(passwordEncoder.matches(eq("666666"), anyString())).thenReturn(false); | ||
| 191 | + | ||
| 192 | + for (int i = 0; i < MAX_FAIL; i++) { | ||
| 193 | + catchEx(() -> service.login(dto(user))); | ||
| 194 | + } | ||
| 195 | + Mockito.clearInvocations(usrUserMapper, jwtUtil); | ||
| 196 | + // 锁定后即使密码正确也 42901,且不进入查用户之后的认证逻辑、不签发 token。 | ||
| 197 | + when(passwordEncoder.matches(eq("666666"), anyString())).thenReturn(true); | ||
| 198 | + | ||
| 199 | + assertThatThrownBy(() -> service.login(dto(user))) | ||
| 200 | + .isInstanceOf(BusinessException.class) | ||
| 201 | + .extracting(e -> ((BusinessException) e).getResultCode()) | ||
| 202 | + .isEqualTo(ResultCode.LOGIN_RATE_LIMITED); | ||
| 203 | + | ||
| 204 | + verify(jwtUtil, never()).generateToken(anyString(), anyString()); | ||
| 205 | + } | ||
| 206 | + | ||
| 207 | + @Test | ||
| 208 | + void successResetsFailCounter() { | ||
| 209 | + String user = "resetme"; | ||
| 210 | + when(usrUserMapper.selectOne(any())).thenReturn(activeUser(user)); | ||
| 211 | + when(usrCompanyMapper.selectById(1)).thenReturn(new UsrCompany()); | ||
| 212 | + when(jwtUtil.generateToken(anyString(), anyString())).thenReturn("jwt.token"); | ||
| 213 | + | ||
| 214 | + // 先失败 2 次(< maxFail=3) | ||
| 215 | + when(passwordEncoder.matches(eq("666666"), anyString())).thenReturn(false); | ||
| 216 | + catchEx(() -> service.login(dto(user))); | ||
| 217 | + catchEx(() -> service.login(dto(user))); | ||
| 218 | + | ||
| 219 | + // 一次成功登录清零 | ||
| 220 | + when(passwordEncoder.matches(eq("666666"), anyString())).thenReturn(true); | ||
| 221 | + service.login(dto(user)); | ||
| 222 | + | ||
| 223 | + // 再连续失败到「成功前次数 + 1」=3 次,不应触发 42901(仍为 40101) | ||
| 224 | + when(passwordEncoder.matches(eq("666666"), anyString())).thenReturn(false); | ||
| 225 | + for (int i = 0; i < MAX_FAIL; i++) { | ||
| 226 | + BusinessException ex = (BusinessException) catchEx(() -> service.login(dto(user))); | ||
| 227 | + assertThat(ex.getResultCode()).isEqualTo(ResultCode.UNAUTHORIZED); | ||
| 228 | + } | ||
| 229 | + } | ||
| 230 | + | ||
| 231 | + @Test | ||
| 232 | + void listCompaniesMapsAllRows() { | ||
| 233 | + UsrCompany a = new UsrCompany(); | ||
| 234 | + a.setIIncrement(1); | ||
| 235 | + a.setSCompanyName("公司A"); | ||
| 236 | + a.setSVersion("v1"); | ||
| 237 | + UsrCompany b = new UsrCompany(); | ||
| 238 | + b.setIIncrement(2); | ||
| 239 | + b.setSCompanyName("公司B"); | ||
| 240 | + b.setSVersion(null); | ||
| 241 | + when(usrCompanyMapper.selectList(any())).thenReturn(List.of(a, b)); | ||
| 242 | + | ||
| 243 | + List<CompanyOptionVO> result = service.listCompanies(); | ||
| 244 | + | ||
| 245 | + assertThat(result).hasSize(2); | ||
| 246 | + assertThat(result.get(0).getId()).isEqualTo(1); | ||
| 247 | + assertThat(result.get(0).getSCompanyName()).isEqualTo("公司A"); | ||
| 248 | + assertThat(result.get(0).getSVersion()).isEqualTo("v1"); | ||
| 249 | + assertThat(result.get(1).getId()).isEqualTo(2); | ||
| 250 | + assertThat(result.get(1).getSVersion()).isNull(); | ||
| 251 | + } | ||
| 252 | + | ||
| 253 | + private static Throwable catchEx(Runnable r) { | ||
| 254 | + try { | ||
| 255 | + r.run(); | ||
| 256 | + } catch (Throwable t) { | ||
| 257 | + return t; | ||
| 258 | + } | ||
| 259 | + throw new AssertionError("expected exception but none thrown"); | ||
| 260 | + } | ||
| 261 | +} |