From 3263e4df9498b257f563d322e89cdfb4730f4ce4 Mon Sep 17 00:00:00 2001 From: zichun Date: Fri, 15 May 2026 00:27:54 +0800 Subject: [PATCH] feat(usr): LoginService 完整实现(公司/用户/作废/锁定/成功路径 + JWT) REQ-USR-001 --- backend/src/main/java/com/xly/erp/module/usr/service/LoginService.java | 14 ++++++++++++++ backend/src/main/java/com/xly/erp/module/usr/service/impl/LoginServiceImpl.java | 142 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ backend/src/test/java/com/xly/erp/module/usr/service/LoginServiceImplTest.java | 182 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 338 insertions(+), 0 deletions(-) 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/module/usr/service/LoginService.java b/backend/src/main/java/com/xly/erp/module/usr/service/LoginService.java new file mode 100644 index 0000000..ef17b5c --- /dev/null +++ b/backend/src/main/java/com/xly/erp/module/usr/service/LoginService.java @@ -0,0 +1,14 @@ +package com.xly.erp.module.usr.service; + +import com.xly.erp.module.usr.vo.LoginVo; + +public interface LoginService { + /** + * 校验用户名 + 密码 + 公司编码并签发 access token。 + * REQ-USR-001。 + * + * @throws com.xly.erp.common.exception.BizException + * 40004 公司不存在 / 40101 凭据错误 / 40103 账号作废 / 42301 账号锁定 + */ + LoginVo login(String username, String password, String companyCode); +} 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..d1adf7d --- /dev/null +++ b/backend/src/main/java/com/xly/erp/module/usr/service/impl/LoginServiceImpl.java @@ -0,0 +1,142 @@ +package com.xly.erp.module.usr.service.impl; + +import com.xly.erp.common.exception.BizException; +import com.xly.erp.common.response.ErrorCode; +import com.xly.erp.common.security.JwtUtil; +import com.xly.erp.module.usr.entity.SysCompany; +import com.xly.erp.module.usr.entity.SysEmployee; +import com.xly.erp.module.usr.entity.SysUser; +import com.xly.erp.module.usr.mapper.SysCompanyMapper; +import com.xly.erp.module.usr.mapper.SysEmployeeMapper; +import com.xly.erp.module.usr.mapper.SysUserMapper; +import com.xly.erp.module.usr.service.LoginService; +import com.xly.erp.module.usr.vo.LoginVo; +import com.xly.erp.module.usr.vo.UserInfoVo; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.Map; + +@Service +@RequiredArgsConstructor +@Slf4j +public class LoginServiceImpl implements LoginService { + + static final int MAX_FAILED_LOGIN_COUNT = 5; + static final long LOCK_DURATION_MINUTES = 30L; + static final long TOKEN_TTL_SEC = 7200L; + + private final SysUserMapper userMapper; + private final SysCompanyMapper companyMapper; + private final SysEmployeeMapper employeeMapper; + private final BCryptPasswordEncoder passwordEncoder; + private final JwtUtil jwtUtil; + + @Override + @Transactional(noRollbackFor = BizException.class) + public LoginVo login(String username, String password, String companyCode) { + // 1. 公司校验 + SysCompany company = companyMapper.selectByCode(companyCode); + if (company == null || Integer.valueOf(1).equals(company.getIIsDeleted())) { + log.warn("[login] companyCode={} 不存在或已删除", companyCode); + throw new BizException(ErrorCode.COMPANY_NOT_FOUND, "公司不存在或已删除"); + } + + // 2. 用户查找 + SysUser user = userMapper.selectByUsername(username); + if (user == null) { + log.warn("[login] username={} 不存在(统一返 40101)", username); + throw new BizException(ErrorCode.BAD_CREDENTIALS, "用户名或密码错误"); + } + + // 3. 作废校验(不计入失败次数) + if (Integer.valueOf(1).equals(user.getIIsDeleted())) { + log.warn("[login] username={} 已作废", username); + throw new BizException(ErrorCode.ACCOUNT_DELETED, "账号已被作废,禁止登录"); + } + + // 4. 锁定校验(不计入失败次数) + if (user.getTLockUntil() != null && user.getTLockUntil().isAfter(LocalDateTime.now())) { + log.warn("[login] username={} 锁定中,lockUntil={}", username, user.getTLockUntil()); + throw new BizException(ErrorCode.ACCOUNT_LOCKED, "账号已锁定,请稍后再试"); + } + + // 5. 密码校验 + if (!passwordEncoder.matches(password, user.getSPasswordHash())) { + handleFailedLogin(user); + log.warn("[login] username={} 密码错误,failedCount={}", username, + user.getIFailedLoginCount() + 1); + throw new BizException(ErrorCode.BAD_CREDENTIALS, "用户名或密码错误"); + } + + // 6. 成功路径 + return loginSuccess(user, company, companyCode); + } + + private void handleFailedLogin(SysUser user) { + int newCount = (user.getIFailedLoginCount() == null ? 0 : user.getIFailedLoginCount()) + 1; + SysUser update = new SysUser(); + update.setIIncrement(user.getIIncrement()); + update.setIFailedLoginCount(newCount); + if (newCount >= MAX_FAILED_LOGIN_COUNT) { + update.setTLockUntil(LocalDateTime.now().plusMinutes(LOCK_DURATION_MINUTES)); + log.warn("[login] username={} 失败累计达 {} 次,触发锁定 {} 分钟", + user.getSUsername(), newCount, LOCK_DURATION_MINUTES); + } + userMapper.updateById(update); + } + + private LoginVo loginSuccess(SysUser user, SysCompany company, String companyCode) { + SysUser update = new SysUser(); + update.setIIncrement(user.getIIncrement()); + update.setIFailedLoginCount(0); + update.setTLockUntil(null); + update.setTLastLoginDate(LocalDateTime.now()); + // tLockUntil 需 explicit null update via mapper — MyBatis-Plus 默认忽略 null, + // 这里用 UpdateWrapper 显式置 null + userMapper.update(update, + new com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper() + .eq("iIncrement", user.getIIncrement()) + .set("tLockUntil", null) + .set("iFailedLoginCount", 0) + .set("tLastLoginDate", LocalDateTime.now())); + + String employeeName = null; + if (user.getIEmployeeId() != null) { + SysEmployee emp = employeeMapper.selectById(user.getIEmployeeId()); + if (emp != null) { + employeeName = emp.getSEmployeeName(); + } + } + + Map claims = new HashMap<>(); + claims.put("sub", user.getIIncrement()); + claims.put("username", user.getSUsername()); + claims.put("userType", user.getSUserType()); + claims.put("companyCode", companyCode); + claims.put("language", user.getSLanguage()); + + String token = jwtUtil.issue(claims, TOKEN_TTL_SEC); + + log.info("[login] username={} 登录成功", user.getSUsername()); + + return LoginVo.builder() + .accessToken(token) + .tokenType("Bearer") + .expiresInSec(TOKEN_TTL_SEC) + .userInfo(UserInfoVo.builder() + .userId(user.getIIncrement()) + .username(user.getSUsername()) + .userType(user.getSUserType()) + .language(user.getSLanguage()) + .companyCode(companyCode) + .employeeName(employeeName) + .build()) + .build(); + } +} 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..35c2f81 --- /dev/null +++ b/backend/src/test/java/com/xly/erp/module/usr/service/LoginServiceImplTest.java @@ -0,0 +1,182 @@ +package com.xly.erp.module.usr.service; + +import com.xly.erp.common.exception.BizException; +import com.xly.erp.common.response.ErrorCode; +import com.xly.erp.common.security.JwtUtil; +import com.xly.erp.module.usr.entity.SysUser; +import com.xly.erp.module.usr.mapper.SysUserMapper; +import com.xly.erp.module.usr.support.LoginTestSeeder; +import com.xly.erp.module.usr.vo.LoginVo; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; + +import java.time.LocalDateTime; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +@SpringBootTest +@ActiveProfiles("test") +class LoginServiceImplTest { + + @Autowired private LoginService loginService; + @Autowired private SysUserMapper userMapper; + @Autowired private JdbcTemplate jdbc; + @Autowired private LoginTestSeeder seeder; + @Autowired private JwtUtil jwtUtil; + + private LoginTestSeeder.Fixture fx; + + @BeforeEach + void setUp() { + fx = seeder.reset(); + } + + @Test + void contextLoads_loginServiceBean() { + assertNotNull(loginService); + } + + // ===== Task 7: company validation ===== + + @Test + void login_unknownCompany_throws40004() { + BizException e = assertThrows(BizException.class, + () -> loginService.login(LoginTestSeeder.USER_OK, LoginTestSeeder.DEFAULT_PASSWORD, "NOPE")); + assertEquals(ErrorCode.COMPANY_NOT_FOUND, e.getCode()); + SysUser u = userMapper.selectByUsername(LoginTestSeeder.USER_OK); + assertEquals(0, u.getIFailedLoginCount(), "公司校验失败不应累加用户失败次数"); + } + + @Test + void login_softDeletedCompany_throws40004() { + BizException e = assertThrows(BizException.class, + () -> loginService.login(LoginTestSeeder.USER_OK, LoginTestSeeder.DEFAULT_PASSWORD, + LoginTestSeeder.COMPANY_DELETED)); + assertEquals(ErrorCode.COMPANY_NOT_FOUND, e.getCode()); + } + + // ===== Task 8: bad credentials ===== + + @Test + void login_unknownUser_throws40101_noDbWrite() { + BizException e = assertThrows(BizException.class, + () -> loginService.login("nobody", "any", LoginTestSeeder.COMPANY_OK)); + assertEquals(ErrorCode.BAD_CREDENTIALS, e.getCode()); + } + + @Test + void login_badPassword_throws40101_andIncrementsFailCount() { + BizException e = assertThrows(BizException.class, + () -> loginService.login(LoginTestSeeder.USER_OK, "WrongPass1!", LoginTestSeeder.COMPANY_OK)); + assertEquals(ErrorCode.BAD_CREDENTIALS, e.getCode()); + SysUser u = userMapper.selectByUsername(LoginTestSeeder.USER_OK); + assertEquals(1, u.getIFailedLoginCount()); + } + + // ===== Task 9: locking ===== + + @Test + void login_5thBadPassword_setsLockUntil_andStillReturns40101() { + for (int i = 0; i < 4; i++) { + assertThrows(BizException.class, + () -> loginService.login(LoginTestSeeder.USER_OK, "WrongPass1!", LoginTestSeeder.COMPANY_OK)); + } + SysUser before5 = userMapper.selectByUsername(LoginTestSeeder.USER_OK); + assertEquals(4, before5.getIFailedLoginCount()); + assertNull(before5.getTLockUntil()); + + BizException e = assertThrows(BizException.class, + () -> loginService.login(LoginTestSeeder.USER_OK, "WrongPass1!", LoginTestSeeder.COMPANY_OK)); + assertEquals(ErrorCode.BAD_CREDENTIALS, e.getCode(), + "第 5 次错误仍返 40101(不暴露阈值)"); + + SysUser after5 = userMapper.selectByUsername(LoginTestSeeder.USER_OK); + assertEquals(5, after5.getIFailedLoginCount()); + assertNotNull(after5.getTLockUntil(), "第 5 次错误应设置锁定截止"); + assertTrue(after5.getTLockUntil().isAfter(LocalDateTime.now().plusMinutes(29)), + "锁定时长应 ~30 分钟"); + } + + @Test + void login_duringLockWindow_throws42301_noCountIncrement() { + jdbc.update("UPDATE sys_user SET iFailedLoginCount=5, tLockUntil=DATE_ADD(NOW(), INTERVAL 30 MINUTE) WHERE sUsername=?", + LoginTestSeeder.USER_OK); + + BizException e = assertThrows(BizException.class, + () -> loginService.login(LoginTestSeeder.USER_OK, LoginTestSeeder.DEFAULT_PASSWORD, + LoginTestSeeder.COMPANY_OK)); + assertEquals(ErrorCode.ACCOUNT_LOCKED, e.getCode()); + + SysUser u = userMapper.selectByUsername(LoginTestSeeder.USER_OK); + assertEquals(5, u.getIFailedLoginCount(), "锁定期间任何登录尝试不应改变计数"); + } + + @Test + void login_afterLockExpired_allowsNewAttempt() { + jdbc.update("UPDATE sys_user SET iFailedLoginCount=5, tLockUntil=DATE_SUB(NOW(), INTERVAL 1 MINUTE) WHERE sUsername=?", + LoginTestSeeder.USER_OK); + + LoginVo vo = loginService.login(LoginTestSeeder.USER_OK, LoginTestSeeder.DEFAULT_PASSWORD, + LoginTestSeeder.COMPANY_OK); + assertNotNull(vo.getAccessToken()); + + SysUser u = userMapper.selectByUsername(LoginTestSeeder.USER_OK); + assertEquals(0, u.getIFailedLoginCount(), "锁定过期 + 成功登录应清零"); + assertNull(u.getTLockUntil(), "成功登录应清空 tLockUntil"); + } + + // ===== Task 10: deleted + success ===== + + @Test + void login_deletedUser_throws40103_noCountIncrement() { + BizException e = assertThrows(BizException.class, + () -> loginService.login(LoginTestSeeder.USER_DELETED, LoginTestSeeder.DEFAULT_PASSWORD, + LoginTestSeeder.COMPANY_OK)); + assertEquals(ErrorCode.ACCOUNT_DELETED, e.getCode()); + + SysUser u = userMapper.selectByUsername(LoginTestSeeder.USER_DELETED); + assertEquals(0, u.getIFailedLoginCount()); + } + + @Test + void login_success_returnsTokenAndClearsFailCount_andUpdatesLastLogin() { + jdbc.update("UPDATE sys_user SET iFailedLoginCount=2 WHERE sUsername=?", LoginTestSeeder.USER_OK); + + LoginVo vo = loginService.login(LoginTestSeeder.USER_OK, LoginTestSeeder.DEFAULT_PASSWORD, + LoginTestSeeder.COMPANY_OK); + + assertNotNull(vo); + assertNotNull(vo.getAccessToken()); + assertEquals("Bearer", vo.getTokenType()); + assertEquals(7200L, vo.getExpiresInSec()); + assertNotNull(vo.getUserInfo()); + assertEquals(LoginTestSeeder.USER_OK, vo.getUserInfo().getUsername()); + assertEquals("NORMAL", vo.getUserInfo().getUserType()); + assertEquals("zh-CN", vo.getUserInfo().getLanguage()); + assertEquals(LoginTestSeeder.COMPANY_OK, vo.getUserInfo().getCompanyCode()); + assertEquals("张三", vo.getUserInfo().getEmployeeName()); + + SysUser u = userMapper.selectByUsername(LoginTestSeeder.USER_OK); + assertEquals(0, u.getIFailedLoginCount()); + assertNull(u.getTLockUntil()); + assertNotNull(u.getTLastLoginDate()); + } + + @Test + void login_success_jwtParsesBack_with_sub_username_companyCode() { + LoginVo vo = loginService.login(LoginTestSeeder.USER_OK, LoginTestSeeder.DEFAULT_PASSWORD, + LoginTestSeeder.COMPANY_OK); + Map claims = jwtUtil.parse(vo.getAccessToken()); + assertEquals(String.valueOf(fx.aliceId()), claims.get("sub")); + assertEquals(LoginTestSeeder.USER_OK, claims.get("username")); + assertEquals(LoginTestSeeder.COMPANY_OK, claims.get("companyCode")); + assertEquals("NORMAL", claims.get("userType")); + assertEquals("zh-CN", claims.get("language")); + assertNotNull(claims.get("jti")); + } +} -- libgit2 0.22.2