Commit 44441c54639d920c2a103c5885d003d8eefff6a9

Authored by zichun
1 parent 91676882

feat(usr): GET + PUT /api/v1/users/{userId} controller + 端到端测试 REQ-USR-003

backend/src/main/java/com/xly/erp/module/usr/controller/UserController.java
@@ -4,13 +4,20 @@ import com.xly.erp.common.response.Result; @@ -4,13 +4,20 @@ import com.xly.erp.common.response.Result;
4 import com.xly.erp.common.security.LoginContext; 4 import com.xly.erp.common.security.LoginContext;
5 import com.xly.erp.common.security.RequireSuperAdmin; 5 import com.xly.erp.common.security.RequireSuperAdmin;
6 import com.xly.erp.module.usr.dto.CreateUserReq; 6 import com.xly.erp.module.usr.dto.CreateUserReq;
  7 +import com.xly.erp.module.usr.dto.UpdateUserReq;
7 import com.xly.erp.module.usr.service.UserCreateService; 8 import com.xly.erp.module.usr.service.UserCreateService;
  9 +import com.xly.erp.module.usr.service.UserDetailService;
  10 +import com.xly.erp.module.usr.service.UserUpdateService;
8 import com.xly.erp.module.usr.vo.CreateUserVo; 11 import com.xly.erp.module.usr.vo.CreateUserVo;
  12 +import com.xly.erp.module.usr.vo.UserDetailVo;
9 import jakarta.validation.Valid; 13 import jakarta.validation.Valid;
10 import lombok.RequiredArgsConstructor; 14 import lombok.RequiredArgsConstructor;
11 import org.springframework.http.HttpStatus; 15 import org.springframework.http.HttpStatus;
12 import org.springframework.http.ResponseEntity; 16 import org.springframework.http.ResponseEntity;
  17 +import org.springframework.web.bind.annotation.GetMapping;
  18 +import org.springframework.web.bind.annotation.PathVariable;
13 import org.springframework.web.bind.annotation.PostMapping; 19 import org.springframework.web.bind.annotation.PostMapping;
  20 +import org.springframework.web.bind.annotation.PutMapping;
14 import org.springframework.web.bind.annotation.RequestBody; 21 import org.springframework.web.bind.annotation.RequestBody;
15 import org.springframework.web.bind.annotation.RequestMapping; 22 import org.springframework.web.bind.annotation.RequestMapping;
16 import org.springframework.web.bind.annotation.RestController; 23 import org.springframework.web.bind.annotation.RestController;
@@ -21,6 +28,8 @@ import org.springframework.web.bind.annotation.RestController; @@ -21,6 +28,8 @@ import org.springframework.web.bind.annotation.RestController;
21 public class UserController { 28 public class UserController {
22 29
23 private final UserCreateService userCreateService; 30 private final UserCreateService userCreateService;
  31 + private final UserDetailService userDetailService;
  32 + private final UserUpdateService userUpdateService;
24 33
25 @PostMapping 34 @PostMapping
26 @RequireSuperAdmin 35 @RequireSuperAdmin
@@ -29,4 +38,19 @@ public class UserController { @@ -29,4 +38,19 @@ public class UserController {
29 CreateUserVo vo = userCreateService.create(req, operator); 38 CreateUserVo vo = userCreateService.create(req, operator);
30 return ResponseEntity.status(HttpStatus.CREATED).body(Result.ok(vo)); 39 return ResponseEntity.status(HttpStatus.CREATED).body(Result.ok(vo));
31 } 40 }
  41 +
  42 + @GetMapping("/{userId}")
  43 + @RequireSuperAdmin
  44 + public Result<UserDetailVo> getById(@PathVariable Integer userId) {
  45 + return Result.ok(userDetailService.getById(userId));
  46 + }
  47 +
  48 + @PutMapping("/{userId}")
  49 + @RequireSuperAdmin
  50 + public Result<UserDetailVo> update(@PathVariable Integer userId,
  51 + @RequestBody @Valid UpdateUserReq req) {
  52 + LoginContext.LoginUser cur = LoginContext.current();
  53 + UserDetailVo vo = userUpdateService.update(userId, req, cur.userId(), cur.username());
  54 + return Result.ok(vo);
  55 + }
32 } 56 }
backend/src/test/java/com/xly/erp/module/usr/controller/UserControllerUpdateTest.java 0 → 100644
  1 +package com.xly.erp.module.usr.controller;
  2 +
  3 +import com.fasterxml.jackson.databind.ObjectMapper;
  4 +import com.fasterxml.jackson.databind.node.ObjectNode;
  5 +import com.xly.erp.common.response.ErrorCode;
  6 +import com.xly.erp.common.security.JwtUtil;
  7 +import com.xly.erp.module.usr.dto.UpdateUserReq;
  8 +import com.xly.erp.module.usr.entity.SysUserPermissionCategory;
  9 +import com.xly.erp.module.usr.mapper.SysUserPermissionCategoryMapper;
  10 +import com.xly.erp.module.usr.support.LoginTestSeeder;
  11 +import org.junit.jupiter.api.BeforeEach;
  12 +import org.junit.jupiter.api.Test;
  13 +import org.springframework.beans.factory.annotation.Autowired;
  14 +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
  15 +import org.springframework.boot.test.context.SpringBootTest;
  16 +import org.springframework.http.MediaType;
  17 +import org.springframework.test.context.ActiveProfiles;
  18 +import org.springframework.test.web.servlet.MockMvc;
  19 +
  20 +import java.util.HashMap;
  21 +import java.util.List;
  22 +import java.util.Map;
  23 +
  24 +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
  25 +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
  26 +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
  27 +
  28 +@SpringBootTest
  29 +@AutoConfigureMockMvc
  30 +@ActiveProfiles("test")
  31 +class UserControllerUpdateTest {
  32 +
  33 + @Autowired private MockMvc mvc;
  34 + @Autowired private ObjectMapper json;
  35 + @Autowired private LoginTestSeeder seeder;
  36 + @Autowired private JwtUtil jwtUtil;
  37 + @Autowired private SysUserPermissionCategoryMapper upcMapper;
  38 + @Autowired private com.xly.erp.module.usr.mapper.SysUserMapper userMapper;
  39 +
  40 + private LoginTestSeeder.Fixture fx;
  41 + private String adminToken;
  42 + private String normalToken;
  43 +
  44 + @BeforeEach
  45 + void setUp() {
  46 + fx = seeder.reset();
  47 + adminToken = issue(LoginTestSeeder.USER_ADMIN, "SUPER_ADMIN", fx.adminId());
  48 + normalToken = issue(LoginTestSeeder.USER_OK, "NORMAL", fx.aliceId());
  49 + }
  50 +
  51 + private String issue(String username, String userType, Integer userId) {
  52 + Map<String, Object> c = new HashMap<>();
  53 + c.put("sub", userId);
  54 + c.put("username", username);
  55 + c.put("userType", userType);
  56 + c.put("companyCode", LoginTestSeeder.COMPANY_OK);
  57 + c.put("language", "zh-CN");
  58 + return jwtUtil.issue(c, 7200);
  59 + }
  60 +
  61 + // ===== GET =====
  62 +
  63 + @Test
  64 + void get_existingUser_returns200_andFullVo() throws Exception {
  65 + mvc.perform(get("/api/v1/users/" + fx.aliceId())
  66 + .header("Authorization", "Bearer " + adminToken))
  67 + .andExpect(status().isOk())
  68 + .andExpect(jsonPath("$.code").value(ErrorCode.OK))
  69 + .andExpect(jsonPath("$.data.userId").value(fx.aliceId()))
  70 + .andExpect(jsonPath("$.data.username").value(LoginTestSeeder.USER_OK))
  71 + .andExpect(jsonPath("$.data.employeeName").value("张三"));
  72 + }
  73 +
  74 + @Test
  75 + void get_unknownUser_returns404_40401() throws Exception {
  76 + mvc.perform(get("/api/v1/users/99999")
  77 + .header("Authorization", "Bearer " + adminToken))
  78 + .andExpect(status().isNotFound())
  79 + .andExpect(jsonPath("$.code").value(ErrorCode.USER_NOT_FOUND));
  80 + }
  81 +
  82 + @Test
  83 + void get_normalUser_returns403_40301() throws Exception {
  84 + mvc.perform(get("/api/v1/users/" + fx.aliceId())
  85 + .header("Authorization", "Bearer " + normalToken))
  86 + .andExpect(status().isForbidden())
  87 + .andExpect(jsonPath("$.code").value(ErrorCode.FORBIDDEN));
  88 + }
  89 +
  90 + @Test
  91 + void get_noAuthHeader_returns401_40101() throws Exception {
  92 + mvc.perform(get("/api/v1/users/" + fx.aliceId()))
  93 + .andExpect(status().isUnauthorized())
  94 + .andExpect(jsonPath("$.code").value(ErrorCode.BAD_CREDENTIALS));
  95 + }
  96 +
  97 + @Test
  98 + void get_deletedUser_stillReturns200() throws Exception {
  99 + mvc.perform(get("/api/v1/users/" + fx.bobDeletedId())
  100 + .header("Authorization", "Bearer " + adminToken))
  101 + .andExpect(status().isOk())
  102 + .andExpect(jsonPath("$.data.isDeleted").value(true));
  103 + }
  104 +
  105 + // ===== PUT =====
  106 +
  107 + private String body(Object o) throws Exception {
  108 + return json.writeValueAsString(o);
  109 + }
  110 +
  111 + private UpdateUserReq req() {
  112 + return new UpdateUserReq();
  113 + }
  114 +
  115 + @Test
  116 + void put_updateUserCodeAndType_returns200() throws Exception {
  117 + UpdateUserReq r = req();
  118 + r.setUserCode("U_NEW");
  119 + r.setUserType("SUPER_ADMIN");
  120 + mvc.perform(put("/api/v1/users/" + fx.aliceId())
  121 + .header("Authorization", "Bearer " + adminToken)
  122 + .contentType(MediaType.APPLICATION_JSON)
  123 + .content(body(r)))
  124 + .andExpect(status().isOk())
  125 + .andExpect(jsonPath("$.data.userCode").value("U_NEW"))
  126 + .andExpect(jsonPath("$.data.userType").value("SUPER_ADMIN"));
  127 + }
  128 +
  129 + @Test
  130 + void put_updateEmployeeId_toAnotherEmployee_setsValue() throws Exception {
  131 + UpdateUserReq r = req();
  132 + r.setEmployeeId(fx.employeeId());
  133 + mvc.perform(put("/api/v1/users/" + fx.adminId())
  134 + .header("Authorization", "Bearer " + adminToken)
  135 + .contentType(MediaType.APPLICATION_JSON)
  136 + .content(body(r)))
  137 + .andExpect(status().isOk())
  138 + .andExpect(jsonPath("$.data.employeeId").value(fx.employeeId()));
  139 + }
  140 +
  141 + @Test
  142 + void put_updateEmployeeId_zero_clearsRelation() throws Exception {
  143 + UpdateUserReq r = req();
  144 + r.setEmployeeId(0);
  145 + mvc.perform(put("/api/v1/users/" + fx.aliceId())
  146 + .header("Authorization", "Bearer " + adminToken)
  147 + .contentType(MediaType.APPLICATION_JSON)
  148 + .content(body(r)))
  149 + .andExpect(status().isOk())
  150 + .andExpect(jsonPath("$.data.employeeId").doesNotExist());
  151 + }
  152 +
  153 + @Test
  154 + void put_updateEmployeeId_unknown_returns400_40004() throws Exception {
  155 + UpdateUserReq r = req();
  156 + r.setEmployeeId(99999);
  157 + mvc.perform(put("/api/v1/users/" + fx.aliceId())
  158 + .header("Authorization", "Bearer " + adminToken)
  159 + .contentType(MediaType.APPLICATION_JSON)
  160 + .content(body(r)))
  161 + .andExpect(status().isBadRequest())
  162 + .andExpect(jsonPath("$.code").value(ErrorCode.COMPANY_NOT_FOUND));
  163 + }
  164 +
  165 + @Test
  166 + void put_isDeletedTrue_marksAndOriginalTokenRejectedNextCall() throws Exception {
  167 + // 把 alice 设为作废
  168 + UpdateUserReq r = req();
  169 + r.setIsDeleted(true);
  170 + mvc.perform(put("/api/v1/users/" + fx.aliceId())
  171 + .header("Authorization", "Bearer " + adminToken)
  172 + .contentType(MediaType.APPLICATION_JSON)
  173 + .content(body(r)))
  174 + .andExpect(status().isOk());
  175 +
  176 + // 用 alice 的 token 调任何 /api/v1/** 接口应 40101
  177 + // 但 alice 是 NORMAL,连 admin 路径都会先 401(用户已作废),不是 403
  178 + mvc.perform(get("/api/v1/users/" + fx.aliceId())
  179 + .header("Authorization", "Bearer " + normalToken))
  180 + .andExpect(status().isUnauthorized())
  181 + .andExpect(jsonPath("$.code").value(ErrorCode.BAD_CREDENTIALS));
  182 + }
  183 +
  184 + @Test
  185 + void put_permissionCategories_subsetDelta() throws Exception {
  186 + Integer pur = fx.activePermissionCategoryIds().get(0);
  187 + Integer sal = fx.activePermissionCategoryIds().get(1);
  188 + // 预置 alice 有 {pur, sal}
  189 + for (Integer pcId : List.of(pur, sal)) {
  190 + SysUserPermissionCategory l = new SysUserPermissionCategory();
  191 + l.setIUserId(fx.aliceId());
  192 + l.setIPermissionCategoryId(pcId);
  193 + l.setSGrantedBy("system");
  194 + upcMapper.insert(l);
  195 + }
  196 +
  197 + UpdateUserReq r = req();
  198 + r.setPermissionCategoryIds(List.of(sal)); // 只保留 sal
  199 + mvc.perform(put("/api/v1/users/" + fx.aliceId())
  200 + .header("Authorization", "Bearer " + adminToken)
  201 + .contentType(MediaType.APPLICATION_JSON)
  202 + .content(body(r)))
  203 + .andExpect(status().isOk())
  204 + .andExpect(jsonPath("$.data.permissionCategoryIds.length()").value(1))
  205 + .andExpect(jsonPath("$.data.permissionCategoryIds[0]").value(sal));
  206 + }
  207 +
  208 + @Test
  209 + void put_permissionCategories_emptyArray_clearsAll() throws Exception {
  210 + Integer pur = fx.activePermissionCategoryIds().get(0);
  211 + SysUserPermissionCategory l = new SysUserPermissionCategory();
  212 + l.setIUserId(fx.aliceId());
  213 + l.setIPermissionCategoryId(pur);
  214 + l.setSGrantedBy("system");
  215 + upcMapper.insert(l);
  216 +
  217 + UpdateUserReq r = req();
  218 + r.setPermissionCategoryIds(List.of());
  219 + mvc.perform(put("/api/v1/users/" + fx.aliceId())
  220 + .header("Authorization", "Bearer " + adminToken)
  221 + .contentType(MediaType.APPLICATION_JSON)
  222 + .content(body(r)))
  223 + .andExpect(status().isOk())
  224 + .andExpect(jsonPath("$.data.permissionCategoryIds.length()").value(0));
  225 + }
  226 +
  227 + @Test
  228 + void put_permissionCategories_unknownId_returns400_40004_andRollsBack() throws Exception {
  229 + UpdateUserReq r = req();
  230 + r.setUserCode("U_NEW");
  231 + r.setPermissionCategoryIds(List.of(99999));
  232 + mvc.perform(put("/api/v1/users/" + fx.aliceId())
  233 + .header("Authorization", "Bearer " + adminToken)
  234 + .contentType(MediaType.APPLICATION_JSON)
  235 + .content(body(r)))
  236 + .andExpect(status().isBadRequest())
  237 + .andExpect(jsonPath("$.code").value(ErrorCode.COMPANY_NOT_FOUND));
  238 +
  239 + // 验证回滚:alice 的 userCode 仍是 U001
  240 + com.xly.erp.module.usr.entity.SysUser db = userMapper.selectById(fx.aliceId());
  241 + org.junit.jupiter.api.Assertions.assertEquals("U001", db.getSUserCode());
  242 + }
  243 +
  244 + @Test
  245 + void put_duplicateUserCode_returns409_40902() throws Exception {
  246 + UpdateUserReq r = req();
  247 + r.setUserCode("U001"); // alice 的 userCode
  248 + mvc.perform(put("/api/v1/users/" + fx.adminId())
  249 + .header("Authorization", "Bearer " + adminToken)
  250 + .contentType(MediaType.APPLICATION_JSON)
  251 + .content(body(r)))
  252 + .andExpect(status().isConflict())
  253 + .andExpect(jsonPath("$.code").value(ErrorCode.CONFLICT_USERCODE));
  254 + }
  255 +
  256 + @Test
  257 + void put_userCodeUnchangedSameAsSelf_returns200() throws Exception {
  258 + UpdateUserReq r = req();
  259 + r.setUserCode("U001"); // alice 的当前 userCode
  260 + mvc.perform(put("/api/v1/users/" + fx.aliceId())
  261 + .header("Authorization", "Bearer " + adminToken)
  262 + .contentType(MediaType.APPLICATION_JSON)
  263 + .content(body(r)))
  264 + .andExpect(status().isOk());
  265 + }
  266 +
  267 + @Test
  268 + void put_selfDeactivate_returns403_40302() throws Exception {
  269 + UpdateUserReq r = req();
  270 + r.setIsDeleted(true);
  271 + mvc.perform(put("/api/v1/users/" + fx.adminId())
  272 + .header("Authorization", "Bearer " + adminToken)
  273 + .contentType(MediaType.APPLICATION_JSON)
  274 + .content(body(r)))
  275 + .andExpect(status().isForbidden())
  276 + .andExpect(jsonPath("$.code").value(ErrorCode.USER_FORBIDDEN_SELF_DEACTIVATE));
  277 + }
  278 +
  279 + @Test
  280 + void put_unknownProperty_username_returns400_40001() throws Exception {
  281 + ObjectNode b = json.createObjectNode();
  282 + b.put("username", "hacker");
  283 + mvc.perform(put("/api/v1/users/" + fx.aliceId())
  284 + .header("Authorization", "Bearer " + adminToken)
  285 + .contentType(MediaType.APPLICATION_JSON)
  286 + .content(b.toString()))
  287 + .andExpect(status().isBadRequest())
  288 + .andExpect(jsonPath("$.code").value(ErrorCode.BAD_REQUEST));
  289 + }
  290 +
  291 + @Test
  292 + void put_unknownProperty_password_returns400_40001() throws Exception {
  293 + ObjectNode b = json.createObjectNode();
  294 + b.put("password", "newpass");
  295 + mvc.perform(put("/api/v1/users/" + fx.aliceId())
  296 + .header("Authorization", "Bearer " + adminToken)
  297 + .contentType(MediaType.APPLICATION_JSON)
  298 + .content(b.toString()))
  299 + .andExpect(status().isBadRequest())
  300 + .andExpect(jsonPath("$.code").value(ErrorCode.BAD_REQUEST));
  301 + }
  302 +
  303 + @Test
  304 + void put_unknownUserId_returns404_40401() throws Exception {
  305 + mvc.perform(put("/api/v1/users/99999")
  306 + .header("Authorization", "Bearer " + adminToken)
  307 + .contentType(MediaType.APPLICATION_JSON)
  308 + .content(body(req())))
  309 + .andExpect(status().isNotFound())
  310 + .andExpect(jsonPath("$.code").value(ErrorCode.USER_NOT_FOUND));
  311 + }
  312 +
  313 + @Test
  314 + void put_normalUser_returns403_40301() throws Exception {
  315 + mvc.perform(put("/api/v1/users/" + fx.aliceId())
  316 + .header("Authorization", "Bearer " + normalToken)
  317 + .contentType(MediaType.APPLICATION_JSON)
  318 + .content(body(req())))
  319 + .andExpect(status().isForbidden())
  320 + .andExpect(jsonPath("$.code").value(ErrorCode.FORBIDDEN));
  321 + }
  322 +
  323 + @Test
  324 + void put_emptyBody_only_updates_audit_fields() throws Exception {
  325 + mvc.perform(put("/api/v1/users/" + fx.aliceId())
  326 + .header("Authorization", "Bearer " + adminToken)
  327 + .contentType(MediaType.APPLICATION_JSON)
  328 + .content("{}"))
  329 + .andExpect(status().isOk())
  330 + .andExpect(jsonPath("$.data.updatedBy").value(LoginTestSeeder.USER_ADMIN));
  331 + }
  332 +}