--- 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`。