Commit aa7b233e99110946f8dc6bbf2bbdf4e933d3a7f3

Authored by zichun
1 parent e0bf3066

feat(usr): POST /api/auth/login controller REQ-USR-004

backend/src/main/java/com/xly/erp/module/usr/controller/LoginController.java 0 → 100644
  1 +package com.xly.erp.module.usr.controller;
  2 +
  3 +import com.xly.erp.common.response.ApiResponse;
  4 +import com.xly.erp.module.usr.dto.LoginDTO;
  5 +import com.xly.erp.module.usr.service.LoginService;
  6 +import com.xly.erp.module.usr.vo.LoginResultVO;
  7 +import jakarta.validation.Valid;
  8 +import lombok.RequiredArgsConstructor;
  9 +import org.springframework.web.bind.annotation.PostMapping;
  10 +import org.springframework.web.bind.annotation.RequestBody;
  11 +import org.springframework.web.bind.annotation.RequestMapping;
  12 +import org.springframework.web.bind.annotation.RestController;
  13 +
  14 +/** REQ-USR-004 用户登录入口 — 永久 permitAll;其他既有端点鉴权切换由后续 REQ 完成。 */
  15 +@RestController
  16 +@RequestMapping("/api/auth")
  17 +@RequiredArgsConstructor
  18 +public class LoginController {
  19 +
  20 + private final LoginService loginService;
  21 +
  22 + @PostMapping("/login")
  23 + public ApiResponse<LoginResultVO> login(@Valid @RequestBody LoginDTO dto) {
  24 + return ApiResponse.ok(loginService.login(dto));
  25 + }
  26 +}
backend/src/test/java/com/xly/erp/module/usr/controller/LoginControllerIT.java 0 → 100644
  1 +package com.xly.erp.module.usr.controller;
  2 +
  3 +import com.fasterxml.jackson.databind.ObjectMapper;
  4 +import com.xly.erp.module.usr.dto.LoginDTO;
  5 +import com.xly.erp.module.usr.entity.UserEntity;
  6 +import com.xly.erp.module.usr.mapper.UserMapper;
  7 +import com.xly.erp.module.usr.security.InMemoryLoginAttemptStore;
  8 +import com.xly.erp.module.usr.security.JwtTokenProvider;
  9 +import io.jsonwebtoken.Claims;
  10 +import org.junit.jupiter.api.BeforeEach;
  11 +import org.junit.jupiter.api.Test;
  12 +import org.springframework.beans.factory.annotation.Autowired;
  13 +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
  14 +import org.springframework.boot.test.context.SpringBootTest;
  15 +import org.springframework.http.MediaType;
  16 +import org.springframework.security.crypto.password.PasswordEncoder;
  17 +import org.springframework.test.annotation.Rollback;
  18 +import org.springframework.test.context.ActiveProfiles;
  19 +import org.springframework.test.web.servlet.MockMvc;
  20 +import org.springframework.test.web.servlet.MvcResult;
  21 +import org.springframework.transaction.annotation.Transactional;
  22 +
  23 +import java.time.LocalDateTime;
  24 +
  25 +import static org.assertj.core.api.Assertions.assertThat;
  26 +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
  27 +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
  28 +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
  29 +
  30 +@SpringBootTest
  31 +@AutoConfigureMockMvc
  32 +@ActiveProfiles("test")
  33 +@Transactional
  34 +@Rollback
  35 +class LoginControllerIT {
  36 +
  37 + @Autowired MockMvc mockMvc;
  38 + @Autowired ObjectMapper objectMapper;
  39 + @Autowired UserMapper userMapper;
  40 + @Autowired PasswordEncoder passwordEncoder;
  41 + @Autowired InMemoryLoginAttemptStore attemptStore;
  42 + @Autowired JwtTokenProvider jwtTokenProvider;
  43 +
  44 + private String userName;
  45 +
  46 + @BeforeEach
  47 + void setUp() {
  48 + userName = "login_" + System.nanoTime();
  49 + // 每个测试在 store 里清干净(store 是 Spring singleton 跨测试存活)
  50 + attemptStore.clear(userName);
  51 + }
  52 +
  53 + private Integer insertUser(String pw) {
  54 + UserEntity u = new UserEntity();
  55 + u.setSUserNo("uno_" + System.nanoTime());
  56 + u.setSUserName(userName);
  57 + u.setSUserType("普通用户");
  58 + u.setSLanguage("zh");
  59 + u.setBCanModifyDocs(false);
  60 + u.setSPasswordHash(passwordEncoder.encode(pw));
  61 + u.setBDeleted(false);
  62 + u.setTCreateDate(LocalDateTime.now());
  63 + userMapper.insert(u);
  64 + return u.getIIncrement();
  65 + }
  66 +
  67 + private LoginDTO loginDto(String name, String pw) {
  68 + LoginDTO d = new LoginDTO();
  69 + d.setSUserName(name);
  70 + d.setSPassword(pw);
  71 + d.setSVersion("standard");
  72 + return d;
  73 + }
  74 +
  75 + private String json(Object o) throws Exception { return objectMapper.writeValueAsString(o); }
  76 +
  77 + @Test
  78 + void login_validCredentials_returns200WithToken() throws Exception {
  79 + insertUser("666666");
  80 +
  81 + mockMvc.perform(post("/api/auth/login")
  82 + .contentType(MediaType.APPLICATION_JSON)
  83 + .content(json(loginDto(userName, "666666"))))
  84 + .andExpect(status().isOk())
  85 + .andExpect(jsonPath("$.code").value(200))
  86 + .andExpect(jsonPath("$.data.accessToken").isString())
  87 + .andExpect(jsonPath("$.data.expiresIn").value(7200))
  88 + .andExpect(jsonPath("$.data.user.sUserName").value(userName))
  89 + .andExpect(jsonPath("$.data.user.sUserType").value("普通用户"));
  90 + }
  91 +
  92 + @Test
  93 + void login_jwtClaimsAreCorrect() throws Exception {
  94 + Integer userId = insertUser("666666");
  95 +
  96 + MvcResult result = mockMvc.perform(post("/api/auth/login")
  97 + .contentType(MediaType.APPLICATION_JSON)
  98 + .content(json(loginDto(userName, "666666"))))
  99 + .andExpect(status().isOk())
  100 + .andReturn();
  101 +
  102 + String body = result.getResponse().getContentAsString();
  103 + String token = objectMapper.readTree(body).path("data").path("accessToken").asText();
  104 + assertThat(token).isNotEmpty();
  105 +
  106 + Claims claims = jwtTokenProvider.parse(token);
  107 + assertThat(claims.getSubject()).isEqualTo(userName);
  108 + assertThat(claims.get("uid", Integer.class)).isEqualTo(userId);
  109 + assertThat(claims.get("type", String.class)).isEqualTo("普通用户");
  110 + }
  111 +
  112 + @Test
  113 + void login_invalidUsername_returns40101() throws Exception {
  114 + // 不插入用户
  115 + mockMvc.perform(post("/api/auth/login")
  116 + .contentType(MediaType.APPLICATION_JSON)
  117 + .content(json(loginDto("ghost_" + System.nanoTime(), "any"))))
  118 + .andExpect(status().isOk())
  119 + .andExpect(jsonPath("$.code").value(40101));
  120 + }
  121 +
  122 + @Test
  123 + void login_wrongPassword_returns40101() throws Exception {
  124 + insertUser("666666");
  125 +
  126 + mockMvc.perform(post("/api/auth/login")
  127 + .contentType(MediaType.APPLICATION_JSON)
  128 + .content(json(loginDto(userName, "wrong_password"))))
  129 + .andExpect(status().isOk())
  130 + .andExpect(jsonPath("$.code").value(40101));
  131 + }
  132 +
  133 + @Test
  134 + void login_softDeletedUser_returns40101() throws Exception {
  135 + Integer userId = insertUser("666666");
  136 + UserEntity patch = new UserEntity();
  137 + patch.setIIncrement(userId);
  138 + patch.setBDeleted(true);
  139 + userMapper.updateById(patch);
  140 +
  141 + mockMvc.perform(post("/api/auth/login")
  142 + .contentType(MediaType.APPLICATION_JSON)
  143 + .content(json(loginDto(userName, "666666"))))
  144 + .andExpect(status().isOk())
  145 + .andExpect(jsonPath("$.code").value(40101));
  146 + }
  147 +
  148 + @Test
  149 + void login_missingPassword_returns40010() throws Exception {
  150 + LoginDTO dto = loginDto(userName, "any");
  151 + dto.setSPassword(null);
  152 +
  153 + mockMvc.perform(post("/api/auth/login")
  154 + .contentType(MediaType.APPLICATION_JSON)
  155 + .content(json(dto)))
  156 + .andExpect(status().isOk())
  157 + .andExpect(jsonPath("$.code").value(40010));
  158 + }
  159 +
  160 + @Test
  161 + void login_invalidVersion_returns40010() throws Exception {
  162 + LoginDTO dto = loginDto(userName, "any");
  163 + dto.setSVersion("experimental");
  164 +
  165 + mockMvc.perform(post("/api/auth/login")
  166 + .contentType(MediaType.APPLICATION_JSON)
  167 + .content(json(dto)))
  168 + .andExpect(status().isOk())
  169 + .andExpect(jsonPath("$.code").value(40010));
  170 + }
  171 +
  172 + @Test
  173 + void login_5thFailureLocks_returns40301() throws Exception {
  174 + insertUser("666666");
  175 +
  176 + // 4 次错误密码(不锁定)
  177 + for (int i = 0; i < 4; i++) {
  178 + mockMvc.perform(post("/api/auth/login")
  179 + .contentType(MediaType.APPLICATION_JSON)
  180 + .content(json(loginDto(userName, "wrong_" + i))))
  181 + .andExpect(jsonPath("$.code").value(40101));
  182 + }
  183 +
  184 + // 第 5 次错误密码:触发锁定,返回 40301 + cooldownSeconds
  185 + mockMvc.perform(post("/api/auth/login")
  186 + .contentType(MediaType.APPLICATION_JSON)
  187 + .content(json(loginDto(userName, "wrong_5"))))
  188 + .andExpect(status().isOk())
  189 + .andExpect(jsonPath("$.code").value(40301))
  190 + .andExpect(jsonPath("$.data.cooldownSeconds").isNumber());
  191 +
  192 + // 锁定后正确密码也 40301
  193 + mockMvc.perform(post("/api/auth/login")
  194 + .contentType(MediaType.APPLICATION_JSON)
  195 + .content(json(loginDto(userName, "666666"))))
  196 + .andExpect(jsonPath("$.code").value(40301));
  197 + }
  198 +
  199 + @Test
  200 + void login_responseExcludesSPasswordHash() throws Exception {
  201 + insertUser("666666");
  202 +
  203 + mockMvc.perform(post("/api/auth/login")
  204 + .contentType(MediaType.APPLICATION_JSON)
  205 + .content(json(loginDto(userName, "666666"))))
  206 + .andExpect(status().isOk())
  207 + .andExpect(jsonPath("$.data.user.sPasswordHash").doesNotExist());
  208 + }
  209 +}