--- req_id: REQ-USR-002 date: 2026-04-30 module: module_usr --- # Spec: REQ-USR-002 — 用户修改 ## 目标 在不破坏唯一性的前提下,更新已有用户的可编辑字段(`sUserNo` / `sUserName` / `iStaffId` / `sUserType` / `sLanguage` / `bCanModifyDocs`)+ 全量重建用户的权限组关联(`tUserPermission`)。`sPasswordHash`、`tCreateDate`、`sCreatedBy`、`sBrandsId` / `sSubsidiaryId`、软删除字段一律保留原值。 ## 输入 / 触发 ### HTTP 接口(docs/05 § REQ-USR-002) - Method / Path: `PUT /api/usr/users/{id}`(path `{id}` = `tUser.iIncrement`) - Auth: 必需(沿用 USR-001 stub:路径已在 SecurityConfig `/api/usr/**` permitAll,USR-004 闭环时统一改为 `hasAuthority('SUPER_ADMIN')`) ### 请求 DTO `UpdateUserDTO` | JSON 字段 | Java 类型 | 必填 | 校验 | 业务校验 | |---|---|---|---|---| | `sUserNo` | `String` | 是 | `@NotBlank @Size(max=50)` | 系统内唯一(依赖 `tUser.uk_user_no`);冲突 → `40020` | | `sUserName` | `String` | 是 | `@NotBlank @Size(max=50)` | 系统内唯一(依赖 `tUser.uk_user_name`);冲突 → `40020` | | `iStaffId` | `Integer` | 否 | — | 非 null 时必须命中存在且 `bDeleted=0` 的记录;不存在 / 已软删 → `40022`(沿用 USR-001 错误码语义;docs/05 § USR-002 未单列 40022,本实现复用 USR-001 已建立的语义) | | `sUserType` | `String` | 是 | `@NotBlank` | 必须在枚举 `[普通用户, 超级管理员]` 内;非法 → `40001` | | `sLanguage` | `String` | 是 | `@NotBlank` | 必须在枚举 `[zh, en, zh-TW]` 内;非法 → `40001` | | `bCanModifyDocs` | `Boolean` | 否 | — | 缺省 `false` | | `permissionCategoryIds` | `List` | 否 | — | 非空时所有 id 必须在 `tPermissionCategory` 中存在 + `bDeleted=0`;任一不合法 → `40023`(同 USR-001)。null / 空 list → 清空该用户权限组(删全部 tUserPermission) | > **`sPasswordHash` 显式从 DTO 中剔除**——API 契约声明该字段不可改;密码修改走独立接口(未来 REQ)。 ### 鉴权与上下文 JWT Filter 解析 token 写 `principal=sUserNo`;伪造 → `code=20001`;缺失 → permitAll 透传。`sCreatedBy` 在更新时**不修改**,无论是否携带 token。 ## 输出 / 结果 ### 成功响应 ```json { "code": 0, "msg": "ok", "data": { "iIncrement": 456 } } ``` ### 持久化效果 事务内三步: 1. UPDATE `tUser` SET `<可编辑列>` WHERE `iIncrement = {id}`(`sUserNo` / `sUserName` / `iStaffId` / `sUserType` / `sLanguage` / `bCanModifyDocs`) 2. DELETE FROM `tUserPermission` WHERE `iUserId = {id}`(清空旧关联) 3. 对 `permissionCategoryIds` 中的每个 id:INSERT `tUserPermission(iUserId={id}, iCategoryId=cid, sCreatedBy=同上, tCreateDate=NOW(), sBrandsId/sSubsidiaryId=配置)` | `tUser` 字段 | 更新策略 | |---|---| | `sUserNo` / `sUserName` / `iStaffId` / `sUserType` / `sLanguage` / `bCanModifyDocs` | DTO 透传 | | 其他字段(`sPasswordHash` / `sCreatedBy` / `tCreateDate` / `sBrandsId` / `sSubsidiaryId` / `tLastLoginDate` / `bDeleted` / `tDeletedDate` / `sDeletedBy` / `sId`) | **不更新**(entity 上对应字段保持 null,依赖 MyBatis-Plus 默认 `FieldStrategy.NOT_NULL` 跳过 null 字段) | > 复用 MOD-002 / USR-001 已建立的"NOT_NULL 跳过 null 不动其他字段"策略;`bCanModifyDocs` DTO null 时 service 先展开为 `false` 再赋值。 ## 业务规则 1. **目标存在性**:`SELECT * FROM tUser WHERE iIncrement = {id}`;行不存在 **或** `bDeleted=true` → `BizException(40400, "用户不存在或已删除")`。 2. **枚举校验**:`sUserType` / `sLanguage` 复用 USR-001 的 `Set.contains` 校验;非法 → `BizException(40001, "<字段>: 取值非法")`。 3. **iStaffId 校验**(非 null 时):`staffMapper.existsActiveById(iStaffId) == false` → `BizException(40022, "职员不存在或已删除")`。 4. **permissionCategoryIds 校验**(非空时):`permissionCategoryMapper.countActiveByIds(ids) != ids.size()` → `BizException(40023, "权限分类含无效 id")`。 5. **唯一冲突**:依赖 DB 唯一索引兜底;`userMapper.updateById` 抛 `DuplicateKeyException` → `BizException(40020, "用户号或用户名已存在")`。 6. **重建权限组**:先 `userPermissionMapper.deleteByUserId(id)` 全量删除,再 for-loop 插入新关联(即使 `permissionCategoryIds` 为空 / null 也执行删除,等价"清空")。 7. **事务**:`@Transactional(rollbackFor = Exception.class)` 包"5 类校验 + UPDATE user + DELETE permission + INSERT permission × N",任一步失败回滚。 ## 边界与约束 - **必填项缺失** → `40001` - **`sUserType` / `sLanguage` 非枚举** → `40001` - **`sUserNo` / `sUserName` 唯一冲突** → `40020` - **目标 id 不存在 / 已软删** → `40400` - **iStaffId 不存在 / 已软删** → `40022` - **permissionCategoryIds 含无效 id** → `40023` - **JWT 伪造** → `20001` - **JWT 缺失** → permitAll stub - **`sPasswordHash` 不被覆盖**:DTO 不暴露字段;entity 上 sPasswordHash=null 由 NOT_NULL 跳过 ## 实现范围与边界抉择 1. **复用 USR-001 工程**:所有 mapper / entity / dto 已就位;本 REQ 仅在 `UserService` / `UserServiceImpl` / `UserController` 上做增量 + `UserPermissionMapper` 追加 `deleteByUserId`。 2. **错误码 40022 语义复用**:docs/05 § USR-002 错误码列表只列 40001/40020/40400,未单列 40022。本 spec 选择复用 USR-001 已建立的语义,与"同字段两个接口错误码一致"原则相符(同 MOD-002 复用 MOD-001 40010 的处理)。 3. **重建权限组策略**:选"先全删再插入"而非"diff 增量更新"——典型模块权限数 < 50,diff 实现复杂度收益不匹配。 4. **iStaffId 不强校验外键 SET NULL**:DB 已 `ON DELETE SET NULL`,service 提前校验存在性给更友好错误码。 ## 依赖的 schema 表 / 字段 写入: - `tUser`:6 个可编辑字段(其余依赖 NOT_NULL 跳过) - `tUserPermission`:`iIncrement` 自增 + 6 字段(先全删后批量插入) 读取(仅校验存在性): - `tUser`(selectById 校验目标) - `tStaff` / `tPermissionCategory`(同 USR-001) 依赖外键:`fk_user_staff: iStaffId → tStaff.iIncrement (ON DELETE SET NULL)` / `tUserPermission` 对 `tUser` 与 `tPermissionCategory` 的外键。 ## 依赖的接口 无(仅本 REQ 内部使用 USR-001 已实现的 mapper + 新增 `userPermissionMapper.deleteByUserId`)。 ## 验收标准 ### 单元测试(追加到 `UserServiceImplTest`) - [x] `updateWithValidDto_invokesUpdateById_andRebuildsPermissions` — Mock `selectById(10)=alive`、`existsActiveById`、`countActiveByIds`;ArgumentCaptor 抓 `userMapper.updateById` + `userPermissionMapper.deleteByUserId(10)` 调用一次 + `userPermissionMapper.insert × N`;断言传入 entity 的 `iIncrement=10` / 可编辑字段被透传 / `sPasswordHash` 等不可改字段为 null - [x] `updateWithTargetNotFound_throws40400` - [x] `updateWithTargetAlreadyDeleted_throws40400` - [x] `updateWithInvalidUserType_throws40001` - [x] `updateWithInvalidLanguage_throws40001` - [x] `updateWithStaffNotFound_throws40022` - [x] `updateWithSomeInvalidPermissionIds_throws40023` - [x] `updateWithDuplicateUserNo_throws40020` — Mock `userMapper.updateById` 抛 `DuplicateKeyException` - [x] `updateWithEmptyPermissionIds_clearsExisting` — `permissionCategoryIds=null` → `deleteByUserId` 调用一次,`insert` 永不调用 - [x] `updateWithBCanModifyDocsNull_setsFalseInEntity` ### Mapper IT(追加到 `UserMapperIT`) - [x] `userPermissionMapper#deleteByUserId_removesAllRowsForGivenUser` — 直插 user + 2 行 permission;调 `deleteByUserId` → tUserPermission 中该 user 的行 == 0;其他 user 不受影响 ### 集成测试(`UserControllerIT`,追加 8 用例) - [x] `putValidBody_with_jwt_returns200_andUpdates` — 直插 user + 2 行 permission;PUT 改 sUserName 同时改 permissionCategoryIds;DB 验:可编辑字段更新;sPasswordHash / sCreatedBy 保留原值;tUserPermission 行数 == 新 ids.size() - [x] `putNonExistentId_returns40400` - [x] `putAlreadyDeletedId_returns40400` - [x] `putInvalidUserType_returns40001` - [x] `putDuplicateUserNo_returns40020` — 先存在 user1(sUserNo=A) + user2(sUserNo=B);PUT user2 改 sUserNo=A → `code=40020` - [x] `putStaffNotFound_returns40022` - [x] `putPermissionCategoryNotFound_returns40023` - [x] `putWithEmptyPermissionIds_clearsAssociations` — 直插 user + 2 行 permission;PUT 不带 permissionCategoryIds;DB 查该 user 的 tUserPermission == 0;sPasswordHash 保留原值 - [x] `putWithoutJwt_permitAllStub_returns200_andDoesNotChangeCreatedBy` - [x] `putTamperedJwt_returns20001` ### 工程验收 - [x] `cd backend && mvn -B test` 全绿(89 + 新增 ≥ 19 = ≥ 108 用例) - [x] DB 中 `sPasswordHash` / `sCreatedBy` / `tCreateDate` 在 PUT 前后字面相同 - [x] tUserPermission 行集与请求 ids 完全等价(删旧 + 插新)