--- req_id: REQ-USR-002 date: 2026-05-08 module: usr --- # Spec: REQ-USR-002 — 修改用户 ## 目标 超级管理员可通过 userId 定位已有用户,更新其用户类型、语言、单据修改权限、启/停用状态、关联员工和权限组,修改立即生效;用户名不可修改,密码由单独接口重置。 ## 输入 / 触发 HTTP 请求:`PUT /api/usr/users/{userId}`(需 Bearer Token 鉴权) Path 参数:`userId`(`usr_user.sId`,目标用户的业务 ID) Request Body(JSON): | 字段 | 类型 | 必填 | 说明 | |---|---|---|---| | userType | String | 是 | `普通用户` 或 `超级管理员` | | language | String | 是 | `中文` / `英文` / `繁体` | | canEditDoc | boolean | 是 | 是否有单据修改权限 | | isDisabled | boolean | 是 | true = 禁用账号 | | employeeId | String\|null | 否 | 关联职员 sId,null = 取消关联 | | permGroupIds | String[] | 是 | 权限组 sId 列表(空数组 = 清空所有权限) | ## 输出 / 结果 HTTP 200,`Result` ```json { "code": 200, "data": { "userId": "string", "username": "string", "updatedAt": "2026-05-08T10:00:00" } } ``` `updatedAt` 为服务端处理时的 `LocalDateTime.now()`,序列化为 ISO-8601。 ## 业务规则 1. **权限校验**:`principal.userType() != "超级管理员"` → 抛 BizException(40300),与 createUser 逻辑一致(来源:`UserServiceImpl.createUser`) 2. **用户存在性**:按 `sId = userId AND sBrandsId = brandId` 查 `usr_user`;找不到 → 抛 BizException(40400) 3. **自身角色保护**:`principal.userId().equals(userId)` 时,禁止将 `userType` 从 `超级管理员` 改为其他值 → 抛 BizException(40301)(防止唯一管理员自我降权导致系统锁死;即使目标 userType 已是普通用户也拦截,保持规则简单) 4. **employeeId 校验**:若 `employeeId != null`,需确认 `tStaff.sId = employeeId AND sBrandsId = brandId` 存在;不存在 → 抛 BizException(40001) 5. **更新字段**:仅更新 `sUserType / sLanguage / bCanEditDoc / bIsDisabled / sEmployeeId`;`sUsername / sUserCode / sPasswordHash / sCreatorUsername / tCreateDate` 不修改 6. **权限组 replace 策略**:先 `DELETE FROM usr_user_permission WHERE sUserId = userId AND sBrandsId = brandId`,再批量 INSERT 新的关联记录(与 createUser 的 permGroupIds 处理方式一致);permGroupIds 为空数组时仅删除,不插入 7. **多租户隔离**:所有查询和写入均带 `sBrandsId = principal.brandId()` ## 边界与约束 - 接口为写操作,需加 `@Transactional` - 鉴权失败返回 401(SecurityConfig 已配置) - `userType` / `language` 仅接受指定枚举值;违反 → 由 `@Valid` + `@NotNull` 在 DTO 层拦截返回 40001 - 密码不在此接口处理,`sPasswordHash` 保持原值不变 - 响应 JSON 不返回 sPasswordHash / iLoginFailCount / tLockUntil ## 依赖的 schema 表 / 字段 - `usr_user (别名 u)`:sId, sUserType, sLanguage, bCanEditDoc, bIsDisabled, sEmployeeId, sBrandsId(查找 + 更新) - `tStaff`:sId, sBrandsId(校验 employeeId 存在性) - `usr_permission_group`:仅前端下拉,不涉及后端写操作 - `usr_user_permission`:sUserId, sPermGroupId, sBrandsId, sId, tCreateDate(先删后插) ## 依赖的接口 - `POST /api/auth/login`(REQ-USR-004,获取 Bearer Token) - `GET /api/usr/users`(REQ-USR-003,获取列表后点击行触发修改抽屉) - `GET /api/usr/users/staffs`(REQ-USR-001,前端抽屉加载员工下拉) - `GET /api/usr/users/permission-groups`(REQ-USR-001,前端抽屉加载权限组列表) ## 验收标准 1. `PUT /api/usr/users/{validId}` Body 合法 → HTTP 200,返回 userId/username/updatedAt,数据库对应字段已更新 2. `userId` 不存在(或属于其他 brand)→ 返回 40400 3. 非超级管理员 Token 调用 → 返回 40300 4. 超级管理员修改自己的 userType → 返回 40301 5. `isDisabled=true` 修改后,目标用户再登录 → 返回 40101(账号已禁用) 6. permGroupIds 为空数组 → 该用户权限组被清空,下次查询 permission-groups 对应关联为空 7. permGroupIds 非空 → 权限组先删后插,关联记录与提交值一致 8. 无 Token → 返回 401 9. 前端:UserListPage 表格行有"修改"按钮,点击弹出预填修改抽屉;提交后关闭抽屉并刷新列表 ## 前端交互设计 **UserFormDrawer 改造(复用已有组件)**: - 新增 props:`userId?: string`,`initialData?: { userType, language, canEditDoc, isDisabled, employeeId, permGroupIds: string[] }` - 当 `userId` 存在时(edit 模式): - 抽屉标题改为 "修改用户" - 隐藏用户号和用户名 Form.Item(或显示为只读文本) - `open` 时用 `initialData` 初始化 form 和 selectedPermIds - 提交调 `updateUser(userId, req)` 而非 `createUser` - 当 `userId` 不存在时(create 模式):行为与现有完全一致 **UserListPage 改造**: - columns 末尾加"操作"列,包含"修改"按钮(PermButton,权限 `usr:edit`) - 点击"修改"按钮:将该行 `UserListItemVO` 转换为 `initialData`(permGroupIds 需额外从后端获取或在列表中推导——因列表不返回 permGroupIds,修改抽屉打开时无法预填权限组复选框,初始化为空数组即可,用户可重新勾选) - 新增状态:`editingUser: UserListItemVO | null`,控制修改抽屉开关 **新增 API 函数**(`frontend/src/api/usr.ts`): ```ts export interface UserUpdateReq { userType: string language: string canEditDoc: boolean isDisabled: boolean employeeId: string | null permGroupIds: string[] } export interface UserUpdateResp { userId: string username: string updatedAt: string } export function updateUser(userId: string, req: UserUpdateReq): Promise { return request.put(`/usr/users/${userId}`, req) } ```