--- req_id: REQ-USR-002 date: 2026-05-15 module: module_usr --- # Spec: REQ-USR-002 — 新增用户 ## 目标 超级管理员通过 `POST /api/v1/users` 新建用户账号。账号立即生效;初始密码由系统统一设为 `666666`(哈希后存入);可选关联职员;同时按勾选写入权限分类授权关系。 ## 输入 / 触发 HTTP 入口 `POST /api/v1/users`。要求请求头 `Authorization: Bearer `。 **请求体 CreateUserReq**(JSON): | 字段 | 类型 | 必填 | 校验规则 | |---|---|---|---| | `username` | string | 是 | 非空;3-20 位字母数字下划线(正则 `^[A-Za-z0-9_]{3,20}$`);系统内唯一(命中 `sys_user.sUsername` 返 40901) | | `userCode` | string | 是 | 非空;最大 50;系统内唯一(命中 `sys_user.sUserCode` 返 40902) | | `userType` | string | 是 | 枚举 `NORMAL` / `SUPER_ADMIN` | | `language` | string | 是 | 枚举 `zh-CN` / `en-US` / `zh-TW` | | `canEditDocument` | boolean | 是 | true / false | | `employeeId` | int | 否 | 若不为 null,必须命中 `sys_employee.iIncrement` AND `iIsDeleted=0`,否则返 40004 | | `permissionCategoryIds` | int[] | 否 | 可为空数组或省略;每个元素必须命中 `sys_permission_category.iIncrement` AND `iIsDeleted=0`,否则返 40004 | > **本 REQ 不接受 `password` 字段**:用户提交的任何 password 字段被忽略(或返 40001)。docs/05 应同步删除 `password` 与 `40002` —— 本 REQ 落地时由实现方在 docs/05 一并修订(spec 自审范畴)。 ## 输出 / 结果 **成功 201 Created**:`Result` ```json { "code": 200, "message": "操作成功", "data": { "userId": 42, "username": "alice", "userCode": "U001" }, "timestamp": ... } ``` > HTTP 201 体现新资源创建;body 仍走统一 `Result` 包装(docs/04 § 1.3)。 副作用(同一事务): 1. `sys_user` 插入一条新记录: - `sPasswordHash` = `BCryptPasswordEncoder.encode("666666")` - `iFailedLoginCount` = 0、`tLockUntil` = NULL、`tLastLoginDate` = NULL - `iIsDeleted` = 0 - `sCreatedBy` = 当前登录用户 username(来自 JWT claim) 2. `sys_user_permission_category` 按 `permissionCategoryIds` 批量插入授权记录,`sGrantedBy` = 当前登录用户 username **失败**: | HTTP | code | 含义 | 触发条件 | |---|---|---|---| | 400 | 40001 | 必填字段缺失或格式错误 | jakarta 校验失败(@NotBlank / @Pattern / 枚举不合法);请求包含 `password` 字段(本 REQ 不允许) | | 400 | 40004 | 员工或权限分类不存在 | `employeeId` 或 `permissionCategoryIds` 中任一不命中 | | 401 | 40101 | 未携带或无效 Token | Authorization 缺失 / 解析失败 / 用户已作废 / 用户已锁定 | | 403 | 40301 | 非超级管理员调用 | JWT claim `userType != SUPER_ADMIN` | | 409 | 40901 | 用户名已存在 | sUsername 唯一冲突(DB 唯一键 `uk_sys_user_username` 兜底;service 层 select 预检以返友好错误) | | 409 | 40902 | 用户号已存在 | sUserCode 唯一冲突 | ## 业务规则 1. **鉴权**:手写 `JwtHandlerInterceptor` 实现 `HandlerInterceptor`,注册到 `WebMvcConfigurer`,匹配 `/api/v1/**` 但排除 `/api/v1/auth/login`: - 读 `Authorization: Bearer ` 头;缺失 → 抛 `BizException(40101, "未携带 token")` - `JwtUtil.parse(token)` 抛 BizException 时透传(JwtUtil 内部已返 40101) - 解析 claims → 查 `sys_user.iIncrement = sub`;若不存在 / `iIsDeleted=1` / `tLockUntil > NOW()` → 抛 `BizException(40101, "token 关联用户不可用")` - 把 username / userType / userId / companyCode 放入 `LoginContext`(ThreadLocal 工具)供后续使用 - 业务方法结束时(`afterCompletion`)清理 ThreadLocal 2. **角色守卫**:用自定义注解 `@RequireSuperAdmin`(标注在 controller 方法上)+ 同一 interceptor 检查 `handler instanceof HandlerMethod && method.isAnnotationPresent(...)` → 校验 `LoginContext.userType == SUPER_ADMIN`;不匹配 → 抛 `BizException(40301, "权限不足,仅超级管理员可调用")` 3. **唯一性校验**:在 service 写入前用 `selectByUsername` / `selectByUserCode` 查重(友好返 40901 / 40902);DB 唯一索引兜底(捕获 `DataIntegrityViolationException` 转 40901 或 40902,避免堆栈泄漏) 4. **外键校验**:employeeId / permissionCategoryIds 在写入前一次性 select 校验(SELECT iIncrement FROM ... WHERE iIncrement IN(...) AND iIsDeleted=0),数量不齐 → 40004 5. **初始密码**:固定字符串 `"666666"`,通过 `BCryptPasswordEncoder.encode` 哈希;常量定义在 `LoginServiceImpl.INITIAL_PASSWORD`(已存在)或新建 `UserCreateServiceImpl.INITIAL_PASSWORD` 6. **事务**:`@Transactional`,sys_user 插入 + sys_user_permission_category 批量插入在同一事务;唯一冲突或 FK 不存在均回滚 7. **本 REQ 不接受 password 字段**:DTO 不定义 password 属性;若客户端误传,Jackson 默认配置(`FAIL_ON_UNKNOWN_PROPERTIES=false`)会忽略——但为防御性,建议 `application.yml` 设 `spring.jackson.deserialization.fail-on-unknown-properties: true`,确保多余字段返 40001 ## 边界与约束 - **HTTPS / 鉴权 / 统一响应 / 异常**:复用 REQ-USR-001 已建的全部基础设施 - **HandlerInterceptor 位置**:放在 `backend/src/main/java/com/xly/erp/common/security/`,与 `JwtUtil` 同包 - **ThreadLocal 工具**:`LoginContext` 单例,提供 `current() / set() / clear()`;测试场景可手工 set 以模拟登录态 - **@RequireSuperAdmin 注解**:放在 `backend/src/main/java/com/xly/erp/common/security/` - **不实现**: - 修改密码 / 重置密码(推迟到后续 REQ) - 用户软删除接口(推迟到 REQ-USR-003 的"作废"路径) - 权限分类的新增 / 编辑(运营模块) - 员工的 CRUD(HR 模块) - JWT 黑名单 / refresh token / 多端互踢(推迟) - **docs/05 同步修订**:本 REQ 实现时需同步修订 docs/05 § REQ-USR-002,去掉 `password` 字段与 `40002` 错误码(与 REQ 卡片对齐为"系统生成初始密码") ## 依赖的 schema 表 / 字段 写 `sys_user`(V1 已建): - 写入:`sUsername`, `sUserCode`, `sPasswordHash`, `iEmployeeId`, `sUserType`, `sLanguage`, `iCanEditDocument`, `iIsDeleted=0`, `iFailedLoginCount=0`, `sCreatedBy` 写 `sys_user_permission_category`(V1 已建): - 写入:`iUserId`, `iPermissionCategoryId`, `sGrantedBy` 只读 `sys_employee`(V1 已建): - 读:`iIncrement`, `iIsDeleted`(校验 employeeId 存在) 只读 `sys_permission_category`(V1 已建): - 读:`iIncrement`, `iIsDeleted`(校验 permissionCategoryIds 全部存在) **本 REQ 不需要新增 migration**。 ## 依赖的接口 - 本 REQ 提供:`POST /api/v1/users` - 鉴权前置依赖:JWT 由 REQ-USR-001 签发 - 下游会消费但本 REQ 不实现的接口: - `GET /api/v1/employees`(员工下拉,后续 HR 模块) - `GET /api/v1/permission-categories`(权限分类下拉,后续运营模块) ## 验收标准 后端集成测试: 1. **正常路径(最小字段)**:管理员 token + 合法 username / userCode / userType / language / canEditDocument,不带 employeeId / permissionCategoryIds → 201;DB 出现新用户记录,`sPasswordHash` 非空且可被 `BCryptPasswordEncoder.matches("666666", ...)` 验证;`iIsDeleted=0`,`iFailedLoginCount=0`,`sCreatedBy` = 管理员 username 2. **正常路径(完整字段)**:带 employeeId + permissionCategoryIds(2 条)→ 201;DB sys_user 一行 + sys_user_permission_category 两行;`sGrantedBy` 正确 3. **新用户立刻可登录**:调用 `POST /api/v1/auth/login` 用初始密码 `666666` + 任意有效公司 → 200 + token 4. **缺 Authorization 头** → 401 / 40101 5. **Token 无效(篡改)** → 401 / 40101 6. **Token 关联用户已作废** → 401 / 40101 7. **NORMAL 用户 token 调用** → 403 / 40301 8. **username 重复** → 409 / 40901;DB 没有新用户被插入 9. **userCode 重复** → 409 / 40902;DB 没有新用户被插入 10. **employeeId 不存在 / 已软删** → 400 / 40004 11. **permissionCategoryIds 含不存在 ID** → 400 / 40004;事务回滚(sys_user 也未插入) 12. **请求体缺 username / username 含非法字符** → 400 / 40001 13. **请求体携带 password 字段** → 400 / 40001(Jackson 严格反序列化) 14. **userType / language 非枚举值** → 400 / 40001 15. **唯一索引兜底**:模拟绕过 service 层预检的并发场景(DataIntegrityViolationException 路径),断言返 40901 而非 50000 测试基础:复用 `LoginTestSeeder` + 扩展 `UserCreateTestSeeder`(管理员 token 注入 + 权限分类 fixture)。