Commit fb750522926bf8fabfae25e078418e38a77094fc

Authored by zichun
1 parent 44441c54

chore(usr): REQ-USR-003 review approve + 归档 spec/plan/review

docs/05-API接口契约.md
@@ -105,11 +105,12 @@ BasePath: `/api/v1` @@ -105,11 +105,12 @@ BasePath: `/api/v1`
105 105
106 #### 错误码 106 #### 错误码
107 - `40001` — 字段格式错误或试图修改 username / password 107 - `40001` — 字段格式错误或试图修改 username / password
  108 +- `40004` — 指定的员工 / 权限分类不存在
  109 +- `40101` — 未携带或无效 Token
108 - `40301` — 当前用户非超级管理员,无权调用 110 - `40301` — 当前用户非超级管理员,无权调用
109 - `40302` — 试图停用当前登录用户自己 111 - `40302` — 试图停用当前登录用户自己
110 - `40401` — 用户不存在 112 - `40401` — 用户不存在
111 - `40902` — 用户号已被占用 113 - `40902` — 用户号已被占用
112 -- `40004` — 指定的员工 / 权限分类不存在  
113 114
114 ### REQ-USR-004 查询用户 115 ### REQ-USR-004 查询用户
115 116
docs/08-模块任务管理.md
@@ -62,7 +62,7 @@ @@ -62,7 +62,7 @@
62 - 功能: 62 - 功能:
63 - [x] REQ-USR-001 用户登录 63 - [x] REQ-USR-001 用户登录
64 - [x] REQ-USR-002 新增用户 64 - [x] REQ-USR-002 新增用户
65 - - [ ] REQ-USR-003 修改用户 65 + - [x] REQ-USR-003 修改用户
66 - [ ] REQ-USR-004 查询用户 66 - [ ] REQ-USR-004 查询用户
67 67
68 ## 三、Coding 阶段(前端整体) 68 ## 三、Coding 阶段(前端整体)
docs/superpowers/plans/2026-05-15-REQ-USR-003.md 0 → 100644
  1 +---
  2 +req_id: REQ-USR-003
  3 +date: 2026-05-15
  4 +spec_ref: docs/superpowers/specs/2026-05-15-REQ-USR-003.md
  5 +---
  6 +
  7 +# REQ-USR-003 修改用户 Implementation Plan
  8 +
  9 +> **Execution:** Parent skill `feature-tdd` executes this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
  10 +
  11 +**Goal:** 实现 `GET /api/v1/users/{userId}` + `PUT /api/v1/users/{userId}` 两个端点;PUT 支持部分字段更新 + permissionCategoryIds 增量增删差集 + 自我停用守卫;GET 返回 UserDetailVo(含 employeeName + permissionCategoryIds)。
  12 +
  13 +**Architecture:**
  14 +- 鉴权 / 角色守卫 / 事务 / Result / 异常处理全部复用 REQ-USR-002 基础设施。
  15 +- 新增 `UserUpdateService`(PUT)+ `UserDetailService`(GET),单一职责。
  16 +- permissionCategoryIds 用差集:先 select 现有 → 计算 toAdd / toRemove → DELETE + INSERT 在同一事务。
  17 +- PATCH 语义简化:缺省 / 显式 null 都视为不变;`employeeId=0` 作为约定的"解除关联"信号。
  18 +
  19 +**Tech Stack:** 复用 REQ-USR-002(Spring Boot 3 + MyBatis-Plus + Jakarta Validation)。
  20 +
  21 +---
  22 +
  23 +## Schema 改动
  24 +
  25 +无。
  26 +
  27 +---
  28 +
  29 +## 文件变更清单
  30 +
  31 +**新增**:
  32 +- `backend/src/main/java/com/xly/erp/module/usr/dto/UpdateUserReq.java`
  33 +- `backend/src/main/java/com/xly/erp/module/usr/vo/UserDetailVo.java`
  34 +- `backend/src/main/java/com/xly/erp/module/usr/service/UserDetailService.java`
  35 +- `backend/src/main/java/com/xly/erp/module/usr/service/impl/UserDetailServiceImpl.java`
  36 +- `backend/src/main/java/com/xly/erp/module/usr/service/UserUpdateService.java`
  37 +- `backend/src/main/java/com/xly/erp/module/usr/service/impl/UserUpdateServiceImpl.java`
  38 +
  39 +**修改**:
  40 +- `backend/src/main/java/com/xly/erp/common/response/ErrorCode.java`(新增 40302 / 40401 + 404 映射)
  41 +- `backend/src/main/java/com/xly/erp/module/usr/mapper/SysUserMapper.java`(新增 `existsByUserCodeExcludingId`)
  42 +- `backend/src/main/java/com/xly/erp/module/usr/mapper/SysUserPermissionCategoryMapper.java`(新增 `selectPermissionCategoryIdsByUserId` + `deleteByUserAndCategoryIds`)
  43 +- `backend/src/main/java/com/xly/erp/module/usr/controller/UserController.java`(新增 GET + PUT 方法)
  44 +
  45 +**文档**:本 REQ 不改 docs/05(已含 REQ-USR-003 段,但 docs/05 当前漏写 GET 详情接口;本 REQ 补充)。
  46 +
  47 +**测试**:
  48 +- `backend/src/test/java/com/xly/erp/module/usr/service/UserDetailServiceImplTest.java`
  49 +- `backend/src/test/java/com/xly/erp/module/usr/service/UserUpdateServiceImplTest.java`
  50 +- `backend/src/test/java/com/xly/erp/module/usr/controller/UserControllerUpdateTest.java`(独立文件避免冲击 REQ-USR-002 既有 UserControllerTest)
  51 +
  52 +---
  53 +
  54 +## 约束常量
  55 +
  56 +**ErrorCode 新增**:
  57 +
  58 +| 常量 | 值 | HTTP |
  59 +|---|---|---|
  60 +| `USER_FORBIDDEN_SELF_DEACTIVATE` | `40302` | 403 |
  61 +| `USER_NOT_FOUND` | `40401` | 404 |
  62 +
  63 +> `ErrorCode.toHttpStatus` 已含 401/403/404 段位映射,不需新增。
  64 +
  65 +**API 形状**:
  66 +
  67 +```
  68 +GET /api/v1/users/{userId} @RequireSuperAdmin
  69 + → Result<UserDetailVo>
  70 + errors: 40101, 40301, 40401
  71 +
  72 +PUT /api/v1/users/{userId} @RequireSuperAdmin
  73 + body: UpdateUserReq (PATCH)
  74 + → Result<UserDetailVo>
  75 + errors: 40001, 40004, 40101, 40301, 40302, 40401, 40902
  76 +
  77 +UserDetailVo {
  78 + Integer userId, String username, String userCode,
  79 + String userType, String language, Boolean canEditDocument, Boolean isDeleted,
  80 + Integer employeeId, String employeeName,
  81 + List<Integer> permissionCategoryIds,
  82 + String updatedBy, LocalDateTime updatedDate
  83 +}
  84 +
  85 +UpdateUserReq {
  86 + String userCode, // 缺省/null = 不变
  87 + String userType, // 同上
  88 + String language, // 同上
  89 + Boolean canEditDocument, // 同上
  90 + Integer employeeId, // 缺省/null = 不变;0 = 解除关联;正整数 = 更新
  91 + Boolean isDeleted, // 同上
  92 + List<Integer> permissionCategoryIds // 缺省/null = 不变;空数组 = 清空;非空 = 增删差集
  93 +}
  94 +```
  95 +
  96 +**PATCH 语义约定**(写死,跨任务一致):所有字段缺省 / null 视为 "保持原值";`employeeId == 0` 视为 "解除关联",DB 写 NULL。其他字段无清除语义。
  97 +
  98 +---
  99 +
  100 +## 任务步骤
  101 +
  102 +### Task 1: ErrorCode 新增 40302 / 40401 + 404 映射 + docs/05 补 GET 详情段
  103 +
  104 +**Files:**
  105 +- Modify: `backend/src/main/java/com/xly/erp/common/response/ErrorCode.java`
  106 +- Modify: `backend/src/test/java/com/xly/erp/common/response/ErrorCodeTest.java`
  107 +- Modify: `docs/05-API接口契约.md` § REQ-USR-003 之前加 `GET /api/v1/users/{userId}` 段
  108 +
  109 +**API shape:**
  110 +- `ErrorCode.USER_FORBIDDEN_SELF_DEACTIVATE = 40302`
  111 +- `ErrorCode.USER_NOT_FOUND = 40401`
  112 +- `ErrorCode.toHttpStatus(40302) == 403`
  113 +- `ErrorCode.toHttpStatus(40401) == 404`
  114 +
  115 +docs/05 补充内容(追加到现 REQ-USR-003 段之前):
  116 +
  117 +```
  118 +### REQ-USR-003 GET 用户详情
  119 +
  120 +- Method: GET
  121 +- Path: /api/v1/users/{userId}
  122 +- Auth: Bearer Token;仅 userType=SUPER_ADMIN 可调用
  123 +- 请求: Path userId: int
  124 +- 响应: JSON UserDetailVo { userId, username, userCode, userType, language, canEditDocument, isDeleted, employeeId, employeeName, permissionCategoryIds[], updatedBy, updatedDate }
  125 +
  126 +#### 错误码
  127 +- 40101 — 未携带或无效 Token
  128 +- 40301 — 当前用户非超级管理员,无权调用
  129 +- 40401 — 用户不存在
  130 +```
  131 +
  132 +- [ ] **Step 1: 写失败测试** `ErrorCodeTest#httpMappings_coverNewCodes_v003`
  133 +- [ ] **Step 2: 实现最小代码** + docs/05 改动
  134 +- [ ] **Step 3: 子会话验证 PASS**
  135 +- [ ] **Step 4: Commit** `chore(usr): docs/05 补 GET 详情 + ErrorCode 新增 40302/40401 REQ-USR-003`
  136 +
  137 +### Task 2: UpdateUserReq + UserDetailVo
  138 +
  139 +**Files:**
  140 +- Create: `backend/src/main/java/com/xly/erp/module/usr/dto/UpdateUserReq.java`
  141 +- Create: `backend/src/main/java/com/xly/erp/module/usr/vo/UserDetailVo.java`
  142 +- Create: `backend/src/test/java/com/xly/erp/module/usr/dto/UpdateUserReqValidationTest.java`
  143 +
  144 +**API shape**: 见 "约束常量"。
  145 +
  146 +校验注解(仅在字段非 null 时生效,jakarta `@Pattern` 等天然 null-safe):
  147 +- `userCode`: `@Size(max=50)`
  148 +- `userType`: `@Pattern(regexp="NORMAL|SUPER_ADMIN")`
  149 +- `language`: `@Pattern(regexp="zh-CN|en-US|zh-TW")`
  150 +- `employeeId`: `@Min(0)` (0 = 解除关联约定)
  151 +- 其余字段无 @ 注解
  152 +
  153 +- [ ] **Step 1: 写失败测试** `UpdateUserReqValidationTest` 覆盖:
  154 + - 全空请求体合法(PATCH,所有字段可选)
  155 + - userType 非枚举 → 失败
  156 + - language 非枚举 → 失败
  157 + - userCode 越长 → 失败
  158 + - userCode 空字符串 → 失败(@Size 不限定下界,但建议加 @Pattern("\S+") 防空白;本任务不强制)
  159 + - employeeId=-1 → 失败
  160 + - employeeId=0 合法(约定的解除关联)
  161 +- [ ] **Step 2: 实现最小代码**
  162 +- [ ] **Step 3: 子会话验证 PASS**
  163 +- [ ] **Step 4: Commit** `feat(usr): UpdateUserReq + UserDetailVo REQ-USR-003`
  164 +
  165 +### Task 3: SysUserMapper.existsByUserCodeExcludingId
  166 +
  167 +**Files:**
  168 +- Modify: `backend/src/main/java/com/xly/erp/module/usr/mapper/SysUserMapper.java`
  169 +- Modify: `backend/src/test/java/com/xly/erp/module/usr/mapper/SysUserMapperTest.java`
  170 +
  171 +**API shape:**
  172 +- `SysUserMapper#existsByUserCodeExcludingId(String userCode, Integer excludedUserId) : boolean`
  173 +- SQL: `SELECT EXISTS(SELECT 1 FROM sys_user WHERE sUserCode = #{userCode} AND iIncrement <> #{excludedUserId})`
  174 +
  175 +- [ ] **Step 1: 写失败测试**
  176 + - `existsByUserCodeExcludingId_otherUserHasCode_returnsTrue` (alice U001, query U001 excluding admin → true)
  177 + - `existsByUserCodeExcludingId_selfHasCode_returnsFalse` (alice U001, query U001 excluding alice → false)
  178 + - `existsByUserCodeExcludingId_unknownCode_returnsFalse`
  179 +- [ ] **Step 2: 实现最小代码**
  180 +- [ ] **Step 3: 子会话验证 PASS**
  181 +- [ ] **Step 4: Commit** `feat(usr): SysUserMapper 排除自身的 userCode 唯一查询 REQ-USR-003`
  182 +
  183 +### Task 4: SysUserPermissionCategoryMapper 增删辅助方法
  184 +
  185 +**Files:**
  186 +- Modify: `backend/src/main/java/com/xly/erp/module/usr/mapper/SysUserPermissionCategoryMapper.java`
  187 +- Create: `backend/src/test/java/com/xly/erp/module/usr/mapper/SysUserPermissionCategoryMapperTest.java`
  188 +
  189 +**API shape:**
  190 +- `SysUserPermissionCategoryMapper#selectPermissionCategoryIdsByUserId(Integer userId) : List<Integer>`
  191 + - SQL: `SELECT iPermissionCategoryId FROM sys_user_permission_category WHERE iUserId = #{userId}`
  192 +- `SysUserPermissionCategoryMapper#deleteByUserAndCategoryIds(@Param("userId") Integer userId, @Param("ids") List<Integer> categoryIds) : int`
  193 + - SQL: `DELETE FROM sys_user_permission_category WHERE iUserId = #{userId} AND iPermissionCategoryId IN (...)` (@Select script)
  194 +
  195 +- [ ] **Step 1: 写失败测试**
  196 + - 准备:admin user + 授权 {PUR, SAL}
  197 + - `selectPermissionCategoryIdsByUserId_returnsAllCurrent`
  198 + - `deleteByUserAndCategoryIds_onlyDeletesGivenSubset`(删 {PUR} 后剩 {SAL})
  199 + - `deleteByUserAndCategoryIds_nonMatchingIds_noop_returns0`
  200 +- [ ] **Step 2: 实现最小代码**
  201 +- [ ] **Step 3: 子会话验证 PASS**
  202 +- [ ] **Step 4: Commit** `feat(usr): SysUserPermissionCategoryMapper 查/删辅助方法 REQ-USR-003`
  203 +
  204 +### Task 5: UserDetailService 接口 + 实现
  205 +
  206 +**Files:**
  207 +- Create: `backend/src/main/java/com/xly/erp/module/usr/service/UserDetailService.java`
  208 +- Create: `backend/src/main/java/com/xly/erp/module/usr/service/impl/UserDetailServiceImpl.java`
  209 +- Create: `backend/src/test/java/com/xly/erp/module/usr/service/UserDetailServiceImplTest.java`
  210 +
  211 +**API shape:**
  212 +- `UserDetailService#getById(Integer userId) : UserDetailVo`
  213 +- 找不到用户 → `BizException(ErrorCode.USER_NOT_FOUND, "用户不存在")`
  214 +- 包含作废用户(用于恢复启用场景的回显)
  215 +- 装配逻辑:select sys_user → 若 iEmployeeId 非空 select sys_employee.sEmployeeName → selectPermissionCategoryIdsByUserId
  216 +
  217 +- [ ] **Step 1: 写失败测试**
  218 + - `getById_existingActiveUser_returnsFullVo`(含 employeeName + permissionCategoryIds)
  219 + - `getById_userWithoutEmployee_employeeNameIsNull`
  220 + - `getById_userWithoutPermissions_emptyList`
  221 + - `getById_deletedUser_stillReturned`(iIsDeleted=1 的用户也能查到,VO.isDeleted=true)
  222 + - `getById_unknownId_throws40401`
  223 +- [ ] **Step 2: 实现最小代码**
  224 +- [ ] **Step 3: 子会话验证 PASS**
  225 +- [ ] **Step 4: Commit** `feat(usr): UserDetailService 用户详情查询 REQ-USR-003`
  226 +
  227 +### Task 6: UserUpdateService 接口 + 校验骨架
  228 +
  229 +**Files:**
  230 +- Create: `backend/src/main/java/com/xly/erp/module/usr/service/UserUpdateService.java`
  231 +- Create: `backend/src/main/java/com/xly/erp/module/usr/service/impl/UserUpdateServiceImpl.java`
  232 +- Create: `backend/src/test/java/com/xly/erp/module/usr/service/UserUpdateServiceImplTest.java`
  233 +
  234 +**API shape:**
  235 +- `UserUpdateService#update(Integer userId, UpdateUserReq req, Integer operatorUserId, String operatorUsername) : UserDetailVo`
  236 +- `@Transactional`
  237 +- 注入:SysUserMapper / SysEmployeeMapper / SysPermissionCategoryMapper / SysUserPermissionCategoryMapper / UserDetailService(复用详情装配返回最终 VO)
  238 +
  239 +本 Task 范围:校验路径(不写入),全部抛 BizException 路径覆盖:
  240 +1. `userId` 不存在 → 40401
  241 +2. `req.isDeleted == true && userId == operatorUserId` → 40302
  242 +3. `req.userCode` 非 null 且 `existsByUserCodeExcludingId(userCode, userId)` → 40902
  243 +4. `req.employeeId` 是正整数(非 0)且不存在或软删 → 40004
  244 +5. `req.permissionCategoryIds` 非 null 且非空且含不存在 ID → 40004
  245 +
  246 +- [ ] **Step 1: 写失败测试** 5 个测试覆盖以上路径
  247 +- [ ] **Step 2: 实现最小代码**(更新方法体仅 throw,不写 DB)
  248 +- [ ] **Step 3: 子会话验证 PASS**
  249 +- [ ] **Step 4: Commit** `feat(usr): UserUpdateService 校验路径骨架 REQ-USR-003`
  250 +
  251 +### Task 7: UserUpdateService 部分字段写入 + employee 三态
  252 +
  253 +**Files:**
  254 +- Modify: `backend/src/main/java/com/xly/erp/module/usr/service/impl/UserUpdateServiceImpl.java`
  255 +- Modify: `backend/src/test/java/com/xly/erp/module/usr/service/UserUpdateServiceImplTest.java`
  256 +
  257 +**API behavior:**
  258 +- 校验全过后用 `UpdateWrapper<SysUser>().eq("iIncrement", userId)` 链式 set:
  259 + - `if (req.userCode != null)` → set sUserCode
  260 + - `if (req.userType != null)` → set sUserType
  261 + - `if (req.language != null)` → set sLanguage
  262 + - `if (req.canEditDocument != null)` → set iCanEditDocument (true → 1, false → 0)
  263 + - `if (req.employeeId != null)`:req.employeeId == 0 → set iEmployeeId NULL;正整数 → set iEmployeeId 值
  264 + - `if (req.isDeleted != null)` → set iIsDeleted (true → 1, false → 0)
  265 + - 总是 set sUpdatedBy=operatorUsername、tUpdatedDate=NOW()
  266 +- 不处理 permissionCategoryIds(推到 Task 8)
  267 +
  268 +- [ ] **Step 1: 写失败测试**
  269 + - `update_userCode_only_persisted_otherFieldsUnchanged`
  270 + - `update_userType_language_canEditDocument`
  271 + - `update_employeeId_positiveInteger_setsToValue`
  272 + - `update_employeeId_zero_setsToNull`
  273 + - `update_employeeId_unchanged_preservesOriginalValue`(缺省字段不变)
  274 + - `update_isDeleted_true_marksUserDeleted`
  275 + - `update_isDeleted_false_revivesUser`
  276 + - `update_emptyRequest_onlyUpdatesAuditFields`(sUpdatedBy + tUpdatedDate)
  277 +- [ ] **Step 2: 实现最小代码**
  278 +- [ ] **Step 3: 子会话验证 PASS**
  279 +- [ ] **Step 4: Commit** `feat(usr): UserUpdateService 部分字段写入 + employeeId 三态 REQ-USR-003`
  280 +
  281 +### Task 8: UserUpdateService permissionCategoryIds 增量差集
  282 +
  283 +**Files:**
  284 +- Modify: `backend/src/main/java/com/xly/erp/module/usr/service/impl/UserUpdateServiceImpl.java`
  285 +- Modify: `backend/src/test/java/com/xly/erp/module/usr/service/UserUpdateServiceImplTest.java`
  286 +
  287 +**API behavior:**
  288 +- 仅当 `req.permissionCategoryIds != null` 时执行:
  289 + - `current = selectPermissionCategoryIdsByUserId(userId)`(Set 化以便比较)
  290 + - `target = new HashSet<>(req.permissionCategoryIds)`(dedup)
  291 + - `toRemove = current \ target` → `deleteByUserAndCategoryIds(userId, toRemove)`(若 toRemove 非空)
  292 + - `toAdd = target \ current` → 循环 insert SysUserPermissionCategory(sGrantedBy=operator)
  293 +- 返回 `userDetailService.getById(userId)` 作为响应(聚合最新状态)
  294 +
  295 +- [ ] **Step 1: 写失败测试**
  296 + - `update_permissionCategories_emptyList_clearsAll`(初始 {PUR,SAL} → 请求 [] → 最终 ∅)
  297 + - `update_permissionCategories_subsetDelta_addsAndRemoves`(初始 {PUR,SAL} → 请求 [SAL, 新分类 X] → 最终 {SAL, X});断言 SAL 行 iIncrement 不变(差集而非全量替换)
  298 + - `update_permissionCategories_omitted_preservesExisting`(请求中无 permissionCategoryIds → 不动)
  299 + - `update_permissionCategories_duplicateInRequest_deduped`(请求 [PUR, PUR, SAL] → 最终 {PUR, SAL})
  300 + - `update_returnsUserDetailVoReflectingFinalState`
  301 +- [ ] **Step 2: 实现最小代码**
  302 +- [ ] **Step 3: 子会话验证 PASS**
  303 +- [ ] **Step 4: Commit** `feat(usr): UserUpdateService 权限分类增量增删差集 REQ-USR-003`
  304 +
  305 +### Task 9: UserController GET + PUT + 端到端测试
  306 +
  307 +**Files:**
  308 +- Modify: `backend/src/main/java/com/xly/erp/module/usr/controller/UserController.java`(追加 GET + PUT 方法)
  309 +- Create: `backend/src/test/java/com/xly/erp/module/usr/controller/UserControllerUpdateTest.java`
  310 +
  311 +**API shape:**
  312 +- `@GetMapping("/{userId}") @RequireSuperAdmin getById(@PathVariable Integer userId) : Result<UserDetailVo>`
  313 +- `@PutMapping("/{userId}") @RequireSuperAdmin update(@PathVariable Integer userId, @RequestBody @Valid UpdateUserReq req) : Result<UserDetailVo>`
  314 +- PUT 调用 `userUpdateService.update(userId, req, LoginContext.current().userId(), LoginContext.current().username())`
  315 +
  316 +端到端测试(覆盖 spec § 验收 1-23):
  317 +
  318 +**GET(5 个)**:
  319 +- `get_existingUser_returns200_andFullVo`
  320 +- `get_unknownUser_returns404_40401`
  321 +- `get_normalUser_returns403_40301`
  322 +- `get_noAuthHeader_returns401_40101`
  323 +- `get_deletedUser_stillReturns200`
  324 +
  325 +**PUT(16 个)**:
  326 +- `put_updateUserCodeAndType_returns200`
  327 +- `put_updateEmployeeId_toAnotherEmployee`
  328 +- `put_updateEmployeeId_zero_clearsRelation`
  329 +- `put_updateEmployeeId_unknown_returns400_40004`
  330 +- `put_isDeletedTrue_marksAndOriginalTokenRejectedNextCall`(修改后用旧 token 调 GET 应得 40101)
  331 +- `put_permissionCategories_subsetDelta`(断言差集行为)
  332 +- `put_permissionCategories_emptyArray_clearsAll`
  333 +- `put_permissionCategories_unknownId_returns400_40004_andRollsBack`(断言事务回滚——sys_user 也未被改)
  334 +- `put_duplicateUserCode_returns409_40902`
  335 +- `put_userCodeUnchangedSameAsSelf_returns200`
  336 +- `put_selfDeactivate_returns403_40302`
  337 +- `put_unknownProperty_username_returns400_40001`
  338 +- `put_unknownProperty_password_returns400_40001`
  339 +- `put_unknownUserId_returns404_40401`
  340 +- `put_normalUser_returns403_40301`
  341 +- `put_emptyBody_only_updates_audit_fields`
  342 +
  343 +- [ ] **Step 1: 写失败测试** (21 个测试)
  344 +- [ ] **Step 2: 实现最小代码**(在现有 UserController 上追加方法)
  345 +- [ ] **Step 3: 子会话验证 PASS**
  346 +- [ ] **Step 4: Commit** `feat(usr): GET + PUT /api/v1/users/{userId} controller + 端到端测试 REQ-USR-003`
  347 +
  348 +---
  349 +
  350 +## 提交计划
  351 +
  352 +| Task | Commit message |
  353 +|---|---|
  354 +| 1 | `chore(usr): docs/05 补 GET 详情 + ErrorCode 新增 40302/40401 REQ-USR-003` |
  355 +| 2 | `feat(usr): UpdateUserReq + UserDetailVo REQ-USR-003` |
  356 +| 3 | `feat(usr): SysUserMapper 排除自身的 userCode 唯一查询 REQ-USR-003` |
  357 +| 4 | `feat(usr): SysUserPermissionCategoryMapper 查/删辅助方法 REQ-USR-003` |
  358 +| 5 | `feat(usr): UserDetailService 用户详情查询 REQ-USR-003` |
  359 +| 6 | `feat(usr): UserUpdateService 校验路径骨架 REQ-USR-003` |
  360 +| 7 | `feat(usr): UserUpdateService 部分字段写入 + employeeId 三态 REQ-USR-003` |
  361 +| 8 | `feat(usr): UserUpdateService 权限分类增量增删差集 REQ-USR-003` |
  362 +| 9 | `feat(usr): GET + PUT /api/v1/users/{userId} controller + 端到端测试 REQ-USR-003` |
docs/superpowers/reviews/2026-05-15-REQ-USR-003.md 0 → 100644
  1 +---
  2 +req_id: REQ-USR-003
  3 +date: 2026-05-15
  4 +round: 1
  5 +reviewer: superpower-code-reviewer
  6 +---
  7 +
  8 +# Review: REQ-USR-003 — round 1
  9 +
  10 +## 结论
  11 +approve
  12 +
  13 +## Must-fix
  14 +(无)
  15 +
  16 +## Nice-to-have
  17 +
  18 +- docs/05-API接口契约.md PUT § REQ-USR-003 错误码列表缺 40101(GET 段已列)— 本轮归档前已修
  19 +- docs/superpowers/specs/2026-05-15-REQ-USR-003.md § 输入表行写 "显式 null 表示解除关联",与 § PATCH 语义实现细节的 "null = 不变;employeeId=0 = 解除关联" 约定矛盾;代码遵循后者,建议日后回填修订前者
  20 +- backend/src/main/java/com/xly/erp/module/usr/mapper/SysUserMapper.java:52 — existsByUserCodeExcludingId 未过滤 iIsDeleted;如希望软删用户不参与唯一性,可补 `AND iIsDeleted=0`,但当前 spec 未要求
  21 +- backend/src/main/java/com/xly/erp/module/usr/service/impl/UserUpdateServiceImpl.java:108 — permissionCategoryIds 差集流程在 @Transactional 内无行锁;并发 PUT 同一用户可能出现交叉写入。建议未来引入 sys_user.iVersion 做乐观锁
  22 +- backend/src/test/java/com/xly/erp/module/usr/service/UserUpdateServiceImplTest.java:127 — update_employeeId_positiveInteger_setsToValue 注释 '避开自我停用守卫' 误导(本测试不会触发守卫);建议改用合法对手用户或删除该注释
  23 +- backend/src/main/java/com/xly/erp/module/usr/service/impl/UserUpdateServiceImpl.java:78 — 复用 COMPANY_NOT_FOUND(40004)抛"权限分类不存在",常量名与 message 字面冲突;建议未来将 40004 重命名为更通用的 RELATED_ENTITY_NOT_FOUND 或拆分专用 code(跨模块技术债)
  24 +
  25 +## 反例 / 测试覆盖缺口
  26 +
  27 +无功能或安全级 gap。spec 验收 1-22 全部映射到测试;验收 23(作废用户登录 40103)spec 明确声明属 REQ-USR-001 既有路径,符合约定。事务边界(@Transactional + BizException extends RuntimeException + Spring 默认回滚)正确;自我停用守卫精确;userCode 唯一性"自身同值跳过查询"优化有效;employee 三态(null/0/正整数)与 permissionCategoryIds 三态(缺省/空数组/非空)语义清晰且测试覆盖;作废即时生效由 JwtHandlerInterceptor 每请求重查保证;权限分类差集策略保留项 iIncrement 不变已用单测显式断言。N+1 仅出现在 UserDetailService(user + employee + pcIds 三查),单详情场景可接受。
  28 +
  29 +## 总结
  30 +
  31 +REQ-USR-003 实现严格对齐 spec / plan:GET + PUT 共用 UserDetailVo、PATCH 三态、permissionCategoryIds 增删差集、自我停用守卫、userCode 唯一性排除自身、作废即时生效、事务回滚全部正确实现,147 测试覆盖 spec 验收 1-22。仅有若干文档不一致与可读性建议,本轮归档前已补 docs/05 PUT 错误码 40101。无功能/安全/正确性阻塞。Approve。
docs/superpowers/specs/2026-05-15-REQ-USR-003.md 0 → 100644
  1 +---
  2 +req_id: REQ-USR-003
  3 +date: 2026-05-15
  4 +module: module_usr
  5 +---
  6 +
  7 +# Spec: REQ-USR-003 — 修改用户
  8 +
  9 +## 目标
  10 +
  11 +超级管理员对已有用户的非密码、非用户名字段做部分更新(PATCH 语义),并支持增量增删权限分类授权。同时提供 GET 详情接口供前端表单回显(与修改入参 / 返回字段同源)。
  12 +
  13 +## 输入 / 触发
  14 +
  15 +两个 HTTP 入口(均需 `Authorization: Bearer <accessToken>` 且 `userType=SUPER_ADMIN`):
  16 +
  17 +### 1. `GET /api/v1/users/{userId}` 用户详情
  18 +
  19 +Path:`userId: int`(命中 `sys_user.iIncrement`,否则 40401)。
  20 +
  21 +无请求体。
  22 +
  23 +### 2. `PUT /api/v1/users/{userId}` 修改用户
  24 +
  25 +Path:`userId: int`。
  26 +
  27 +**请求体 UpdateUserReq**(JSON,PATCH 语义;任一字段缺省视为不变):
  28 +
  29 +| 字段 | 类型 | 必填 | 校验 |
  30 +|---|---|---|---|
  31 +| `userCode` | string | 否 | 提供时 `@Size(max=50) @NotBlank`;若与其他用户的 sUserCode 冲突返 40902(当前用户自身的同值视为不变,跳过冲突判定)|
  32 +| `userType` | string | 否 | 提供时 `NORMAL` / `SUPER_ADMIN` |
  33 +| `language` | string | 否 | 提供时 `zh-CN` / `en-US` / `zh-TW` |
  34 +| `canEditDocument` | boolean | 否 | true / false |
  35 +| `employeeId` | int 或 null | 否 | 提供为非 null 时必须命中 `sys_employee.iIncrement` AND `iIsDeleted=0`,否则返 40004;显式传 `null` 表示解除关联,DB 写 NULL |
  36 +| `isDeleted` | boolean | 否 | true 表示作废 / false 表示恢复启用;尝试停用当前登录用户自己返 40302 |
  37 +| `permissionCategoryIds` | int[] | 否 | 提供时按差集做增删;每个元素必须命中 `sys_permission_category.iIncrement AND iIsDeleted=0`,否则返 40004;缺省表示权限分类不变 |
  38 +
  39 +**严禁字段**:请求体含 `username` 或 `password` 字段直接返 40001(Jackson `fail-on-unknown-properties=true` 已在 REQ-USR-002 启用全局,缺省命中此防御)。
  40 +
  41 +### permissionCategoryIds 增删差集策略
  42 +
  43 +- 设当前已授权集合 `current = {pcId1, pcId2, ...}`(从 `sys_user_permission_category WHERE iUserId=?` 查)
  44 +- 设请求集合 `target = req.permissionCategoryIds`
  45 +- `toRemove = current \ target` → DELETE FROM sys_user_permission_category WHERE iUserId=? AND iPermissionCategoryId IN (toRemove)
  46 +- `toAdd = target \ current` → INSERT 对应行,`sGrantedBy = 当前登录 username`
  47 +- 同 `target ∩ current` 项保持不动(iIncrement / tCreateDate 不变)
  48 +
  49 +## 输出 / 结果
  50 +
  51 +两个接口共用 `UserDetailVo`:
  52 +
  53 +```json
  54 +{
  55 + "userId": 42,
  56 + "username": "alice",
  57 + "userCode": "U001",
  58 + "userType": "NORMAL",
  59 + "language": "zh-CN",
  60 + "canEditDocument": false,
  61 + "isDeleted": false,
  62 + "employeeId": 7,
  63 + "employeeName": "张三",
  64 + "permissionCategoryIds": [1, 2],
  65 + "updatedBy": "admin",
  66 + "updatedDate": "2026-05-15T09:30:00"
  67 +}
  68 +```
  69 +
  70 +- `employeeName` 通过 `iEmployeeId` JOIN `sys_employee.sEmployeeName`;未关联职员时省略
  71 +- `permissionCategoryIds` 是当前授权集合(增删后的最终状态);空数组表示无授权
  72 +- `updatedBy` / `updatedDate` 由本接口在更新时写入(`sUpdatedBy` / `tUpdatedDate`);GET 取 DB 现有值,可能为 null
  73 +
  74 +**成功 200 OK**:`Result<UserDetailVo>`
  75 +
  76 +**失败**:
  77 +
  78 +| HTTP | code | 含义 | 触发条件 |
  79 +|---|---|---|---|
  80 +| 400 | 40001 | 请求体格式错误 / 含未知字段(username/password) | jakarta 校验 OR Jackson fail-on-unknown |
  81 +| 400 | 40004 | 员工或权限分类不存在 | employeeId / permissionCategoryIds 校验失败 |
  82 +| 401 | 40101 | 未携带或无效 Token | 鉴权层 |
  83 +| 403 | 40301 | 非超级管理员调用 | 角色守卫 |
  84 +| 403 | 40302 | 试图停用当前登录用户自己 | `req.isDeleted == true && userId == LoginContext.userId()` |
  85 +| 404 | 40401 | 用户不存在 | `userId` 不命中 `sys_user.iIncrement` |
  86 +| 409 | 40902 | 用户号已被占用 | 提供的 userCode 命中其他用户的 sUserCode(排除自身) |
  87 +
  88 +## 业务规则
  89 +
  90 +1. **鉴权 / 角色守卫**:复用 REQ-USR-002 的 JwtHandlerInterceptor + `@RequireSuperAdmin`。两个接口都标 `@RequireSuperAdmin`。
  91 +2. **存在性校验**:先查 `sys_user.iIncrement = userId AND iIsDeleted ∈ {0,1}`(包含作废用户,因为恢复启用允许);找不到 → 40401。
  92 +3. **自我停用守卫**(PUT 专属):`req.isDeleted == true && userId == LoginContext.current().userId()` → 40302。**注意**:恢复启用(`isDeleted == false`)即便针对自己也允许(不会有此场景,因为已登录用户必然非作废,但仍保留对称语义)。
  93 +4. **userCode 唯一性**(PUT 专属):仅当 `req.userCode != null && !req.userCode.equals(currentUser.sUserCode)` 时才检查;用 `selectByUserCodeExcludingId(userCode, userId)` 排除自身。冲突 → 40902。
  94 +5. **外键校验**:employeeId(非 null)和 permissionCategoryIds(非 null)按 REQ-USR-002 同样的方式校验。`employeeId == null` 显式表示解除关联(DB 置 NULL),与字段缺省不同(缺省 = 不变)。
  95 +6. **部分更新**:只更新请求体中显式提供的字段;用 MyBatis-Plus `UpdateWrapper` 显式列出 set 项。`sUpdatedBy = LoginContext.current().username()`、`tUpdatedDate = NOW()` 一定写。
  96 +7. **作废即时生效**:PUT 把 `iIsDeleted=1` 写库后,该用户已签发的 token 在下一次请求时会被 JwtHandlerInterceptor 检测到 iIsDeleted=1 并返 40101(已由 REQ-USR-002 基础设施保证)。
  97 +8. **GET 详情**:聚合 sys_user + sys_employee(JOIN)+ sys_user_permission_category(IN 查询)一次返回;不查询作废过滤之外的额外字段。
  98 +
  99 +## PATCH 语义实现细节
  100 +
  101 +`UpdateUserReq` 用包装类型(Integer / Boolean / String / List)+ 自定义 `JsonNode` 检测"字段是否在 JSON 中出现"以区分"显式 null"与"缺省"。
  102 +
  103 +具体方案:
  104 +- `userCode` / `userType` / `language` / `canEditDocument` / `isDeleted` / `permissionCategoryIds` — 缺省 = 不变;提供(非 null)= 更新;**不接受**显式 null(@NotNull 在 service 层不强制,但缺省即视为 null 表示"不变")
  105 +- `employeeId` — 三态:缺省(不变)/ 非 null 整数(更新)/ 显式 null(解除关联)。用 `JsonNullable<Integer>`(来自 `openapi-generator` 工具库)实现三态。**最小可行替代**:在 controller 层用 `JsonNode` 解析 `employeeId` 字段,传给 service 一个 `EmployeeIdUpdate` 三态枚举(`UNCHANGED / SET(value) / UNSET`)。
  106 +
  107 +> **简化决策**:本 REQ 不引入 `jakarta.json` 或 `JsonNullable` 第三方库(违反技术栈表)。采用如下约定:
  108 +> - 请求体仅当字段**存在且值非 null**时才更新;字段**完全缺省**视为不变;字段**显式 null** 视为不变(即不区分缺省与显式 null)
  109 +> - 单独提供"清除关联"语义:`employeeId == 0` 视为解除关联(DB 写 NULL)。这是一个约定(非业界标准 PATCH),spec 必须明示
  110 +
  111 +实现简化后:`UpdateUserReq` 所有字段都是普通可空包装类型;`employeeId` 取值规则:null / 缺省 → 不变;`0` → 解除关联;正整数 → 更新到该 ID。
  112 +
  113 +## 边界与约束
  114 +
  115 +- **基础设施复用**:鉴权 / GlobalExceptionHandler / Result / BizException / LoginContext / JwtUtil / BCryptPasswordEncoder / SeederFixture 全部复用 REQ-USR-002
  116 +- **ErrorCode 新增**:`USER_FORBIDDEN_SELF_DEACTIVATE = 40302`(HTTP 403)、`USER_NOT_FOUND = 40401`(HTTP 404)。`ErrorCode.toHttpStatus` 已含 401/403/404 段位映射,本 REQ 不需要新增映射。
  117 +- **不实现**:
  118 + - 用户名 / 密码修改(推迟到独立 REQ)
  119 + - 批量修改(YAGNI)
  120 + - 修改历史审计表(推迟)
  121 + - GET 列表(REQ-USR-004 范围)
  122 +
  123 +## 依赖的 schema 表 / 字段
  124 +
  125 +读 + 写 `sys_user`(V1 已建):
  126 +- 读:iIncrement / sUsername / sUserCode / iEmployeeId / sUserType / sLanguage / iCanEditDocument / iIsDeleted / sCreatedBy / sUpdatedBy / tUpdatedDate / tCreateDate(详情查询需要)
  127 +- 写:sUserCode / iEmployeeId / sUserType / sLanguage / iCanEditDocument / iIsDeleted / sUpdatedBy / tUpdatedDate
  128 +
  129 +读 + 写 `sys_user_permission_category`(V1 已建):
  130 +- 增删差集
  131 +
  132 +只读 `sys_employee`(V1 已建):
  133 +- 校验 employeeId 存在 + 取 sEmployeeName
  134 +
  135 +只读 `sys_permission_category`(V1 已建):
  136 +- countActiveByIds 校验
  137 +
  138 +**本 REQ 不需要新增 migration**。
  139 +
  140 +## 依赖的接口
  141 +
  142 +- 本 REQ 提供:
  143 + - `GET /api/v1/users/{userId}` — 用户详情
  144 + - `PUT /api/v1/users/{userId}` — 修改用户
  145 +- 前置依赖:JWT 由 REQ-USR-001 签发;JwtHandlerInterceptor 由 REQ-USR-002 提供
  146 +
  147 +## 验收标准
  148 +
  149 +后端集成测试:
  150 +
  151 +### GET 详情
  152 +
  153 +1. **admin token + 存在用户** → 200,UserDetailVo 完整(含 employeeName + permissionCategoryIds)
  154 +2. **admin token + 不存在 userId** → 404 / 40401
  155 +3. **NORMAL token** → 403 / 40301
  156 +4. **无 Authorization** → 401 / 40101
  157 +5. **作废用户** → 200(详情查询包含作废用户;不过滤)
  158 +
  159 +### PUT 修改
  160 +
  161 +6. **修改 userCode + userType + language**(admin token)→ 200,DB 已更新;sUpdatedBy=admin、tUpdatedDate 非空;其他字段不变
  162 +7. **修改 employeeId 为另一个有效员工** → 200,DB 写新值
  163 +8. **修改 employeeId=0 解除关联** → 200,DB 写 NULL
  164 +9. **修改 employeeId=99999 不存在** → 400 / 40004
  165 +10. **修改 isDeleted=true** → 200,DB 写 1;该用户原 token 下一次请求返 40101
  166 +11. **修改 permissionCategoryIds(差集增删)**:初始权限 `[1,2]`,请求 `[2,3]` → 200,最终 DB 状态 `[2,3]`;分类 1 的行被 DELETE,分类 3 的行被 INSERT;分类 2 的行 iIncrement 保持不变(验证差集而非全量替换)
  167 +12. **permissionCategoryIds 为空数组** → 200,最终授权清空
  168 +13. **permissionCategoryIds 含不存在 ID** → 400 / 40004,事务回滚(DB 授权无变化)
  169 +14. **修改 userCode 冲突(其他用户已用)** → 409 / 40902
  170 +15. **修改 userCode 等于自身原值** → 200,无 40902
  171 +16. **试图停用自己**(admin 用 admin token 改 admin 用户 isDeleted=true)→ 403 / 40302
  172 +17. **请求体含 username 字段** → 400 / 40001
  173 +18. **请求体含 password 字段** → 400 / 40001
  174 +19. **userId 不存在** → 404 / 40401
  175 +20. **NORMAL token 调用 PUT** → 403 / 40301
  176 +21. **空请求体 `{}`** → 200,仅写 sUpdatedBy / tUpdatedDate(其他字段不变)
  177 +
  178 +### PUT + 立即可观察
  179 +
  180 +22. **修改后调用 GET 详情** → 返回的字段反映 PUT 的写入值
  181 +23. **作废用户尝试登录** → 401 / 40103(REQ-USR-001 既有路径,不在本 REQ 添加新测试,但成功路径验收应链路一致)