Commit 68ba20bc31df6f06ba7308fc4d345558c86244e3
1 parent
3a64246d
docs(usr): review approval REQ-USR-002
Showing
4 changed files
with
388 additions
and
1 deletions
docs/08-模块任务管理.md
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`。 | ... | ... |