--- req_id: REQ-USR-003 date: 2026-05-15 spec_ref: docs/superpowers/specs/2026-05-15-REQ-USR-003.md --- # REQ-USR-003 修改用户 Implementation Plan > **Execution:** Parent skill `feature-tdd` executes this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** 实现 `GET /api/v1/users/{userId}` + `PUT /api/v1/users/{userId}` 两个端点;PUT 支持部分字段更新 + permissionCategoryIds 增量增删差集 + 自我停用守卫;GET 返回 UserDetailVo(含 employeeName + permissionCategoryIds)。 **Architecture:** - 鉴权 / 角色守卫 / 事务 / Result / 异常处理全部复用 REQ-USR-002 基础设施。 - 新增 `UserUpdateService`(PUT)+ `UserDetailService`(GET),单一职责。 - permissionCategoryIds 用差集:先 select 现有 → 计算 toAdd / toRemove → DELETE + INSERT 在同一事务。 - PATCH 语义简化:缺省 / 显式 null 都视为不变;`employeeId=0` 作为约定的"解除关联"信号。 **Tech Stack:** 复用 REQ-USR-002(Spring Boot 3 + MyBatis-Plus + Jakarta Validation)。 --- ## Schema 改动 无。 --- ## 文件变更清单 **新增**: - `backend/src/main/java/com/xly/erp/module/usr/dto/UpdateUserReq.java` - `backend/src/main/java/com/xly/erp/module/usr/vo/UserDetailVo.java` - `backend/src/main/java/com/xly/erp/module/usr/service/UserDetailService.java` - `backend/src/main/java/com/xly/erp/module/usr/service/impl/UserDetailServiceImpl.java` - `backend/src/main/java/com/xly/erp/module/usr/service/UserUpdateService.java` - `backend/src/main/java/com/xly/erp/module/usr/service/impl/UserUpdateServiceImpl.java` **修改**: - `backend/src/main/java/com/xly/erp/common/response/ErrorCode.java`(新增 40302 / 40401 + 404 映射) - `backend/src/main/java/com/xly/erp/module/usr/mapper/SysUserMapper.java`(新增 `existsByUserCodeExcludingId`) - `backend/src/main/java/com/xly/erp/module/usr/mapper/SysUserPermissionCategoryMapper.java`(新增 `selectPermissionCategoryIdsByUserId` + `deleteByUserAndCategoryIds`) - `backend/src/main/java/com/xly/erp/module/usr/controller/UserController.java`(新增 GET + PUT 方法) **文档**:本 REQ 不改 docs/05(已含 REQ-USR-003 段,但 docs/05 当前漏写 GET 详情接口;本 REQ 补充)。 **测试**: - `backend/src/test/java/com/xly/erp/module/usr/service/UserDetailServiceImplTest.java` - `backend/src/test/java/com/xly/erp/module/usr/service/UserUpdateServiceImplTest.java` - `backend/src/test/java/com/xly/erp/module/usr/controller/UserControllerUpdateTest.java`(独立文件避免冲击 REQ-USR-002 既有 UserControllerTest) --- ## 约束常量 **ErrorCode 新增**: | 常量 | 值 | HTTP | |---|---|---| | `USER_FORBIDDEN_SELF_DEACTIVATE` | `40302` | 403 | | `USER_NOT_FOUND` | `40401` | 404 | > `ErrorCode.toHttpStatus` 已含 401/403/404 段位映射,不需新增。 **API 形状**: ``` GET /api/v1/users/{userId} @RequireSuperAdmin → Result errors: 40101, 40301, 40401 PUT /api/v1/users/{userId} @RequireSuperAdmin body: UpdateUserReq (PATCH) → Result errors: 40001, 40004, 40101, 40301, 40302, 40401, 40902 UserDetailVo { Integer userId, String username, String userCode, String userType, String language, Boolean canEditDocument, Boolean isDeleted, Integer employeeId, String employeeName, List permissionCategoryIds, String updatedBy, LocalDateTime updatedDate } UpdateUserReq { String userCode, // 缺省/null = 不变 String userType, // 同上 String language, // 同上 Boolean canEditDocument, // 同上 Integer employeeId, // 缺省/null = 不变;0 = 解除关联;正整数 = 更新 Boolean isDeleted, // 同上 List permissionCategoryIds // 缺省/null = 不变;空数组 = 清空;非空 = 增删差集 } ``` **PATCH 语义约定**(写死,跨任务一致):所有字段缺省 / null 视为 "保持原值";`employeeId == 0` 视为 "解除关联",DB 写 NULL。其他字段无清除语义。 --- ## 任务步骤 ### Task 1: ErrorCode 新增 40302 / 40401 + 404 映射 + docs/05 补 GET 详情段 **Files:** - Modify: `backend/src/main/java/com/xly/erp/common/response/ErrorCode.java` - Modify: `backend/src/test/java/com/xly/erp/common/response/ErrorCodeTest.java` - Modify: `docs/05-API接口契约.md` § REQ-USR-003 之前加 `GET /api/v1/users/{userId}` 段 **API shape:** - `ErrorCode.USER_FORBIDDEN_SELF_DEACTIVATE = 40302` - `ErrorCode.USER_NOT_FOUND = 40401` - `ErrorCode.toHttpStatus(40302) == 403` - `ErrorCode.toHttpStatus(40401) == 404` docs/05 补充内容(追加到现 REQ-USR-003 段之前): ``` ### REQ-USR-003 GET 用户详情 - Method: GET - Path: /api/v1/users/{userId} - Auth: Bearer Token;仅 userType=SUPER_ADMIN 可调用 - 请求: Path userId: int - 响应: JSON UserDetailVo { userId, username, userCode, userType, language, canEditDocument, isDeleted, employeeId, employeeName, permissionCategoryIds[], updatedBy, updatedDate } #### 错误码 - 40101 — 未携带或无效 Token - 40301 — 当前用户非超级管理员,无权调用 - 40401 — 用户不存在 ``` - [ ] **Step 1: 写失败测试** `ErrorCodeTest#httpMappings_coverNewCodes_v003` - [ ] **Step 2: 实现最小代码** + docs/05 改动 - [ ] **Step 3: 子会话验证 PASS** - [ ] **Step 4: Commit** `chore(usr): docs/05 补 GET 详情 + ErrorCode 新增 40302/40401 REQ-USR-003` ### Task 2: UpdateUserReq + UserDetailVo **Files:** - Create: `backend/src/main/java/com/xly/erp/module/usr/dto/UpdateUserReq.java` - Create: `backend/src/main/java/com/xly/erp/module/usr/vo/UserDetailVo.java` - Create: `backend/src/test/java/com/xly/erp/module/usr/dto/UpdateUserReqValidationTest.java` **API shape**: 见 "约束常量"。 校验注解(仅在字段非 null 时生效,jakarta `@Pattern` 等天然 null-safe): - `userCode`: `@Size(max=50)` - `userType`: `@Pattern(regexp="NORMAL|SUPER_ADMIN")` - `language`: `@Pattern(regexp="zh-CN|en-US|zh-TW")` - `employeeId`: `@Min(0)` (0 = 解除关联约定) - 其余字段无 @ 注解 - [ ] **Step 1: 写失败测试** `UpdateUserReqValidationTest` 覆盖: - 全空请求体合法(PATCH,所有字段可选) - userType 非枚举 → 失败 - language 非枚举 → 失败 - userCode 越长 → 失败 - userCode 空字符串 → 失败(@Size 不限定下界,但建议加 @Pattern("\S+") 防空白;本任务不强制) - employeeId=-1 → 失败 - employeeId=0 合法(约定的解除关联) - [ ] **Step 2: 实现最小代码** - [ ] **Step 3: 子会话验证 PASS** - [ ] **Step 4: Commit** `feat(usr): UpdateUserReq + UserDetailVo REQ-USR-003` ### Task 3: SysUserMapper.existsByUserCodeExcludingId **Files:** - Modify: `backend/src/main/java/com/xly/erp/module/usr/mapper/SysUserMapper.java` - Modify: `backend/src/test/java/com/xly/erp/module/usr/mapper/SysUserMapperTest.java` **API shape:** - `SysUserMapper#existsByUserCodeExcludingId(String userCode, Integer excludedUserId) : boolean` - SQL: `SELECT EXISTS(SELECT 1 FROM sys_user WHERE sUserCode = #{userCode} AND iIncrement <> #{excludedUserId})` - [ ] **Step 1: 写失败测试** - `existsByUserCodeExcludingId_otherUserHasCode_returnsTrue` (alice U001, query U001 excluding admin → true) - `existsByUserCodeExcludingId_selfHasCode_returnsFalse` (alice U001, query U001 excluding alice → false) - `existsByUserCodeExcludingId_unknownCode_returnsFalse` - [ ] **Step 2: 实现最小代码** - [ ] **Step 3: 子会话验证 PASS** - [ ] **Step 4: Commit** `feat(usr): SysUserMapper 排除自身的 userCode 唯一查询 REQ-USR-003` ### Task 4: SysUserPermissionCategoryMapper 增删辅助方法 **Files:** - Modify: `backend/src/main/java/com/xly/erp/module/usr/mapper/SysUserPermissionCategoryMapper.java` - Create: `backend/src/test/java/com/xly/erp/module/usr/mapper/SysUserPermissionCategoryMapperTest.java` **API shape:** - `SysUserPermissionCategoryMapper#selectPermissionCategoryIdsByUserId(Integer userId) : List` - SQL: `SELECT iPermissionCategoryId FROM sys_user_permission_category WHERE iUserId = #{userId}` - `SysUserPermissionCategoryMapper#deleteByUserAndCategoryIds(@Param("userId") Integer userId, @Param("ids") List categoryIds) : int` - SQL: `DELETE FROM sys_user_permission_category WHERE iUserId = #{userId} AND iPermissionCategoryId IN (...)` (@Select script) - [ ] **Step 1: 写失败测试** - 准备:admin user + 授权 {PUR, SAL} - `selectPermissionCategoryIdsByUserId_returnsAllCurrent` - `deleteByUserAndCategoryIds_onlyDeletesGivenSubset`(删 {PUR} 后剩 {SAL}) - `deleteByUserAndCategoryIds_nonMatchingIds_noop_returns0` - [ ] **Step 2: 实现最小代码** - [ ] **Step 3: 子会话验证 PASS** - [ ] **Step 4: Commit** `feat(usr): SysUserPermissionCategoryMapper 查/删辅助方法 REQ-USR-003` ### Task 5: UserDetailService 接口 + 实现 **Files:** - Create: `backend/src/main/java/com/xly/erp/module/usr/service/UserDetailService.java` - Create: `backend/src/main/java/com/xly/erp/module/usr/service/impl/UserDetailServiceImpl.java` - Create: `backend/src/test/java/com/xly/erp/module/usr/service/UserDetailServiceImplTest.java` **API shape:** - `UserDetailService#getById(Integer userId) : UserDetailVo` - 找不到用户 → `BizException(ErrorCode.USER_NOT_FOUND, "用户不存在")` - 包含作废用户(用于恢复启用场景的回显) - 装配逻辑:select sys_user → 若 iEmployeeId 非空 select sys_employee.sEmployeeName → selectPermissionCategoryIdsByUserId - [ ] **Step 1: 写失败测试** - `getById_existingActiveUser_returnsFullVo`(含 employeeName + permissionCategoryIds) - `getById_userWithoutEmployee_employeeNameIsNull` - `getById_userWithoutPermissions_emptyList` - `getById_deletedUser_stillReturned`(iIsDeleted=1 的用户也能查到,VO.isDeleted=true) - `getById_unknownId_throws40401` - [ ] **Step 2: 实现最小代码** - [ ] **Step 3: 子会话验证 PASS** - [ ] **Step 4: Commit** `feat(usr): UserDetailService 用户详情查询 REQ-USR-003` ### Task 6: UserUpdateService 接口 + 校验骨架 **Files:** - Create: `backend/src/main/java/com/xly/erp/module/usr/service/UserUpdateService.java` - Create: `backend/src/main/java/com/xly/erp/module/usr/service/impl/UserUpdateServiceImpl.java` - Create: `backend/src/test/java/com/xly/erp/module/usr/service/UserUpdateServiceImplTest.java` **API shape:** - `UserUpdateService#update(Integer userId, UpdateUserReq req, Integer operatorUserId, String operatorUsername) : UserDetailVo` - `@Transactional` - 注入:SysUserMapper / SysEmployeeMapper / SysPermissionCategoryMapper / SysUserPermissionCategoryMapper / UserDetailService(复用详情装配返回最终 VO) 本 Task 范围:校验路径(不写入),全部抛 BizException 路径覆盖: 1. `userId` 不存在 → 40401 2. `req.isDeleted == true && userId == operatorUserId` → 40302 3. `req.userCode` 非 null 且 `existsByUserCodeExcludingId(userCode, userId)` → 40902 4. `req.employeeId` 是正整数(非 0)且不存在或软删 → 40004 5. `req.permissionCategoryIds` 非 null 且非空且含不存在 ID → 40004 - [ ] **Step 1: 写失败测试** 5 个测试覆盖以上路径 - [ ] **Step 2: 实现最小代码**(更新方法体仅 throw,不写 DB) - [ ] **Step 3: 子会话验证 PASS** - [ ] **Step 4: Commit** `feat(usr): UserUpdateService 校验路径骨架 REQ-USR-003` ### Task 7: UserUpdateService 部分字段写入 + employee 三态 **Files:** - Modify: `backend/src/main/java/com/xly/erp/module/usr/service/impl/UserUpdateServiceImpl.java` - Modify: `backend/src/test/java/com/xly/erp/module/usr/service/UserUpdateServiceImplTest.java` **API behavior:** - 校验全过后用 `UpdateWrapper().eq("iIncrement", userId)` 链式 set: - `if (req.userCode != null)` → set sUserCode - `if (req.userType != null)` → set sUserType - `if (req.language != null)` → set sLanguage - `if (req.canEditDocument != null)` → set iCanEditDocument (true → 1, false → 0) - `if (req.employeeId != null)`:req.employeeId == 0 → set iEmployeeId NULL;正整数 → set iEmployeeId 值 - `if (req.isDeleted != null)` → set iIsDeleted (true → 1, false → 0) - 总是 set sUpdatedBy=operatorUsername、tUpdatedDate=NOW() - 不处理 permissionCategoryIds(推到 Task 8) - [ ] **Step 1: 写失败测试** - `update_userCode_only_persisted_otherFieldsUnchanged` - `update_userType_language_canEditDocument` - `update_employeeId_positiveInteger_setsToValue` - `update_employeeId_zero_setsToNull` - `update_employeeId_unchanged_preservesOriginalValue`(缺省字段不变) - `update_isDeleted_true_marksUserDeleted` - `update_isDeleted_false_revivesUser` - `update_emptyRequest_onlyUpdatesAuditFields`(sUpdatedBy + tUpdatedDate) - [ ] **Step 2: 实现最小代码** - [ ] **Step 3: 子会话验证 PASS** - [ ] **Step 4: Commit** `feat(usr): UserUpdateService 部分字段写入 + employeeId 三态 REQ-USR-003` ### Task 8: UserUpdateService permissionCategoryIds 增量差集 **Files:** - Modify: `backend/src/main/java/com/xly/erp/module/usr/service/impl/UserUpdateServiceImpl.java` - Modify: `backend/src/test/java/com/xly/erp/module/usr/service/UserUpdateServiceImplTest.java` **API behavior:** - 仅当 `req.permissionCategoryIds != null` 时执行: - `current = selectPermissionCategoryIdsByUserId(userId)`(Set 化以便比较) - `target = new HashSet<>(req.permissionCategoryIds)`(dedup) - `toRemove = current \ target` → `deleteByUserAndCategoryIds(userId, toRemove)`(若 toRemove 非空) - `toAdd = target \ current` → 循环 insert SysUserPermissionCategory(sGrantedBy=operator) - 返回 `userDetailService.getById(userId)` 作为响应(聚合最新状态) - [ ] **Step 1: 写失败测试** - `update_permissionCategories_emptyList_clearsAll`(初始 {PUR,SAL} → 请求 [] → 最终 ∅) - `update_permissionCategories_subsetDelta_addsAndRemoves`(初始 {PUR,SAL} → 请求 [SAL, 新分类 X] → 最终 {SAL, X});断言 SAL 行 iIncrement 不变(差集而非全量替换) - `update_permissionCategories_omitted_preservesExisting`(请求中无 permissionCategoryIds → 不动) - `update_permissionCategories_duplicateInRequest_deduped`(请求 [PUR, PUR, SAL] → 最终 {PUR, SAL}) - `update_returnsUserDetailVoReflectingFinalState` - [ ] **Step 2: 实现最小代码** - [ ] **Step 3: 子会话验证 PASS** - [ ] **Step 4: Commit** `feat(usr): UserUpdateService 权限分类增量增删差集 REQ-USR-003` ### Task 9: UserController GET + PUT + 端到端测试 **Files:** - Modify: `backend/src/main/java/com/xly/erp/module/usr/controller/UserController.java`(追加 GET + PUT 方法) - Create: `backend/src/test/java/com/xly/erp/module/usr/controller/UserControllerUpdateTest.java` **API shape:** - `@GetMapping("/{userId}") @RequireSuperAdmin getById(@PathVariable Integer userId) : Result` - `@PutMapping("/{userId}") @RequireSuperAdmin update(@PathVariable Integer userId, @RequestBody @Valid UpdateUserReq req) : Result` - PUT 调用 `userUpdateService.update(userId, req, LoginContext.current().userId(), LoginContext.current().username())` 端到端测试(覆盖 spec § 验收 1-23): **GET(5 个)**: - `get_existingUser_returns200_andFullVo` - `get_unknownUser_returns404_40401` - `get_normalUser_returns403_40301` - `get_noAuthHeader_returns401_40101` - `get_deletedUser_stillReturns200` **PUT(16 个)**: - `put_updateUserCodeAndType_returns200` - `put_updateEmployeeId_toAnotherEmployee` - `put_updateEmployeeId_zero_clearsRelation` - `put_updateEmployeeId_unknown_returns400_40004` - `put_isDeletedTrue_marksAndOriginalTokenRejectedNextCall`(修改后用旧 token 调 GET 应得 40101) - `put_permissionCategories_subsetDelta`(断言差集行为) - `put_permissionCategories_emptyArray_clearsAll` - `put_permissionCategories_unknownId_returns400_40004_andRollsBack`(断言事务回滚——sys_user 也未被改) - `put_duplicateUserCode_returns409_40902` - `put_userCodeUnchangedSameAsSelf_returns200` - `put_selfDeactivate_returns403_40302` - `put_unknownProperty_username_returns400_40001` - `put_unknownProperty_password_returns400_40001` - `put_unknownUserId_returns404_40401` - `put_normalUser_returns403_40301` - `put_emptyBody_only_updates_audit_fields` - [ ] **Step 1: 写失败测试** (21 个测试) - [ ] **Step 2: 实现最小代码**(在现有 UserController 上追加方法) - [ ] **Step 3: 子会话验证 PASS** - [ ] **Step 4: Commit** `feat(usr): GET + PUT /api/v1/users/{userId} controller + 端到端测试 REQ-USR-003` --- ## 提交计划 | Task | Commit message | |---|---| | 1 | `chore(usr): docs/05 补 GET 详情 + ErrorCode 新增 40302/40401 REQ-USR-003` | | 2 | `feat(usr): UpdateUserReq + UserDetailVo REQ-USR-003` | | 3 | `feat(usr): SysUserMapper 排除自身的 userCode 唯一查询 REQ-USR-003` | | 4 | `feat(usr): SysUserPermissionCategoryMapper 查/删辅助方法 REQ-USR-003` | | 5 | `feat(usr): UserDetailService 用户详情查询 REQ-USR-003` | | 6 | `feat(usr): UserUpdateService 校验路径骨架 REQ-USR-003` | | 7 | `feat(usr): UserUpdateService 部分字段写入 + employeeId 三态 REQ-USR-003` | | 8 | `feat(usr): UserUpdateService 权限分类增量增删差集 REQ-USR-003` | | 9 | `feat(usr): GET + PUT /api/v1/users/{userId} controller + 端到端测试 REQ-USR-003` |