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 <accessToken> + USR:UPDATE。沿用 SecurityConfig permitAll;Controller Javadoc:REQ-USR-004 完成后追加 @PreAuthorize("hasAuthority('USR:UPDATE')")。
输出 / 结果
HTTP 200,响应体复用 REQ-USR-001 的 UserVO(10 个字段含 permissionCategoryIds),不暴露 sPasswordHash。
{
"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
}
业务规则
-
目标用户必须存在且未软删除:
selectById(id)返回 null 或bDeleted=1→BizException(USR_NOT_FOUND)(40431)。 -
sUserNo/sUserName/sPasswordHash不可改:DTO 不接受这些字段;service 层读取目标行后保留这三个字段不变。 -
iStaffId 校验(仅当 dto.iStaffId 非空):
staffMapper.selectById(...)不存在或bDeleted=true→BizException(STAFF_NOT_FOUND)(40421);显式null表示清空员工关联,不校验。 -
权限分类校验(仅当 dto.permissionCategoryIds 非空):每元素必须存在且未软删除(
selectBatchIds一次性查);任一不存在 →BizException(PERM_CATEGORY_NOT_FOUND)(40422)。 -
权限组重建语义:service 内先删后插——先
userPermissionMapper.delete(eq(iUserId, {id}))清空目标用户所有现有关联,再按permissionCategoryIds顺序插入。空数组 / 不传则只删不插(清空授权)。 -
bCanModifyDocs/iStaffId部分更新:DTO 中null时——-
bCanModifyDocs == null→ 保持原值; -
iStaffId == null→ 显式清空为 NULL(与"未传"语义一致;DTO 字段语义本就是"目标值")。
-
-
保留字段:
iIncrement/sId/sBrandsId/sSubsidiaryId/tCreateDate/sCreatedBy/tLastLoginDate/bDeleted/tDeletedDate/sDeletedBy在本接口不被修改。 - 审计:本期 schema 未规划"最近修改时间 / 修改人"列,不维护。
-
事务:
@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 建立的体系完全复用)。
验收标准
功能正确性
- 正向 — 全字段更新:先建一个用户(带 staff + 3 个权限),PUT 改 iStaffId(另一个 staff)+ sUserType + sLanguage + bCanModifyDocs + permissionCategoryIds(2 个新分类),返回 200;DB 中 user 字段更新;tUserPermission 替换为 2 条新关联(原 3 条已删)。
-
正向 — 清空 iStaffId:PUT 时显式
iStaffId=null,DB user.iStaffId 变 NULL(验证 IGNORED 策略生效)。 - 正向 — 清空权限组:PUT permissionCategoryIds=[],DB tUserPermission 清空(按 iUserId);返回 VO permissionCategoryIds=[]。
- 正向 — 保留字段:先记录原 sUserNo / sUserName / sPasswordHash / tCreateDate;PUT 后查 DB 这 4 个字段保持原值。
-
正向 — 部分字段保留原值:DTO 中
bCanModifyDocs=null,DB 保留原值(验证 NOT_NULL 策略生效)。 -
目标不存在:
PUT /api/users/999999,返回 40431。 - 目标已软删除:先建 user 后置 bDeleted=1,PUT 返回 40431。
- 必填缺失 / 枚举非法 / 长度超限:返回 40010。
- iStaffId 不存在:iStaffId=999999,返回 40421。
- iStaffId 已软删除:返回 40421。
- permissionCategoryIds 任一不存在:返回 40422;DB user 与 tUserPermission 都不变(事务回滚)。
-
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 方法、新增 ErrorCodeUSR_NOT_FOUND。 - 提交按
feat(usr): <subject> REQ-USR-002。