diff --git a/backend/src/main/java/com/xly/erp/module/usr/dto/LoginDTO.java b/backend/src/main/java/com/xly/erp/module/usr/dto/LoginDTO.java new file mode 100644 index 0000000..88f1fcd --- /dev/null +++ b/backend/src/main/java/com/xly/erp/module/usr/dto/LoginDTO.java @@ -0,0 +1,26 @@ +package com.xly.erp.module.usr.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import jakarta.validation.constraints.NotBlank; + +public class LoginDTO { + + @JsonProperty("sUserName") + @NotBlank + private String sUserName; + + @JsonProperty("password") + @NotBlank + private String password; + + @JsonProperty("version") + @NotBlank + private String version; + + public String getSUserName() { return sUserName; } + public void setSUserName(String sUserName) { this.sUserName = sUserName; } + public String getPassword() { return password; } + public void setPassword(String password) { this.password = password; } + public String getVersion() { return version; } + public void setVersion(String version) { this.version = version; } +} diff --git a/backend/src/main/java/com/xly/erp/module/usr/service/UserService.java b/backend/src/main/java/com/xly/erp/module/usr/service/UserService.java index 6fe3796..a7d0ec1 100644 --- a/backend/src/main/java/com/xly/erp/module/usr/service/UserService.java +++ b/backend/src/main/java/com/xly/erp/module/usr/service/UserService.java @@ -1,7 +1,9 @@ package com.xly.erp.module.usr.service; import com.xly.erp.module.usr.dto.CreateUserDTO; +import com.xly.erp.module.usr.dto.LoginDTO; import com.xly.erp.module.usr.dto.UpdateUserDTO; +import com.xly.erp.module.usr.vo.LoginVO; import java.util.Map; @@ -11,4 +13,6 @@ public interface UserService { Integer update(Integer id, UpdateUserDTO dto); Map list(String field, String match, String value, Integer pageNum, Integer pageSize); + + LoginVO login(LoginDTO dto); } diff --git a/backend/src/main/java/com/xly/erp/module/usr/service/impl/UserServiceImpl.java b/backend/src/main/java/com/xly/erp/module/usr/service/impl/UserServiceImpl.java index 35afd8b..b672a75 100644 --- a/backend/src/main/java/com/xly/erp/module/usr/service/impl/UserServiceImpl.java +++ b/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; import com.xly.erp.common.config.StubSecurityProperties; import com.xly.erp.common.config.TenantProperties; import com.xly.erp.common.exception.BizException; +import com.xly.erp.common.security.JwtUtil; import com.xly.erp.common.security.SecurityContextHelper; import com.xly.erp.module.usr.dto.CreateUserDTO; +import com.xly.erp.module.usr.dto.LoginDTO; import com.xly.erp.module.usr.dto.UpdateUserDTO; import com.xly.erp.module.usr.entity.User; +import com.xly.erp.module.usr.security.LoginAttemptStore; +import com.xly.erp.module.usr.vo.LoginVO; +import com.xly.erp.module.usr.vo.UserBriefVO; import com.xly.erp.module.usr.vo.UserListVO; import com.xly.erp.module.usr.entity.UserPermission; import com.xly.erp.module.usr.mapper.PermissionCategoryMapper; @@ -66,6 +71,8 @@ public class UserServiceImpl implements UserService { private final TenantProperties tenant; private final StubSecurityProperties stub; private final BCryptPasswordEncoder passwordEncoder; + private final JwtUtil jwtUtil; + private final LoginAttemptStore loginAttemptStore; public UserServiceImpl(UserMapper userMapper, UserPermissionMapper userPermissionMapper, @@ -73,7 +80,9 @@ public class UserServiceImpl implements UserService { PermissionCategoryMapper permissionCategoryMapper, TenantProperties tenant, StubSecurityProperties stub, - BCryptPasswordEncoder passwordEncoder) { + BCryptPasswordEncoder passwordEncoder, + JwtUtil jwtUtil, + LoginAttemptStore loginAttemptStore) { this.userMapper = userMapper; this.userPermissionMapper = userPermissionMapper; this.staffMapper = staffMapper; @@ -81,6 +90,8 @@ public class UserServiceImpl implements UserService { this.tenant = tenant; this.stub = stub; this.passwordEncoder = passwordEncoder; + this.jwtUtil = jwtUtil; + this.loginAttemptStore = loginAttemptStore; } @Override @@ -257,4 +268,43 @@ public class UserServiceImpl implements UserService { default -> -1; }; } + + @Override + public LoginVO login(LoginDTO dto) { + String userName = dto.getSUserName(); + loginAttemptStore.isLocked(userName).ifPresent(seconds -> { + throw new BizException(42301, "账号临时锁定,剩余 " + seconds + " 秒"); + }); + User user = userMapper.selectByUserName(userName); + if (user == null) { + throw new BizException(40101, "用户名或密码错误"); + } + if (Boolean.TRUE.equals(user.getBDeleted())) { + throw new BizException(40102, "账号已禁用"); + } + if (!passwordEncoder.matches(dto.getPassword(), user.getSPasswordHash())) { + int newCount = loginAttemptStore.recordFailure(userName); + if (newCount >= LoginAttemptStore.MAX_ATTEMPTS) { + long remaining = loginAttemptStore.isLocked(userName).orElse(0L); + throw new BizException(42301, "账号临时锁定,剩余 " + remaining + " 秒"); + } + throw new BizException(40101, "用户名或密码错误"); + } + loginAttemptStore.clearFailures(userName); + userMapper.updateLastLoginDate(user.getIIncrement(), LocalDateTime.now()); + + UserBriefVO brief = new UserBriefVO(); + brief.setIIncrement(user.getIIncrement()); + brief.setSUserNo(user.getSUserNo()); + brief.setSUserName(user.getSUserName()); + brief.setSUserType(user.getSUserType()); + brief.setSLanguage(user.getSLanguage()); + + LoginVO vo = new LoginVO(); + vo.setAccessToken(jwtUtil.sign(user.getSUserNo())); + vo.setRefreshToken(jwtUtil.signRefresh(user.getSUserNo())); + vo.setExpiresIn(JwtUtil.ACCESS_TTL.toSeconds()); + vo.setUser(brief); + return vo; + } } diff --git a/backend/src/main/java/com/xly/erp/module/usr/vo/LoginVO.java b/backend/src/main/java/com/xly/erp/module/usr/vo/LoginVO.java new file mode 100644 index 0000000..fe76657 --- /dev/null +++ b/backend/src/main/java/com/xly/erp/module/usr/vo/LoginVO.java @@ -0,0 +1,27 @@ +package com.xly.erp.module.usr.vo; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public class LoginVO { + + @JsonProperty("accessToken") + private String accessToken; + + @JsonProperty("refreshToken") + private String refreshToken; + + @JsonProperty("expiresIn") + private long expiresIn; + + @JsonProperty("user") + private UserBriefVO user; + + public String getAccessToken() { return accessToken; } + public void setAccessToken(String accessToken) { this.accessToken = accessToken; } + public String getRefreshToken() { return refreshToken; } + public void setRefreshToken(String refreshToken) { this.refreshToken = refreshToken; } + public long getExpiresIn() { return expiresIn; } + public void setExpiresIn(long expiresIn) { this.expiresIn = expiresIn; } + public UserBriefVO getUser() { return user; } + public void setUser(UserBriefVO user) { this.user = user; } +} diff --git a/backend/src/main/java/com/xly/erp/module/usr/vo/UserBriefVO.java b/backend/src/main/java/com/xly/erp/module/usr/vo/UserBriefVO.java new file mode 100644 index 0000000..e92ae75 --- /dev/null +++ b/backend/src/main/java/com/xly/erp/module/usr/vo/UserBriefVO.java @@ -0,0 +1,32 @@ +package com.xly.erp.module.usr.vo; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public class UserBriefVO { + + @JsonProperty("iIncrement") + private Integer iIncrement; + + @JsonProperty("sUserNo") + private String sUserNo; + + @JsonProperty("sUserName") + private String sUserName; + + @JsonProperty("sUserType") + private String sUserType; + + @JsonProperty("sLanguage") + private String sLanguage; + + public Integer getIIncrement() { return iIncrement; } + public void setIIncrement(Integer iIncrement) { this.iIncrement = iIncrement; } + public String getSUserNo() { return sUserNo; } + public void setSUserNo(String sUserNo) { this.sUserNo = sUserNo; } + public String getSUserName() { return sUserName; } + public void setSUserName(String sUserName) { this.sUserName = sUserName; } + public String getSUserType() { return sUserType; } + public void setSUserType(String sUserType) { this.sUserType = sUserType; } + public String getSLanguage() { return sLanguage; } + public void setSLanguage(String sLanguage) { this.sLanguage = sLanguage; } +} diff --git a/backend/src/test/java/com/xly/erp/module/usr/service/UserServiceImplTest.java b/backend/src/test/java/com/xly/erp/module/usr/service/UserServiceImplTest.java index e6731fa..8d54395 100644 --- a/backend/src/test/java/com/xly/erp/module/usr/service/UserServiceImplTest.java +++ b/backend/src/test/java/com/xly/erp/module/usr/service/UserServiceImplTest.java @@ -3,9 +3,13 @@ package com.xly.erp.module.usr.service; import com.xly.erp.common.config.StubSecurityProperties; import com.xly.erp.common.config.TenantProperties; import com.xly.erp.common.exception.BizException; +import com.xly.erp.common.security.JwtUtil; import com.xly.erp.module.usr.dto.CreateUserDTO; +import com.xly.erp.module.usr.dto.LoginDTO; import com.xly.erp.module.usr.dto.UpdateUserDTO; import com.xly.erp.module.usr.entity.User; +import com.xly.erp.module.usr.security.LoginAttemptStore; +import com.xly.erp.module.usr.vo.LoginVO; import com.xly.erp.module.usr.entity.UserPermission; import com.xly.erp.module.usr.mapper.PermissionCategoryMapper; import com.xly.erp.module.usr.mapper.StaffMapper; @@ -45,6 +49,8 @@ class UserServiceImplTest { private StaffMapper staffMapper; private PermissionCategoryMapper permissionCategoryMapper; private BCryptPasswordEncoder encoder; + private JwtUtil jwtUtil; + private LoginAttemptStore loginAttemptStore; private UserServiceImpl service; @BeforeEach @@ -54,6 +60,8 @@ class UserServiceImplTest { staffMapper = mock(StaffMapper.class); permissionCategoryMapper = mock(PermissionCategoryMapper.class); encoder = new BCryptPasswordEncoder(); + jwtUtil = new JwtUtil("f8d4be76bff13bf32fa33ca0b14a4b152ad01ca5719f57df18ec4ecf2370b235"); + loginAttemptStore = new LoginAttemptStore(); TenantProperties tenant = new TenantProperties(); tenant.setBrandsId("XLY"); tenant.setSubsidiaryId("XLY"); @@ -61,7 +69,7 @@ class UserServiceImplTest { stub.setStubUserNo("STUB_ADMIN"); service = new UserServiceImpl(userMapper, userPermissionMapper, staffMapper, - permissionCategoryMapper, tenant, stub, encoder); + permissionCategoryMapper, tenant, stub, encoder, jwtUtil, loginAttemptStore); lenient().when(userMapper.insert(any(User.class))).thenAnswer(inv -> { User u = inv.getArgument(0); @@ -407,6 +415,125 @@ class UserServiceImplTest { verify(userMapper).pageWithFilter(eq("u.bDeleted"), eq("equals"), eq(1), eq(0), eq(20)); } + @Test + void loginWithValidCredentials_returnsTokens_andUpdatesLastLoginDate() { + User u = new User(); + u.setIIncrement(100); + u.setSUserNo("u100"); + u.setSUserName("login_ok"); + u.setSUserType("普通用户"); + u.setSLanguage("zh"); + u.setSPasswordHash(encoder.encode("666666")); + u.setBDeleted(false); + when(userMapper.selectByUserName("login_ok")).thenReturn(u); + + LoginDTO dto = new LoginDTO(); + dto.setSUserName("login_ok"); + dto.setPassword("666666"); + dto.setVersion("标准版"); + + LoginVO vo = service.login(dto); + + assertThat(vo.getAccessToken()).isNotBlank(); + assertThat(vo.getRefreshToken()).isNotBlank(); + assertThat(vo.getRefreshToken()).isNotEqualTo(vo.getAccessToken()); + assertThat(vo.getExpiresIn()).isEqualTo(28800); + assertThat(vo.getUser().getIIncrement()).isEqualTo(100); + assertThat(vo.getUser().getSUserNo()).isEqualTo("u100"); + verify(userMapper).updateLastLoginDate(eq(100), any()); + } + + @Test + void loginWithUserNotFound_throws40101() { + when(userMapper.selectByUserName("ghost")).thenReturn(null); + LoginDTO dto = loginDto("ghost", "x"); + assertThatThrownBy(() -> service.login(dto)) + .isInstanceOf(BizException.class) + .hasFieldOrPropertyWithValue("code", 40101); + } + + @Test + void loginWithDeletedUser_throws40102() { + User u = stubLoginUser("dead"); + u.setBDeleted(true); + when(userMapper.selectByUserName("dead")).thenReturn(u); + LoginDTO dto = loginDto("dead", "666666"); + assertThatThrownBy(() -> service.login(dto)) + .isInstanceOf(BizException.class) + .hasFieldOrPropertyWithValue("code", 40102); + } + + @Test + void loginWithWrongPassword_incrementsCounter_throws40101() { + when(userMapper.selectByUserName("u_wrong")).thenReturn(stubLoginUser("u_wrong")); + LoginDTO dto = loginDto("u_wrong", "wrong"); + assertThatThrownBy(() -> service.login(dto)) + .isInstanceOf(BizException.class) + .hasFieldOrPropertyWithValue("code", 40101); + } + + @Test + void loginAfterMaxAttemptsReached_throws42301() { + when(userMapper.selectByUserName("u_lock")).thenReturn(stubLoginUser("u_lock")); + LoginDTO dto = loginDto("u_lock", "wrong"); + for (int i = 0; i < LoginAttemptStore.MAX_ATTEMPTS - 1; i++) { + try { service.login(dto); } catch (BizException ignored) {} + } + // 第 5 次失败应触发锁定 → 42301 + assertThatThrownBy(() -> service.login(dto)) + .isInstanceOf(BizException.class) + .hasFieldOrPropertyWithValue("code", 42301); + } + + @Test + void loginWhileLocked_throws42301() { + when(userMapper.selectByUserName("u_locked2")).thenReturn(stubLoginUser("u_locked2")); + LoginDTO bad = loginDto("u_locked2", "wrong"); + for (int i = 0; i < LoginAttemptStore.MAX_ATTEMPTS; i++) { + try { service.login(bad); } catch (BizException ignored) {} + } + // 锁定后即使密码正确也走 42301 + LoginDTO good = loginDto("u_locked2", "666666"); + assertThatThrownBy(() -> service.login(good)) + .isInstanceOf(BizException.class) + .hasFieldOrPropertyWithValue("code", 42301); + } + + @Test + void loginSuccess_clearsFailureCounter() { + when(userMapper.selectByUserName("u_clear")).thenReturn(stubLoginUser("u_clear")); + try { service.login(loginDto("u_clear", "wrong")); } catch (BizException ignored) {} + try { service.login(loginDto("u_clear", "wrong")); } catch (BizException ignored) {} + // 现在 2 次失败;正确登录后清空 + service.login(loginDto("u_clear", "666666")); + // 之后再 4 次错误应不锁(计数已重置) + for (int i = 0; i < LoginAttemptStore.MAX_ATTEMPTS - 1; i++) { + try { service.login(loginDto("u_clear", "wrong")); } catch (BizException ignored) {} + } + // MAX-1 次错误后仍未锁 + assertThat(loginAttemptStore.isLocked("u_clear")).isEmpty(); + } + + private LoginDTO loginDto(String userName, String password) { + LoginDTO dto = new LoginDTO(); + dto.setSUserName(userName); + dto.setPassword(password); + dto.setVersion("标准版"); + return dto; + } + + private User stubLoginUser(String userName) { + User u = new User(); + u.setIIncrement(200); + u.setSUserNo("u200"); + u.setSUserName(userName); + u.setSUserType("普通用户"); + u.setSLanguage("zh"); + u.setSPasswordHash(encoder.encode("666666")); + u.setBDeleted(false); + return u; + } + private UpdateUserDTO baseUpdateDto() { UpdateUserDTO dto = new UpdateUserDTO(); dto.setSUserNo("u_new");