--- req_id: REQ-USR-001 date: 2026-05-06 module: module_usr --- # Spec: REQ-USR-001 — 用户新增 ## 目标 实现后端 `POST /api/users` 接口:录入新用户基本信息 + 员工关联(可选)+ 权限组关联,密码默认 `666666` 经 BCrypt 哈希后落库;返回 `iIncrement` + 用户 VO(不含密码哈希)。 ## 输入 / 触发 **接口**:`POST /api/users`,Content-Type `application/json`。 **Request body**(`UserCreateDTO`)字段: | 字段 | 类型 | 必填 | 校验 / 取值 | 落库列 | |---|---|---|---|---| | `sUserNo` | String | 是 | 长度 1-50;`bDeleted=0` 范围内系统内唯一 | `tUser.sUserNo` | | `sUserName` | String | 是 | 长度 1-50;`bDeleted=0` 范围内系统内唯一(登录账号) | `tUser.sUserName` | | `iStaffId` | Integer | 否 | 必须指向存在且未软删除的 `tStaff.iIncrement` | `tUser.iStaffId` | | `sUserType` | String | 是 | 枚举:`普通用户` / `超级管理员` | `tUser.sUserType` | | `sLanguage` | String | 是 | 枚举:`zh` / `en` / `zh-TW` | `tUser.sLanguage` | | `bCanModifyDocs` | Boolean | 否 | 默认 `false` | `tUser.bCanModifyDocs` | | `permissionCategoryIds` | List | 否 | 每个元素须指向存在且未软删除的 `tPermissionCategory.iIncrement`;可空数组(无授权) | 写入 `tUserPermission` 关联表 | **鉴权**:契约要求 `Authorization: Bearer ` + `USR:CREATE`。沿用 module_mod 的 SecurityConfig permitAll;Controller Javadoc:`REQ-USR-004 完成后追加 @PreAuthorize("hasAuthority('USR:CREATE')")`。 > **密码不在 DTO 里**:默认 `666666` 经 `org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder` 哈希后落库。BCrypt 已经在 spring-boot-starter-security 中包含,无需新增依赖。 ## 输出 / 结果 **HTTP 200,响应体**: ```json { "code": 200, "message": "操作成功", "data": { "iIncrement": 12, "sUserNo": "u001", "sUserName": "alice", "iStaffId": 7, "sUserType": "普通用户", "sLanguage": "zh", "bCanModifyDocs": false, "tCreateDate": "2026-05-06T10:30:00", "bDeleted": false, "permissionCategoryIds": [1, 2, 3] }, "timestamp": 1746528600000 } ``` 新建 VO `UserVO`:字段 `iIncrement` / `sUserNo` / `sUserName` / `iStaffId` / `sUserType` / `sLanguage` / `bCanModifyDocs` / `tCreateDate` / `bDeleted` / `permissionCategoryIds`(聚合自 tUserPermission)。 **不返回**:`sPasswordHash` / `sId` / `sBrandsId` / `sSubsidiaryId` / `sCreatedBy` / `tLastLoginDate` / `tDeletedDate` / `sDeletedBy`。 ## 业务规则 1. **唯一性**:`sUserNo` 与 `sUserName` 在 `bDeleted=0` 范围内系统内全局唯一。冲突 → `BizException(USR_USER_NAME_OR_NO_DUP)` (40921)。 2. **职员校验**:若 `iStaffId` 非空,必须 `selectById(iStaffId)` 存在且 `bDeleted=0`;不存在或已删 → `BizException(STAFF_NOT_FOUND)` (40421)。 3. **权限分类校验**:若 `permissionCategoryIds` 非空,每个 id 都要存在且未软删除(一次 `selectBatchIds` 一次性校验);任一不存在 → `BizException(PERM_CATEGORY_NOT_FOUND)` (40422)。 4. **密码哈希**:固定初始密码字符串 `"666666"` → `BCryptPasswordEncoder().encode("666666")` → 落 `tUser.sPasswordHash`。`BCryptPasswordEncoder` 注册为 Spring Bean(`PasswordConfig`)便于 REQ-USR-004 复用。 5. **关联表写入**:`tUserPermission` 按 `permissionCategoryIds` 逐条 `insert`(每条 `iUserId=新用户 id` / `iCategoryId=对应分类 id` / `tCreateDate=now`;docs/03 修订版无 bSelected 列,**关联记录存在即「已选」**)。 6. **新建记录初始状态**:`bDeleted=0`、`tDeletedDate=NULL`、`sDeletedBy=NULL`、`tCreateDate=LocalDateTime.now()`、`tLastLoginDate=NULL`、`sCreatedBy=NULL`(多租户/登录上下文未引入)、`sBrandsId=NULL`、`sSubsidiaryId=NULL`、`sId=NULL`。 7. **事务边界**:`@Transactional(rollbackFor = Exception.class)` 包住 用户校验 + 用户 insert + 权限关联批量 insert 三步;任一失败整体回滚。 8. **并发兜底**:DB 唯一索引 `uk_user_no` / `uk_user_name` 兜底唯一性;service 捕 `DuplicateKeyException` 映射为 `USR_USER_NAME_OR_NO_DUP`。 ## 边界与约束 ### 鉴权策略 沿用 module_mod 的 SecurityConfig permitAll。注释 `// REQ-USR-004 完成后追加 @PreAuthorize("hasAuthority('USR:CREATE')")`。 ### 错误码映射 | 场景 | 错误码 | ErrorCode 枚举 | |---|---|---| | 必填缺失 / 类型 / 长度 / 枚举非法 | 40010 | `PARAM_INVALID`(已存在) | | `iStaffId` 不存在 / 已删除 | 40421 | `STAFF_NOT_FOUND`(**新增**) | | `permissionCategoryIds` 任一不存在 / 已删除 | 40422 | `PERM_CATEGORY_NOT_FOUND`(**新增**) | | `sUserNo` / `sUserName` 唯一冲突 | 40921 | `USR_USER_NAME_OR_NO_DUP`(**新增**) | | 服务端兜底 | 50000 | `INTERNAL_ERROR` | > docs/05 § REQ-USR-001 中列的错误码 `40020` / `40921` / `40421` / `40422` —— 段位约定 `40020` 是 USR 模块的参数错(非 MOD 模块的 `40010`)。本 spec **统一沿用 `40010` 作为参数错(与 GlobalExceptionHandler 现有映射一致)**,避免在两套段位里折腾。docs/05 后续 sweep 时再统一对齐。 ### 性能 / 并发 - 单条用户 + 至多 N 条权限关联 insert,预期低并发。`uk_user_no` / `uk_user_name` 唯一约束兜底。 - `permissionCategoryIds` 批量校验用 `selectBatchIds` 单次 SQL,O(1) round-trip。 ### 字符集 / 长度 - utf8mb4,允许中文姓名 / 用户名(虽然多数业务侧用英文)。 - 长度超 schema 上限 → 视为参数错 40010。 ### 与 docs/04 § 1.4 / 3.5 一致性 - 异常走 GlobalExceptionHandler。 - BCryptPasswordEncoder bean 不硬编码 strength(默认 10),从 application.yml 读取 strength(暂留默认)。 ### 已知技术债 - **`sCreatedBy=NULL`**:REQ-USR-004 引入登录上下文后回填。 - **多租户字段 NULL**:与 module_mod 一致,REQ-USR-004 后由拦截器注入。 ## 依赖的 schema 表 / 字段 **写表**:`tUser`、`tUserPermission` **读表**:`tStaff`(关联校验 / 后续 REQ-USR-003 列表 join)、`tPermissionCategory`(关联校验 / 列表只读字典) | `tUser` 字段 | 落库逻辑 | |---|---| | `iIncrement` | DB AUTO_INCREMENT | | `sUserNo` / `sUserName` | 入参(必填,唯一) | | `iStaffId` | 入参(可选;FK 校验通过的 `tStaff.iIncrement`) | | `sUserType` / `sLanguage` | 入参(必填,枚举) | | `bCanModifyDocs` | 入参(可选,默认 false) | | `sPasswordHash` | BCrypt("666666") | | `tCreateDate` | LocalDateTime.now() | | `tLastLoginDate` / `sCreatedBy` / 多租户 / `sId` / `bDeleted` 三件套 | 见 § 业务规则 6 | | `tUserPermission` 字段 | 落库逻辑 | |---|---| | `iIncrement` | DB AUTO_INCREMENT | | `iUserId` | 新用户 iIncrement | | `iCategoryId` | dto.permissionCategoryIds[i] | | `tCreateDate` | LocalDateTime.now() | | `sCreatedBy` | NULL(REQ-USR-004 后回填) | | 多租户 / `sId` | NULL | **索引利用**: - `uk_user_no` / `uk_user_name`(UNIQUE):用户唯一性预检 + 兜底 - `uk_user_perm` (UNIQUE iUserId+iCategoryId):防重复授权(应用层不会触发,DB 兜底) **外键**: - `fk_user_staff`(tUser.iStaffId → tStaff.iIncrement):应用层先查再 insert,避免直接抛 SQL 完整性异常 - `fk_up_user`(tUserPermission.iUserId → tUser.iIncrement):CASCADE,本接口无需关心 - `fk_up_category`(tUserPermission.iCategoryId → tPermissionCategory.iIncrement):应用层先 selectBatchIds 再 insert ## 依赖的接口 无(本接口独立工作)。 REQ-USR-002 / 003 / 004 都会读 tUser,但不依赖本接口运行时 — 仅依赖本接口建立的数据。 ## 验收标准 ### 功能正确性 1. **正向 — 最小字段(无 staff、无权限)**:传入 sUserNo / sUserName / sUserType / sLanguage 必填,返回 200 + `data.iIncrement` + 默认 bCanModifyDocs=false / permissionCategoryIds=[];DB 中 sPasswordHash 为 BCrypt 哈希(不等于 "666666" 明文)。 2. **正向 — 含 staff + 权限**:传入合法 iStaffId + 3 个 permissionCategoryIds,返回 200;DB tUser.iStaffId 等于入参;tUserPermission 中存在 3 条关联(iUserId=新用户)。 3. **唯一性冲突 — sUserName**:先建一个 sUserName=alice 的用户,再用同 sUserName 提交,返回 40921。 4. **唯一性冲突 — sUserNo**:同上,sUserNo 冲突。 5. **iStaffId 不存在**:传入 iStaffId=999999,返回 40421。 6. **iStaffId 已软删除**:先建 staff 后置 bDeleted=1,再 POST,返回 40421。 7. **permissionCategoryIds 任一不存在**:传入 [1, 999999],返回 40422,且 DB 中 tUser 与 tUserPermission 都未写入(事务回滚)。 8. **必填缺失 / 枚举非法 / 长度超限**:返回 40010。 9. **空 permissionCategoryIds**:传 `[]` 或不传该字段,正向通过(无关联记录)。 10. **密码哈希不可逆**:直接读 DB sPasswordHash,断言以 `$2a$` 或 `$2b$` 开头(BCrypt 标准前缀),且不含 "666666" 明文。 11. **响应不暴露 sPasswordHash**:jsonPath `$.data.sPasswordHash` doesNotExist。 ### 接口契约一致性 - 响应格式 `{code, message, data, timestamp}`。 - 不回显堆栈。 ### 测试覆盖 - **单元测试** `UserServiceImplTest`:mock UserMapper / StaffMapper / PermissionCategoryMapper / UserPermissionMapper / BCryptPasswordEncoder - create_minimalFields_returnsVOWithBCryptHash - create_withStaffAndPermissions_writesAssociation - create_duplicateUserName_throws40921 - create_duplicateUserNo_throws40921 - create_staffNotFound_throws40421 - create_staffSoftDeleted_throws40421 - create_permissionCategoryNotFound_throws40422 - create_emptyPermissionCategoryIds_doesNotInsertAssociation - create_concurrentDuplicate_dupKeyException_mappedTo40921 - **集成测试** `UserControllerIT`: - post_minimalFields_returns200 - post_withStaffAndPermissions_returns200_andDbAssociated - post_duplicateUserName_returns40921 - post_staffNotFound_returns40421 - post_permissionCategoryNotFound_returns40422 - post_passwordHashedInDb_notPlaintext - post_responseExcludesSPasswordHash ### 代码与文档 - `// REQ-USR-001` 注释贴在 Controller / Service / 新增 ErrorCode / DTO / VO。 - 提交按 `feat(usr): REQ-USR-001` 规范。 - 不引入 docs/04 § 零 技术栈外的依赖(BCryptPasswordEncoder 已在 spring-boot-starter-security 中)。