Commit 3263e4df9498b257f563d322e89cdfb4730f4ce4
1 parent
8939e0fc
feat(usr): LoginService 完整实现(公司/用户/作废/锁定/成功路径 + JWT) REQ-USR-001
Showing
3 changed files
with
338 additions
and
0 deletions
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.vo.LoginVo; | |
| 4 | + | |
| 5 | +public interface LoginService { | |
| 6 | + /** | |
| 7 | + * 校验用户名 + 密码 + 公司编码并签发 access token。 | |
| 8 | + * REQ-USR-001。 | |
| 9 | + * | |
| 10 | + * @throws com.xly.erp.common.exception.BizException | |
| 11 | + * 40004 公司不存在 / 40101 凭据错误 / 40103 账号作废 / 42301 账号锁定 | |
| 12 | + */ | |
| 13 | + LoginVo login(String username, String password, String companyCode); | |
| 14 | +} | ... | ... |
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.xly.erp.common.exception.BizException; | |
| 4 | +import com.xly.erp.common.response.ErrorCode; | |
| 5 | +import com.xly.erp.common.security.JwtUtil; | |
| 6 | +import com.xly.erp.module.usr.entity.SysCompany; | |
| 7 | +import com.xly.erp.module.usr.entity.SysEmployee; | |
| 8 | +import com.xly.erp.module.usr.entity.SysUser; | |
| 9 | +import com.xly.erp.module.usr.mapper.SysCompanyMapper; | |
| 10 | +import com.xly.erp.module.usr.mapper.SysEmployeeMapper; | |
| 11 | +import com.xly.erp.module.usr.mapper.SysUserMapper; | |
| 12 | +import com.xly.erp.module.usr.service.LoginService; | |
| 13 | +import com.xly.erp.module.usr.vo.LoginVo; | |
| 14 | +import com.xly.erp.module.usr.vo.UserInfoVo; | |
| 15 | +import lombok.RequiredArgsConstructor; | |
| 16 | +import lombok.extern.slf4j.Slf4j; | |
| 17 | +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; | |
| 18 | +import org.springframework.stereotype.Service; | |
| 19 | +import org.springframework.transaction.annotation.Transactional; | |
| 20 | + | |
| 21 | +import java.time.LocalDateTime; | |
| 22 | +import java.util.HashMap; | |
| 23 | +import java.util.Map; | |
| 24 | + | |
| 25 | +@Service | |
| 26 | +@RequiredArgsConstructor | |
| 27 | +@Slf4j | |
| 28 | +public class LoginServiceImpl implements LoginService { | |
| 29 | + | |
| 30 | + static final int MAX_FAILED_LOGIN_COUNT = 5; | |
| 31 | + static final long LOCK_DURATION_MINUTES = 30L; | |
| 32 | + static final long TOKEN_TTL_SEC = 7200L; | |
| 33 | + | |
| 34 | + private final SysUserMapper userMapper; | |
| 35 | + private final SysCompanyMapper companyMapper; | |
| 36 | + private final SysEmployeeMapper employeeMapper; | |
| 37 | + private final BCryptPasswordEncoder passwordEncoder; | |
| 38 | + private final JwtUtil jwtUtil; | |
| 39 | + | |
| 40 | + @Override | |
| 41 | + @Transactional(noRollbackFor = BizException.class) | |
| 42 | + public LoginVo login(String username, String password, String companyCode) { | |
| 43 | + // 1. 公司校验 | |
| 44 | + SysCompany company = companyMapper.selectByCode(companyCode); | |
| 45 | + if (company == null || Integer.valueOf(1).equals(company.getIIsDeleted())) { | |
| 46 | + log.warn("[login] companyCode={} 不存在或已删除", companyCode); | |
| 47 | + throw new BizException(ErrorCode.COMPANY_NOT_FOUND, "公司不存在或已删除"); | |
| 48 | + } | |
| 49 | + | |
| 50 | + // 2. 用户查找 | |
| 51 | + SysUser user = userMapper.selectByUsername(username); | |
| 52 | + if (user == null) { | |
| 53 | + log.warn("[login] username={} 不存在(统一返 40101)", username); | |
| 54 | + throw new BizException(ErrorCode.BAD_CREDENTIALS, "用户名或密码错误"); | |
| 55 | + } | |
| 56 | + | |
| 57 | + // 3. 作废校验(不计入失败次数) | |
| 58 | + if (Integer.valueOf(1).equals(user.getIIsDeleted())) { | |
| 59 | + log.warn("[login] username={} 已作废", username); | |
| 60 | + throw new BizException(ErrorCode.ACCOUNT_DELETED, "账号已被作废,禁止登录"); | |
| 61 | + } | |
| 62 | + | |
| 63 | + // 4. 锁定校验(不计入失败次数) | |
| 64 | + if (user.getTLockUntil() != null && user.getTLockUntil().isAfter(LocalDateTime.now())) { | |
| 65 | + log.warn("[login] username={} 锁定中,lockUntil={}", username, user.getTLockUntil()); | |
| 66 | + throw new BizException(ErrorCode.ACCOUNT_LOCKED, "账号已锁定,请稍后再试"); | |
| 67 | + } | |
| 68 | + | |
| 69 | + // 5. 密码校验 | |
| 70 | + if (!passwordEncoder.matches(password, user.getSPasswordHash())) { | |
| 71 | + handleFailedLogin(user); | |
| 72 | + log.warn("[login] username={} 密码错误,failedCount={}", username, | |
| 73 | + user.getIFailedLoginCount() + 1); | |
| 74 | + throw new BizException(ErrorCode.BAD_CREDENTIALS, "用户名或密码错误"); | |
| 75 | + } | |
| 76 | + | |
| 77 | + // 6. 成功路径 | |
| 78 | + return loginSuccess(user, company, companyCode); | |
| 79 | + } | |
| 80 | + | |
| 81 | + private void handleFailedLogin(SysUser user) { | |
| 82 | + int newCount = (user.getIFailedLoginCount() == null ? 0 : user.getIFailedLoginCount()) + 1; | |
| 83 | + SysUser update = new SysUser(); | |
| 84 | + update.setIIncrement(user.getIIncrement()); | |
| 85 | + update.setIFailedLoginCount(newCount); | |
| 86 | + if (newCount >= MAX_FAILED_LOGIN_COUNT) { | |
| 87 | + update.setTLockUntil(LocalDateTime.now().plusMinutes(LOCK_DURATION_MINUTES)); | |
| 88 | + log.warn("[login] username={} 失败累计达 {} 次,触发锁定 {} 分钟", | |
| 89 | + user.getSUsername(), newCount, LOCK_DURATION_MINUTES); | |
| 90 | + } | |
| 91 | + userMapper.updateById(update); | |
| 92 | + } | |
| 93 | + | |
| 94 | + private LoginVo loginSuccess(SysUser user, SysCompany company, String companyCode) { | |
| 95 | + SysUser update = new SysUser(); | |
| 96 | + update.setIIncrement(user.getIIncrement()); | |
| 97 | + update.setIFailedLoginCount(0); | |
| 98 | + update.setTLockUntil(null); | |
| 99 | + update.setTLastLoginDate(LocalDateTime.now()); | |
| 100 | + // tLockUntil 需 explicit null update via mapper — MyBatis-Plus 默认忽略 null, | |
| 101 | + // 这里用 UpdateWrapper 显式置 null | |
| 102 | + userMapper.update(update, | |
| 103 | + new com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper<SysUser>() | |
| 104 | + .eq("iIncrement", user.getIIncrement()) | |
| 105 | + .set("tLockUntil", null) | |
| 106 | + .set("iFailedLoginCount", 0) | |
| 107 | + .set("tLastLoginDate", LocalDateTime.now())); | |
| 108 | + | |
| 109 | + String employeeName = null; | |
| 110 | + if (user.getIEmployeeId() != null) { | |
| 111 | + SysEmployee emp = employeeMapper.selectById(user.getIEmployeeId()); | |
| 112 | + if (emp != null) { | |
| 113 | + employeeName = emp.getSEmployeeName(); | |
| 114 | + } | |
| 115 | + } | |
| 116 | + | |
| 117 | + Map<String, Object> claims = new HashMap<>(); | |
| 118 | + claims.put("sub", user.getIIncrement()); | |
| 119 | + claims.put("username", user.getSUsername()); | |
| 120 | + claims.put("userType", user.getSUserType()); | |
| 121 | + claims.put("companyCode", companyCode); | |
| 122 | + claims.put("language", user.getSLanguage()); | |
| 123 | + | |
| 124 | + String token = jwtUtil.issue(claims, TOKEN_TTL_SEC); | |
| 125 | + | |
| 126 | + log.info("[login] username={} 登录成功", user.getSUsername()); | |
| 127 | + | |
| 128 | + return LoginVo.builder() | |
| 129 | + .accessToken(token) | |
| 130 | + .tokenType("Bearer") | |
| 131 | + .expiresInSec(TOKEN_TTL_SEC) | |
| 132 | + .userInfo(UserInfoVo.builder() | |
| 133 | + .userId(user.getIIncrement()) | |
| 134 | + .username(user.getSUsername()) | |
| 135 | + .userType(user.getSUserType()) | |
| 136 | + .language(user.getSLanguage()) | |
| 137 | + .companyCode(companyCode) | |
| 138 | + .employeeName(employeeName) | |
| 139 | + .build()) | |
| 140 | + .build(); | |
| 141 | + } | |
| 142 | +} | ... | ... |
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.xly.erp.common.exception.BizException; | |
| 4 | +import com.xly.erp.common.response.ErrorCode; | |
| 5 | +import com.xly.erp.common.security.JwtUtil; | |
| 6 | +import com.xly.erp.module.usr.entity.SysUser; | |
| 7 | +import com.xly.erp.module.usr.mapper.SysUserMapper; | |
| 8 | +import com.xly.erp.module.usr.support.LoginTestSeeder; | |
| 9 | +import com.xly.erp.module.usr.vo.LoginVo; | |
| 10 | +import org.junit.jupiter.api.BeforeEach; | |
| 11 | +import org.junit.jupiter.api.Test; | |
| 12 | +import org.springframework.beans.factory.annotation.Autowired; | |
| 13 | +import org.springframework.boot.test.context.SpringBootTest; | |
| 14 | +import org.springframework.jdbc.core.JdbcTemplate; | |
| 15 | +import org.springframework.test.context.ActiveProfiles; | |
| 16 | + | |
| 17 | +import java.time.LocalDateTime; | |
| 18 | +import java.util.Map; | |
| 19 | + | |
| 20 | +import static org.junit.jupiter.api.Assertions.*; | |
| 21 | + | |
| 22 | +@SpringBootTest | |
| 23 | +@ActiveProfiles("test") | |
| 24 | +class LoginServiceImplTest { | |
| 25 | + | |
| 26 | + @Autowired private LoginService loginService; | |
| 27 | + @Autowired private SysUserMapper userMapper; | |
| 28 | + @Autowired private JdbcTemplate jdbc; | |
| 29 | + @Autowired private LoginTestSeeder seeder; | |
| 30 | + @Autowired private JwtUtil jwtUtil; | |
| 31 | + | |
| 32 | + private LoginTestSeeder.Fixture fx; | |
| 33 | + | |
| 34 | + @BeforeEach | |
| 35 | + void setUp() { | |
| 36 | + fx = seeder.reset(); | |
| 37 | + } | |
| 38 | + | |
| 39 | + @Test | |
| 40 | + void contextLoads_loginServiceBean() { | |
| 41 | + assertNotNull(loginService); | |
| 42 | + } | |
| 43 | + | |
| 44 | + // ===== Task 7: company validation ===== | |
| 45 | + | |
| 46 | + @Test | |
| 47 | + void login_unknownCompany_throws40004() { | |
| 48 | + BizException e = assertThrows(BizException.class, | |
| 49 | + () -> loginService.login(LoginTestSeeder.USER_OK, LoginTestSeeder.DEFAULT_PASSWORD, "NOPE")); | |
| 50 | + assertEquals(ErrorCode.COMPANY_NOT_FOUND, e.getCode()); | |
| 51 | + SysUser u = userMapper.selectByUsername(LoginTestSeeder.USER_OK); | |
| 52 | + assertEquals(0, u.getIFailedLoginCount(), "公司校验失败不应累加用户失败次数"); | |
| 53 | + } | |
| 54 | + | |
| 55 | + @Test | |
| 56 | + void login_softDeletedCompany_throws40004() { | |
| 57 | + BizException e = assertThrows(BizException.class, | |
| 58 | + () -> loginService.login(LoginTestSeeder.USER_OK, LoginTestSeeder.DEFAULT_PASSWORD, | |
| 59 | + LoginTestSeeder.COMPANY_DELETED)); | |
| 60 | + assertEquals(ErrorCode.COMPANY_NOT_FOUND, e.getCode()); | |
| 61 | + } | |
| 62 | + | |
| 63 | + // ===== Task 8: bad credentials ===== | |
| 64 | + | |
| 65 | + @Test | |
| 66 | + void login_unknownUser_throws40101_noDbWrite() { | |
| 67 | + BizException e = assertThrows(BizException.class, | |
| 68 | + () -> loginService.login("nobody", "any", LoginTestSeeder.COMPANY_OK)); | |
| 69 | + assertEquals(ErrorCode.BAD_CREDENTIALS, e.getCode()); | |
| 70 | + } | |
| 71 | + | |
| 72 | + @Test | |
| 73 | + void login_badPassword_throws40101_andIncrementsFailCount() { | |
| 74 | + BizException e = assertThrows(BizException.class, | |
| 75 | + () -> loginService.login(LoginTestSeeder.USER_OK, "WrongPass1!", LoginTestSeeder.COMPANY_OK)); | |
| 76 | + assertEquals(ErrorCode.BAD_CREDENTIALS, e.getCode()); | |
| 77 | + SysUser u = userMapper.selectByUsername(LoginTestSeeder.USER_OK); | |
| 78 | + assertEquals(1, u.getIFailedLoginCount()); | |
| 79 | + } | |
| 80 | + | |
| 81 | + // ===== Task 9: locking ===== | |
| 82 | + | |
| 83 | + @Test | |
| 84 | + void login_5thBadPassword_setsLockUntil_andStillReturns40101() { | |
| 85 | + for (int i = 0; i < 4; i++) { | |
| 86 | + assertThrows(BizException.class, | |
| 87 | + () -> loginService.login(LoginTestSeeder.USER_OK, "WrongPass1!", LoginTestSeeder.COMPANY_OK)); | |
| 88 | + } | |
| 89 | + SysUser before5 = userMapper.selectByUsername(LoginTestSeeder.USER_OK); | |
| 90 | + assertEquals(4, before5.getIFailedLoginCount()); | |
| 91 | + assertNull(before5.getTLockUntil()); | |
| 92 | + | |
| 93 | + BizException e = assertThrows(BizException.class, | |
| 94 | + () -> loginService.login(LoginTestSeeder.USER_OK, "WrongPass1!", LoginTestSeeder.COMPANY_OK)); | |
| 95 | + assertEquals(ErrorCode.BAD_CREDENTIALS, e.getCode(), | |
| 96 | + "第 5 次错误仍返 40101(不暴露阈值)"); | |
| 97 | + | |
| 98 | + SysUser after5 = userMapper.selectByUsername(LoginTestSeeder.USER_OK); | |
| 99 | + assertEquals(5, after5.getIFailedLoginCount()); | |
| 100 | + assertNotNull(after5.getTLockUntil(), "第 5 次错误应设置锁定截止"); | |
| 101 | + assertTrue(after5.getTLockUntil().isAfter(LocalDateTime.now().plusMinutes(29)), | |
| 102 | + "锁定时长应 ~30 分钟"); | |
| 103 | + } | |
| 104 | + | |
| 105 | + @Test | |
| 106 | + void login_duringLockWindow_throws42301_noCountIncrement() { | |
| 107 | + jdbc.update("UPDATE sys_user SET iFailedLoginCount=5, tLockUntil=DATE_ADD(NOW(), INTERVAL 30 MINUTE) WHERE sUsername=?", | |
| 108 | + LoginTestSeeder.USER_OK); | |
| 109 | + | |
| 110 | + BizException e = assertThrows(BizException.class, | |
| 111 | + () -> loginService.login(LoginTestSeeder.USER_OK, LoginTestSeeder.DEFAULT_PASSWORD, | |
| 112 | + LoginTestSeeder.COMPANY_OK)); | |
| 113 | + assertEquals(ErrorCode.ACCOUNT_LOCKED, e.getCode()); | |
| 114 | + | |
| 115 | + SysUser u = userMapper.selectByUsername(LoginTestSeeder.USER_OK); | |
| 116 | + assertEquals(5, u.getIFailedLoginCount(), "锁定期间任何登录尝试不应改变计数"); | |
| 117 | + } | |
| 118 | + | |
| 119 | + @Test | |
| 120 | + void login_afterLockExpired_allowsNewAttempt() { | |
| 121 | + jdbc.update("UPDATE sys_user SET iFailedLoginCount=5, tLockUntil=DATE_SUB(NOW(), INTERVAL 1 MINUTE) WHERE sUsername=?", | |
| 122 | + LoginTestSeeder.USER_OK); | |
| 123 | + | |
| 124 | + LoginVo vo = loginService.login(LoginTestSeeder.USER_OK, LoginTestSeeder.DEFAULT_PASSWORD, | |
| 125 | + LoginTestSeeder.COMPANY_OK); | |
| 126 | + assertNotNull(vo.getAccessToken()); | |
| 127 | + | |
| 128 | + SysUser u = userMapper.selectByUsername(LoginTestSeeder.USER_OK); | |
| 129 | + assertEquals(0, u.getIFailedLoginCount(), "锁定过期 + 成功登录应清零"); | |
| 130 | + assertNull(u.getTLockUntil(), "成功登录应清空 tLockUntil"); | |
| 131 | + } | |
| 132 | + | |
| 133 | + // ===== Task 10: deleted + success ===== | |
| 134 | + | |
| 135 | + @Test | |
| 136 | + void login_deletedUser_throws40103_noCountIncrement() { | |
| 137 | + BizException e = assertThrows(BizException.class, | |
| 138 | + () -> loginService.login(LoginTestSeeder.USER_DELETED, LoginTestSeeder.DEFAULT_PASSWORD, | |
| 139 | + LoginTestSeeder.COMPANY_OK)); | |
| 140 | + assertEquals(ErrorCode.ACCOUNT_DELETED, e.getCode()); | |
| 141 | + | |
| 142 | + SysUser u = userMapper.selectByUsername(LoginTestSeeder.USER_DELETED); | |
| 143 | + assertEquals(0, u.getIFailedLoginCount()); | |
| 144 | + } | |
| 145 | + | |
| 146 | + @Test | |
| 147 | + void login_success_returnsTokenAndClearsFailCount_andUpdatesLastLogin() { | |
| 148 | + jdbc.update("UPDATE sys_user SET iFailedLoginCount=2 WHERE sUsername=?", LoginTestSeeder.USER_OK); | |
| 149 | + | |
| 150 | + LoginVo vo = loginService.login(LoginTestSeeder.USER_OK, LoginTestSeeder.DEFAULT_PASSWORD, | |
| 151 | + LoginTestSeeder.COMPANY_OK); | |
| 152 | + | |
| 153 | + assertNotNull(vo); | |
| 154 | + assertNotNull(vo.getAccessToken()); | |
| 155 | + assertEquals("Bearer", vo.getTokenType()); | |
| 156 | + assertEquals(7200L, vo.getExpiresInSec()); | |
| 157 | + assertNotNull(vo.getUserInfo()); | |
| 158 | + assertEquals(LoginTestSeeder.USER_OK, vo.getUserInfo().getUsername()); | |
| 159 | + assertEquals("NORMAL", vo.getUserInfo().getUserType()); | |
| 160 | + assertEquals("zh-CN", vo.getUserInfo().getLanguage()); | |
| 161 | + assertEquals(LoginTestSeeder.COMPANY_OK, vo.getUserInfo().getCompanyCode()); | |
| 162 | + assertEquals("张三", vo.getUserInfo().getEmployeeName()); | |
| 163 | + | |
| 164 | + SysUser u = userMapper.selectByUsername(LoginTestSeeder.USER_OK); | |
| 165 | + assertEquals(0, u.getIFailedLoginCount()); | |
| 166 | + assertNull(u.getTLockUntil()); | |
| 167 | + assertNotNull(u.getTLastLoginDate()); | |
| 168 | + } | |
| 169 | + | |
| 170 | + @Test | |
| 171 | + void login_success_jwtParsesBack_with_sub_username_companyCode() { | |
| 172 | + LoginVo vo = loginService.login(LoginTestSeeder.USER_OK, LoginTestSeeder.DEFAULT_PASSWORD, | |
| 173 | + LoginTestSeeder.COMPANY_OK); | |
| 174 | + Map<String, Object> claims = jwtUtil.parse(vo.getAccessToken()); | |
| 175 | + assertEquals(String.valueOf(fx.aliceId()), claims.get("sub")); | |
| 176 | + assertEquals(LoginTestSeeder.USER_OK, claims.get("username")); | |
| 177 | + assertEquals(LoginTestSeeder.COMPANY_OK, claims.get("companyCode")); | |
| 178 | + assertEquals("NORMAL", claims.get("userType")); | |
| 179 | + assertEquals("zh-CN", claims.get("language")); | |
| 180 | + assertNotNull(claims.get("jti")); | |
| 181 | + } | |
| 182 | +} | ... | ... |