--- req_id: REQ-USR-003 date: 2026-05-15 module: module_usr --- # Spec: REQ-USR-003 — 修改用户 ## 目标 超级管理员对已有用户的非密码、非用户名字段做部分更新(PATCH 语义),并支持增量增删权限分类授权。同时提供 GET 详情接口供前端表单回显(与修改入参 / 返回字段同源)。 ## 输入 / 触发 两个 HTTP 入口(均需 `Authorization: Bearer ` 且 `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`: ```json { "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 OK**:`Result` **失败**: | 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`(来自 `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 详情 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 修改 6. **修改 userCode + userType + language**(admin token)→ 200,DB 已更新;sUpdatedBy=admin、tUpdatedDate 非空;其他字段不变 7. **修改 employeeId 为另一个有效员工** → 200,DB 写新值 8. **修改 employeeId=0 解除关联** → 200,DB 写 NULL 9. **修改 employeeId=99999 不存在** → 400 / 40004 10. **修改 isDeleted=true** → 200,DB 写 1;该用户原 token 下一次请求返 40101 11. **修改 permissionCategoryIds(差集增删)**:初始权限 `[1,2]`,请求 `[2,3]` → 200,最终 DB 状态 `[2,3]`;分类 1 的行被 DELETE,分类 3 的行被 INSERT;分类 2 的行 iIncrement 保持不变(验证差集而非全量替换) 12. **permissionCategoryIds 为空数组** → 200,最终授权清空 13. **permissionCategoryIds 含不存在 ID** → 400 / 40004,事务回滚(DB 授权无变化) 14. **修改 userCode 冲突(其他用户已用)** → 409 / 40902 15. **修改 userCode 等于自身原值** → 200,无 40902 16. **试图停用自己**(admin 用 admin token 改 admin 用户 isDeleted=true)→ 403 / 40302 17. **请求体含 username 字段** → 400 / 40001 18. **请求体含 password 字段** → 400 / 40001 19. **userId 不存在** → 404 / 40401 20. **NORMAL token 调用 PUT** → 403 / 40301 21. **空请求体 `{}`** → 200,仅写 sUpdatedBy / tUpdatedDate(其他字段不变) ### PUT + 立即可观察 22. **修改后调用 GET 详情** → 返回的字段反映 PUT 的写入值 23. **作废用户尝试登录** → 401 / 40103(REQ-USR-001 既有路径,不在本 REQ 添加新测试,但成功路径验收应链路一致)