2026-05-06-REQ-USR-002.md 9.49 KB

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 bodyUserUpdateDTO)字段——与 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
}

业务规则

  1. 目标用户必须存在且未软删除selectById(id) 返回 null 或 bDeleted=1BizException(USR_NOT_FOUND) (40431)。
  2. sUserNo / sUserName / sPasswordHash 不可改:DTO 不接受这些字段;service 层读取目标行后保留这三个字段不变。
  3. iStaffId 校验(仅当 dto.iStaffId 非空):staffMapper.selectById(...) 不存在或 bDeleted=trueBizException(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): <subject> REQ-USR-002