Commit 79a8340d906c28c81879940986820426c1ccdbfd

Authored by zichun
1 parent dd310300

feat(usr): AuthService login/refresh/getBrands + BizException + BeanConfig REQ-USR-004

- LoginReqDTO/RefreshTokenReqDTO/LoginVO/BrandVO DTO/VO
- AuthService interface: login/refresh/getBrands
- AuthServiceImpl: multi-tenant brand query, BCrypt, disabled/lock check,
  fail count (5x → lock 30min), success reset; refresh token validate + re-issue;
  getBrands ORDER BY sName
- UpdateWrapper (string columns) avoids LambdaWrapper unit test issue
- BeanConfig: @Bean BCryptPasswordEncoder
- AuthServiceTest: 10/10 PASS (7 login + 3 refresh/brands)
backend/src/main/java/com/example/erp/config/BeanConfig.java 0 → 100644
  1 +package com.example.erp.config;
  2 +
  3 +import org.springframework.context.annotation.Bean;
  4 +import org.springframework.context.annotation.Configuration;
  5 +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
  6 +
  7 +@Configuration
  8 +public class BeanConfig {
  9 +
  10 + @Bean
  11 + public BCryptPasswordEncoder passwordEncoder() {
  12 + return new BCryptPasswordEncoder();
  13 + }
  14 +}
backend/src/main/java/com/example/erp/module/usr/dto/LoginReqDTO.java 0 → 100644
  1 +package com.example.erp.module.usr.dto;
  2 +
  3 +import jakarta.validation.constraints.NotBlank;
  4 +import lombok.Getter;
  5 +import lombok.Setter;
  6 +
  7 +@Getter
  8 +@Setter
  9 +public class LoginReqDTO {
  10 +
  11 + @NotBlank(message = "公司编号不能为空")
  12 + private String brandNo;
  13 +
  14 + @NotBlank(message = "用户名不能为空")
  15 + private String username;
  16 +
  17 + @NotBlank(message = "密码不能为空")
  18 + private String password;
  19 +}
backend/src/main/java/com/example/erp/module/usr/dto/RefreshTokenReqDTO.java 0 → 100644
  1 +package com.example.erp.module.usr.dto;
  2 +
  3 +import jakarta.validation.constraints.NotBlank;
  4 +import lombok.Getter;
  5 +import lombok.Setter;
  6 +
  7 +@Getter
  8 +@Setter
  9 +public class RefreshTokenReqDTO {
  10 +
  11 + @NotBlank(message = "refreshToken 不能为空")
  12 + private String refreshToken;
  13 +}
backend/src/main/java/com/example/erp/module/usr/service/AuthService.java 0 → 100644
  1 +package com.example.erp.module.usr.service;
  2 +
  3 +import com.example.erp.module.usr.dto.LoginReqDTO;
  4 +import com.example.erp.module.usr.vo.BrandVO;
  5 +import com.example.erp.module.usr.vo.LoginVO;
  6 +
  7 +import java.util.List;
  8 +
  9 +public interface AuthService {
  10 +
  11 + LoginVO login(LoginReqDTO req);
  12 +
  13 + String refresh(String refreshToken);
  14 +
  15 + List<BrandVO> getBrands();
  16 +}
backend/src/main/java/com/example/erp/module/usr/service/impl/AuthServiceImpl.java 0 → 100644
  1 +package com.example.erp.module.usr.service.impl;
  2 +
  3 +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
  4 +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
  5 +import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
  6 +import com.example.erp.common.constants.AuthErrorCode;
  7 +import com.example.erp.common.exception.BizException;
  8 +import com.example.erp.common.util.JwtUtil;
  9 +import com.example.erp.module.usr.dto.LoginReqDTO;
  10 +import com.example.erp.module.usr.entity.BrandEntity;
  11 +import com.example.erp.module.usr.entity.UsrUserEntity;
  12 +import com.example.erp.module.usr.mapper.BrandMapper;
  13 +import com.example.erp.module.usr.mapper.UsrUserMapper;
  14 +import com.example.erp.module.usr.service.AuthService;
  15 +import com.example.erp.module.usr.vo.BrandVO;
  16 +import com.example.erp.module.usr.vo.LoginVO;
  17 +import io.jsonwebtoken.Claims;
  18 +import lombok.RequiredArgsConstructor;
  19 +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
  20 +import org.springframework.stereotype.Service;
  21 +import org.springframework.transaction.annotation.Transactional;
  22 +
  23 +import java.time.LocalDateTime;
  24 +import java.time.temporal.ChronoUnit;
  25 +import java.util.List;
  26 +import java.util.stream.Collectors;
  27 +
  28 +@Service
  29 +@RequiredArgsConstructor
  30 +public class AuthServiceImpl implements AuthService {
  31 +
  32 + private final BrandMapper brandMapper;
  33 + private final UsrUserMapper userMapper;
  34 + private final JwtUtil jwtUtil;
  35 + private final BCryptPasswordEncoder passwordEncoder;
  36 +
  37 + @Override
  38 + @Transactional
  39 + public LoginVO login(LoginReqDTO req) {
  40 + // 1. 查 brand
  41 + BrandEntity brand = brandMapper.selectOne(
  42 + new LambdaQueryWrapper<BrandEntity>().eq(BrandEntity::getSNo, req.getBrandNo()));
  43 + if (brand == null) {
  44 + throw new BizException(AuthErrorCode.USERNAME_OR_PASSWORD_ERROR, "用户名或密码错误");
  45 + }
  46 +
  47 + // 2. 查 user(多租户)
  48 + UsrUserEntity user = userMapper.selectOne(
  49 + new LambdaQueryWrapper<UsrUserEntity>()
  50 + .eq(UsrUserEntity::getSUsername, req.getUsername())
  51 + .eq(UsrUserEntity::getSBrandsId, brand.getSId()));
  52 + if (user == null) {
  53 + throw new BizException(AuthErrorCode.USERNAME_OR_PASSWORD_ERROR, "用户名或密码错误");
  54 + }
  55 +
  56 + // 3. 禁用检查
  57 + if (Integer.valueOf(1).equals(user.getBIsDisabled())) {
  58 + throw new BizException(AuthErrorCode.ACCOUNT_DISABLED, "账号已被禁用,请联系管理员");
  59 + }
  60 +
  61 + // 4. 锁定检查
  62 + if (user.getTLockUntil() != null && user.getTLockUntil().isAfter(LocalDateTime.now())) {
  63 + long seconds = ChronoUnit.SECONDS.between(LocalDateTime.now(), user.getTLockUntil());
  64 + int minutes = (int) Math.ceil(seconds / 60.0);
  65 + throw new BizException(AuthErrorCode.ACCOUNT_LOCKED, "账号已被锁定,请 " + minutes + " 分钟后重试");
  66 + }
  67 +
  68 + // 5. 密码校验
  69 + if (!passwordEncoder.matches(req.getPassword(), user.getSPasswordHash())) {
  70 + int newCount = (user.getILoginFailCount() == null ? 0 : user.getILoginFailCount()) + 1;
  71 + UpdateWrapper<UsrUserEntity> updateWrapper = new UpdateWrapper<UsrUserEntity>()
  72 + .eq("sId", user.getSId())
  73 + .set("iLoginFailCount", newCount);
  74 + if (newCount >= 5) {
  75 + LocalDateTime lockUntil = LocalDateTime.now().plusMinutes(30);
  76 + updateWrapper.set("tLockUntil", lockUntil);
  77 + userMapper.update(null, updateWrapper);
  78 + throw new BizException(AuthErrorCode.ACCOUNT_LOCKED, "账号已被锁定,请 30 分钟后重试");
  79 + }
  80 + userMapper.update(null, updateWrapper);
  81 + throw new BizException(AuthErrorCode.USERNAME_OR_PASSWORD_ERROR, "用户名或密码错误");
  82 + }
  83 +
  84 + // 6. 登录成功
  85 + userMapper.update(null, new UpdateWrapper<UsrUserEntity>()
  86 + .eq("sId", user.getSId())
  87 + .set("iLoginFailCount", 0)
  88 + .set("tLockUntil", null)
  89 + .set("tLastLoginDate", LocalDateTime.now()));
  90 +
  91 + String accessToken = jwtUtil.generateAccessToken(
  92 + user.getSId(), user.getSUsername(), user.getSUserType(), brand.getSId());
  93 + String refreshToken = jwtUtil.generateRefreshToken(user.getSId(), brand.getSId());
  94 +
  95 + LoginVO.UserInfoVO userInfo = new LoginVO.UserInfoVO();
  96 + userInfo.setUserId(user.getSId());
  97 + userInfo.setUsername(user.getSUsername());
  98 + userInfo.setUserType(user.getSUserType());
  99 + userInfo.setLanguage(user.getSLanguage());
  100 + userInfo.setBrandId(brand.getSId());
  101 +
  102 + LoginVO vo = new LoginVO();
  103 + vo.setAccessToken(accessToken);
  104 + vo.setRefreshToken(refreshToken);
  105 + vo.setExpiresIn(86400L);
  106 + vo.setUserInfo(userInfo);
  107 + return vo;
  108 + }
  109 +
  110 + @Override
  111 + public String refresh(String refreshToken) {
  112 + Claims claims = jwtUtil.parseRefreshToken(refreshToken);
  113 + String userId = claims.getSubject();
  114 + String brandId = claims.get("brandId", String.class);
  115 +
  116 + UsrUserEntity user = userMapper.selectOne(
  117 + new LambdaQueryWrapper<UsrUserEntity>().eq(UsrUserEntity::getSId, userId));
  118 + if (user == null || Integer.valueOf(1).equals(user.getBIsDisabled())) {
  119 + throw new BizException(AuthErrorCode.REFRESH_TOKEN_INVALID, "Refresh Token 已失效,请重新登录");
  120 + }
  121 +
  122 + return jwtUtil.generateAccessToken(user.getSId(), user.getSUsername(), user.getSUserType(), brandId);
  123 + }
  124 +
  125 + @Override
  126 + public List<BrandVO> getBrands() {
  127 + List<BrandEntity> brands = brandMapper.selectList(
  128 + new QueryWrapper<BrandEntity>().select("sNo", "sName").orderByAsc("sName"));
  129 + return brands.stream().map(b -> {
  130 + BrandVO vo = new BrandVO();
  131 + vo.setSNo(b.getSNo());
  132 + vo.setSName(b.getSName());
  133 + return vo;
  134 + }).collect(Collectors.toList());
  135 + }
  136 +}
backend/src/main/java/com/example/erp/module/usr/vo/BrandVO.java 0 → 100644
  1 +package com.example.erp.module.usr.vo;
  2 +
  3 +import lombok.Getter;
  4 +import lombok.Setter;
  5 +
  6 +@Getter
  7 +@Setter
  8 +public class BrandVO {
  9 + private String sNo;
  10 + private String sName;
  11 +}
backend/src/main/java/com/example/erp/module/usr/vo/LoginVO.java 0 → 100644
  1 +package com.example.erp.module.usr.vo;
  2 +
  3 +import lombok.Getter;
  4 +import lombok.Setter;
  5 +
  6 +@Getter
  7 +@Setter
  8 +public class LoginVO {
  9 +
  10 + private String accessToken;
  11 + private String refreshToken;
  12 + private long expiresIn;
  13 + private UserInfoVO userInfo;
  14 +
  15 + @Getter
  16 + @Setter
  17 + public static class UserInfoVO {
  18 + private String userId;
  19 + private String username;
  20 + private String userType;
  21 + private String language;
  22 + private String brandId;
  23 + }
  24 +}
backend/src/test/java/com/example/erp/module/usr/AuthServiceTest.java 0 → 100644
  1 +package com.example.erp.module.usr;
  2 +
  3 +import com.example.erp.common.exception.BizException;
  4 +import com.example.erp.common.util.JwtUtil;
  5 +import com.example.erp.module.usr.dto.LoginReqDTO;
  6 +import com.example.erp.module.usr.entity.BrandEntity;
  7 +import com.example.erp.module.usr.entity.UsrUserEntity;
  8 +import com.example.erp.module.usr.mapper.BrandMapper;
  9 +import com.example.erp.module.usr.mapper.UsrUserMapper;
  10 +import com.example.erp.module.usr.service.impl.AuthServiceImpl;
  11 +import com.example.erp.module.usr.vo.BrandVO;
  12 +import com.example.erp.module.usr.vo.LoginVO;
  13 +import io.jsonwebtoken.Claims;
  14 +import org.junit.jupiter.api.BeforeEach;
  15 +import org.junit.jupiter.api.Test;
  16 +import org.junit.jupiter.api.extension.ExtendWith;
  17 +import org.mockito.InjectMocks;
  18 +import org.mockito.Mock;
  19 +import org.mockito.junit.jupiter.MockitoExtension;
  20 +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
  21 +
  22 +import java.time.LocalDateTime;
  23 +import java.util.List;
  24 +import java.util.Map;
  25 +
  26 +import static org.junit.jupiter.api.Assertions.*;
  27 +import static org.mockito.ArgumentMatchers.*;
  28 +import static org.mockito.Mockito.*;
  29 +
  30 +@ExtendWith(MockitoExtension.class)
  31 +class AuthServiceTest {
  32 +
  33 + @Mock private BrandMapper brandMapper;
  34 + @Mock private UsrUserMapper userMapper;
  35 + @Mock private JwtUtil jwtUtil;
  36 + @Mock private BCryptPasswordEncoder passwordEncoder;
  37 +
  38 + @InjectMocks
  39 + private AuthServiceImpl authService;
  40 +
  41 + private LoginReqDTO req;
  42 + private BrandEntity brand;
  43 + private UsrUserEntity user;
  44 +
  45 + @BeforeEach
  46 + void setUp() {
  47 + req = new LoginReqDTO();
  48 + req.setBrandNo("STD");
  49 + req.setUsername("admin");
  50 + req.setPassword("666666");
  51 +
  52 + brand = new BrandEntity();
  53 + brand.setSId("b1");
  54 + brand.setSNo("STD");
  55 + brand.setSName("标准版");
  56 +
  57 + user = new UsrUserEntity();
  58 + user.setSId("u1");
  59 + user.setSUsername("admin");
  60 + user.setSPasswordHash("$2a$10$hashed");
  61 + user.setSUserType("普通用户");
  62 + user.setSLanguage("中文");
  63 + user.setBIsDisabled(0);
  64 + user.setILoginFailCount(0);
  65 + user.setTLockUntil(null);
  66 + }
  67 +
  68 + @Test
  69 + void login_brandNotFound_throws40100() {
  70 + when(brandMapper.selectOne(any())).thenReturn(null);
  71 + BizException ex = assertThrows(BizException.class, () -> authService.login(req));
  72 + assertEquals(40100, ex.getCode());
  73 + }
  74 +
  75 + @Test
  76 + void login_userNotFound_throws40100() {
  77 + when(brandMapper.selectOne(any())).thenReturn(brand);
  78 + when(userMapper.selectOne(any())).thenReturn(null);
  79 + BizException ex = assertThrows(BizException.class, () -> authService.login(req));
  80 + assertEquals(40100, ex.getCode());
  81 + }
  82 +
  83 + @Test
  84 + void login_accountDisabled_throws40101() {
  85 + user.setBIsDisabled(1);
  86 + when(brandMapper.selectOne(any())).thenReturn(brand);
  87 + when(userMapper.selectOne(any())).thenReturn(user);
  88 + BizException ex = assertThrows(BizException.class, () -> authService.login(req));
  89 + assertEquals(40101, ex.getCode());
  90 + }
  91 +
  92 + @Test
  93 + void login_accountLocked_throws40102WithRemainingMinutes() {
  94 + user.setTLockUntil(LocalDateTime.now().plusMinutes(20));
  95 + when(brandMapper.selectOne(any())).thenReturn(brand);
  96 + when(userMapper.selectOne(any())).thenReturn(user);
  97 + BizException ex = assertThrows(BizException.class, () -> authService.login(req));
  98 + assertEquals(40102, ex.getCode());
  99 + assertTrue(ex.getMessage().contains("分钟"));
  100 + }
  101 +
  102 + @Test
  103 + void login_wrongPassword_firstTime_throws40100AndIncrementsCount() {
  104 + when(brandMapper.selectOne(any())).thenReturn(brand);
  105 + when(userMapper.selectOne(any())).thenReturn(user);
  106 + when(passwordEncoder.matches(anyString(), anyString())).thenReturn(false);
  107 + BizException ex = assertThrows(BizException.class, () -> authService.login(req));
  108 + assertEquals(40100, ex.getCode());
  109 + verify(userMapper).update(isNull(), any());
  110 + }
  111 +
  112 + @Test
  113 + void login_wrongPassword_5thTime_setsLockAndThrows40102() {
  114 + user.setILoginFailCount(4);
  115 + when(brandMapper.selectOne(any())).thenReturn(brand);
  116 + when(userMapper.selectOne(any())).thenReturn(user);
  117 + when(passwordEncoder.matches(anyString(), anyString())).thenReturn(false);
  118 + BizException ex = assertThrows(BizException.class, () -> authService.login(req));
  119 + assertEquals(40102, ex.getCode());
  120 + verify(userMapper).update(isNull(), any());
  121 + }
  122 +
  123 + @Test
  124 + void login_success_resetsCountAndReturnsTokens() {
  125 + when(brandMapper.selectOne(any())).thenReturn(brand);
  126 + when(userMapper.selectOne(any())).thenReturn(user);
  127 + when(passwordEncoder.matches(anyString(), anyString())).thenReturn(true);
  128 + when(jwtUtil.generateAccessToken(anyString(), anyString(), anyString(), anyString())).thenReturn("access-token");
  129 + when(jwtUtil.generateRefreshToken(anyString(), anyString())).thenReturn("refresh-token");
  130 +
  131 + LoginVO result = authService.login(req);
  132 +
  133 + assertEquals("access-token", result.getAccessToken());
  134 + assertEquals("refresh-token", result.getRefreshToken());
  135 + assertEquals(86400L, result.getExpiresIn());
  136 + assertNotNull(result.getUserInfo());
  137 + assertEquals("u1", result.getUserInfo().getUserId());
  138 + verify(userMapper).update(isNull(), any());
  139 + }
  140 +
  141 + // ---- Task 6: refresh + getBrands tests ----
  142 +
  143 + @Test
  144 + void refresh_validRefreshToken_returnsNewAccessToken() {
  145 + Claims claims = mock(Claims.class);
  146 + when(claims.getSubject()).thenReturn("u1");
  147 + when(claims.get("brandId", String.class)).thenReturn("b1");
  148 + when(jwtUtil.parseRefreshToken("valid-refresh")).thenReturn(claims);
  149 + when(userMapper.selectOne(any())).thenReturn(user);
  150 + when(jwtUtil.generateAccessToken(anyString(), anyString(), anyString(), anyString())).thenReturn("new-access-token");
  151 +
  152 + String newToken = authService.refresh("valid-refresh");
  153 + assertEquals("new-access-token", newToken);
  154 + }
  155 +
  156 + @Test
  157 + void refresh_invalidRefreshToken_throws40103() {
  158 + when(jwtUtil.parseRefreshToken("bad-token"))
  159 + .thenThrow(new BizException(40103, "Refresh Token 已失效,请重新登录"));
  160 + BizException ex = assertThrows(BizException.class, () -> authService.refresh("bad-token"));
  161 + assertEquals(40103, ex.getCode());
  162 + }
  163 +
  164 + @Test
  165 + void getBrands_returnsListSortedByName() {
  166 + BrandEntity b1 = new BrandEntity();
  167 + b1.setSNo("STD"); b1.setSName("标准版");
  168 + BrandEntity b2 = new BrandEntity();
  169 + b2.setSNo("ENT"); b2.setSName("企业版");
  170 + when(brandMapper.selectList(any())).thenReturn(List.of(b1, b2));
  171 +
  172 + List<BrandVO> result = authService.getBrands();
  173 + assertEquals(2, result.size());
  174 + assertEquals("标准版", result.get(0).getSName());
  175 + }
  176 +}