Commit 68ba20bc31df6f06ba7308fc4d345558c86244e3

Authored by zichun
1 parent 3a64246d

docs(usr): review approval REQ-USR-002

docs/08-模块任务管理.md
@@ -71,6 +71,6 @@ @@ -71,6 +71,6 @@
71 - MR: — 71 - MR: —
72 - 功能: 72 - 功能:
73 - [x] REQ-USR-001 用户新增 73 - [x] REQ-USR-001 用户新增
74 - - [ ] REQ-USR-002 用户修改 74 + - [x] REQ-USR-002 用户修改
75 - [ ] REQ-USR-003 用户查询 75 - [ ] REQ-USR-003 用户查询
76 - [ ] REQ-USR-004 用户登录 76 - [ ] REQ-USR-004 用户登录
docs/superpowers/plans/2026-05-06-REQ-USR-002.md 0 → 100644
  1 +---
  2 +req_id: REQ-USR-002
  3 +date: 2026-05-06
  4 +spec_ref: docs/superpowers/specs/2026-05-06-REQ-USR-002.md
  5 +---
  6 +
  7 +# REQ-USR-002 用户修改 Implementation Plan
  8 +
  9 +> **Execution:** Parent skill `feature-tdd` executes this plan task-by-task.
  10 +
  11 +**Goal:** 实现 `PUT /api/users/{id}`:除 sUserNo / sUserName / sPasswordHash 外的字段全量替换 + 权限组重建(先删后插),返回 UserVO。
  12 +
  13 +**Architecture:** 复用 REQ-USR-001 的 entity/mapper/service/exception/Jackson 体系。Service load-then-modify:`selectById` → 校验 + 字段合并 → `updateById`(user)→ `delete`(关联) + 循环 `insert`(关联)。`iStaffId` 字段加 `FieldStrategy.IGNORED` 让 NULL 写入生效。
  14 +
  15 +**Tech Stack:** 沿用前序 REQ。
  16 +
  17 +---
  18 +
  19 +## Schema 改动
  20 +
  21 +无。
  22 +
  23 +## 文件变更清单
  24 +
  25 +- 修改: `backend/src/main/java/com/xly/erp/common/response/ErrorCode.java` — 追加 `USR_NOT_FOUND(40431, "用户不存在或已删除")`
  26 +- 修改: `backend/src/main/java/com/xly/erp/module/usr/entity/UserEntity.java` — `iStaffId` 加 `updateStrategy = FieldStrategy.IGNORED`
  27 +- 创建: `backend/src/main/java/com/xly/erp/module/usr/dto/UserUpdateDTO.java`
  28 +- 修改: `backend/src/main/java/com/xly/erp/module/usr/service/UserService.java` — 追加 `update(Integer id, UserUpdateDTO dto): UserVO`
  29 +- 修改: `backend/src/main/java/com/xly/erp/module/usr/service/impl/UserServiceImpl.java` — 实现 update
  30 +- 修改: `backend/src/main/java/com/xly/erp/module/usr/controller/UserController.java` — 追加 `@PutMapping("/{id}")`
  31 +- 修改: `backend/src/test/java/com/xly/erp/common/response/ApiResponseTest.java` — 追加 1 个错误码断言
  32 +- 创建: `backend/src/test/java/com/xly/erp/module/usr/dto/UserUpdateDTOValidationTest.java`
  33 +- 修改: `backend/src/test/java/com/xly/erp/module/usr/service/UserServiceImplTest.java` — 追加 9 个 update 单测
  34 +- 修改: `backend/src/test/java/com/xly/erp/module/usr/controller/UserControllerIT.java` — 追加 8 个 PUT 集成测试
  35 +
  36 +---
  37 +
  38 +## 任务步骤
  39 +
  40 +### Task 1: 错误码追加 + UserEntity.iStaffId IGNORED
  41 +
  42 +**Files:**
  43 +- Modify: `backend/src/main/java/com/xly/erp/common/response/ErrorCode.java`
  44 +- Modify: `backend/src/main/java/com/xly/erp/module/usr/entity/UserEntity.java`
  45 +- Modify: `backend/src/test/java/com/xly/erp/common/response/ApiResponseTest.java`
  46 +
  47 +**API shape:**
  48 +- `USR_NOT_FOUND(40431, "用户不存在或已删除")` 追加到 ErrorCode 枚举
  49 +- `UserEntity#iStaffId` 字段注解改为 `@TableField(value = "iStaffId", updateStrategy = FieldStrategy.IGNORED)`,并加注释说明(与 REQ-MOD-002 / module_mod 同样的副作用警告)
  50 +
  51 +- [ ] **Step 1.1 写失败断言**
  52 + - `ApiResponseTest#errorCode_constantsMatchDocs05Spec` 追加 `assertThat(ErrorCode.USR_NOT_FOUND.getCode()).isEqualTo(40431);`
  53 + - 子会话: FAIL
  54 +
  55 +- [ ] **Step 1.2 实现错误码 + Entity 注解**
  56 + - 子会话验证:`mvn -B test`(全量;让 SpringBootTest 预热 lambda cache)应仍 PASS(USR-001 现有用例 + 新断言)
  57 +
  58 +- [ ] **Step 1.3 提交**
  59 + - `git commit -m "feat(common): USR_NOT_FOUND + iStaffId IGNORED REQ-USR-002"`
  60 +
  61 +---
  62 +
  63 +### Task 2: UserUpdateDTO + 校验单测
  64 +
  65 +**Files:**
  66 +- Create: `backend/src/main/java/com/xly/erp/module/usr/dto/UserUpdateDTO.java`
  67 +- Test: `backend/src/test/java/com/xly/erp/module/usr/dto/UserUpdateDTOValidationTest.java`
  68 +
  69 +**API shape:**
  70 +- 字段(与 UserCreateDTO 相比剥除 `sUserNo` / `sUserName`):
  71 + - `Integer iStaffId`(可空)
  72 + - `@NotBlank @Pattern(regexp="^(普通用户|超级管理员)$") String sUserType`
  73 + - `@NotBlank @Pattern(regexp="^(zh|en|zh-TW)$") String sLanguage`
  74 + - `Boolean bCanModifyDocs`(可空,service 层语义:null 保留原值)
  75 + - `List<Integer> permissionCategoryIds`(可空,空数组 / null 都视为清空)
  76 +
  77 +- [ ] **Step 2.1 写失败测试(4 个)**
  78 + - `UserUpdateDTOValidationTest#allValidFields_yieldsNoViolations`
  79 + - `UserUpdateDTOValidationTest#blankRequiredFields_yieldsViolations`(2 个 @NotBlank)
  80 + - `UserUpdateDTOValidationTest#invalidUserTypeEnum_yieldsViolation`
  81 + - `UserUpdateDTOValidationTest#invalidLanguageEnum_yieldsViolation`
  82 + - 子会话: FAIL
  83 +
  84 +- [ ] **Step 2.2 实现 DTO**
  85 + - 子会话: PASS
  86 +
  87 +- [ ] **Step 2.3 提交**
  88 + - `git commit -m "feat(usr): user update DTO REQ-USR-002"`
  89 +
  90 +---
  91 +
  92 +### Task 3: UserService.update + Mockito 单测
  93 +
  94 +**Files:**
  95 +- Modify: `backend/src/main/java/com/xly/erp/module/usr/service/UserService.java`(追加方法签名)
  96 +- Modify: `backend/src/main/java/com/xly/erp/module/usr/service/impl/UserServiceImpl.java`
  97 +- Modify: `backend/src/test/java/com/xly/erp/module/usr/service/UserServiceImplTest.java`(追加 9 个测试)
  98 +
  99 +**API shape:**
  100 +- `UserService.update(Integer id, UserUpdateDTO dto): UserVO`
  101 +- 实现步骤(plan 锁定):
  102 + 1. `target = userMapper.selectById(id)`;`null` 或 `bDeleted=true` → `BizException(USR_NOT_FOUND)`
  103 + 2. iStaffId 校验(仅当 dto.iStaffId 非空):`staffMapper.selectById(...)` null / bDeleted → `BizException(STAFF_NOT_FOUND)`
  104 + 3. 权限分类校验(仅当 dto.permissionCategoryIds 非空):`selectBatchIds` 长度 / bDeleted 检查 → `BizException(PERM_CATEGORY_NOT_FOUND)`
  105 + 4. 字段合并到 target(保留 sUserNo / sUserName / sPasswordHash / iIncrement / tCreateDate / sCreatedBy / tLastLoginDate / 多租户 / sId / bDeleted 三件套):
  106 + - `target.setIStaffId(dto.getIStaffId())`(含 null 清空)
  107 + - `target.setSUserType(dto.getSUserType())`
  108 + - `target.setSLanguage(dto.getSLanguage())`
  109 + - `if (dto.getBCanModifyDocs() != null) target.setBCanModifyDocs(...)`(部分更新)
  110 + 5. `userMapper.updateById(target)`
  111 + 6. 重建权限关联:
  112 + - `userPermissionMapper.delete(LambdaQueryWrapper.eq(iUserId, id))` 清空所有
  113 + - 若 dto.permissionCategoryIds 非空 → 循环 insert(每条 iUserId / iCategoryId / tCreateDate=now)
  114 + 7. 返回 `UserVO.from(target, dto.permissionCategoryIds 或 [])`
  115 +- 标 `@Transactional(rollbackFor = Exception.class)`
  116 +
  117 +- [ ] **Step 3.1 写失败测试(9 个)**
  118 + - `update_targetNotFound_throws40431`
  119 + - `update_targetSoftDeleted_throws40431`
  120 + - `update_staffNotFound_throws40421`
  121 + - `update_staffSoftDeleted_throws40421`
  122 + - `update_permissionCategoryNotFound_throws40422`
  123 + - `update_full_returnsVOWithUpdatedFields_andRebuildsPermissions`:mock target;ArgumentCaptor 验
  124 + - user 已修改字段:iStaffId / sUserType / sLanguage / bCanModifyDocs
  125 + - user 保留字段:sUserNo / sUserName / sPasswordHash / iIncrement / tCreateDate / sCreatedBy
  126 + - userPermissionMapper.delete 调一次(wrapper 含 eq iUserId);insert 调 N 次(按 dto 的 ids)
  127 + - `update_partialNullBCanModifyDocs_keepsOriginal`
  128 + - `update_clearStaffId_setsToNull`:dto.iStaffId=null,断言 captor entity.iStaffId == null
  129 + - `update_emptyPermissionCategoryIds_clearsAllAssociations`:dto.permissionCategoryIds=[],verify userPermissionMapper.delete 被调一次 + insert never
  130 + - 子会话: FAIL
  131 +
  132 +- [ ] **Step 3.2 实现 service.update**
  133 + - 子会话: PASS(含原 9 个 USR-001 单测 + 9 个 USR-002 单测共 18 个)
  134 +
  135 +- [ ] **Step 3.3 提交**
  136 + - `git commit -m "feat(usr): update user service REQ-USR-002"`
  137 +
  138 +---
  139 +
  140 +### Task 4: UserController PUT + 端到端 IT
  141 +
  142 +**Files:**
  143 +- Modify: `backend/src/main/java/com/xly/erp/module/usr/controller/UserController.java`
  144 +- Modify: `backend/src/test/java/com/xly/erp/module/usr/controller/UserControllerIT.java`(追加 8 个 PUT IT)
  145 +
  146 +**API shape:**
  147 +- `@PutMapping("/{id}") ApiResponse<UserVO> update(@PathVariable Integer id, @Valid @RequestBody UserUpdateDTO dto)`
  148 +- Javadoc:`REQ-USR-002 用户修改 — REQ-USR-004 完成后追加 @PreAuthorize("hasAuthority('USR:UPDATE')")`
  149 +
  150 +- [ ] **Step 4.1 写失败测试(8 个)**
  151 + - `put_validUpdate_returns200_andDbReflects`:先 mapper.insert 一个 user + staff + 3 个 cat + 3 个 userPermission;PUT 改 staff(另一个) + sUserType + sLanguage + bCanModifyDocs + permissionCategoryIds(2 个新 cat);断言 200 + reload 验证字段 + 关联表替换为 2 条
  152 + - `put_clearStaffId_setsNull`:原 user.iStaffId=staffId,PUT 时 iStaffId=null,断言 DB user.iStaffId IS NULL
  153 + - `put_emptyPermissionCategoryIds_clearsAssociations`:原有 3 条关联,PUT 传 [],断言 DB tUserPermission count = 0
  154 + - `put_targetNotFound_returns40431`
  155 + - `put_staffNotFound_returns40421`
  156 + - `put_permissionCategoryNotFound_returns40422`
  157 + - `put_missingRequired_returns40010`:缺 sUserType
  158 + - `put_ignoresProtectedFields_doesNotChangeUserNoOrName`:手工拼 body 含 sUserNo / sUserName / sPasswordHash 字段,PUT 后 reload;断言 sUserNo / sUserName / sPasswordHash 与原值相同
  159 + - 子会话: FAIL
  160 +
  161 +- [ ] **Step 4.2 实现 PUT 端点**
  162 + - 子会话: PASS
  163 +
  164 +- [ ] **Step 4.3 跑全量 backend 测试**
  165 + - `cd backend && mvn -B test`
  166 + - 期望 101 + 1(新错误码)+ 4(DTO valid)+ 9(service unit)+ 8(controller IT)= 123 测试,全绿
  167 +
  168 +- [ ] **Step 4.4 提交**
  169 + - `git commit -m "feat(usr): PUT /api/users/{id} controller REQ-USR-002"`
  170 +
  171 +---
  172 +
  173 +## 提交计划
  174 +
  175 +- `feat(common): USR_NOT_FOUND + iStaffId IGNORED REQ-USR-002`(Task 1)
  176 +- `feat(usr): user update DTO REQ-USR-002`(Task 2)
  177 +- `feat(usr): update user service REQ-USR-002`(Task 3)
  178 +- `feat(usr): PUT /api/users/{id} controller REQ-USR-002`(Task 4)
docs/superpowers/reviews/2026-05-06-REQ-USR-002.md 0 → 100644
  1 +---
  2 +req_id: REQ-USR-002
  3 +date: 2026-05-06
  4 +round: 1
  5 +reviewer: superpower-code-reviewer
  6 +---
  7 +
  8 +# Review: REQ-USR-002 — round 1
  9 +
  10 +## 结论
  11 +approve
  12 +
  13 +## Must-fix
  14 +(无)
  15 +
  16 +## Nice-to-have
  17 +
  18 +- 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 校验字段未变。
  19 +- 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)`。
  20 +- backend/src/test/java/com/xly/erp/module/usr/controller/UserControllerIT.java — IT 缺 `put_targetSoftDeleted_returns40431` 和 `put_staffSoftDeleted_returns40421` 两条端到端用例(spec § 验收 #7 / #10 仅在 service 单测覆盖)。
  21 +- backend/src/test/java/com/xly/erp/module/usr/controller/UserControllerIT.java:327 — `put_ignoresProtectedFields_doesNotChangeUserNoOrName` 未断言 tCreateDate 保留(spec § 业务规则 #7 / 验收 #4 列入"不被修改"范围)。
  22 +- 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 同类风险并案管理。
  23 +- backend/src/main/java/com/xly/erp/module/usr/service/impl/UserServiceImpl.java — Service 中 iStaffId/sUserType/sLanguage 直接覆盖、bCanModifyDocs null 保留——建议加注释或抽 mergeUpdate 私有方法集中 null 语义。
  24 +- backend/src/test/java/com/xly/erp/module/usr/service/UserServiceImplTest.java — 未覆盖 `dto.permissionCategoryIds == null` 分支(spec § 业务规则 #5 "不传则只删不插")。当前所有相关测试都用 `List.of()` 而非 null。
  25 +
  26 +## 反例 / 测试覆盖缺口
  27 +
  28 +1. **AC11 回滚证据**:`put_permissionCategoryNotFound_returns40422` 受 `@Transactional+@Rollback` 制约,无法直接观测 service 层的回滚。
  29 +2. **AC1 重建关联粒度**:`upCount==2` 可被任何 insert 顺序异常掩盖,需细化断言。
  30 +3. **AC7 / AC10 端到端缺失**:目标软删除 / staff 软删除均仅在 service 单测覆盖。
  31 +4. **AC4 tCreateDate 保留**:实现走 load-then-modify 默认 NOT_NULL 策略行为正确,但缺测试佐证。
  32 +5. **业务规则 #5 null permissionCategoryIds 分支**:单测未直接覆盖。
  33 +6. **iStaffId IGNORED 全局副作用**:本期安全,未来 partial-update path 风险,需在 module_usr 完成报告中登记。
  34 +
  35 +**核心结论**: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。
docs/superpowers/specs/2026-05-06-REQ-USR-002.md 0 → 100644
  1 +---
  2 +req_id: REQ-USR-002
  3 +date: 2026-05-06
  4 +module: module_usr
  5 +---
  6 +
  7 +# Spec: REQ-USR-002 — 用户修改
  8 +
  9 +## 目标
  10 +
  11 +实现后端 `PUT /api/users/{id}` 接口:在不破坏唯一性的前提下,更新已有用户的可编辑字段(含权限组关联),返回最新 UserVO(不含 sPasswordHash)。
  12 +
  13 +## 输入 / 触发
  14 +
  15 +**接口**:`PUT /api/users/{id}`,Content-Type `application/json`。`{id}` = `tUser.iIncrement`。
  16 +
  17 +**Request body**(`UserUpdateDTO`)字段——与 REQ-USR-001 输入相比**剥除 `sUserNo` / `sUserName`**(不可改,登录身份固定);其余字段均可修改:
  18 +
  19 +| 字段 | 类型 | 必填 | 校验 / 取值 | 行为 |
  20 +|---|---|---|---|---|
  21 +| `iStaffId` | Integer | 否 | 必须指向存在且未软删除的 `tStaff.iIncrement`;显式 `null` 表示清空员工关联 | 覆盖 |
  22 +| `sUserType` | String | 是 | 枚举:`普通用户` / `超级管理员` | 覆盖 |
  23 +| `sLanguage` | String | 是 | 枚举:`zh` / `en` / `zh-TW` | 覆盖 |
  24 +| `bCanModifyDocs` | Boolean | 否 | `null` 保持原值;显式覆盖 | 部分更新 |
  25 +| `permissionCategoryIds` | List<Integer> | 否 | 每元素必须存在且未软删除;可空数组(清空所有授权) | 重建关联(先删后插,幂等) |
  26 +
  27 +> **不在 DTO 中**:`sUserNo`(用户号唯一不可改)、`sUserName`(登录账号唯一不可改)、`sPasswordHash`(密码不通过本接口修改)。Jackson 默认忽略未知字段。
  28 +> 前端 UI 应把这些字段渲染为只读。
  29 +
  30 +**鉴权**:契约要求 `Authorization: Bearer <accessToken>` + `USR:UPDATE`。沿用 SecurityConfig permitAll;Controller Javadoc:`REQ-USR-004 完成后追加 @PreAuthorize("hasAuthority('USR:UPDATE')")`。
  31 +
  32 +## 输出 / 结果
  33 +
  34 +**HTTP 200,响应体**复用 REQ-USR-001 的 `UserVO`(10 个字段含 `permissionCategoryIds`),不暴露 sPasswordHash。
  35 +
  36 +```json
  37 +{
  38 + "code": 200,
  39 + "message": "操作成功",
  40 + "data": {
  41 + "iIncrement": 12,
  42 + "sUserNo": "u001",
  43 + "sUserName": "alice",
  44 + "iStaffId": 7,
  45 + "sUserType": "超级管理员",
  46 + "sLanguage": "en",
  47 + "bCanModifyDocs": true,
  48 + "tCreateDate": "2026-05-06T10:30:00",
  49 + "bDeleted": false,
  50 + "permissionCategoryIds": [1, 2]
  51 + },
  52 + "timestamp": 1746528600000
  53 +}
  54 +```
  55 +
  56 +## 业务规则
  57 +
  58 +1. **目标用户必须存在且未软删除**:`selectById(id)` 返回 null 或 `bDeleted=1` → `BizException(USR_NOT_FOUND)` (40431)。
  59 +2. **`sUserNo` / `sUserName` / `sPasswordHash` 不可改**:DTO 不接受这些字段;service 层读取目标行后保留这三个字段不变。
  60 +3. **iStaffId 校验**(仅当 dto.iStaffId 非空):`staffMapper.selectById(...)` 不存在或 `bDeleted=true` → `BizException(STAFF_NOT_FOUND)` (40421);显式 `null` 表示清空员工关联,不校验。
  61 +4. **权限分类校验**(仅当 dto.permissionCategoryIds 非空):每元素必须存在且未软删除(`selectBatchIds` 一次性查);任一不存在 → `BizException(PERM_CATEGORY_NOT_FOUND)` (40422)。
  62 +5. **权限组重建语义**:service 内**先删后插**——先 `userPermissionMapper.delete(eq(iUserId, {id}))` 清空目标用户所有现有关联,再按 `permissionCategoryIds` 顺序插入。空数组 / 不传则只删不插(清空授权)。
  63 +6. **`bCanModifyDocs` / `iStaffId` 部分更新**:DTO 中 `null` 时——
  64 + - `bCanModifyDocs == null` → 保持原值;
  65 + - `iStaffId == null` → **显式清空**为 NULL(与"未传"语义一致;DTO 字段语义本就是"目标值")。
  66 +7. **保留字段**:`iIncrement` / `sId` / `sBrandsId` / `sSubsidiaryId` / `tCreateDate` / `sCreatedBy` / `tLastLoginDate` / `bDeleted` / `tDeletedDate` / `sDeletedBy` 在本接口**不被修改**。
  67 +8. **审计**:本期 schema 未规划"最近修改时间 / 修改人"列,不维护。
  68 +9. **事务**:`@Transactional(rollbackFor = Exception.class)`,覆盖目标校验 → 父校验 → 权限校验 → user update → tUserPermission delete → tUserPermission insert,整体回滚。
  69 +
  70 +## 边界与约束
  71 +
  72 +### 鉴权策略
  73 +
  74 +沿用 module_mod / REQ-USR-001 SecurityConfig permitAll。
  75 +
  76 +### 错误码映射
  77 +
  78 +| 场景 | 错误码 | ErrorCode 枚举 |
  79 +|---|---|---|
  80 +| 必填缺失 / 类型 / 长度 / 枚举非法 | 40010 | `PARAM_INVALID`(已存在) |
  81 +| `{id}` 用户不存在或已软删除 | 40431 | `USR_NOT_FOUND`(**新增**) |
  82 +| `iStaffId` 不存在 / 已删除 | 40421 | `STAFF_NOT_FOUND`(已存在) |
  83 +| `permissionCategoryIds` 任一不存在 / 已删除 | 40422 | `PERM_CATEGORY_NOT_FOUND`(已存在) |
  84 +| 服务端兜底 | 50000 | `INTERNAL_ERROR` |
  85 +
  86 +> docs/05 § REQ-USR-002 中列的 40331(角色变更权限)涉及操作员角色权限模型,需要 SecurityContext + RBAC 才能落地,**REQ-USR-004 后再补**;本 REQ 不实施。
  87 +
  88 +### iStaffId 的 NULL 写入
  89 +
  90 +借鉴 REQ-MOD-002 经验:MyBatis-Plus 默认 update 跳过 null 字段,需让 `iStaffId` 字段加 `FieldStrategy.IGNORED` 才能把 NULL 写入 SQL。本期在 `UserEntity#iStaffId` 上加 `@TableField(value="iStaffId", updateStrategy=FieldStrategy.IGNORED)`。
  91 +
  92 +> 风险与 REQ-MOD-002 相同:未来 partial update 路径会埋雷。本 REQ 走 load-then-modify 全量回填,安全。
  93 +
  94 +### 权限组重建的并发
  95 +
  96 +- "先删后插"在事务内是原子的;`uk_user_perm` 唯一约束兜底。
  97 +- 同一用户并发修改权限:MySQL 默认行级锁 + 事务隔离 → 后写覆盖(与 REQ-MOD-002 一致)。
  98 +
  99 +### 性能
  100 +
  101 +- `selectBatchIds` 单次 round-trip 校验 N 个权限分类。
  102 +- `delete + insert` 为 N+1 次 SQL,本期不优化。
  103 +
  104 +## 依赖的 schema 表 / 字段
  105 +
  106 +**写表**:`tUser`(主体字段更新)、`tUserPermission`(先删后插)
  107 +
  108 +**读表**:`tStaff`(iStaffId 校验)、`tPermissionCategory`(权限分类校验)
  109 +
  110 +| `tUser` 字段 | 行为 |
  111 +|---|---|
  112 +| `iIncrement` / `sUserNo` / `sUserName` / `sPasswordHash` / `tCreateDate` / `sCreatedBy` / `tLastLoginDate` / `sId` / 多租户 / `bDeleted` 三件套 | **不修改** |
  113 +| `iStaffId` | 入参覆盖(含 null 设根;需 `FieldStrategy.IGNORED`) |
  114 +| `sUserType` / `sLanguage` | 入参覆盖(必填) |
  115 +| `bCanModifyDocs` | 入参非 null 覆盖;null 保留 |
  116 +
  117 +`tUserPermission` 操作:先 `delete(eq(iUserId, {id}))`,再按 `permissionCategoryIds` 顺序 `insert`(每条 `iUserId={id}` / `iCategoryId=...` / `tCreateDate=now`,无 bSelected)。
  118 +
  119 +**索引利用**:`uk_user_no` / `uk_user_name`(不会触发,因为本接口不改这两列);`uk_user_perm`(兜底重复授权)。
  120 +
  121 +## 依赖的接口
  122 +
  123 +无(独立接口;REQ-USR-001 建立的体系完全复用)。
  124 +
  125 +## 验收标准
  126 +
  127 +### 功能正确性
  128 +
  129 +1. **正向 — 全字段更新**:先建一个用户(带 staff + 3 个权限),PUT 改 iStaffId(另一个 staff)+ sUserType + sLanguage + bCanModifyDocs + permissionCategoryIds(2 个新分类),返回 200;DB 中 user 字段更新;tUserPermission 替换为 2 条新关联(原 3 条已删)。
  130 +2. **正向 — 清空 iStaffId**:PUT 时显式 `iStaffId=null`,DB user.iStaffId 变 NULL(验证 IGNORED 策略生效)。
  131 +3. **正向 — 清空权限组**:PUT permissionCategoryIds=[],DB tUserPermission 清空(按 iUserId);返回 VO permissionCategoryIds=[]。
  132 +4. **正向 — 保留字段**:先记录原 sUserNo / sUserName / sPasswordHash / tCreateDate;PUT 后查 DB 这 4 个字段保持原值。
  133 +5. **正向 — 部分字段保留原值**:DTO 中 `bCanModifyDocs=null`,DB 保留原值(验证 NOT_NULL 策略生效)。
  134 +6. **目标不存在**:`PUT /api/users/999999`,返回 40431。
  135 +7. **目标已软删除**:先建 user 后置 bDeleted=1,PUT 返回 40431。
  136 +8. **必填缺失 / 枚举非法 / 长度超限**:返回 40010。
  137 +9. **iStaffId 不存在**:iStaffId=999999,返回 40421。
  138 +10. **iStaffId 已软删除**:返回 40421。
  139 +11. **permissionCategoryIds 任一不存在**:返回 40422;DB user 与 tUserPermission 都不变(事务回滚)。
  140 +12. **sUserNo / sUserName / sPasswordHash 字段被忽略**:客户端误传 `sUserNo="hijack"` / `sUserName="hijack"` / `sPasswordHash="$2a$10$xxx"`,DB 中这 3 个字段保持原值。
  141 +
  142 +### 接口契约一致性
  143 +
  144 +- 响应格式 `{code, message, data, timestamp}`。
  145 +- 错误码 200 / 40010 / 40421 / 40422 / 40431 / 50000。
  146 +- 不暴露 sPasswordHash;不回显堆栈。
  147 +
  148 +### 测试覆盖
  149 +
  150 +- **单元测试** `UserServiceImplTest` 追加(mock 5 个 mapper + PasswordEncoder):
  151 + - update_targetNotFound_throws40431
  152 + - update_targetSoftDeleted_throws40431
  153 + - update_staffNotFound_throws40421
  154 + - update_staffSoftDeleted_throws40421
  155 + - update_permissionCategoryNotFound_throws40422
  156 + - update_full_returnsVOWithUpdatedFields_andRebuildsPermissions(捕 ArgumentCaptor,断言 user 字段保留 sUserNo/sUserName/sPasswordHash + 新值字段;断言 userPermissionMapper.delete 调一次 + insert 调 N 次)
  157 + - update_partialNullBCanModifyDocs_keepsOriginal
  158 + - update_clearStaffId_setsToNull
  159 + - update_emptyPermissionCategoryIds_clearsAllAssociations(delete 调一次 + insert 不调)
  160 +
  161 +- **集成测试** `UserControllerIT` 追加:
  162 + - put_validUpdate_returns200_andDbReflects
  163 + - put_clearStaffId_setsNull
  164 + - put_emptyPermissionCategoryIds_clearsAssociations
  165 + - put_targetNotFound_returns40431
  166 + - put_staffNotFound_returns40421
  167 + - put_permissionCategoryNotFound_returns40422
  168 + - put_missingRequired_returns40010
  169 + - put_ignoresProtectedFields_doesNotChangeUserNoOrName(body 含 sUserNo / sUserName / sPasswordHash 但 DB 中这三列保持原值)
  170 +
  171 +### 代码与文档
  172 +
  173 +- `// REQ-USR-002` 注释贴在 Controller 方法、Service 方法、新增 ErrorCode `USR_NOT_FOUND`。
  174 +- 提交按 `feat(usr): <subject> REQ-USR-002`。