Commit c74ab3c0bbcc3593670a1afaaae8e5e45eee7b48

Authored by zichun
1 parent 9c6dcd52

feat(usr): login service + dto/vo REQ-USR-004

backend/src/main/java/com/xly/erp/module/usr/dto/LoginDTO.java 0 → 100644
  1 +package com.xly.erp.module.usr.dto;
  2 +
  3 +import com.fasterxml.jackson.annotation.JsonProperty;
  4 +import jakarta.validation.constraints.NotBlank;
  5 +
  6 +public class LoginDTO {
  7 +
  8 + @JsonProperty("sUserName")
  9 + @NotBlank
  10 + private String sUserName;
  11 +
  12 + @JsonProperty("password")
  13 + @NotBlank
  14 + private String password;
  15 +
  16 + @JsonProperty("version")
  17 + @NotBlank
  18 + private String version;
  19 +
  20 + public String getSUserName() { return sUserName; }
  21 + public void setSUserName(String sUserName) { this.sUserName = sUserName; }
  22 + public String getPassword() { return password; }
  23 + public void setPassword(String password) { this.password = password; }
  24 + public String getVersion() { return version; }
  25 + public void setVersion(String version) { this.version = version; }
  26 +}
... ...
backend/src/main/java/com/xly/erp/module/usr/service/UserService.java
1 1 package com.xly.erp.module.usr.service;
2 2  
3 3 import com.xly.erp.module.usr.dto.CreateUserDTO;
  4 +import com.xly.erp.module.usr.dto.LoginDTO;
4 5 import com.xly.erp.module.usr.dto.UpdateUserDTO;
  6 +import com.xly.erp.module.usr.vo.LoginVO;
5 7  
6 8 import java.util.Map;
7 9  
... ... @@ -11,4 +13,6 @@ public interface UserService {
11 13 Integer update(Integer id, UpdateUserDTO dto);
12 14  
13 15 Map<String, Object> list(String field, String match, String value, Integer pageNum, Integer pageSize);
  16 +
  17 + LoginVO login(LoginDTO dto);
14 18 }
... ...
backend/src/main/java/com/xly/erp/module/usr/service/impl/UserServiceImpl.java
... ... @@ -3,10 +3,15 @@ package com.xly.erp.module.usr.service.impl;
3 3 import com.xly.erp.common.config.StubSecurityProperties;
4 4 import com.xly.erp.common.config.TenantProperties;
5 5 import com.xly.erp.common.exception.BizException;
  6 +import com.xly.erp.common.security.JwtUtil;
6 7 import com.xly.erp.common.security.SecurityContextHelper;
7 8 import com.xly.erp.module.usr.dto.CreateUserDTO;
  9 +import com.xly.erp.module.usr.dto.LoginDTO;
8 10 import com.xly.erp.module.usr.dto.UpdateUserDTO;
9 11 import com.xly.erp.module.usr.entity.User;
  12 +import com.xly.erp.module.usr.security.LoginAttemptStore;
  13 +import com.xly.erp.module.usr.vo.LoginVO;
  14 +import com.xly.erp.module.usr.vo.UserBriefVO;
10 15 import com.xly.erp.module.usr.vo.UserListVO;
11 16 import com.xly.erp.module.usr.entity.UserPermission;
12 17 import com.xly.erp.module.usr.mapper.PermissionCategoryMapper;
... ... @@ -66,6 +71,8 @@ public class UserServiceImpl implements UserService {
66 71 private final TenantProperties tenant;
67 72 private final StubSecurityProperties stub;
68 73 private final BCryptPasswordEncoder passwordEncoder;
  74 + private final JwtUtil jwtUtil;
  75 + private final LoginAttemptStore loginAttemptStore;
69 76  
70 77 public UserServiceImpl(UserMapper userMapper,
71 78 UserPermissionMapper userPermissionMapper,
... ... @@ -73,7 +80,9 @@ public class UserServiceImpl implements UserService {
73 80 PermissionCategoryMapper permissionCategoryMapper,
74 81 TenantProperties tenant,
75 82 StubSecurityProperties stub,
76   - BCryptPasswordEncoder passwordEncoder) {
  83 + BCryptPasswordEncoder passwordEncoder,
  84 + JwtUtil jwtUtil,
  85 + LoginAttemptStore loginAttemptStore) {
77 86 this.userMapper = userMapper;
78 87 this.userPermissionMapper = userPermissionMapper;
79 88 this.staffMapper = staffMapper;
... ... @@ -81,6 +90,8 @@ public class UserServiceImpl implements UserService {
81 90 this.tenant = tenant;
82 91 this.stub = stub;
83 92 this.passwordEncoder = passwordEncoder;
  93 + this.jwtUtil = jwtUtil;
  94 + this.loginAttemptStore = loginAttemptStore;
84 95 }
85 96  
86 97 @Override
... ... @@ -257,4 +268,43 @@ public class UserServiceImpl implements UserService {
257 268 default -> -1;
258 269 };
259 270 }
  271 +
  272 + @Override
  273 + public LoginVO login(LoginDTO dto) {
  274 + String userName = dto.getSUserName();
  275 + loginAttemptStore.isLocked(userName).ifPresent(seconds -> {
  276 + throw new BizException(42301, "账号临时锁定,剩余 " + seconds + " 秒");
  277 + });
  278 + User user = userMapper.selectByUserName(userName);
  279 + if (user == null) {
  280 + throw new BizException(40101, "用户名或密码错误");
  281 + }
  282 + if (Boolean.TRUE.equals(user.getBDeleted())) {
  283 + throw new BizException(40102, "账号已禁用");
  284 + }
  285 + if (!passwordEncoder.matches(dto.getPassword(), user.getSPasswordHash())) {
  286 + int newCount = loginAttemptStore.recordFailure(userName);
  287 + if (newCount >= LoginAttemptStore.MAX_ATTEMPTS) {
  288 + long remaining = loginAttemptStore.isLocked(userName).orElse(0L);
  289 + throw new BizException(42301, "账号临时锁定,剩余 " + remaining + " 秒");
  290 + }
  291 + throw new BizException(40101, "用户名或密码错误");
  292 + }
  293 + loginAttemptStore.clearFailures(userName);
  294 + userMapper.updateLastLoginDate(user.getIIncrement(), LocalDateTime.now());
  295 +
  296 + UserBriefVO brief = new UserBriefVO();
  297 + brief.setIIncrement(user.getIIncrement());
  298 + brief.setSUserNo(user.getSUserNo());
  299 + brief.setSUserName(user.getSUserName());
  300 + brief.setSUserType(user.getSUserType());
  301 + brief.setSLanguage(user.getSLanguage());
  302 +
  303 + LoginVO vo = new LoginVO();
  304 + vo.setAccessToken(jwtUtil.sign(user.getSUserNo()));
  305 + vo.setRefreshToken(jwtUtil.signRefresh(user.getSUserNo()));
  306 + vo.setExpiresIn(JwtUtil.ACCESS_TTL.toSeconds());
  307 + vo.setUser(brief);
  308 + return vo;
  309 + }
260 310 }
... ...
backend/src/main/java/com/xly/erp/module/usr/vo/LoginVO.java 0 → 100644
  1 +package com.xly.erp.module.usr.vo;
  2 +
  3 +import com.fasterxml.jackson.annotation.JsonProperty;
  4 +
  5 +public class LoginVO {
  6 +
  7 + @JsonProperty("accessToken")
  8 + private String accessToken;
  9 +
  10 + @JsonProperty("refreshToken")
  11 + private String refreshToken;
  12 +
  13 + @JsonProperty("expiresIn")
  14 + private long expiresIn;
  15 +
  16 + @JsonProperty("user")
  17 + private UserBriefVO user;
  18 +
  19 + public String getAccessToken() { return accessToken; }
  20 + public void setAccessToken(String accessToken) { this.accessToken = accessToken; }
  21 + public String getRefreshToken() { return refreshToken; }
  22 + public void setRefreshToken(String refreshToken) { this.refreshToken = refreshToken; }
  23 + public long getExpiresIn() { return expiresIn; }
  24 + public void setExpiresIn(long expiresIn) { this.expiresIn = expiresIn; }
  25 + public UserBriefVO getUser() { return user; }
  26 + public void setUser(UserBriefVO user) { this.user = user; }
  27 +}
... ...
backend/src/main/java/com/xly/erp/module/usr/vo/UserBriefVO.java 0 → 100644
  1 +package com.xly.erp.module.usr.vo;
  2 +
  3 +import com.fasterxml.jackson.annotation.JsonProperty;
  4 +
  5 +public class UserBriefVO {
  6 +
  7 + @JsonProperty("iIncrement")
  8 + private Integer iIncrement;
  9 +
  10 + @JsonProperty("sUserNo")
  11 + private String sUserNo;
  12 +
  13 + @JsonProperty("sUserName")
  14 + private String sUserName;
  15 +
  16 + @JsonProperty("sUserType")
  17 + private String sUserType;
  18 +
  19 + @JsonProperty("sLanguage")
  20 + private String sLanguage;
  21 +
  22 + public Integer getIIncrement() { return iIncrement; }
  23 + public void setIIncrement(Integer iIncrement) { this.iIncrement = iIncrement; }
  24 + public String getSUserNo() { return sUserNo; }
  25 + public void setSUserNo(String sUserNo) { this.sUserNo = sUserNo; }
  26 + public String getSUserName() { return sUserName; }
  27 + public void setSUserName(String sUserName) { this.sUserName = sUserName; }
  28 + public String getSUserType() { return sUserType; }
  29 + public void setSUserType(String sUserType) { this.sUserType = sUserType; }
  30 + public String getSLanguage() { return sLanguage; }
  31 + public void setSLanguage(String sLanguage) { this.sLanguage = sLanguage; }
  32 +}
... ...
backend/src/test/java/com/xly/erp/module/usr/service/UserServiceImplTest.java
... ... @@ -3,9 +3,13 @@ package com.xly.erp.module.usr.service;
3 3 import com.xly.erp.common.config.StubSecurityProperties;
4 4 import com.xly.erp.common.config.TenantProperties;
5 5 import com.xly.erp.common.exception.BizException;
  6 +import com.xly.erp.common.security.JwtUtil;
6 7 import com.xly.erp.module.usr.dto.CreateUserDTO;
  8 +import com.xly.erp.module.usr.dto.LoginDTO;
7 9 import com.xly.erp.module.usr.dto.UpdateUserDTO;
8 10 import com.xly.erp.module.usr.entity.User;
  11 +import com.xly.erp.module.usr.security.LoginAttemptStore;
  12 +import com.xly.erp.module.usr.vo.LoginVO;
9 13 import com.xly.erp.module.usr.entity.UserPermission;
10 14 import com.xly.erp.module.usr.mapper.PermissionCategoryMapper;
11 15 import com.xly.erp.module.usr.mapper.StaffMapper;
... ... @@ -45,6 +49,8 @@ class UserServiceImplTest {
45 49 private StaffMapper staffMapper;
46 50 private PermissionCategoryMapper permissionCategoryMapper;
47 51 private BCryptPasswordEncoder encoder;
  52 + private JwtUtil jwtUtil;
  53 + private LoginAttemptStore loginAttemptStore;
48 54 private UserServiceImpl service;
49 55  
50 56 @BeforeEach
... ... @@ -54,6 +60,8 @@ class UserServiceImplTest {
54 60 staffMapper = mock(StaffMapper.class);
55 61 permissionCategoryMapper = mock(PermissionCategoryMapper.class);
56 62 encoder = new BCryptPasswordEncoder();
  63 + jwtUtil = new JwtUtil("f8d4be76bff13bf32fa33ca0b14a4b152ad01ca5719f57df18ec4ecf2370b235");
  64 + loginAttemptStore = new LoginAttemptStore();
57 65 TenantProperties tenant = new TenantProperties();
58 66 tenant.setBrandsId("XLY");
59 67 tenant.setSubsidiaryId("XLY");
... ... @@ -61,7 +69,7 @@ class UserServiceImplTest {
61 69 stub.setStubUserNo("STUB_ADMIN");
62 70  
63 71 service = new UserServiceImpl(userMapper, userPermissionMapper, staffMapper,
64   - permissionCategoryMapper, tenant, stub, encoder);
  72 + permissionCategoryMapper, tenant, stub, encoder, jwtUtil, loginAttemptStore);
65 73  
66 74 lenient().when(userMapper.insert(any(User.class))).thenAnswer(inv -> {
67 75 User u = inv.getArgument(0);
... ... @@ -407,6 +415,125 @@ class UserServiceImplTest {
407 415 verify(userMapper).pageWithFilter(eq("u.bDeleted"), eq("equals"), eq(1), eq(0), eq(20));
408 416 }
409 417  
  418 + @Test
  419 + void loginWithValidCredentials_returnsTokens_andUpdatesLastLoginDate() {
  420 + User u = new User();
  421 + u.setIIncrement(100);
  422 + u.setSUserNo("u100");
  423 + u.setSUserName("login_ok");
  424 + u.setSUserType("普通用户");
  425 + u.setSLanguage("zh");
  426 + u.setSPasswordHash(encoder.encode("666666"));
  427 + u.setBDeleted(false);
  428 + when(userMapper.selectByUserName("login_ok")).thenReturn(u);
  429 +
  430 + LoginDTO dto = new LoginDTO();
  431 + dto.setSUserName("login_ok");
  432 + dto.setPassword("666666");
  433 + dto.setVersion("标准版");
  434 +
  435 + LoginVO vo = service.login(dto);
  436 +
  437 + assertThat(vo.getAccessToken()).isNotBlank();
  438 + assertThat(vo.getRefreshToken()).isNotBlank();
  439 + assertThat(vo.getRefreshToken()).isNotEqualTo(vo.getAccessToken());
  440 + assertThat(vo.getExpiresIn()).isEqualTo(28800);
  441 + assertThat(vo.getUser().getIIncrement()).isEqualTo(100);
  442 + assertThat(vo.getUser().getSUserNo()).isEqualTo("u100");
  443 + verify(userMapper).updateLastLoginDate(eq(100), any());
  444 + }
  445 +
  446 + @Test
  447 + void loginWithUserNotFound_throws40101() {
  448 + when(userMapper.selectByUserName("ghost")).thenReturn(null);
  449 + LoginDTO dto = loginDto("ghost", "x");
  450 + assertThatThrownBy(() -> service.login(dto))
  451 + .isInstanceOf(BizException.class)
  452 + .hasFieldOrPropertyWithValue("code", 40101);
  453 + }
  454 +
  455 + @Test
  456 + void loginWithDeletedUser_throws40102() {
  457 + User u = stubLoginUser("dead");
  458 + u.setBDeleted(true);
  459 + when(userMapper.selectByUserName("dead")).thenReturn(u);
  460 + LoginDTO dto = loginDto("dead", "666666");
  461 + assertThatThrownBy(() -> service.login(dto))
  462 + .isInstanceOf(BizException.class)
  463 + .hasFieldOrPropertyWithValue("code", 40102);
  464 + }
  465 +
  466 + @Test
  467 + void loginWithWrongPassword_incrementsCounter_throws40101() {
  468 + when(userMapper.selectByUserName("u_wrong")).thenReturn(stubLoginUser("u_wrong"));
  469 + LoginDTO dto = loginDto("u_wrong", "wrong");
  470 + assertThatThrownBy(() -> service.login(dto))
  471 + .isInstanceOf(BizException.class)
  472 + .hasFieldOrPropertyWithValue("code", 40101);
  473 + }
  474 +
  475 + @Test
  476 + void loginAfterMaxAttemptsReached_throws42301() {
  477 + when(userMapper.selectByUserName("u_lock")).thenReturn(stubLoginUser("u_lock"));
  478 + LoginDTO dto = loginDto("u_lock", "wrong");
  479 + for (int i = 0; i < LoginAttemptStore.MAX_ATTEMPTS - 1; i++) {
  480 + try { service.login(dto); } catch (BizException ignored) {}
  481 + }
  482 + // 第 5 次失败应触发锁定 → 42301
  483 + assertThatThrownBy(() -> service.login(dto))
  484 + .isInstanceOf(BizException.class)
  485 + .hasFieldOrPropertyWithValue("code", 42301);
  486 + }
  487 +
  488 + @Test
  489 + void loginWhileLocked_throws42301() {
  490 + when(userMapper.selectByUserName("u_locked2")).thenReturn(stubLoginUser("u_locked2"));
  491 + LoginDTO bad = loginDto("u_locked2", "wrong");
  492 + for (int i = 0; i < LoginAttemptStore.MAX_ATTEMPTS; i++) {
  493 + try { service.login(bad); } catch (BizException ignored) {}
  494 + }
  495 + // 锁定后即使密码正确也走 42301
  496 + LoginDTO good = loginDto("u_locked2", "666666");
  497 + assertThatThrownBy(() -> service.login(good))
  498 + .isInstanceOf(BizException.class)
  499 + .hasFieldOrPropertyWithValue("code", 42301);
  500 + }
  501 +
  502 + @Test
  503 + void loginSuccess_clearsFailureCounter() {
  504 + when(userMapper.selectByUserName("u_clear")).thenReturn(stubLoginUser("u_clear"));
  505 + try { service.login(loginDto("u_clear", "wrong")); } catch (BizException ignored) {}
  506 + try { service.login(loginDto("u_clear", "wrong")); } catch (BizException ignored) {}
  507 + // 现在 2 次失败;正确登录后清空
  508 + service.login(loginDto("u_clear", "666666"));
  509 + // 之后再 4 次错误应不锁(计数已重置)
  510 + for (int i = 0; i < LoginAttemptStore.MAX_ATTEMPTS - 1; i++) {
  511 + try { service.login(loginDto("u_clear", "wrong")); } catch (BizException ignored) {}
  512 + }
  513 + // MAX-1 次错误后仍未锁
  514 + assertThat(loginAttemptStore.isLocked("u_clear")).isEmpty();
  515 + }
  516 +
  517 + private LoginDTO loginDto(String userName, String password) {
  518 + LoginDTO dto = new LoginDTO();
  519 + dto.setSUserName(userName);
  520 + dto.setPassword(password);
  521 + dto.setVersion("标准版");
  522 + return dto;
  523 + }
  524 +
  525 + private User stubLoginUser(String userName) {
  526 + User u = new User();
  527 + u.setIIncrement(200);
  528 + u.setSUserNo("u200");
  529 + u.setSUserName(userName);
  530 + u.setSUserType("普通用户");
  531 + u.setSLanguage("zh");
  532 + u.setSPasswordHash(encoder.encode("666666"));
  533 + u.setBDeleted(false);
  534 + return u;
  535 + }
  536 +
410 537 private UpdateUserDTO baseUpdateDto() {
411 538 UpdateUserDTO dto = new UpdateUserDTO();
412 539 dto.setSUserNo("u_new");
... ...