From c231102e5775e894a55a3e281875809352b20845 Mon Sep 17 00:00:00 2001 From: zichun Date: Mon, 1 Jun 2026 15:11:29 +0800 Subject: [PATCH] feat(usr): 登录与公司列表 Controller 及安全放行 REQ-USR-004 --- backend/src/main/java/com/xly/erp/common/config/SecurityConfig.java | 22 ++++++++++++++++------ backend/src/main/java/com/xly/erp/modules/usr/controller/UsrAuthController.java | 47 +++++++++++++++++++++++++++++++++++++++++++++++ backend/src/test/java/com/xly/erp/common/config/SecurityConfigTest.java | 10 ++++++++++ backend/src/test/java/com/xly/erp/modules/usr/controller/UsrAuthControllerTest.java | 144 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 217 insertions(+), 6 deletions(-) create mode 100644 backend/src/main/java/com/xly/erp/modules/usr/controller/UsrAuthController.java create mode 100644 backend/src/test/java/com/xly/erp/modules/usr/controller/UsrAuthControllerTest.java 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 Result login(@Valid @RequestBody LoginDTO dto) { + return Result.success(usrAuthService.login(dto)); + } + + /** + * 公司下拉列表(放行,无参):供登录页「版本」下拉。 + */ + @GetMapping("/companies") + public Result> listCompanies() { + return Result.success(usrAuthService.listCompanies()); + } +} diff --git a/backend/src/test/java/com/xly/erp/common/config/SecurityConfigTest.java b/backend/src/test/java/com/xly/erp/common/config/SecurityConfigTest.java index d5389c6..2fa13af 100644 --- a/backend/src/test/java/com/xly/erp/common/config/SecurityConfigTest.java +++ b/backend/src/test/java/com/xly/erp/common/config/SecurityConfigTest.java @@ -22,4 +22,14 @@ class SecurityConfigTest { assertThat(encoder.matches("666666", hash)).isTrue(); assertThat(encoder.matches("wrong", hash)).isFalse(); } + + /** + * REQ-USR-004 T5:放行清单含 GET /api/usr/companies(与既有 /api/usr/login 同口径放行)。 + */ + @Test + void companiesEndpointPermitted() { + assertThat(SecurityConfig.PERMIT_ALL_PATHS).contains("/api/usr/companies"); + // 不删除既有放行项:/api/usr/login 仍在清单。 + assertThat(SecurityConfig.PERMIT_ALL_PATHS).contains("/api/usr/login"); + } } diff --git a/backend/src/test/java/com/xly/erp/modules/usr/controller/UsrAuthControllerTest.java b/backend/src/test/java/com/xly/erp/modules/usr/controller/UsrAuthControllerTest.java new file mode 100644 index 0000000..6910b9b --- /dev/null +++ b/backend/src/test/java/com/xly/erp/modules/usr/controller/UsrAuthControllerTest.java @@ -0,0 +1,144 @@ +package com.xly.erp.modules.usr.controller; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.xly.erp.common.exception.BusinessException; +import com.xly.erp.common.exception.GlobalExceptionHandler; +import com.xly.erp.common.response.ResultCode; +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 java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +/** + * REQ-USR-004 T5:UsrAuthController(MockMvc standaloneSetup + 真实 GlobalExceptionHandler)。 + * + *

Service 用 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("总部")); + } +} -- libgit2 0.22.2