diff --git a/backend/src/main/java/com/xly/erp/common/config/SecurityConfig.java b/backend/src/main/java/com/xly/erp/common/config/SecurityConfig.java index 42522d7..c644da3 100644 --- a/backend/src/main/java/com/xly/erp/common/config/SecurityConfig.java +++ b/backend/src/main/java/com/xly/erp/common/config/SecurityConfig.java @@ -24,6 +24,21 @@ import org.springframework.security.web.authentication.UsernamePasswordAuthentic public class SecurityConfig { /** + * 放行清单(无需 token 即可访问)。 + * + *
REQ-USR-001 放行 /api/usr/login;REQ-USR-004 T5 追加 /api/usr/companies(登录页版本下拉)。 + * 抽为常量供单测断言与过滤链共用,避免清单漂移。
+ */ + public static final String[] PERMIT_ALL_PATHS = { + "/api/usr/login", + "/api/usr/companies", + "/swagger-ui/**", + "/swagger-ui.html", + "/v3/api-docs/**", + "/actuator/health", + }; + + /** * 密码哈希编码器(BCrypt),禁止明文存储 / 比对。 */ @Bean @@ -41,12 +56,7 @@ public class SecurityConfig { .exceptionHandling(eh -> eh .authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED))) .authorizeHttpRequests(auth -> auth - .requestMatchers( - "/api/usr/login", - "/swagger-ui/**", - "/swagger-ui.html", - "/v3/api-docs/**", - "/actuator/health") + .requestMatchers(PERMIT_ALL_PATHS) .permitAll() .anyRequest().authenticated()) .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); diff --git a/backend/src/main/java/com/xly/erp/modules/usr/controller/UsrAuthController.java b/backend/src/main/java/com/xly/erp/modules/usr/controller/UsrAuthController.java new file mode 100644 index 0000000..44259fb --- /dev/null +++ b/backend/src/main/java/com/xly/erp/modules/usr/controller/UsrAuthController.java @@ -0,0 +1,47 @@ +package com.xly.erp.modules.usr.controller; + +import com.xly.erp.common.response.Result; +import com.xly.erp.modules.usr.dto.LoginDTO; +import com.xly.erp.modules.usr.service.UsrAuthService; +import com.xly.erp.modules.usr.vo.CompanyOptionVO; +import com.xly.erp.modules.usr.vo.LoginVO; +import jakarta.validation.Valid; +import java.util.List; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * 认证 Controller(docs/05 REQ-USR-004 / spec § 2)。REQ-USR-004 T5。 + * + *仅做参数校验 + 委派 Service,不写业务逻辑、不直接调 Mapper、无管理员前置 + * (登录 / 公司列表为放行端点)。
+ */ +@RestController +@RequestMapping("/api/usr") +public class UsrAuthController { + + private final UsrAuthService usrAuthService; + + public UsrAuthController(UsrAuthService usrAuthService) { + this.usrAuthService = usrAuthService; + } + + /** + * 登录认证(放行,无需 token):@Valid 校验入参后委派 Service。 + */ + @PostMapping("/login") + public ResultService 用 Mockito mock;验证登录成功 code=0 + token + 嵌套 user、缺字段 40001、 + * 业务异常透传转码(40101/40302/42901)、公司列表 code=0。响应体不含密码。
+ */ +class UsrAuthControllerTest { + + private final ObjectMapper objectMapper = new ObjectMapper(); + private final UsrAuthService usrAuthService = Mockito.mock(UsrAuthService.class); + private MockMvc mockMvc; + + @BeforeEach + void setUp() { + UsrAuthController controller = new UsrAuthController(usrAuthService); + mockMvc = MockMvcBuilders.standaloneSetup(controller) + .setControllerAdvice(new GlobalExceptionHandler()) + .build(); + } + + private String validBody() throws Exception { + LoginDTO dto = new LoginDTO(); + dto.setSUserName("admin"); + dto.setPassword("666666"); + dto.setCompanyId(1); + return objectMapper.writeValueAsString(dto); + } + + private LoginVO sampleVO() { + LoginVO.UserInfo info = new LoginVO.UserInfo(); + info.setId(1); + info.setSUserName("admin"); + info.setSUserType("超级管理员"); + info.setSLanguage("中文"); + LoginVO vo = new LoginVO(); + vo.setToken("jwt.token.value"); + vo.setUser(info); + return vo; + } + + @Test + void loginReturnsCodeZeroWithTokenAndUser() throws Exception { + when(usrAuthService.login(any(LoginDTO.class))).thenReturn(sampleVO()); + + mockMvc.perform(post("/api/usr/login") + .contentType("application/json") + .content(validBody())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.token").value("jwt.token.value")) + .andExpect(jsonPath("$.data.user.id").value(1)) + .andExpect(jsonPath("$.data.user.sUserName").value("admin")) + .andExpect(jsonPath("$.data.user.sUserType").value("超级管理员")) + .andExpect(jsonPath("$.data.user.sLanguage").value("中文")) + .andExpect(jsonPath("$.data.sPassword").doesNotExist()) + .andExpect(jsonPath("$.data.password").doesNotExist()); + } + + @Test + void loginMissingFieldReturns40001() throws Exception { + // 缺 companyId(@NotNull 失败) + String body = "{\"sUserName\":\"admin\",\"password\":\"666666\"}"; + mockMvc.perform(post("/api/usr/login") + .contentType("application/json") + .content(body)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(40001)); + verify(usrAuthService, never()).login(any(LoginDTO.class)); + } + + @Test + void loginAuthFailReturns40101() throws Exception { + when(usrAuthService.login(any(LoginDTO.class))) + .thenThrow(new BusinessException(ResultCode.UNAUTHORIZED)); + mockMvc.perform(post("/api/usr/login") + .contentType("application/json") + .content(validBody())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(40101)); + } + + @Test + void loginDisabledReturns40302() throws Exception { + when(usrAuthService.login(any(LoginDTO.class))) + .thenThrow(new BusinessException(ResultCode.ACCOUNT_DISABLED)); + mockMvc.perform(post("/api/usr/login") + .contentType("application/json") + .content(validBody())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(40302)); + } + + @Test + void loginRateLimitedReturns42901() throws Exception { + when(usrAuthService.login(any(LoginDTO.class))) + .thenThrow(new BusinessException(ResultCode.LOGIN_RATE_LIMITED)); + mockMvc.perform(post("/api/usr/login") + .contentType("application/json") + .content(validBody())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(42901)); + } + + @Test + void companiesReturnsCodeZeroList() throws Exception { + CompanyOptionVO vo = new CompanyOptionVO(); + vo.setId(3); + vo.setSCompanyName("总部"); + vo.setSVersion("企业版"); + when(usrAuthService.listCompanies()).thenReturn(List.of(vo)); + + mockMvc.perform(get("/api/usr/companies")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data[0].id").value(3)) + .andExpect(jsonPath("$.data[0].sCompanyName").value("总部")); + } +}