Commit c231102e5775e894a55a3e281875809352b20845
1 parent
293118bc
feat(usr): 登录与公司列表 Controller 及安全放行 REQ-USR-004
Showing
4 changed files
with
217 additions
and
6 deletions
backend/src/main/java/com/xly/erp/common/config/SecurityConfig.java
| ... | ... | @@ -24,6 +24,21 @@ import org.springframework.security.web.authentication.UsernamePasswordAuthentic |
| 24 | 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 | 42 | * 密码哈希编码器(BCrypt),禁止明文存储 / 比对。 |
| 28 | 43 | */ |
| 29 | 44 | @Bean |
| ... | ... | @@ -41,12 +56,7 @@ public class SecurityConfig { |
| 41 | 56 | .exceptionHandling(eh -> eh |
| 42 | 57 | .authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED))) |
| 43 | 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 | 60 | .permitAll() |
| 51 | 61 | .anyRequest().authenticated()) |
| 52 | 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 | 22 | assertThat(encoder.matches("666666", hash)).isTrue(); |
| 23 | 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 | +} | ... | ... |