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;缺省表示权限分类不变 |
严禁字段:请求体含 username 或 password 字段直接返 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通过iEmployeeIdJOINsys_employee.sEmployeeName;未关联职员时省略 -
permissionCategoryIds是当前授权集合(增删后的最终状态);空数组表示无授权 -
updatedBy/updatedDate由本接口在更新时写入(sUpdatedBy/tUpdatedDate);GET 取 DB 现有值,可能为 null
成功 200 OK:Result<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(排除自身) |
业务规则
-
鉴权 / 角色守卫:复用 REQ-USR-002 的 JwtHandlerInterceptor +
@RequireSuperAdmin。两个接口都标@RequireSuperAdmin。 -
存在性校验:先查
sys_user.iIncrement = userId AND iIsDeleted ∈ {0,1}(包含作废用户,因为恢复启用允许);找不到 → 40401。 -
自我停用守卫(PUT 专属):
req.isDeleted == true && userId == LoginContext.current().userId()→ 40302。注意:恢复启用(isDeleted == false)即便针对自己也允许(不会有此场景,因为已登录用户必然非作废,但仍保留对称语义)。 -
userCode 唯一性(PUT 专属):仅当
req.userCode != null && !req.userCode.equals(currentUser.sUserCode)时才检查;用selectByUserCodeExcludingId(userCode, userId)排除自身。冲突 → 40902。 -
外键校验:employeeId(非 null)和 permissionCategoryIds(非 null)按 REQ-USR-002 同样的方式校验。
employeeId == null显式表示解除关联(DB 置 NULL),与字段缺省不同(缺省 = 不变)。 -
部分更新:只更新请求体中显式提供的字段;用 MyBatis-Plus
UpdateWrapper显式列出 set 项。sUpdatedBy = LoginContext.current().username()、tUpdatedDate = NOW()一定写。 -
作废即时生效:PUT 把
iIsDeleted=1写库后,该用户已签发的 token 在下一次请求时会被 JwtHandlerInterceptor 检测到 iIsDeleted=1 并返 40101(已由 REQ-USR-002 基础设施保证)。 - 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.json或JsonNullable第三方库(违反技术栈表)。采用如下约定:
- 请求体仅当字段存在且值非 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 详情
- admin token + 存在用户 → 200,UserDetailVo 完整(含 employeeName + permissionCategoryIds)
- admin token + 不存在 userId → 404 / 40401
- NORMAL token → 403 / 40301
- 无 Authorization → 401 / 40101
- 作废用户 → 200(详情查询包含作废用户;不过滤)
PUT 修改
- 修改 userCode + userType + language(admin token)→ 200,DB 已更新;sUpdatedBy=admin、tUpdatedDate 非空;其他字段不变
- 修改 employeeId 为另一个有效员工 → 200,DB 写新值
- 修改 employeeId=0 解除关联 → 200,DB 写 NULL
- 修改 employeeId=99999 不存在 → 400 / 40004
- 修改 isDeleted=true → 200,DB 写 1;该用户原 token 下一次请求返 40101
-
修改 permissionCategoryIds(差集增删):初始权限
[1,2],请求[2,3]→ 200,最终 DB 状态[2,3];分类 1 的行被 DELETE,分类 3 的行被 INSERT;分类 2 的行 iIncrement 保持不变(验证差集而非全量替换) - permissionCategoryIds 为空数组 → 200,最终授权清空
- permissionCategoryIds 含不存在 ID → 400 / 40004,事务回滚(DB 授权无变化)
- 修改 userCode 冲突(其他用户已用) → 409 / 40902
- 修改 userCode 等于自身原值 → 200,无 40902
- 试图停用自己(admin 用 admin token 改 admin 用户 isDeleted=true)→ 403 / 40302
- 请求体含 username 字段 → 400 / 40001
- 请求体含 password 字段 → 400 / 40001
- userId 不存在 → 404 / 40401
- NORMAL token 调用 PUT → 403 / 40301
-
空请求体
{}→ 200,仅写 sUpdatedBy / tUpdatedDate(其他字段不变)
PUT + 立即可观察
- 修改后调用 GET 详情 → 返回的字段反映 PUT 的写入值
- 作废用户尝试登录 → 401 / 40103(REQ-USR-001 既有路径,不在本 REQ 添加新测试,但成功路径验收应链路一致)