Commit 3263e4df9498b257f563d322e89cdfb4730f4ce4

Authored by zichun
1 parent 8939e0fc

feat(usr): LoginService 完整实现(公司/用户/作废/锁定/成功路径 + JWT) REQ-USR-001

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 +}