Commit c231102e5775e894a55a3e281875809352b20845

Authored by zichun
1 parent 293118bc

feat(usr): 登录与公司列表 Controller 及安全放行 REQ-USR-004

backend/src/main/java/com/xly/erp/common/config/SecurityConfig.java
@@ -24,6 +24,21 @@ import org.springframework.security.web.authentication.UsernamePasswordAuthentic @@ -24,6 +24,21 @@ import org.springframework.security.web.authentication.UsernamePasswordAuthentic
24 public class SecurityConfig { 24 public class SecurityConfig {
25 25
26 /** 26 /**
  27 + * 放行清单(无需 token 即可访问)。
  28 + *
  29 + * <p>REQ-USR-001 放行 /api/usr/login;REQ-USR-004 T5 追加 /api/usr/companies(登录页版本下拉)。
  30 + * 抽为常量供单测断言与过滤链共用,避免清单漂移。</p>
  31 + */
  32 + public static final String[] PERMIT_ALL_PATHS = {
  33 + "/api/usr/login",
  34 + "/api/usr/companies",
  35 + "/swagger-ui/**",
  36 + "/swagger-ui.html",
  37 + "/v3/api-docs/**",
  38 + "/actuator/health",
  39 + };
  40 +
  41 + /**
27 * 密码哈希编码器(BCrypt),禁止明文存储 / 比对。 42 * 密码哈希编码器(BCrypt),禁止明文存储 / 比对。
28 */ 43 */
29 @Bean 44 @Bean
@@ -41,12 +56,7 @@ public class SecurityConfig { @@ -41,12 +56,7 @@ public class SecurityConfig {
41 .exceptionHandling(eh -> eh 56 .exceptionHandling(eh -> eh
42 .authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED))) 57 .authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)))
43 .authorizeHttpRequests(auth -> auth 58 .authorizeHttpRequests(auth -> auth
44 - .requestMatchers(  
45 - "/api/usr/login",  
46 - "/swagger-ui/**",  
47 - "/swagger-ui.html",  
48 - "/v3/api-docs/**",  
49 - "/actuator/health") 59 + .requestMatchers(PERMIT_ALL_PATHS)
50 .permitAll() 60 .permitAll()
51 .anyRequest().authenticated()) 61 .anyRequest().authenticated())
52 .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); 62 .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
backend/src/main/java/com/xly/erp/modules/usr/controller/UsrAuthController.java 0 → 100644
  1 +package com.xly.erp.modules.usr.controller;
  2 +
  3 +import com.xly.erp.common.response.Result;
  4 +import com.xly.erp.modules.usr.dto.LoginDTO;
  5 +import com.xly.erp.modules.usr.service.UsrAuthService;
  6 +import com.xly.erp.modules.usr.vo.CompanyOptionVO;
  7 +import com.xly.erp.modules.usr.vo.LoginVO;
  8 +import jakarta.validation.Valid;
  9 +import java.util.List;
  10 +import org.springframework.web.bind.annotation.GetMapping;
  11 +import org.springframework.web.bind.annotation.PostMapping;
  12 +import org.springframework.web.bind.annotation.RequestBody;
  13 +import org.springframework.web.bind.annotation.RequestMapping;
  14 +import org.springframework.web.bind.annotation.RestController;
  15 +
  16 +/**
  17 + * 认证 Controller(docs/05 REQ-USR-004 / spec § 2)。REQ-USR-004 T5。
  18 + *
  19 + * <p>仅做参数校验 + 委派 Service,不写业务逻辑、不直接调 Mapper、无管理员前置
  20 + * (登录 / 公司列表为放行端点)。</p>
  21 + */
  22 +@RestController
  23 +@RequestMapping("/api/usr")
  24 +public class UsrAuthController {
  25 +
  26 + private final UsrAuthService usrAuthService;
  27 +
  28 + public UsrAuthController(UsrAuthService usrAuthService) {
  29 + this.usrAuthService = usrAuthService;
  30 + }
  31 +
  32 + /**
  33 + * 登录认证(放行,无需 token):@Valid 校验入参后委派 Service。
  34 + */
  35 + @PostMapping("/login")
  36 + public Result<LoginVO> login(@Valid @RequestBody LoginDTO dto) {
  37 + return Result.success(usrAuthService.login(dto));
  38 + }
  39 +
  40 + /**
  41 + * 公司下拉列表(放行,无参):供登录页「版本」下拉。
  42 + */
  43 + @GetMapping("/companies")
  44 + public Result<List<CompanyOptionVO>> listCompanies() {
  45 + return Result.success(usrAuthService.listCompanies());
  46 + }
  47 +}
backend/src/test/java/com/xly/erp/common/config/SecurityConfigTest.java
@@ -22,4 +22,14 @@ class SecurityConfigTest { @@ -22,4 +22,14 @@ class SecurityConfigTest {
22 assertThat(encoder.matches("666666", hash)).isTrue(); 22 assertThat(encoder.matches("666666", hash)).isTrue();
23 assertThat(encoder.matches("wrong", hash)).isFalse(); 23 assertThat(encoder.matches("wrong", hash)).isFalse();
24 } 24 }
  25 +
  26 + /**
  27 + * REQ-USR-004 T5:放行清单含 GET /api/usr/companies(与既有 /api/usr/login 同口径放行)。
  28 + */
  29 + @Test
  30 + void companiesEndpointPermitted() {
  31 + assertThat(SecurityConfig.PERMIT_ALL_PATHS).contains("/api/usr/companies");
  32 + // 不删除既有放行项:/api/usr/login 仍在清单。
  33 + assertThat(SecurityConfig.PERMIT_ALL_PATHS).contains("/api/usr/login");
  34 + }
25 } 35 }
backend/src/test/java/com/xly/erp/modules/usr/controller/UsrAuthControllerTest.java 0 → 100644
  1 +package com.xly.erp.modules.usr.controller;
  2 +
  3 +import static org.mockito.ArgumentMatchers.any;
  4 +import static org.mockito.Mockito.never;
  5 +import static org.mockito.Mockito.verify;
  6 +import static org.mockito.Mockito.when;
  7 +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
  8 +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
  9 +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
  10 +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
  11 +
  12 +import com.fasterxml.jackson.databind.ObjectMapper;
  13 +import com.xly.erp.common.exception.BusinessException;
  14 +import com.xly.erp.common.exception.GlobalExceptionHandler;
  15 +import com.xly.erp.common.response.ResultCode;
  16 +import com.xly.erp.modules.usr.dto.LoginDTO;
  17 +import com.xly.erp.modules.usr.service.UsrAuthService;
  18 +import com.xly.erp.modules.usr.vo.CompanyOptionVO;
  19 +import com.xly.erp.modules.usr.vo.LoginVO;
  20 +import java.util.List;
  21 +import org.junit.jupiter.api.BeforeEach;
  22 +import org.junit.jupiter.api.Test;
  23 +import org.mockito.Mockito;
  24 +import org.springframework.test.web.servlet.MockMvc;
  25 +import org.springframework.test.web.servlet.setup.MockMvcBuilders;
  26 +
  27 +/**
  28 + * REQ-USR-004 T5:UsrAuthController(MockMvc standaloneSetup + 真实 GlobalExceptionHandler)。
  29 + *
  30 + * <p>Service 用 Mockito mock;验证登录成功 code=0 + token + 嵌套 user、缺字段 40001、
  31 + * 业务异常透传转码(40101/40302/42901)、公司列表 code=0。响应体不含密码。</p>
  32 + */
  33 +class UsrAuthControllerTest {
  34 +
  35 + private final ObjectMapper objectMapper = new ObjectMapper();
  36 + private final UsrAuthService usrAuthService = Mockito.mock(UsrAuthService.class);
  37 + private MockMvc mockMvc;
  38 +
  39 + @BeforeEach
  40 + void setUp() {
  41 + UsrAuthController controller = new UsrAuthController(usrAuthService);
  42 + mockMvc = MockMvcBuilders.standaloneSetup(controller)
  43 + .setControllerAdvice(new GlobalExceptionHandler())
  44 + .build();
  45 + }
  46 +
  47 + private String validBody() throws Exception {
  48 + LoginDTO dto = new LoginDTO();
  49 + dto.setSUserName("admin");
  50 + dto.setPassword("666666");
  51 + dto.setCompanyId(1);
  52 + return objectMapper.writeValueAsString(dto);
  53 + }
  54 +
  55 + private LoginVO sampleVO() {
  56 + LoginVO.UserInfo info = new LoginVO.UserInfo();
  57 + info.setId(1);
  58 + info.setSUserName("admin");
  59 + info.setSUserType("超级管理员");
  60 + info.setSLanguage("中文");
  61 + LoginVO vo = new LoginVO();
  62 + vo.setToken("jwt.token.value");
  63 + vo.setUser(info);
  64 + return vo;
  65 + }
  66 +
  67 + @Test
  68 + void loginReturnsCodeZeroWithTokenAndUser() throws Exception {
  69 + when(usrAuthService.login(any(LoginDTO.class))).thenReturn(sampleVO());
  70 +
  71 + mockMvc.perform(post("/api/usr/login")
  72 + .contentType("application/json")
  73 + .content(validBody()))
  74 + .andExpect(status().isOk())
  75 + .andExpect(jsonPath("$.code").value(0))
  76 + .andExpect(jsonPath("$.data.token").value("jwt.token.value"))
  77 + .andExpect(jsonPath("$.data.user.id").value(1))
  78 + .andExpect(jsonPath("$.data.user.sUserName").value("admin"))
  79 + .andExpect(jsonPath("$.data.user.sUserType").value("超级管理员"))
  80 + .andExpect(jsonPath("$.data.user.sLanguage").value("中文"))
  81 + .andExpect(jsonPath("$.data.sPassword").doesNotExist())
  82 + .andExpect(jsonPath("$.data.password").doesNotExist());
  83 + }
  84 +
  85 + @Test
  86 + void loginMissingFieldReturns40001() throws Exception {
  87 + // 缺 companyId(@NotNull 失败)
  88 + String body = "{\"sUserName\":\"admin\",\"password\":\"666666\"}";
  89 + mockMvc.perform(post("/api/usr/login")
  90 + .contentType("application/json")
  91 + .content(body))
  92 + .andExpect(status().isOk())
  93 + .andExpect(jsonPath("$.code").value(40001));
  94 + verify(usrAuthService, never()).login(any(LoginDTO.class));
  95 + }
  96 +
  97 + @Test
  98 + void loginAuthFailReturns40101() throws Exception {
  99 + when(usrAuthService.login(any(LoginDTO.class)))
  100 + .thenThrow(new BusinessException(ResultCode.UNAUTHORIZED));
  101 + mockMvc.perform(post("/api/usr/login")
  102 + .contentType("application/json")
  103 + .content(validBody()))
  104 + .andExpect(status().isOk())
  105 + .andExpect(jsonPath("$.code").value(40101));
  106 + }
  107 +
  108 + @Test
  109 + void loginDisabledReturns40302() throws Exception {
  110 + when(usrAuthService.login(any(LoginDTO.class)))
  111 + .thenThrow(new BusinessException(ResultCode.ACCOUNT_DISABLED));
  112 + mockMvc.perform(post("/api/usr/login")
  113 + .contentType("application/json")
  114 + .content(validBody()))
  115 + .andExpect(status().isOk())
  116 + .andExpect(jsonPath("$.code").value(40302));
  117 + }
  118 +
  119 + @Test
  120 + void loginRateLimitedReturns42901() throws Exception {
  121 + when(usrAuthService.login(any(LoginDTO.class)))
  122 + .thenThrow(new BusinessException(ResultCode.LOGIN_RATE_LIMITED));
  123 + mockMvc.perform(post("/api/usr/login")
  124 + .contentType("application/json")
  125 + .content(validBody()))
  126 + .andExpect(status().isOk())
  127 + .andExpect(jsonPath("$.code").value(42901));
  128 + }
  129 +
  130 + @Test
  131 + void companiesReturnsCodeZeroList() throws Exception {
  132 + CompanyOptionVO vo = new CompanyOptionVO();
  133 + vo.setId(3);
  134 + vo.setSCompanyName("总部");
  135 + vo.setSVersion("企业版");
  136 + when(usrAuthService.listCompanies()).thenReturn(List.of(vo));
  137 +
  138 + mockMvc.perform(get("/api/usr/companies"))
  139 + .andExpect(status().isOk())
  140 + .andExpect(jsonPath("$.code").value(0))
  141 + .andExpect(jsonPath("$.data[0].id").value(3))
  142 + .andExpect(jsonPath("$.data[0].sCompanyName").value("总部"));
  143 + }
  144 +}