Commit fb750522926bf8fabfae25e078418e38a77094fc
1 parent
44441c54
chore(usr): REQ-USR-003 review approve + 归档 spec/plan/review
Showing
5 changed files
with
577 additions
and
2 deletions
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 添加新测试,但成功路径验收应链路一致) |