Commit 3d2c0ad35aeb118714c4402f72eaaeea87adce87

Authored by zichun
1 parent 677b3e48

fix(usr): 修复 review round 1 must-fix REQ-USR-001

- 42301 响应携带 data.lockUntil(BizException 扩 data + handler 写入)
- 失败计数改原子单 SQL UPDATE(去 noRollbackFor,行锁由 DB 保证)
- SysUserMapper/SysCompanyMapper 去 SELECT *,改显式列名
- JwtUtil 短密钥静默补零 → 启动期硬抛 IllegalStateException
- loginSuccess 去 entity 冗余路径,只保留 UpdateWrapper
- 补 exp-iat==7200 / lockUntil 字段 / 并发原子累加 测试
backend/src/main/java/com/xly/erp/common/exception/BizException.java
@@ -9,14 +9,22 @@ import lombok.Getter; @@ -9,14 +9,22 @@ import lombok.Getter;
9 @Getter 9 @Getter
10 public class BizException extends RuntimeException { 10 public class BizException extends RuntimeException {
11 private final int code; 11 private final int code;
  12 + /** 可选附带的响应数据(例如 42301 锁定返 lockUntil)。null 表示无 data 字段。 */
  13 + private final Object data;
12 14
13 public BizException(int code, String message) { 15 public BizException(int code, String message) {
  16 + this(code, message, (Object) null);
  17 + }
  18 +
  19 + public BizException(int code, String message, Object data) {
14 super(message); 20 super(message);
15 this.code = code; 21 this.code = code;
  22 + this.data = data;
16 } 23 }
17 24
18 public BizException(int code, String message, Throwable cause) { 25 public BizException(int code, String message, Throwable cause) {
19 super(message, cause); 26 super(message, cause);
20 this.code = code; 27 this.code = code;
  28 + this.data = null;
21 } 29 }
22 } 30 }
backend/src/main/java/com/xly/erp/common/exception/GlobalExceptionHandler.java
@@ -19,11 +19,14 @@ import org.springframework.web.bind.annotation.RestControllerAdvice; @@ -19,11 +19,14 @@ import org.springframework.web.bind.annotation.RestControllerAdvice;
19 public class GlobalExceptionHandler { 19 public class GlobalExceptionHandler {
20 20
21 @ExceptionHandler(BizException.class) 21 @ExceptionHandler(BizException.class)
22 - public ResponseEntity<Result<Void>> handleBiz(BizException e) {  
23 - log.warn("[BizException] code={} message={}", e.getCode(), e.getMessage()); 22 + public ResponseEntity<Result<Object>> handleBiz(BizException e) {
  23 + log.warn("[BizException] code={} message={} hasData={}", e.getCode(), e.getMessage(), e.getData() != null);
  24 + Result<Object> body = e.getData() != null
  25 + ? Result.fail(e.getCode(), e.getMessage(), e.getData())
  26 + : Result.fail(e.getCode(), e.getMessage());
24 return ResponseEntity 27 return ResponseEntity
25 .status(ErrorCode.toHttpStatus(e.getCode())) 28 .status(ErrorCode.toHttpStatus(e.getCode()))
26 - .body(Result.fail(e.getCode(), e.getMessage())); 29 + .body(body);
27 } 30 }
28 31
29 @ExceptionHandler(MethodArgumentNotValidException.class) 32 @ExceptionHandler(MethodArgumentNotValidException.class)
backend/src/main/java/com/xly/erp/common/response/Result.java
@@ -31,4 +31,9 @@ public class Result&lt;T&gt; { @@ -31,4 +31,9 @@ public class Result&lt;T&gt; {
31 public static <T> Result<T> fail(int code, String message) { 31 public static <T> Result<T> fail(int code, String message) {
32 return new Result<>(code, message, null); 32 return new Result<>(code, message, null);
33 } 33 }
  34 +
  35 + @SuppressWarnings("unchecked")
  36 + public static <T> Result<T> fail(int code, String message, T data) {
  37 + return new Result<>(code, message, data);
  38 + }
34 } 39 }
backend/src/main/java/com/xly/erp/common/security/JwtUtil.java
@@ -33,9 +33,9 @@ public class JwtUtil { @@ -33,9 +33,9 @@ public class JwtUtil {
33 void init() { 33 void init() {
34 byte[] bytes = secret.getBytes(StandardCharsets.UTF_8); 34 byte[] bytes = secret.getBytes(StandardCharsets.UTF_8);
35 if (bytes.length < 32) { 35 if (bytes.length < 32) {
36 - byte[] padded = new byte[32];  
37 - System.arraycopy(bytes, 0, padded, 0, bytes.length);  
38 - bytes = padded; 36 + throw new IllegalStateException(
  37 + "JWT_SECRET 长度不足 32 字节(HS256 要求),实际 " + bytes.length
  38 + + " 字节。请在 .env.local 配置至少 256 位的随机字符串。");
39 } 39 }
40 this.key = Keys.hmacShaKeyFor(bytes); 40 this.key = Keys.hmacShaKeyFor(bytes);
41 } 41 }
backend/src/main/java/com/xly/erp/module/usr/mapper/SysCompanyMapper.java
@@ -8,6 +8,7 @@ import org.apache.ibatis.annotations.Select; @@ -8,6 +8,7 @@ import org.apache.ibatis.annotations.Select;
8 @Mapper 8 @Mapper
9 public interface SysCompanyMapper extends BaseMapper<SysCompany> { 9 public interface SysCompanyMapper extends BaseMapper<SysCompany> {
10 10
11 - @Select("SELECT * FROM sys_company WHERE sCompanyCode = #{code} LIMIT 1") 11 + @Select("SELECT iIncrement, sCompanyCode, sCompanyName, iIsDeleted " +
  12 + "FROM sys_company WHERE sCompanyCode = #{code} LIMIT 1")
12 SysCompany selectByCode(String code); 13 SysCompany selectByCode(String code);
13 } 14 }
backend/src/main/java/com/xly/erp/module/usr/mapper/SysUserMapper.java
@@ -3,11 +3,43 @@ package com.xly.erp.module.usr.mapper; @@ -3,11 +3,43 @@ package com.xly.erp.module.usr.mapper;
3 import com.baomidou.mybatisplus.core.mapper.BaseMapper; 3 import com.baomidou.mybatisplus.core.mapper.BaseMapper;
4 import com.xly.erp.module.usr.entity.SysUser; 4 import com.xly.erp.module.usr.entity.SysUser;
5 import org.apache.ibatis.annotations.Mapper; 5 import org.apache.ibatis.annotations.Mapper;
  6 +import org.apache.ibatis.annotations.Param;
6 import org.apache.ibatis.annotations.Select; 7 import org.apache.ibatis.annotations.Select;
  8 +import org.apache.ibatis.annotations.Update;
7 9
8 @Mapper 10 @Mapper
9 public interface SysUserMapper extends BaseMapper<SysUser> { 11 public interface SysUserMapper extends BaseMapper<SysUser> {
10 12
11 - @Select("SELECT * FROM sys_user WHERE sUsername = #{username} LIMIT 1") 13 + String LOGIN_COLUMNS = "iIncrement, sUsername, sUserCode, sPasswordHash, iEmployeeId, " +
  14 + "sUserType, sLanguage, iCanEditDocument, iIsDeleted, iFailedLoginCount, " +
  15 + "tLockUntil, tLastLoginDate";
  16 +
  17 + @Select("SELECT " + LOGIN_COLUMNS + " FROM sys_user WHERE sUsername = #{username} LIMIT 1")
12 SysUser selectByUsername(String username); 18 SysUser selectByUsername(String username);
  19 +
  20 + /**
  21 + * 原子累加失败登录次数;达到阈值 maxCount 时同步写 tLockUntil = NOW() + lockMinutes 分钟。
  22 + * 单 SQL,DB 层保证并发安全。返回受影响行数(应为 1)。
  23 + */
  24 + /*
  25 + * MySQL 按 SET 子句从左到右求值,所以放在 +1 之后的引用看到的是新值。
  26 + * 第二条 SET 用 `iFailedLoginCount >= maxCount` 即等价于"新计数 >= 阈值"判定。
  27 + */
  28 + @Update("UPDATE sys_user " +
  29 + "SET iFailedLoginCount = iFailedLoginCount + 1, " +
  30 + " tLockUntil = IF(iFailedLoginCount >= #{maxCount}, " +
  31 + " DATE_ADD(NOW(), INTERVAL #{lockMinutes} MINUTE), " +
  32 + " tLockUntil) " +
  33 + "WHERE iIncrement = #{userId}")
  34 + int incrementFailedLoginCountAtomic(@Param("userId") Integer userId,
  35 + @Param("maxCount") int maxCount,
  36 + @Param("lockMinutes") long lockMinutes);
  37 +
  38 + /**
  39 + * 成功登录写入:清零计数 + 清空锁定 + 更新登录时间。一次 UPDATE。
  40 + */
  41 + @Update("UPDATE sys_user " +
  42 + "SET iFailedLoginCount = 0, tLockUntil = NULL, tLastLoginDate = NOW() " +
  43 + "WHERE iIncrement = #{userId}")
  44 + int markLoginSuccess(@Param("userId") Integer userId);
13 } 45 }
backend/src/main/java/com/xly/erp/module/usr/service/impl/LoginServiceImpl.java
@@ -16,9 +16,9 @@ import lombok.RequiredArgsConstructor; @@ -16,9 +16,9 @@ import lombok.RequiredArgsConstructor;
16 import lombok.extern.slf4j.Slf4j; 16 import lombok.extern.slf4j.Slf4j;
17 import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; 17 import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
18 import org.springframework.stereotype.Service; 18 import org.springframework.stereotype.Service;
19 -import org.springframework.transaction.annotation.Transactional;  
20 19
21 import java.time.LocalDateTime; 20 import java.time.LocalDateTime;
  21 +import java.time.format.DateTimeFormatter;
22 import java.util.HashMap; 22 import java.util.HashMap;
23 import java.util.Map; 23 import java.util.Map;
24 24
@@ -38,9 +38,8 @@ public class LoginServiceImpl implements LoginService { @@ -38,9 +38,8 @@ public class LoginServiceImpl implements LoginService {
38 private final JwtUtil jwtUtil; 38 private final JwtUtil jwtUtil;
39 39
40 @Override 40 @Override
41 - @Transactional(noRollbackFor = BizException.class)  
42 public LoginVo login(String username, String password, String companyCode) { 41 public LoginVo login(String username, String password, String companyCode) {
43 - // 1. 公司校验 42 + // 1. 公司校验(只读,不需事务)
44 SysCompany company = companyMapper.selectByCode(companyCode); 43 SysCompany company = companyMapper.selectByCode(companyCode);
45 if (company == null || Integer.valueOf(1).equals(company.getIIsDeleted())) { 44 if (company == null || Integer.valueOf(1).equals(company.getIIsDeleted())) {
46 log.warn("[login] companyCode={} 不存在或已删除", companyCode); 45 log.warn("[login] companyCode={} 不存在或已删除", companyCode);
@@ -60,51 +59,29 @@ public class LoginServiceImpl implements LoginService { @@ -60,51 +59,29 @@ public class LoginServiceImpl implements LoginService {
60 throw new BizException(ErrorCode.ACCOUNT_DELETED, "账号已被作废,禁止登录"); 59 throw new BizException(ErrorCode.ACCOUNT_DELETED, "账号已被作废,禁止登录");
61 } 60 }
62 61
63 - // 4. 锁定校验(不计入失败次数 62 + // 4. 锁定校验(不计入失败次数;过期锁定视为已解锁
64 if (user.getTLockUntil() != null && user.getTLockUntil().isAfter(LocalDateTime.now())) { 63 if (user.getTLockUntil() != null && user.getTLockUntil().isAfter(LocalDateTime.now())) {
65 log.warn("[login] username={} 锁定中,lockUntil={}", username, user.getTLockUntil()); 64 log.warn("[login] username={} 锁定中,lockUntil={}", username, user.getTLockUntil());
66 - throw new BizException(ErrorCode.ACCOUNT_LOCKED, "账号已锁定,请稍后再试"); 65 + Map<String, Object> data = new HashMap<>();
  66 + data.put("lockUntil", user.getTLockUntil()
  67 + .format(DateTimeFormatter.ISO_LOCAL_DATE_TIME));
  68 + throw new BizException(ErrorCode.ACCOUNT_LOCKED, "账号已锁定,请稍后再试", data);
67 } 69 }
68 70
69 // 5. 密码校验 71 // 5. 密码校验
70 if (!passwordEncoder.matches(password, user.getSPasswordHash())) { 72 if (!passwordEncoder.matches(password, user.getSPasswordHash())) {
71 - handleFailedLogin(user);  
72 - log.warn("[login] username={} 密码错误,failedCount={}", username,  
73 - user.getIFailedLoginCount() + 1); 73 + int rows = userMapper.incrementFailedLoginCountAtomic(
  74 + user.getIIncrement(), MAX_FAILED_LOGIN_COUNT, LOCK_DURATION_MINUTES);
  75 + log.warn("[login] username={} 密码错误,原子累加失败次数 rows={}", username, rows);
74 throw new BizException(ErrorCode.BAD_CREDENTIALS, "用户名或密码错误"); 76 throw new BizException(ErrorCode.BAD_CREDENTIALS, "用户名或密码错误");
75 } 77 }
76 78
77 // 6. 成功路径 79 // 6. 成功路径
78 - return loginSuccess(user, company, companyCode); 80 + return loginSuccess(user, companyCode);
79 } 81 }
80 82
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())); 83 + private LoginVo loginSuccess(SysUser user, String companyCode) {
  84 + userMapper.markLoginSuccess(user.getIIncrement());
108 85
109 String employeeName = null; 86 String employeeName = null;
110 if (user.getIEmployeeId() != null) { 87 if (user.getIEmployeeId() != null) {
backend/src/test/java/com/xly/erp/common/security/JwtUtilTest.java
@@ -44,6 +44,10 @@ class JwtUtilTest { @@ -44,6 +44,10 @@ class JwtUtilTest {
44 assertNotNull(parsed.get("jti")); 44 assertNotNull(parsed.get("jti"));
45 assertNotNull(parsed.get("iat")); 45 assertNotNull(parsed.get("iat"));
46 assertNotNull(parsed.get("exp")); 46 assertNotNull(parsed.get("exp"));
  47 +
  48 + long iat = ((Number) parsed.get("iat")).longValue();
  49 + long exp = ((Number) parsed.get("exp")).longValue();
  50 + assertEquals(7200L, exp - iat, "exp - iat 必须严格等于 ttlSec(spec § 验收 § 2)");
47 } 51 }
48 52
49 @Test 53 @Test
backend/src/test/java/com/xly/erp/module/usr/controller/AuthControllerTest.java
@@ -74,7 +74,7 @@ class AuthControllerTest { @@ -74,7 +74,7 @@ class AuthControllerTest {
74 } 74 }
75 75
76 @Test 76 @Test
77 - void post_login_lockedAccount_returns423_code42301() throws Exception { 77 + void post_login_lockedAccount_returns423_code42301_withLockUntil() throws Exception {
78 jdbc.update("UPDATE sys_user SET iFailedLoginCount=5, tLockUntil=DATE_ADD(NOW(), INTERVAL 30 MINUTE) WHERE sUsername=?", 78 jdbc.update("UPDATE sys_user SET iFailedLoginCount=5, tLockUntil=DATE_ADD(NOW(), INTERVAL 30 MINUTE) WHERE sUsername=?",
79 LoginTestSeeder.USER_OK); 79 LoginTestSeeder.USER_OK);
80 mvc.perform(post("/api/v1/auth/login") 80 mvc.perform(post("/api/v1/auth/login")
@@ -82,7 +82,8 @@ class AuthControllerTest { @@ -82,7 +82,8 @@ class AuthControllerTest {
82 .content(body(LoginTestSeeder.USER_OK, LoginTestSeeder.DEFAULT_PASSWORD, 82 .content(body(LoginTestSeeder.USER_OK, LoginTestSeeder.DEFAULT_PASSWORD,
83 LoginTestSeeder.COMPANY_OK))) 83 LoginTestSeeder.COMPANY_OK)))
84 .andExpect(status().isLocked()) 84 .andExpect(status().isLocked())
85 - .andExpect(jsonPath("$.code").value(ErrorCode.ACCOUNT_LOCKED)); 85 + .andExpect(jsonPath("$.code").value(ErrorCode.ACCOUNT_LOCKED))
  86 + .andExpect(jsonPath("$.data.lockUntil").isNotEmpty());
86 } 87 }
87 88
88 @Test 89 @Test
backend/src/test/java/com/xly/erp/module/usr/service/LoginServiceImplTest.java
@@ -168,6 +168,35 @@ class LoginServiceImplTest { @@ -168,6 +168,35 @@ class LoginServiceImplTest {
168 } 168 }
169 169
170 @Test 170 @Test
  171 + void login_concurrentBadPassword_atomicallyIncrementsCount() throws Exception {
  172 + // 2 线程并发各跑 2 次错误密码 → 计数累加必须 == 4(低于 5 不触发锁定,
  173 + // 专注验证原子性。锁定路径在 login_5thBadPassword_* 单线程测试中验证)
  174 + int perThread = 2;
  175 + int threadCount = 2;
  176 + Thread t1 = new Thread(() -> {
  177 + for (int i = 0; i < perThread; i++) {
  178 + try {
  179 + loginService.login(LoginTestSeeder.USER_OK, "WrongPass1!", LoginTestSeeder.COMPANY_OK);
  180 + } catch (BizException ignored) {}
  181 + }
  182 + });
  183 + Thread t2 = new Thread(() -> {
  184 + for (int i = 0; i < perThread; i++) {
  185 + try {
  186 + loginService.login(LoginTestSeeder.USER_OK, "WrongPass1!", LoginTestSeeder.COMPANY_OK);
  187 + } catch (BizException ignored) {}
  188 + }
  189 + });
  190 + t1.start(); t2.start();
  191 + t1.join(); t2.join();
  192 +
  193 + SysUser u = userMapper.selectByUsername(LoginTestSeeder.USER_OK);
  194 + assertEquals(perThread * threadCount, u.getIFailedLoginCount(),
  195 + "并发失败累加必须 == 总次数(原子 UPDATE 保证;非原子实现会丢失累加)");
  196 + assertNull(u.getTLockUntil(), "总次数低于阈值不应触发锁定");
  197 + }
  198 +
  199 + @Test
171 void login_success_jwtParsesBack_with_sub_username_companyCode() { 200 void login_success_jwtParsesBack_with_sub_username_companyCode() {
172 LoginVo vo = loginService.login(LoginTestSeeder.USER_OK, LoginTestSeeder.DEFAULT_PASSWORD, 201 LoginVo vo = loginService.login(LoginTestSeeder.USER_OK, LoginTestSeeder.DEFAULT_PASSWORD,
173 LoginTestSeeder.COMPANY_OK); 202 LoginTestSeeder.COMPANY_OK);
@@ -178,5 +207,8 @@ class LoginServiceImplTest { @@ -178,5 +207,8 @@ class LoginServiceImplTest {
178 assertEquals("NORMAL", claims.get("userType")); 207 assertEquals("NORMAL", claims.get("userType"));
179 assertEquals("zh-CN", claims.get("language")); 208 assertEquals("zh-CN", claims.get("language"));
180 assertNotNull(claims.get("jti")); 209 assertNotNull(claims.get("jti"));
  210 + long iat = ((Number) claims.get("iat")).longValue();
  211 + long exp = ((Number) claims.get("exp")).longValue();
  212 + assertEquals(7200L, exp - iat, "exp - iat 必须 == TOKEN_TTL_SEC");
181 } 213 }
182 } 214 }