2026-05-15-REQ-USR-003.md 10.4 KB

req_id: REQ-USR-003 date: 2026-05-15

module: module_usr

Spec: REQ-USR-003 — 修改用户

目标

超级管理员对已有用户的非密码、非用户名字段做部分更新(PATCH 语义),并支持增量增删权限分类授权。同时提供 GET 详情接口供前端表单回显(与修改入参 / 返回字段同源)。

输入 / 触发

两个 HTTP 入口(均需 Authorization: Bearer <accessToken>userType=SUPER_ADMIN):

1. GET /api/v1/users/{userId} 用户详情

Path:userId: int(命中 sys_user.iIncrement,否则 40401)。

无请求体。

2. PUT /api/v1/users/{userId} 修改用户

Path:userId: int

请求体 UpdateUserReq(JSON,PATCH 语义;任一字段缺省视为不变):

字段 类型 必填 校验
userCode string 提供时 @Size(max=50) @NotBlank;若与其他用户的 sUserCode 冲突返 40902(当前用户自身的同值视为不变,跳过冲突判定)
userType string 提供时 NORMAL / SUPER_ADMIN
language string 提供时 zh-CN / en-US / zh-TW
canEditDocument boolean true / false
employeeId int 或 null 提供为非 null 时必须命中 sys_employee.iIncrement AND iIsDeleted=0,否则返 40004;显式传 null 表示解除关联,DB 写 NULL
isDeleted boolean true 表示作废 / false 表示恢复启用;尝试停用当前登录用户自己返 40302
permissionCategoryIds int[] 提供时按差集做增删;每个元素必须命中 sys_permission_category.iIncrement AND iIsDeleted=0,否则返 40004;缺省表示权限分类不变

严禁字段:请求体含 usernamepassword 字段直接返 40001(Jackson fail-on-unknown-properties=true 已在 REQ-USR-002 启用全局,缺省命中此防御)。

permissionCategoryIds 增删差集策略

  • 设当前已授权集合 current = {pcId1, pcId2, ...}(从 sys_user_permission_category WHERE iUserId=? 查)
  • 设请求集合 target = req.permissionCategoryIds
  • toRemove = current \ target → DELETE FROM sys_user_permission_category WHERE iUserId=? AND iPermissionCategoryId IN (toRemove)
  • toAdd = target \ current → INSERT 对应行,sGrantedBy = 当前登录 username
  • target ∩ current 项保持不动(iIncrement / tCreateDate 不变)

输出 / 结果

两个接口共用 UserDetailVo

{
  "userId": 42,
  "username": "alice",
  "userCode": "U001",
  "userType": "NORMAL",
  "language": "zh-CN",
  "canEditDocument": false,
  "isDeleted": false,
  "employeeId": 7,
  "employeeName": "张三",
  "permissionCategoryIds": [1, 2],
  "updatedBy": "admin",
  "updatedDate": "2026-05-15T09:30:00"
}
  • employeeName 通过 iEmployeeId JOIN sys_employee.sEmployeeName;未关联职员时省略
  • permissionCategoryIds 是当前授权集合(增删后的最终状态);空数组表示无授权
  • updatedBy / updatedDate 由本接口在更新时写入(sUpdatedBy / tUpdatedDate);GET 取 DB 现有值,可能为 null

成功 200 OKResult<UserDetailVo>

失败

HTTP code 含义 触发条件
400 40001 请求体格式错误 / 含未知字段(username/password) jakarta 校验 OR Jackson fail-on-unknown
400 40004 员工或权限分类不存在 employeeId / permissionCategoryIds 校验失败
401 40101 未携带或无效 Token 鉴权层
403 40301 非超级管理员调用 角色守卫
403 40302 试图停用当前登录用户自己 req.isDeleted == true && userId == LoginContext.userId()
404 40401 用户不存在 userId 不命中 sys_user.iIncrement
409 40902 用户号已被占用 提供的 userCode 命中其他用户的 sUserCode(排除自身)

业务规则

  1. 鉴权 / 角色守卫:复用 REQ-USR-002 的 JwtHandlerInterceptor + @RequireSuperAdmin。两个接口都标 @RequireSuperAdmin
  2. 存在性校验:先查 sys_user.iIncrement = userId AND iIsDeleted ∈ {0,1}(包含作废用户,因为恢复启用允许);找不到 → 40401。
  3. 自我停用守卫(PUT 专属):req.isDeleted == true && userId == LoginContext.current().userId() → 40302。注意:恢复启用(isDeleted == false)即便针对自己也允许(不会有此场景,因为已登录用户必然非作废,但仍保留对称语义)。
  4. userCode 唯一性(PUT 专属):仅当 req.userCode != null && !req.userCode.equals(currentUser.sUserCode) 时才检查;用 selectByUserCodeExcludingId(userCode, userId) 排除自身。冲突 → 40902。
  5. 外键校验:employeeId(非 null)和 permissionCategoryIds(非 null)按 REQ-USR-002 同样的方式校验。employeeId == null 显式表示解除关联(DB 置 NULL),与字段缺省不同(缺省 = 不变)。
  6. 部分更新:只更新请求体中显式提供的字段;用 MyBatis-Plus UpdateWrapper 显式列出 set 项。sUpdatedBy = LoginContext.current().username()tUpdatedDate = NOW() 一定写。
  7. 作废即时生效:PUT 把 iIsDeleted=1 写库后,该用户已签发的 token 在下一次请求时会被 JwtHandlerInterceptor 检测到 iIsDeleted=1 并返 40101(已由 REQ-USR-002 基础设施保证)。
  8. GET 详情:聚合 sys_user + sys_employee(JOIN)+ sys_user_permission_category(IN 查询)一次返回;不查询作废过滤之外的额外字段。

PATCH 语义实现细节

UpdateUserReq 用包装类型(Integer / Boolean / String / List)+ 自定义 JsonNode 检测"字段是否在 JSON 中出现"以区分"显式 null"与"缺省"。

具体方案:

  • userCode / userType / language / canEditDocument / isDeleted / permissionCategoryIds — 缺省 = 不变;提供(非 null)= 更新;不接受显式 null(@NotNull 在 service 层不强制,但缺省即视为 null 表示"不变")
  • employeeId — 三态:缺省(不变)/ 非 null 整数(更新)/ 显式 null(解除关联)。用 JsonNullable<Integer>(来自 openapi-generator 工具库)实现三态。最小可行替代:在 controller 层用 JsonNode 解析 employeeId 字段,传给 service 一个 EmployeeIdUpdate 三态枚举(UNCHANGED / SET(value) / UNSET)。

简化决策:本 REQ 不引入 jakarta.jsonJsonNullable 第三方库(违反技术栈表)。采用如下约定:

  • 请求体仅当字段存在且值非 null时才更新;字段完全缺省视为不变;字段显式 null 视为不变(即不区分缺省与显式 null)
  • 单独提供"清除关联"语义:employeeId == 0 视为解除关联(DB 写 NULL)。这是一个约定(非业界标准 PATCH),spec 必须明示

实现简化后:UpdateUserReq 所有字段都是普通可空包装类型;employeeId 取值规则:null / 缺省 → 不变;0 → 解除关联;正整数 → 更新到该 ID。

边界与约束

  • 基础设施复用:鉴权 / GlobalExceptionHandler / Result / BizException / LoginContext / JwtUtil / BCryptPasswordEncoder / SeederFixture 全部复用 REQ-USR-002
  • ErrorCode 新增USER_FORBIDDEN_SELF_DEACTIVATE = 40302(HTTP 403)、USER_NOT_FOUND = 40401(HTTP 404)。ErrorCode.toHttpStatus 已含 401/403/404 段位映射,本 REQ 不需要新增映射。
  • 不实现
    • 用户名 / 密码修改(推迟到独立 REQ)
    • 批量修改(YAGNI)
    • 修改历史审计表(推迟)
    • GET 列表(REQ-USR-004 范围)

依赖的 schema 表 / 字段

读 + 写 sys_user(V1 已建):

  • 读:iIncrement / sUsername / sUserCode / iEmployeeId / sUserType / sLanguage / iCanEditDocument / iIsDeleted / sCreatedBy / sUpdatedBy / tUpdatedDate / tCreateDate(详情查询需要)
  • 写:sUserCode / iEmployeeId / sUserType / sLanguage / iCanEditDocument / iIsDeleted / sUpdatedBy / tUpdatedDate

读 + 写 sys_user_permission_category(V1 已建):

  • 增删差集

只读 sys_employee(V1 已建):

  • 校验 employeeId 存在 + 取 sEmployeeName

只读 sys_permission_category(V1 已建):

  • countActiveByIds 校验

本 REQ 不需要新增 migration

依赖的接口

  • 本 REQ 提供:
    • GET /api/v1/users/{userId} — 用户详情
    • PUT /api/v1/users/{userId} — 修改用户
  • 前置依赖:JWT 由 REQ-USR-001 签发;JwtHandlerInterceptor 由 REQ-USR-002 提供

验收标准

后端集成测试:

GET 详情

  1. admin token + 存在用户 → 200,UserDetailVo 完整(含 employeeName + permissionCategoryIds)
  2. admin token + 不存在 userId → 404 / 40401
  3. NORMAL token → 403 / 40301
  4. 无 Authorization → 401 / 40101
  5. 作废用户 → 200(详情查询包含作废用户;不过滤)

PUT 修改

  1. 修改 userCode + userType + language(admin token)→ 200,DB 已更新;sUpdatedBy=admin、tUpdatedDate 非空;其他字段不变
  2. 修改 employeeId 为另一个有效员工 → 200,DB 写新值
  3. 修改 employeeId=0 解除关联 → 200,DB 写 NULL
  4. 修改 employeeId=99999 不存在 → 400 / 40004
  5. 修改 isDeleted=true → 200,DB 写 1;该用户原 token 下一次请求返 40101
  6. 修改 permissionCategoryIds(差集增删):初始权限 [1,2],请求 [2,3] → 200,最终 DB 状态 [2,3];分类 1 的行被 DELETE,分类 3 的行被 INSERT;分类 2 的行 iIncrement 保持不变(验证差集而非全量替换)
  7. permissionCategoryIds 为空数组 → 200,最终授权清空
  8. permissionCategoryIds 含不存在 ID → 400 / 40004,事务回滚(DB 授权无变化)
  9. 修改 userCode 冲突(其他用户已用) → 409 / 40902
  10. 修改 userCode 等于自身原值 → 200,无 40902
  11. 试图停用自己(admin 用 admin token 改 admin 用户 isDeleted=true)→ 403 / 40302
  12. 请求体含 username 字段 → 400 / 40001
  13. 请求体含 password 字段 → 400 / 40001
  14. userId 不存在 → 404 / 40401
  15. NORMAL token 调用 PUT → 403 / 40301
  16. 空请求体 {} → 200,仅写 sUpdatedBy / tUpdatedDate(其他字段不变)

PUT + 立即可观察

  1. 修改后调用 GET 详情 → 返回的字段反映 PUT 的写入值
  2. 作废用户尝试登录 → 401 / 40103(REQ-USR-001 既有路径,不在本 REQ 添加新测试,但成功路径验收应链路一致)