2026-04-30-REQ-USR-002.md
8.86 KB
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<Integer> |
否 | — | 非空时所有 id 必须在 tPermissionCategory 中存在 + bDeleted=0;任一不合法 → 40023(同 USR-001)。null / 空 list → 清空该用户权限组(删全部 tUserPermission) |
sPasswordHash显式从 DTO 中剔除——API 契约声明该字段不可改;密码修改走独立接口(未来 REQ)。
鉴权与上下文
JWT Filter 解析 token 写 principal=sUserNo;伪造 → code=20001;缺失 → permitAll 透传。sCreatedBy 在更新时不修改,无论是否携带 token。
输出 / 结果
成功响应
{ "code": 0, "msg": "ok", "data": { "iIncrement": 456 } }
持久化效果
事务内三步:
- UPDATE
tUserSET<可编辑列>WHEREiIncrement = {id}(sUserNo/sUserName/iStaffId/sUserType/sLanguage/bCanModifyDocs) - DELETE FROM
tUserPermissionWHEREiUserId = {id}(清空旧关联) - 对
permissionCategoryIds中的每个 id:INSERTtUserPermission(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 不动其他字段"策略;
bCanModifyDocsDTO null 时 service 先展开为false再赋值。
业务规则
-
目标存在性:
SELECT * FROM tUser WHERE iIncrement = {id};行不存在 或bDeleted=true→BizException(40400, "用户不存在或已删除")。 -
枚举校验:
sUserType/sLanguage复用 USR-001 的Set.contains校验;非法 →BizException(40001, "<字段>: 取值非法")。 -
iStaffId 校验(非 null 时):
staffMapper.existsActiveById(iStaffId) == false→BizException(40022, "职员不存在或已删除")。 -
permissionCategoryIds 校验(非空时):
permissionCategoryMapper.countActiveByIds(ids) != ids.size()→BizException(40023, "权限分类含无效 id")。 -
唯一冲突:依赖 DB 唯一索引兜底;
userMapper.updateById抛DuplicateKeyException→BizException(40020, "用户号或用户名已存在")。 -
重建权限组:先
userPermissionMapper.deleteByUserId(id)全量删除,再 for-loop 插入新关联(即使permissionCategoryIds为空 / null 也执行删除,等价"清空")。 -
事务:
@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 跳过
实现范围与边界抉择
-
复用 USR-001 工程:所有 mapper / entity / dto 已就位;本 REQ 仅在
UserService/UserServiceImpl/UserController上做增量 +UserPermissionMapper追加deleteByUserId。 - 错误码 40022 语义复用:docs/05 § USR-002 错误码列表只列 40001/40020/40400,未单列 40022。本 spec 选择复用 USR-001 已建立的语义,与"同字段两个接口错误码一致"原则相符(同 MOD-002 复用 MOD-001 40010 的处理)。
- 重建权限组策略:选"先全删再插入"而非"diff 增量更新"——典型模块权限数 < 50,diff 实现复杂度收益不匹配。
-
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)
-
updateWithValidDto_invokesUpdateById_andRebuildsPermissions— MockselectById(10)=alive、existsActiveById、countActiveByIds;ArgumentCaptor 抓userMapper.updateById+userPermissionMapper.deleteByUserId(10)调用一次 +userPermissionMapper.insert × N;断言传入 entity 的iIncrement=10/ 可编辑字段被透传 /sPasswordHash等不可改字段为 null -
updateWithTargetNotFound_throws40400 -
updateWithTargetAlreadyDeleted_throws40400 -
updateWithInvalidUserType_throws40001 -
updateWithInvalidLanguage_throws40001 -
updateWithStaffNotFound_throws40022 -
updateWithSomeInvalidPermissionIds_throws40023 -
updateWithDuplicateUserNo_throws40020— MockuserMapper.updateById抛DuplicateKeyException -
updateWithEmptyPermissionIds_clearsExisting—permissionCategoryIds=null→deleteByUserId调用一次,insert永不调用 -
updateWithBCanModifyDocsNull_setsFalseInEntity
Mapper IT(追加到 UserMapperIT)
-
userPermissionMapper#deleteByUserId_removesAllRowsForGivenUser— 直插 user + 2 行 permission;调deleteByUserId→ tUserPermission 中该 user 的行 == 0;其他 user 不受影响
集成测试(UserControllerIT,追加 8 用例)
-
putValidBody_with_jwt_returns200_andUpdates— 直插 user + 2 行 permission;PUT 改 sUserName 同时改 permissionCategoryIds;DB 验:可编辑字段更新;sPasswordHash / sCreatedBy 保留原值;tUserPermission 行数 == 新 ids.size() -
putNonExistentId_returns40400 -
putAlreadyDeletedId_returns40400 -
putInvalidUserType_returns40001 -
putDuplicateUserNo_returns40020— 先存在 user1(sUserNo=A) + user2(sUserNo=B);PUT user2 改 sUserNo=A →code=40020 -
putStaffNotFound_returns40022 -
putPermissionCategoryNotFound_returns40023 -
putWithEmptyPermissionIds_clearsAssociations— 直插 user + 2 行 permission;PUT 不带 permissionCategoryIds;DB 查该 user 的 tUserPermission == 0;sPasswordHash 保留原值 -
putWithoutJwt_permitAllStub_returns200_andDoesNotChangeCreatedBy -
putTamperedJwt_returns20001
工程验收
-
cd backend && mvn -B test全绿(89 + 新增 ≥ 19 = ≥ 108 用例) - DB 中
sPasswordHash/sCreatedBy/tCreateDate在 PUT 前后字面相同 - tUserPermission 行集与请求 ids 完全等价(删旧 + 插新)