--- req_id: REQ-USR-001 date: 2026-04-30 module: module_usr --- # Spec: REQ-USR-001 — 用户新增 ## 目标 录入新用户基本信息 + 初始化默认密码(`666666` 的 BCrypt 哈希)+ 同步建立用户与权限分类的多对多关联,返回新用户 `iIncrement` + `sUserNo`。 ## 输入 / 触发 ### HTTP 接口(docs/05 § REQ-USR-001) - Method / Path: `POST /api/usr/users` - Auth: 必需(沿用 MOD 模块 stub:本 REQ 在 SecurityConfig 加 `/api/usr/**` permitAll,USR-004 闭环时统一改为 `hasAuthority('SUPER_ADMIN')`) - Permission: 仅超级管理员(stub 期不强制) ### 请求 DTO `CreateUserDTO` | JSON 字段 | Java 类型 | 必填 | 校验 | 业务校验 | |---|---|---|---|---| | `sUserNo` | `String` | 是 | `@NotBlank @Size(max=50)` | 系统内唯一(依赖 `tUser.uk_user_no`);冲突 → `40020` | | `sUserName` | `String` | 是 | `@NotBlank @Size(max=50)` | 系统内唯一(依赖 `tUser.uk_user_name`);冲突 → `40020` | | `iStaffId` | `Integer` | 否 | — | 非 null 时必须命中 `tStaff` 中存在且 `bDeleted=0` 的记录;不存在 / 已软删 → `40022` | | `sUserType` | `String` | 是 | `@NotBlank` | 必须在枚举 `[普通用户, 超级管理员]` 内;非法 → `40001`(沿用 docs/05 错误码列表) | | `sLanguage` | `String` | 是 | `@NotBlank` | 必须在枚举 `[zh, en, zh-TW]`(代码值,前端做 i18n 标签映射)内;非法 → `40001` | | `bCanModifyDocs` | `Boolean` | 否 | — | 缺省 `false` | | `permissionCategoryIds` | `List` | 否 | — | 非空时所有 id 必须在 `tPermissionCategory` 中存在 + `bDeleted=0`;任一不合法 → `40023`。空 list / null → 不建关联 | ### 鉴权与上下文 JWT Filter 解析 token 写 `principal=sUserNo`;伪造 token → `code=20001`;缺失 token → permitAll 透传。`sCreatedBy` 取 `SecurityContextHelper.currentUserNo()`,匿名状态回退 `stubProps.stubUserNo`(与 MOD 模块同策略)。 ## 输出 / 结果 ### 成功响应 ```json { "code": 0, "msg": "ok", "data": { "iIncrement": 456, "sUserNo": "u001" } } ``` ### 持久化效果 事务内两步: 1. INSERT `tUser`:DTO 字段 + 标准列 + `sPasswordHash = bcrypt("666666")` + `tLastLoginDate=NULL` + `bDeleted=0` 2. 对 `permissionCategoryIds` 中的每个 id:INSERT `tUserPermission(iUserId=新用户.iIncrement, iCategoryId=id, sCreatedBy=同上, tCreateDate=NOW(), sBrandsId/sSubsidiaryId=配置默认)` | `tUser` 字段 | 来源 | |---|---| | `iIncrement` | DB 自增 | | `sId` | NULL | | `sBrandsId` / `sSubsidiaryId` | `TenantProperties`(XLY/XLY) | | `tCreateDate` | `LocalDateTime.now()` | | `sUserNo` / `sUserName` / `iStaffId` / `sUserType` / `sLanguage` / `bCanModifyDocs` | DTO 透传 | | `sPasswordHash` | `BCryptPasswordEncoder.encode("666666")`(每次 hash salt 不同) | | `tLastLoginDate` | NULL(USR-004 登录时更新) | | `sCreatedBy` | JWT principal 或 stub | | `bDeleted` | `0` | | `tDeletedDate` / `sDeletedBy` | NULL | ## 业务规则 1. **唯一性策略**:DB `uk_user_no` + `uk_user_name` 包含已软删行;本期不支持"软删后用户号复用"——若 sUserNo 历史上有过删除记录,再次创建会被 DB 唯一索引拒绝。docs/03 § tUser 业务注记说"应用层保证(仅约束未删除部分)"是设计意向,本 REQ 实现保持与 V1 schema 一致:依赖 DB 唯一索引兜底,service 层捕获 `DuplicateKeyException` → `BizException(40020,"用户号或用户名已存在")`,不再做"先查后插"二次校验(避免竞态)。 2. **iStaffId 校验**:非 null 时调 `staffMapper.existsActiveById(iStaffId)`;false → `BizException(40022,"职员不存在或已删除")`。 3. **permissionCategoryIds 校验**:非空时一次性查 `permissionCategoryMapper.countActiveByIds(ids)`,若返回数 != ids.size → `BizException(40023,"权限分类含无效 id")`。**避免 N+1**(一条 IN 查询)。 4. **sUserType / sLanguage 枚举**:service 入口处用 `Set.contains` 校验;非法 → `BizException(40001, "<字段名>: 取值非法")`。 5. **密码哈希**:使用 Spring Security 的 `BCryptPasswordEncoder`(已通过 starter-security 引入),强度默认 10。`@Bean BCryptPasswordEncoder` 在 `SecurityConfig` 注册(仅本 REQ 引入,避免循环依赖,参 § 实现范围抉择)。 6. **事务**:service 上 `@Transactional(rollbackFor = Exception.class)`,包"校验 + INSERT user + INSERT user_permission * N"。任一步骤失败回滚,不留残行。 7. **批量插入策略**:本 REQ 用 `for (Integer id : ids) userPermissionMapper.insert(rec)` 简单循环。permissionCategoryIds 典型 < 50,N+1 影响可接受。后续若性能瓶颈再改 batch INSERT。 ## 边界与约束 - **必填项缺失** → `40001` - **`sUserType` / `sLanguage` 非枚举** → `40001` - **`sUserNo` / `sUserName` 唯一冲突** → `40020` - **`iStaffId` 不存在 / 已软删** → `40022` - **`permissionCategoryIds` 含无效 id / 已软删** → `40023` - **JWT 伪造** → `20001` - **JWT 缺失** → permitAll stub(USR-004 后改 401) - **不返回 `sPasswordHash`**:响应 data 仅含 `iIncrement` + `sUserNo`,避免哈希泄漏 ## 实现范围与边界抉择 1. **复用 MOD 模块工程**:无新增 pom 依赖;`backend/src/main/java/com/xly/erp/module/usr/` 全新模块树,与 `module/mod/` 平行。 2. **SecurityConfig 路径扩展**:在现有 `/api/mod/**` permitAll 同位加 `/api/usr/**` permitAll,stub 注释保持 `// REQ-MOD-001 stub: see USR-004 follow-up`(USR-004 时整段一次性收紧)。 3. **`BCryptPasswordEncoder` 注册位置**:本 REQ 在 `SecurityConfig` 加 `@Bean BCryptPasswordEncoder`,service 通过构造器注入。USR-004 登录接口同样依赖此 bean,无重复定义。 4. **Staff / PermissionCategory 仅做存在性校验**:本 REQ 不建 `Staff` / `PermissionCategory` 完整 entity,仅建 `StaffMapper` / `PermissionCategoryMapper` 两个最小化接口(注解 SELECT 1 / SELECT COUNT)。后续 USR-002/003 真正用到完整字段时再补 entity。 5. **唯一冲突处理走 DB 索引兜底**:与 MOD-001 `sProcedureName` 风格一致;不做"先查后插"避免竞态。 6. **批量插入 `tUserPermission` 暂用循环**:本 REQ 数据量小,YAGNI;性能问题后续 REQ 出现时再优化。 7. **测试用 stub JWT 注入**:复用 `TestJwtHelper`,无需新建。 ## 依赖的 schema 表 / 字段 写入: - `tUser`:14 个字段(除 `tDeletedDate` / `sDeletedBy`) - `tUserPermission`:`iUserId` / `iCategoryId` / `sCreatedBy` / `tCreateDate` / `sBrandsId` / `sSubsidiaryId` 读取(仅校验存在性): - `tStaff`:`iIncrement` + `bDeleted` - `tPermissionCategory`:`iIncrement` + `bDeleted` 依赖索引:`tUser.uk_user_no` / `uk_user_name` 兜底唯一冲突;`tStaff.iIncrement` PK;`tPermissionCategory.iIncrement` PK。 外键:`fk_user_staff: iStaffId → tStaff.iIncrement (ON DELETE SET NULL)` 在 INSERT 时由 DB 兜底(service 提前校验给更友好错误码)。 ## 依赖的接口 无(USR-001 是用户域 CRUD 起点)。 ## 验收标准 ### 单元测试(`UserServiceImplTest`,Mockito) - [x] `createWithValidDto_persistsUser_andUserPermissions` — Mock mappers,ArgumentCaptor 抓 `userMapper.insert` + `userPermissionMapper.insert × N`;断言:`sBrandsId="XLY"` / `sCreatedBy="STUB_ADMIN"` / `tCreateDate != null` / `sPasswordHash` 是 BCrypt 格式(以 `$2a$` 或 `$2b$` 开头)/ N 条权限关联含正确 `iUserId` / `iCategoryId` - [x] `createWithoutPermissionCategoryIds_skipsUserPermissionInserts` — `permissionCategoryIds=null` / 空 list → `userPermissionMapper.insert` 永不调用 - [x] `createWithInvalidUserType_throws40001` - [x] `createWithInvalidLanguage_throws40001` - [x] `createWithStaffNotFound_throws40022` — `staffMapper.existsActiveById(...)=false` - [x] `createWithSomeInvalidPermissionIds_throws40023` — `permissionCategoryMapper.countActiveByIds([1,2,3])=2`,期望抛 40023;`userMapper.insert` 永不调用 - [x] `createWithDuplicateUserNo_throws40020` — `userMapper.insert` 抛 `DuplicateKeyException` - [x] `createUsesAuthenticatedUserNoAsCreatedBy` — SecurityContextHolder 注 "ALICE",断言 `sCreatedBy="ALICE"` ### Mapper IT(`UserMapperIT`,真实 DB) - [x] `insertAndSelectById_persistsAllStandardCols` — 构造 User 实例插入,`selectById` 比较;`sPasswordHash` 非空且 BCrypt 格式 - [x] `uniqueUserNoConstraint_rejectsDuplicate` — 插入两条同 sUserNo(不同 sUserName)→ 第二次 `DuplicateKeyException` ### Mapper IT(`StaffMapperIT` + `PermissionCategoryMapperIT`,最小化) - [x] `staffMapper#existsActiveById_handlesAliveDeletedMissing` - [x] `permissionCategoryMapper#countActiveByIds_returnsCorrectCount` ### 集成测试(`UserControllerIT`) - [x] `postValidBody_with_jwt_returns200_andPersists` — 直插一条职员 + 两条权限分类作为前置数据;POST 完整 body 带 JWT;`code=0` / `data.iIncrement>0` / `data.sUserNo` 等于请求;JdbcTemplate 验证 `tUser` + `tUserPermission` 行 - [x] `postEmptyBody_returns40001` - [x] `postInvalidUserType_returns40001` - [x] `postInvalidLanguage_returns40001` - [x] `postDuplicateUserNo_returns40020` - [x] `postStaffNotFound_returns40022` - [x] `postPermissionCategoryNotFound_returns40023` - [x] `postWithoutJwt_permitAllStub_returns200_andCreatedBySTUBADMIN` - [x] `postTamperedJwt_returns20001` ### 工程验收 - [x] `cd backend && mvn -B test` 全绿(67 + USR-001 新增 8(svc) + 4(mapperIT) + 9(controllerIT) = 88 用例) - [x] `BCryptPasswordEncoder` bean 在 `SecurityConfig` 注册,service 通过构造器注入 - [x] SecurityConfig 路径白名单含 `/api/usr/**` - [x] `// REQ-MOD-001 stub: see USR-004 follow-up` 锚点保持 - [x] `tUser.sPasswordHash` 在响应中**不**回显