Commit c74ab3c0bbcc3593670a1afaaae8e5e45eee7b48
1 parent
9c6dcd52
feat(usr): login service + dto/vo REQ-USR-004
Showing
6 changed files
with
268 additions
and
2 deletions
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"); | ... | ... |