diff --git a/docs/08-模块任务管理.md b/docs/08-模块任务管理.md index d0b9548..eba0530 100644 --- a/docs/08-模块任务管理.md +++ b/docs/08-模块任务管理.md @@ -71,6 +71,6 @@ - MR: — - 功能: - [x] REQ-USR-001 用户新增 - - [ ] REQ-USR-002 用户修改 + - [x] REQ-USR-002 用户修改 - [ ] REQ-USR-003 用户查询 - [ ] REQ-USR-004 用户登录 diff --git a/docs/superpowers/plans/2026-05-06-REQ-USR-002.md b/docs/superpowers/plans/2026-05-06-REQ-USR-002.md new file mode 100644 index 0000000..a65e5e8 --- /dev/null +++ b/docs/superpowers/plans/2026-05-06-REQ-USR-002.md @@ -0,0 +1,178 @@ +--- +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) diff --git a/docs/superpowers/reviews/2026-05-06-REQ-USR-002.md b/docs/superpowers/reviews/2026-05-06-REQ-USR-002.md new file mode 100644 index 0000000..98861d9 --- /dev/null +++ b/docs/superpowers/reviews/2026-05-06-REQ-USR-002.md @@ -0,0 +1,35 @@ +--- +req_id: REQ-USR-002 +date: 2026-05-06 +round: 1 +reviewer: superpower-code-reviewer +--- + +# Review: REQ-USR-002 — round 1 + +## 结论 +approve + +## Must-fix +(无) + +## Nice-to-have + +- backend/src/test/java/com/xly/erp/module/usr/controller/UserControllerIT.java:285 — `put_permissionCategoryNotFound_returns40422` 只断言响应 code,未断言 spec § 验收 #11 要求的 "DB user 与 tUserPermission 都不变(事务回滚)"。受 IT 类整体 `@Transactional+@Rollback` 制约,需另起 `Propagation.NOT_SUPPORTED` 测试或在 service 抛异常前后分别 reload 校验字段未变。 +- backend/src/test/java/com/xly/erp/module/usr/controller/UserControllerIT.java:235 — `put_validUpdate_returns200_andDbReflects` 仅断言 `upCount==2L`,没显式断言新 2 条关联指向 `catNew1/catNew2` 且原 3 条已不存在。建议追加 `containsExactlyInAnyOrder(catNew1, catNew2)` + `doesNotContain(cat1, cat2, cat3)`。 +- backend/src/test/java/com/xly/erp/module/usr/controller/UserControllerIT.java — IT 缺 `put_targetSoftDeleted_returns40431` 和 `put_staffSoftDeleted_returns40421` 两条端到端用例(spec § 验收 #7 / #10 仅在 service 单测覆盖)。 +- backend/src/test/java/com/xly/erp/module/usr/controller/UserControllerIT.java:327 — `put_ignoresProtectedFields_doesNotChangeUserNoOrName` 未断言 tCreateDate 保留(spec § 业务规则 #7 / 验收 #4 列入"不被修改"范围)。 +- backend/src/main/java/com/xly/erp/module/usr/entity/UserEntity.java:40 — `iStaffId` 全局 `FieldStrategy.IGNORED` 是已知副作用(注释已说明)。建议在 module_usr 完成报告 § ⑦ 跨模块改动 / 风险登记追加一条:"UserEntity.iStaffId 已加 IGNORED;后续 partial-update path 必须 selectById 后再 updateById",与 module_mod ModuleEntity.iParentId 同类风险并案管理。 +- backend/src/main/java/com/xly/erp/module/usr/service/impl/UserServiceImpl.java — Service 中 iStaffId/sUserType/sLanguage 直接覆盖、bCanModifyDocs null 保留——建议加注释或抽 mergeUpdate 私有方法集中 null 语义。 +- backend/src/test/java/com/xly/erp/module/usr/service/UserServiceImplTest.java — 未覆盖 `dto.permissionCategoryIds == null` 分支(spec § 业务规则 #5 "不传则只删不插")。当前所有相关测试都用 `List.of()` 而非 null。 + +## 反例 / 测试覆盖缺口 + +1. **AC11 回滚证据**:`put_permissionCategoryNotFound_returns40422` 受 `@Transactional+@Rollback` 制约,无法直接观测 service 层的回滚。 +2. **AC1 重建关联粒度**:`upCount==2` 可被任何 insert 顺序异常掩盖,需细化断言。 +3. **AC7 / AC10 端到端缺失**:目标软删除 / staff 软删除均仅在 service 单测覆盖。 +4. **AC4 tCreateDate 保留**:实现走 load-then-modify 默认 NOT_NULL 策略行为正确,但缺测试佐证。 +5. **业务规则 #5 null permissionCategoryIds 分支**:单测未直接覆盖。 +6. **iStaffId IGNORED 全局副作用**:本期安全,未来 partial-update path 风险,需在 module_usr 完成报告中登记。 + +**核心结论**:Spec/Plan 业务规则 1-9 全部实现到位,错误码 40010 / 40421 / 40422 / 40431 全部回归,UserVO 不暴露 sPasswordHash,保留字段 sUserNo / sUserName / sPasswordHash 不被改通过 IT 验证。docs/05 § REQ-USR-002 列的 40331 / 40931 是 RBAC 范畴的本期不实施项,spec 已说明,不阻塞。verdict: approve;改进项放下一 REQ 或 module-report sweep。 diff --git a/docs/superpowers/specs/2026-05-06-REQ-USR-002.md b/docs/superpowers/specs/2026-05-06-REQ-USR-002.md new file mode 100644 index 0000000..87b303c --- /dev/null +++ b/docs/superpowers/specs/2026-05-06-REQ-USR-002.md @@ -0,0 +1,174 @@ +--- +req_id: REQ-USR-002 +date: 2026-05-06 +module: module_usr +--- + +# Spec: REQ-USR-002 — 用户修改 + +## 目标 + +实现后端 `PUT /api/users/{id}` 接口:在不破坏唯一性的前提下,更新已有用户的可编辑字段(含权限组关联),返回最新 UserVO(不含 sPasswordHash)。 + +## 输入 / 触发 + +**接口**:`PUT /api/users/{id}`,Content-Type `application/json`。`{id}` = `tUser.iIncrement`。 + +**Request body**(`UserUpdateDTO`)字段——与 REQ-USR-001 输入相比**剥除 `sUserNo` / `sUserName`**(不可改,登录身份固定);其余字段均可修改: + +| 字段 | 类型 | 必填 | 校验 / 取值 | 行为 | +|---|---|---|---|---| +| `iStaffId` | Integer | 否 | 必须指向存在且未软删除的 `tStaff.iIncrement`;显式 `null` 表示清空员工关联 | 覆盖 | +| `sUserType` | String | 是 | 枚举:`普通用户` / `超级管理员` | 覆盖 | +| `sLanguage` | String | 是 | 枚举:`zh` / `en` / `zh-TW` | 覆盖 | +| `bCanModifyDocs` | Boolean | 否 | `null` 保持原值;显式覆盖 | 部分更新 | +| `permissionCategoryIds` | List | 否 | 每元素必须存在且未软删除;可空数组(清空所有授权) | 重建关联(先删后插,幂等) | + +> **不在 DTO 中**:`sUserNo`(用户号唯一不可改)、`sUserName`(登录账号唯一不可改)、`sPasswordHash`(密码不通过本接口修改)。Jackson 默认忽略未知字段。 +> 前端 UI 应把这些字段渲染为只读。 + +**鉴权**:契约要求 `Authorization: Bearer ` + `USR:UPDATE`。沿用 SecurityConfig permitAll;Controller Javadoc:`REQ-USR-004 完成后追加 @PreAuthorize("hasAuthority('USR:UPDATE')")`。 + +## 输出 / 结果 + +**HTTP 200,响应体**复用 REQ-USR-001 的 `UserVO`(10 个字段含 `permissionCategoryIds`),不暴露 sPasswordHash。 + +```json +{ + "code": 200, + "message": "操作成功", + "data": { + "iIncrement": 12, + "sUserNo": "u001", + "sUserName": "alice", + "iStaffId": 7, + "sUserType": "超级管理员", + "sLanguage": "en", + "bCanModifyDocs": true, + "tCreateDate": "2026-05-06T10:30:00", + "bDeleted": false, + "permissionCategoryIds": [1, 2] + }, + "timestamp": 1746528600000 +} +``` + +## 业务规则 + +1. **目标用户必须存在且未软删除**:`selectById(id)` 返回 null 或 `bDeleted=1` → `BizException(USR_NOT_FOUND)` (40431)。 +2. **`sUserNo` / `sUserName` / `sPasswordHash` 不可改**:DTO 不接受这些字段;service 层读取目标行后保留这三个字段不变。 +3. **iStaffId 校验**(仅当 dto.iStaffId 非空):`staffMapper.selectById(...)` 不存在或 `bDeleted=true` → `BizException(STAFF_NOT_FOUND)` (40421);显式 `null` 表示清空员工关联,不校验。 +4. **权限分类校验**(仅当 dto.permissionCategoryIds 非空):每元素必须存在且未软删除(`selectBatchIds` 一次性查);任一不存在 → `BizException(PERM_CATEGORY_NOT_FOUND)` (40422)。 +5. **权限组重建语义**:service 内**先删后插**——先 `userPermissionMapper.delete(eq(iUserId, {id}))` 清空目标用户所有现有关联,再按 `permissionCategoryIds` 顺序插入。空数组 / 不传则只删不插(清空授权)。 +6. **`bCanModifyDocs` / `iStaffId` 部分更新**:DTO 中 `null` 时—— + - `bCanModifyDocs == null` → 保持原值; + - `iStaffId == null` → **显式清空**为 NULL(与"未传"语义一致;DTO 字段语义本就是"目标值")。 +7. **保留字段**:`iIncrement` / `sId` / `sBrandsId` / `sSubsidiaryId` / `tCreateDate` / `sCreatedBy` / `tLastLoginDate` / `bDeleted` / `tDeletedDate` / `sDeletedBy` 在本接口**不被修改**。 +8. **审计**:本期 schema 未规划"最近修改时间 / 修改人"列,不维护。 +9. **事务**:`@Transactional(rollbackFor = Exception.class)`,覆盖目标校验 → 父校验 → 权限校验 → user update → tUserPermission delete → tUserPermission insert,整体回滚。 + +## 边界与约束 + +### 鉴权策略 + +沿用 module_mod / REQ-USR-001 SecurityConfig permitAll。 + +### 错误码映射 + +| 场景 | 错误码 | ErrorCode 枚举 | +|---|---|---| +| 必填缺失 / 类型 / 长度 / 枚举非法 | 40010 | `PARAM_INVALID`(已存在) | +| `{id}` 用户不存在或已软删除 | 40431 | `USR_NOT_FOUND`(**新增**) | +| `iStaffId` 不存在 / 已删除 | 40421 | `STAFF_NOT_FOUND`(已存在) | +| `permissionCategoryIds` 任一不存在 / 已删除 | 40422 | `PERM_CATEGORY_NOT_FOUND`(已存在) | +| 服务端兜底 | 50000 | `INTERNAL_ERROR` | + +> docs/05 § REQ-USR-002 中列的 40331(角色变更权限)涉及操作员角色权限模型,需要 SecurityContext + RBAC 才能落地,**REQ-USR-004 后再补**;本 REQ 不实施。 + +### iStaffId 的 NULL 写入 + +借鉴 REQ-MOD-002 经验:MyBatis-Plus 默认 update 跳过 null 字段,需让 `iStaffId` 字段加 `FieldStrategy.IGNORED` 才能把 NULL 写入 SQL。本期在 `UserEntity#iStaffId` 上加 `@TableField(value="iStaffId", updateStrategy=FieldStrategy.IGNORED)`。 + +> 风险与 REQ-MOD-002 相同:未来 partial update 路径会埋雷。本 REQ 走 load-then-modify 全量回填,安全。 + +### 权限组重建的并发 + +- "先删后插"在事务内是原子的;`uk_user_perm` 唯一约束兜底。 +- 同一用户并发修改权限:MySQL 默认行级锁 + 事务隔离 → 后写覆盖(与 REQ-MOD-002 一致)。 + +### 性能 + +- `selectBatchIds` 单次 round-trip 校验 N 个权限分类。 +- `delete + insert` 为 N+1 次 SQL,本期不优化。 + +## 依赖的 schema 表 / 字段 + +**写表**:`tUser`(主体字段更新)、`tUserPermission`(先删后插) + +**读表**:`tStaff`(iStaffId 校验)、`tPermissionCategory`(权限分类校验) + +| `tUser` 字段 | 行为 | +|---|---| +| `iIncrement` / `sUserNo` / `sUserName` / `sPasswordHash` / `tCreateDate` / `sCreatedBy` / `tLastLoginDate` / `sId` / 多租户 / `bDeleted` 三件套 | **不修改** | +| `iStaffId` | 入参覆盖(含 null 设根;需 `FieldStrategy.IGNORED`) | +| `sUserType` / `sLanguage` | 入参覆盖(必填) | +| `bCanModifyDocs` | 入参非 null 覆盖;null 保留 | + +`tUserPermission` 操作:先 `delete(eq(iUserId, {id}))`,再按 `permissionCategoryIds` 顺序 `insert`(每条 `iUserId={id}` / `iCategoryId=...` / `tCreateDate=now`,无 bSelected)。 + +**索引利用**:`uk_user_no` / `uk_user_name`(不会触发,因为本接口不改这两列);`uk_user_perm`(兜底重复授权)。 + +## 依赖的接口 + +无(独立接口;REQ-USR-001 建立的体系完全复用)。 + +## 验收标准 + +### 功能正确性 + +1. **正向 — 全字段更新**:先建一个用户(带 staff + 3 个权限),PUT 改 iStaffId(另一个 staff)+ sUserType + sLanguage + bCanModifyDocs + permissionCategoryIds(2 个新分类),返回 200;DB 中 user 字段更新;tUserPermission 替换为 2 条新关联(原 3 条已删)。 +2. **正向 — 清空 iStaffId**:PUT 时显式 `iStaffId=null`,DB user.iStaffId 变 NULL(验证 IGNORED 策略生效)。 +3. **正向 — 清空权限组**:PUT permissionCategoryIds=[],DB tUserPermission 清空(按 iUserId);返回 VO permissionCategoryIds=[]。 +4. **正向 — 保留字段**:先记录原 sUserNo / sUserName / sPasswordHash / tCreateDate;PUT 后查 DB 这 4 个字段保持原值。 +5. **正向 — 部分字段保留原值**:DTO 中 `bCanModifyDocs=null`,DB 保留原值(验证 NOT_NULL 策略生效)。 +6. **目标不存在**:`PUT /api/users/999999`,返回 40431。 +7. **目标已软删除**:先建 user 后置 bDeleted=1,PUT 返回 40431。 +8. **必填缺失 / 枚举非法 / 长度超限**:返回 40010。 +9. **iStaffId 不存在**:iStaffId=999999,返回 40421。 +10. **iStaffId 已软删除**:返回 40421。 +11. **permissionCategoryIds 任一不存在**:返回 40422;DB user 与 tUserPermission 都不变(事务回滚)。 +12. **sUserNo / sUserName / sPasswordHash 字段被忽略**:客户端误传 `sUserNo="hijack"` / `sUserName="hijack"` / `sPasswordHash="$2a$10$xxx"`,DB 中这 3 个字段保持原值。 + +### 接口契约一致性 + +- 响应格式 `{code, message, data, timestamp}`。 +- 错误码 200 / 40010 / 40421 / 40422 / 40431 / 50000。 +- 不暴露 sPasswordHash;不回显堆栈。 + +### 测试覆盖 + +- **单元测试** `UserServiceImplTest` 追加(mock 5 个 mapper + PasswordEncoder): + - update_targetNotFound_throws40431 + - update_targetSoftDeleted_throws40431 + - update_staffNotFound_throws40421 + - update_staffSoftDeleted_throws40421 + - update_permissionCategoryNotFound_throws40422 + - update_full_returnsVOWithUpdatedFields_andRebuildsPermissions(捕 ArgumentCaptor,断言 user 字段保留 sUserNo/sUserName/sPasswordHash + 新值字段;断言 userPermissionMapper.delete 调一次 + insert 调 N 次) + - update_partialNullBCanModifyDocs_keepsOriginal + - update_clearStaffId_setsToNull + - update_emptyPermissionCategoryIds_clearsAllAssociations(delete 调一次 + insert 不调) + +- **集成测试** `UserControllerIT` 追加: + - put_validUpdate_returns200_andDbReflects + - put_clearStaffId_setsNull + - put_emptyPermissionCategoryIds_clearsAssociations + - put_targetNotFound_returns40431 + - put_staffNotFound_returns40421 + - put_permissionCategoryNotFound_returns40422 + - put_missingRequired_returns40010 + - put_ignoresProtectedFields_doesNotChangeUserNoOrName(body 含 sUserNo / sUserName / sPasswordHash 但 DB 中这三列保持原值) + +### 代码与文档 + +- `// REQ-USR-002` 注释贴在 Controller 方法、Service 方法、新增 ErrorCode `USR_NOT_FOUND`。 +- 提交按 `feat(usr): REQ-USR-002`。