--- req_id: REQ-USR-002 date: 2026-05-06 spec_ref: docs/superpowers/specs/2026-05-06-REQ-USR-002.md --- # REQ-USR-002 用户修改 Implementation Plan > **Execution:** Parent skill `feature-tdd` executes this plan task-by-task. **Goal:** 实现 `PUT /api/users/{id}`:除 sUserNo / sUserName / sPasswordHash 外的字段全量替换 + 权限组重建(先删后插),返回 UserVO。 **Architecture:** 复用 REQ-USR-001 的 entity/mapper/service/exception/Jackson 体系。Service load-then-modify:`selectById` → 校验 + 字段合并 → `updateById`(user)→ `delete`(关联) + 循环 `insert`(关联)。`iStaffId` 字段加 `FieldStrategy.IGNORED` 让 NULL 写入生效。 **Tech Stack:** 沿用前序 REQ。 --- ## Schema 改动 无。 ## 文件变更清单 - 修改: `backend/src/main/java/com/xly/erp/common/response/ErrorCode.java` — 追加 `USR_NOT_FOUND(40431, "用户不存在或已删除")` - 修改: `backend/src/main/java/com/xly/erp/module/usr/entity/UserEntity.java` — `iStaffId` 加 `updateStrategy = FieldStrategy.IGNORED` - 创建: `backend/src/main/java/com/xly/erp/module/usr/dto/UserUpdateDTO.java` - 修改: `backend/src/main/java/com/xly/erp/module/usr/service/UserService.java` — 追加 `update(Integer id, UserUpdateDTO dto): UserVO` - 修改: `backend/src/main/java/com/xly/erp/module/usr/service/impl/UserServiceImpl.java` — 实现 update - 修改: `backend/src/main/java/com/xly/erp/module/usr/controller/UserController.java` — 追加 `@PutMapping("/{id}")` - 修改: `backend/src/test/java/com/xly/erp/common/response/ApiResponseTest.java` — 追加 1 个错误码断言 - 创建: `backend/src/test/java/com/xly/erp/module/usr/dto/UserUpdateDTOValidationTest.java` - 修改: `backend/src/test/java/com/xly/erp/module/usr/service/UserServiceImplTest.java` — 追加 9 个 update 单测 - 修改: `backend/src/test/java/com/xly/erp/module/usr/controller/UserControllerIT.java` — 追加 8 个 PUT 集成测试 --- ## 任务步骤 ### Task 1: 错误码追加 + UserEntity.iStaffId IGNORED **Files:** - Modify: `backend/src/main/java/com/xly/erp/common/response/ErrorCode.java` - Modify: `backend/src/main/java/com/xly/erp/module/usr/entity/UserEntity.java` - Modify: `backend/src/test/java/com/xly/erp/common/response/ApiResponseTest.java` **API shape:** - `USR_NOT_FOUND(40431, "用户不存在或已删除")` 追加到 ErrorCode 枚举 - `UserEntity#iStaffId` 字段注解改为 `@TableField(value = "iStaffId", updateStrategy = FieldStrategy.IGNORED)`,并加注释说明(与 REQ-MOD-002 / module_mod 同样的副作用警告) - [ ] **Step 1.1 写失败断言** - `ApiResponseTest#errorCode_constantsMatchDocs05Spec` 追加 `assertThat(ErrorCode.USR_NOT_FOUND.getCode()).isEqualTo(40431);` - 子会话: FAIL - [ ] **Step 1.2 实现错误码 + Entity 注解** - 子会话验证:`mvn -B test`(全量;让 SpringBootTest 预热 lambda cache)应仍 PASS(USR-001 现有用例 + 新断言) - [ ] **Step 1.3 提交** - `git commit -m "feat(common): USR_NOT_FOUND + iStaffId IGNORED REQ-USR-002"` --- ### Task 2: UserUpdateDTO + 校验单测 **Files:** - Create: `backend/src/main/java/com/xly/erp/module/usr/dto/UserUpdateDTO.java` - Test: `backend/src/test/java/com/xly/erp/module/usr/dto/UserUpdateDTOValidationTest.java` **API shape:** - 字段(与 UserCreateDTO 相比剥除 `sUserNo` / `sUserName`): - `Integer iStaffId`(可空) - `@NotBlank @Pattern(regexp="^(普通用户|超级管理员)$") String sUserType` - `@NotBlank @Pattern(regexp="^(zh|en|zh-TW)$") String sLanguage` - `Boolean bCanModifyDocs`(可空,service 层语义:null 保留原值) - `List permissionCategoryIds`(可空,空数组 / null 都视为清空) - [ ] **Step 2.1 写失败测试(4 个)** - `UserUpdateDTOValidationTest#allValidFields_yieldsNoViolations` - `UserUpdateDTOValidationTest#blankRequiredFields_yieldsViolations`(2 个 @NotBlank) - `UserUpdateDTOValidationTest#invalidUserTypeEnum_yieldsViolation` - `UserUpdateDTOValidationTest#invalidLanguageEnum_yieldsViolation` - 子会话: FAIL - [ ] **Step 2.2 实现 DTO** - 子会话: PASS - [ ] **Step 2.3 提交** - `git commit -m "feat(usr): user update DTO REQ-USR-002"` --- ### Task 3: UserService.update + Mockito 单测 **Files:** - Modify: `backend/src/main/java/com/xly/erp/module/usr/service/UserService.java`(追加方法签名) - Modify: `backend/src/main/java/com/xly/erp/module/usr/service/impl/UserServiceImpl.java` - Modify: `backend/src/test/java/com/xly/erp/module/usr/service/UserServiceImplTest.java`(追加 9 个测试) **API shape:** - `UserService.update(Integer id, UserUpdateDTO dto): UserVO` - 实现步骤(plan 锁定): 1. `target = userMapper.selectById(id)`;`null` 或 `bDeleted=true` → `BizException(USR_NOT_FOUND)` 2. iStaffId 校验(仅当 dto.iStaffId 非空):`staffMapper.selectById(...)` null / bDeleted → `BizException(STAFF_NOT_FOUND)` 3. 权限分类校验(仅当 dto.permissionCategoryIds 非空):`selectBatchIds` 长度 / bDeleted 检查 → `BizException(PERM_CATEGORY_NOT_FOUND)` 4. 字段合并到 target(保留 sUserNo / sUserName / sPasswordHash / iIncrement / tCreateDate / sCreatedBy / tLastLoginDate / 多租户 / sId / bDeleted 三件套): - `target.setIStaffId(dto.getIStaffId())`(含 null 清空) - `target.setSUserType(dto.getSUserType())` - `target.setSLanguage(dto.getSLanguage())` - `if (dto.getBCanModifyDocs() != null) target.setBCanModifyDocs(...)`(部分更新) 5. `userMapper.updateById(target)` 6. 重建权限关联: - `userPermissionMapper.delete(LambdaQueryWrapper.eq(iUserId, id))` 清空所有 - 若 dto.permissionCategoryIds 非空 → 循环 insert(每条 iUserId / iCategoryId / tCreateDate=now) 7. 返回 `UserVO.from(target, dto.permissionCategoryIds 或 [])` - 标 `@Transactional(rollbackFor = Exception.class)` - [ ] **Step 3.1 写失败测试(9 个)** - `update_targetNotFound_throws40431` - `update_targetSoftDeleted_throws40431` - `update_staffNotFound_throws40421` - `update_staffSoftDeleted_throws40421` - `update_permissionCategoryNotFound_throws40422` - `update_full_returnsVOWithUpdatedFields_andRebuildsPermissions`:mock target;ArgumentCaptor 验 - user 已修改字段:iStaffId / sUserType / sLanguage / bCanModifyDocs - user 保留字段:sUserNo / sUserName / sPasswordHash / iIncrement / tCreateDate / sCreatedBy - userPermissionMapper.delete 调一次(wrapper 含 eq iUserId);insert 调 N 次(按 dto 的 ids) - `update_partialNullBCanModifyDocs_keepsOriginal` - `update_clearStaffId_setsToNull`:dto.iStaffId=null,断言 captor entity.iStaffId == null - `update_emptyPermissionCategoryIds_clearsAllAssociations`:dto.permissionCategoryIds=[],verify userPermissionMapper.delete 被调一次 + insert never - 子会话: FAIL - [ ] **Step 3.2 实现 service.update** - 子会话: PASS(含原 9 个 USR-001 单测 + 9 个 USR-002 单测共 18 个) - [ ] **Step 3.3 提交** - `git commit -m "feat(usr): update user service REQ-USR-002"` --- ### Task 4: UserController PUT + 端到端 IT **Files:** - Modify: `backend/src/main/java/com/xly/erp/module/usr/controller/UserController.java` - Modify: `backend/src/test/java/com/xly/erp/module/usr/controller/UserControllerIT.java`(追加 8 个 PUT IT) **API shape:** - `@PutMapping("/{id}") ApiResponse update(@PathVariable Integer id, @Valid @RequestBody UserUpdateDTO dto)` - Javadoc:`REQ-USR-002 用户修改 — REQ-USR-004 完成后追加 @PreAuthorize("hasAuthority('USR:UPDATE')")` - [ ] **Step 4.1 写失败测试(8 个)** - `put_validUpdate_returns200_andDbReflects`:先 mapper.insert 一个 user + staff + 3 个 cat + 3 个 userPermission;PUT 改 staff(另一个) + sUserType + sLanguage + bCanModifyDocs + permissionCategoryIds(2 个新 cat);断言 200 + reload 验证字段 + 关联表替换为 2 条 - `put_clearStaffId_setsNull`:原 user.iStaffId=staffId,PUT 时 iStaffId=null,断言 DB user.iStaffId IS NULL - `put_emptyPermissionCategoryIds_clearsAssociations`:原有 3 条关联,PUT 传 [],断言 DB tUserPermission count = 0 - `put_targetNotFound_returns40431` - `put_staffNotFound_returns40421` - `put_permissionCategoryNotFound_returns40422` - `put_missingRequired_returns40010`:缺 sUserType - `put_ignoresProtectedFields_doesNotChangeUserNoOrName`:手工拼 body 含 sUserNo / sUserName / sPasswordHash 字段,PUT 后 reload;断言 sUserNo / sUserName / sPasswordHash 与原值相同 - 子会话: FAIL - [ ] **Step 4.2 实现 PUT 端点** - 子会话: PASS - [ ] **Step 4.3 跑全量 backend 测试** - `cd backend && mvn -B test` - 期望 101 + 1(新错误码)+ 4(DTO valid)+ 9(service unit)+ 8(controller IT)= 123 测试,全绿 - [ ] **Step 4.4 提交** - `git commit -m "feat(usr): PUT /api/users/{id} controller REQ-USR-002"` --- ## 提交计划 - `feat(common): USR_NOT_FOUND + iStaffId IGNORED REQ-USR-002`(Task 1) - `feat(usr): user update DTO REQ-USR-002`(Task 2) - `feat(usr): update user service REQ-USR-002`(Task 3) - `feat(usr): PUT /api/users/{id} controller REQ-USR-002`(Task 4)