diff --git a/backend/src/main/java/com/example/erp/config/BeanConfig.java b/backend/src/main/java/com/example/erp/config/BeanConfig.java new file mode 100644 index 0000000..62858c0 --- /dev/null +++ b/backend/src/main/java/com/example/erp/config/BeanConfig.java @@ -0,0 +1,14 @@ +package com.example.erp.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; + +@Configuration +public class BeanConfig { + + @Bean + public BCryptPasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} diff --git a/backend/src/main/java/com/example/erp/module/usr/dto/LoginReqDTO.java b/backend/src/main/java/com/example/erp/module/usr/dto/LoginReqDTO.java new file mode 100644 index 0000000..4074d97 --- /dev/null +++ b/backend/src/main/java/com/example/erp/module/usr/dto/LoginReqDTO.java @@ -0,0 +1,19 @@ +package com.example.erp.module.usr.dto; + +import jakarta.validation.constraints.NotBlank; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class LoginReqDTO { + + @NotBlank(message = "公司编号不能为空") + private String brandNo; + + @NotBlank(message = "用户名不能为空") + private String username; + + @NotBlank(message = "密码不能为空") + private String password; +} diff --git a/backend/src/main/java/com/example/erp/module/usr/dto/RefreshTokenReqDTO.java b/backend/src/main/java/com/example/erp/module/usr/dto/RefreshTokenReqDTO.java new file mode 100644 index 0000000..fcb23a2 --- /dev/null +++ b/backend/src/main/java/com/example/erp/module/usr/dto/RefreshTokenReqDTO.java @@ -0,0 +1,13 @@ +package com.example.erp.module.usr.dto; + +import jakarta.validation.constraints.NotBlank; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class RefreshTokenReqDTO { + + @NotBlank(message = "refreshToken 不能为空") + private String refreshToken; +} diff --git a/backend/src/main/java/com/example/erp/module/usr/service/AuthService.java b/backend/src/main/java/com/example/erp/module/usr/service/AuthService.java new file mode 100644 index 0000000..c99be52 --- /dev/null +++ b/backend/src/main/java/com/example/erp/module/usr/service/AuthService.java @@ -0,0 +1,16 @@ +package com.example.erp.module.usr.service; + +import com.example.erp.module.usr.dto.LoginReqDTO; +import com.example.erp.module.usr.vo.BrandVO; +import com.example.erp.module.usr.vo.LoginVO; + +import java.util.List; + +public interface AuthService { + + LoginVO login(LoginReqDTO req); + + String refresh(String refreshToken); + + List getBrands(); +} diff --git a/backend/src/main/java/com/example/erp/module/usr/service/impl/AuthServiceImpl.java b/backend/src/main/java/com/example/erp/module/usr/service/impl/AuthServiceImpl.java new file mode 100644 index 0000000..de5d607 --- /dev/null +++ b/backend/src/main/java/com/example/erp/module/usr/service/impl/AuthServiceImpl.java @@ -0,0 +1,136 @@ +package com.example.erp.module.usr.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper; +import com.example.erp.common.constants.AuthErrorCode; +import com.example.erp.common.exception.BizException; +import com.example.erp.common.util.JwtUtil; +import com.example.erp.module.usr.dto.LoginReqDTO; +import com.example.erp.module.usr.entity.BrandEntity; +import com.example.erp.module.usr.entity.UsrUserEntity; +import com.example.erp.module.usr.mapper.BrandMapper; +import com.example.erp.module.usr.mapper.UsrUserMapper; +import com.example.erp.module.usr.service.AuthService; +import com.example.erp.module.usr.vo.BrandVO; +import com.example.erp.module.usr.vo.LoginVO; +import io.jsonwebtoken.Claims; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; +import java.util.List; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class AuthServiceImpl implements AuthService { + + private final BrandMapper brandMapper; + private final UsrUserMapper userMapper; + private final JwtUtil jwtUtil; + private final BCryptPasswordEncoder passwordEncoder; + + @Override + @Transactional + public LoginVO login(LoginReqDTO req) { + // 1. 查 brand + BrandEntity brand = brandMapper.selectOne( + new LambdaQueryWrapper().eq(BrandEntity::getSNo, req.getBrandNo())); + if (brand == null) { + throw new BizException(AuthErrorCode.USERNAME_OR_PASSWORD_ERROR, "用户名或密码错误"); + } + + // 2. 查 user(多租户) + UsrUserEntity user = userMapper.selectOne( + new LambdaQueryWrapper() + .eq(UsrUserEntity::getSUsername, req.getUsername()) + .eq(UsrUserEntity::getSBrandsId, brand.getSId())); + if (user == null) { + throw new BizException(AuthErrorCode.USERNAME_OR_PASSWORD_ERROR, "用户名或密码错误"); + } + + // 3. 禁用检查 + if (Integer.valueOf(1).equals(user.getBIsDisabled())) { + throw new BizException(AuthErrorCode.ACCOUNT_DISABLED, "账号已被禁用,请联系管理员"); + } + + // 4. 锁定检查 + if (user.getTLockUntil() != null && user.getTLockUntil().isAfter(LocalDateTime.now())) { + long seconds = ChronoUnit.SECONDS.between(LocalDateTime.now(), user.getTLockUntil()); + int minutes = (int) Math.ceil(seconds / 60.0); + throw new BizException(AuthErrorCode.ACCOUNT_LOCKED, "账号已被锁定,请 " + minutes + " 分钟后重试"); + } + + // 5. 密码校验 + if (!passwordEncoder.matches(req.getPassword(), user.getSPasswordHash())) { + int newCount = (user.getILoginFailCount() == null ? 0 : user.getILoginFailCount()) + 1; + UpdateWrapper updateWrapper = new UpdateWrapper() + .eq("sId", user.getSId()) + .set("iLoginFailCount", newCount); + if (newCount >= 5) { + LocalDateTime lockUntil = LocalDateTime.now().plusMinutes(30); + updateWrapper.set("tLockUntil", lockUntil); + userMapper.update(null, updateWrapper); + throw new BizException(AuthErrorCode.ACCOUNT_LOCKED, "账号已被锁定,请 30 分钟后重试"); + } + userMapper.update(null, updateWrapper); + throw new BizException(AuthErrorCode.USERNAME_OR_PASSWORD_ERROR, "用户名或密码错误"); + } + + // 6. 登录成功 + userMapper.update(null, new UpdateWrapper() + .eq("sId", user.getSId()) + .set("iLoginFailCount", 0) + .set("tLockUntil", null) + .set("tLastLoginDate", LocalDateTime.now())); + + String accessToken = jwtUtil.generateAccessToken( + user.getSId(), user.getSUsername(), user.getSUserType(), brand.getSId()); + String refreshToken = jwtUtil.generateRefreshToken(user.getSId(), brand.getSId()); + + LoginVO.UserInfoVO userInfo = new LoginVO.UserInfoVO(); + userInfo.setUserId(user.getSId()); + userInfo.setUsername(user.getSUsername()); + userInfo.setUserType(user.getSUserType()); + userInfo.setLanguage(user.getSLanguage()); + userInfo.setBrandId(brand.getSId()); + + LoginVO vo = new LoginVO(); + vo.setAccessToken(accessToken); + vo.setRefreshToken(refreshToken); + vo.setExpiresIn(86400L); + vo.setUserInfo(userInfo); + return vo; + } + + @Override + public String refresh(String refreshToken) { + Claims claims = jwtUtil.parseRefreshToken(refreshToken); + String userId = claims.getSubject(); + String brandId = claims.get("brandId", String.class); + + UsrUserEntity user = userMapper.selectOne( + new LambdaQueryWrapper().eq(UsrUserEntity::getSId, userId)); + if (user == null || Integer.valueOf(1).equals(user.getBIsDisabled())) { + throw new BizException(AuthErrorCode.REFRESH_TOKEN_INVALID, "Refresh Token 已失效,请重新登录"); + } + + return jwtUtil.generateAccessToken(user.getSId(), user.getSUsername(), user.getSUserType(), brandId); + } + + @Override + public List getBrands() { + List brands = brandMapper.selectList( + new QueryWrapper().select("sNo", "sName").orderByAsc("sName")); + return brands.stream().map(b -> { + BrandVO vo = new BrandVO(); + vo.setSNo(b.getSNo()); + vo.setSName(b.getSName()); + return vo; + }).collect(Collectors.toList()); + } +} diff --git a/backend/src/main/java/com/example/erp/module/usr/vo/BrandVO.java b/backend/src/main/java/com/example/erp/module/usr/vo/BrandVO.java new file mode 100644 index 0000000..3001f6e --- /dev/null +++ b/backend/src/main/java/com/example/erp/module/usr/vo/BrandVO.java @@ -0,0 +1,11 @@ +package com.example.erp.module.usr.vo; + +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class BrandVO { + private String sNo; + private String sName; +} diff --git a/backend/src/main/java/com/example/erp/module/usr/vo/LoginVO.java b/backend/src/main/java/com/example/erp/module/usr/vo/LoginVO.java new file mode 100644 index 0000000..39bc955 --- /dev/null +++ b/backend/src/main/java/com/example/erp/module/usr/vo/LoginVO.java @@ -0,0 +1,24 @@ +package com.example.erp.module.usr.vo; + +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class LoginVO { + + private String accessToken; + private String refreshToken; + private long expiresIn; + private UserInfoVO userInfo; + + @Getter + @Setter + public static class UserInfoVO { + private String userId; + private String username; + private String userType; + private String language; + private String brandId; + } +} diff --git a/backend/src/test/java/com/example/erp/module/usr/AuthServiceTest.java b/backend/src/test/java/com/example/erp/module/usr/AuthServiceTest.java new file mode 100644 index 0000000..bcaef42 --- /dev/null +++ b/backend/src/test/java/com/example/erp/module/usr/AuthServiceTest.java @@ -0,0 +1,176 @@ +package com.example.erp.module.usr; + +import com.example.erp.common.exception.BizException; +import com.example.erp.common.util.JwtUtil; +import com.example.erp.module.usr.dto.LoginReqDTO; +import com.example.erp.module.usr.entity.BrandEntity; +import com.example.erp.module.usr.entity.UsrUserEntity; +import com.example.erp.module.usr.mapper.BrandMapper; +import com.example.erp.module.usr.mapper.UsrUserMapper; +import com.example.erp.module.usr.service.impl.AuthServiceImpl; +import com.example.erp.module.usr.vo.BrandVO; +import com.example.erp.module.usr.vo.LoginVO; +import io.jsonwebtoken.Claims; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class AuthServiceTest { + + @Mock private BrandMapper brandMapper; + @Mock private UsrUserMapper userMapper; + @Mock private JwtUtil jwtUtil; + @Mock private BCryptPasswordEncoder passwordEncoder; + + @InjectMocks + private AuthServiceImpl authService; + + private LoginReqDTO req; + private BrandEntity brand; + private UsrUserEntity user; + + @BeforeEach + void setUp() { + req = new LoginReqDTO(); + req.setBrandNo("STD"); + req.setUsername("admin"); + req.setPassword("666666"); + + brand = new BrandEntity(); + brand.setSId("b1"); + brand.setSNo("STD"); + brand.setSName("标准版"); + + user = new UsrUserEntity(); + user.setSId("u1"); + user.setSUsername("admin"); + user.setSPasswordHash("$2a$10$hashed"); + user.setSUserType("普通用户"); + user.setSLanguage("中文"); + user.setBIsDisabled(0); + user.setILoginFailCount(0); + user.setTLockUntil(null); + } + + @Test + void login_brandNotFound_throws40100() { + when(brandMapper.selectOne(any())).thenReturn(null); + BizException ex = assertThrows(BizException.class, () -> authService.login(req)); + assertEquals(40100, ex.getCode()); + } + + @Test + void login_userNotFound_throws40100() { + when(brandMapper.selectOne(any())).thenReturn(brand); + when(userMapper.selectOne(any())).thenReturn(null); + BizException ex = assertThrows(BizException.class, () -> authService.login(req)); + assertEquals(40100, ex.getCode()); + } + + @Test + void login_accountDisabled_throws40101() { + user.setBIsDisabled(1); + when(brandMapper.selectOne(any())).thenReturn(brand); + when(userMapper.selectOne(any())).thenReturn(user); + BizException ex = assertThrows(BizException.class, () -> authService.login(req)); + assertEquals(40101, ex.getCode()); + } + + @Test + void login_accountLocked_throws40102WithRemainingMinutes() { + user.setTLockUntil(LocalDateTime.now().plusMinutes(20)); + when(brandMapper.selectOne(any())).thenReturn(brand); + when(userMapper.selectOne(any())).thenReturn(user); + BizException ex = assertThrows(BizException.class, () -> authService.login(req)); + assertEquals(40102, ex.getCode()); + assertTrue(ex.getMessage().contains("分钟")); + } + + @Test + void login_wrongPassword_firstTime_throws40100AndIncrementsCount() { + when(brandMapper.selectOne(any())).thenReturn(brand); + when(userMapper.selectOne(any())).thenReturn(user); + when(passwordEncoder.matches(anyString(), anyString())).thenReturn(false); + BizException ex = assertThrows(BizException.class, () -> authService.login(req)); + assertEquals(40100, ex.getCode()); + verify(userMapper).update(isNull(), any()); + } + + @Test + void login_wrongPassword_5thTime_setsLockAndThrows40102() { + user.setILoginFailCount(4); + when(brandMapper.selectOne(any())).thenReturn(brand); + when(userMapper.selectOne(any())).thenReturn(user); + when(passwordEncoder.matches(anyString(), anyString())).thenReturn(false); + BizException ex = assertThrows(BizException.class, () -> authService.login(req)); + assertEquals(40102, ex.getCode()); + verify(userMapper).update(isNull(), any()); + } + + @Test + void login_success_resetsCountAndReturnsTokens() { + when(brandMapper.selectOne(any())).thenReturn(brand); + when(userMapper.selectOne(any())).thenReturn(user); + when(passwordEncoder.matches(anyString(), anyString())).thenReturn(true); + when(jwtUtil.generateAccessToken(anyString(), anyString(), anyString(), anyString())).thenReturn("access-token"); + when(jwtUtil.generateRefreshToken(anyString(), anyString())).thenReturn("refresh-token"); + + LoginVO result = authService.login(req); + + assertEquals("access-token", result.getAccessToken()); + assertEquals("refresh-token", result.getRefreshToken()); + assertEquals(86400L, result.getExpiresIn()); + assertNotNull(result.getUserInfo()); + assertEquals("u1", result.getUserInfo().getUserId()); + verify(userMapper).update(isNull(), any()); + } + + // ---- Task 6: refresh + getBrands tests ---- + + @Test + void refresh_validRefreshToken_returnsNewAccessToken() { + Claims claims = mock(Claims.class); + when(claims.getSubject()).thenReturn("u1"); + when(claims.get("brandId", String.class)).thenReturn("b1"); + when(jwtUtil.parseRefreshToken("valid-refresh")).thenReturn(claims); + when(userMapper.selectOne(any())).thenReturn(user); + when(jwtUtil.generateAccessToken(anyString(), anyString(), anyString(), anyString())).thenReturn("new-access-token"); + + String newToken = authService.refresh("valid-refresh"); + assertEquals("new-access-token", newToken); + } + + @Test + void refresh_invalidRefreshToken_throws40103() { + when(jwtUtil.parseRefreshToken("bad-token")) + .thenThrow(new BizException(40103, "Refresh Token 已失效,请重新登录")); + BizException ex = assertThrows(BizException.class, () -> authService.refresh("bad-token")); + assertEquals(40103, ex.getCode()); + } + + @Test + void getBrands_returnsListSortedByName() { + BrandEntity b1 = new BrandEntity(); + b1.setSNo("STD"); b1.setSName("标准版"); + BrandEntity b2 = new BrandEntity(); + b2.setSNo("ENT"); b2.setSName("企业版"); + when(brandMapper.selectList(any())).thenReturn(List.of(b1, b2)); + + List result = authService.getBrands(); + assertEquals(2, result.size()); + assertEquals("标准版", result.get(0).getSName()); + } +}