--- req_id: REQ-USR-001 date: 2026-05-06 spec_ref: docs/superpowers/specs/2026-05-06-REQ-USR-001.md --- # REQ-USR-001 用户新增 Implementation Plan > **Execution:** Parent skill `feature-tdd` executes this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** 实现 `POST /api/users`:录入用户基本信息 + 可选员工关联 + 权限组关联,密码 `666666` 经 BCrypt 哈希落库;返回 UserVO(不含哈希)。 **Architecture:** 复用 module_mod 已建立的 common / config / 异常 / Jackson / Security 体系。新建 4 个 entity + 4 个 mapper(tUser / tStaff / tPermissionCategory / tUserPermission),UserService 协调跨表写入并用 `@Transactional` 包裹整体一致性。BCryptPasswordEncoder 注册为 Spring bean 供 REQ-USR-004 复用。 **Tech Stack:** Spring Boot 3.2.5 + Spring Security 6(BCryptPasswordEncoder)+ MyBatis-Plus 3.5.7 + JUnit 5 + Mockito。 --- ## Schema 改动 无(V1 已建 5 张表 + FK + UNIQUE 索引)。 ## 文件变更清单 - 修改: `backend/src/main/java/com/xly/erp/common/response/ErrorCode.java` — 追加 3 个常量 - 创建: `backend/src/main/java/com/xly/erp/config/PasswordConfig.java` — BCryptPasswordEncoder bean - 创建: `backend/src/main/java/com/xly/erp/module/usr/entity/UserEntity.java` - 创建: `backend/src/main/java/com/xly/erp/module/usr/entity/StaffEntity.java` - 创建: `backend/src/main/java/com/xly/erp/module/usr/entity/PermissionCategoryEntity.java` - 创建: `backend/src/main/java/com/xly/erp/module/usr/entity/UserPermissionEntity.java` - 创建: `backend/src/main/java/com/xly/erp/module/usr/mapper/{UserMapper,StaffMapper,PermissionCategoryMapper,UserPermissionMapper}.java` - 创建: `backend/src/main/java/com/xly/erp/module/usr/dto/UserCreateDTO.java` - 创建: `backend/src/main/java/com/xly/erp/module/usr/vo/UserVO.java` - 创建: `backend/src/main/java/com/xly/erp/module/usr/service/UserService.java` - 创建: `backend/src/main/java/com/xly/erp/module/usr/service/impl/UserServiceImpl.java` - 创建: `backend/src/main/java/com/xly/erp/module/usr/controller/UserController.java` - 修改: `backend/src/test/java/com/xly/erp/common/response/ApiResponseTest.java` — 追加 3 个新错误码断言 - 创建: `backend/src/test/java/com/xly/erp/module/usr/dto/UserCreateDTOValidationTest.java` - 创建: `backend/src/test/java/com/xly/erp/module/usr/mapper/UsrMappersIT.java` — 4 张表 insert/select smoke test - 创建: `backend/src/test/java/com/xly/erp/module/usr/service/UserServiceImplTest.java` — 9 个 mock 单测 - 创建: `backend/src/test/java/com/xly/erp/module/usr/controller/UserControllerIT.java` — 7 个 MockMvc 集成测试 --- ## 任务步骤 ### Task 1: 错误码 + PasswordConfig **Files:** - Modify: `backend/src/main/java/com/xly/erp/common/response/ErrorCode.java` - Create: `backend/src/main/java/com/xly/erp/config/PasswordConfig.java` - Modify: `backend/src/test/java/com/xly/erp/common/response/ApiResponseTest.java` **API shape:** - 新增 ErrorCode 常量(注意:与 MOD 段位共享但枚举名不同): - `STAFF_NOT_FOUND(40421, "职员不存在或已删除")` - `PERM_CATEGORY_NOT_FOUND(40422, "权限分类不存在或已删除")` - `USR_USER_NAME_OR_NO_DUP(40921, "用户名或用户号已存在")` > 注:`MOD_NOT_FOUND(40421)` 与 `STAFF_NOT_FOUND(40421)` code 相同但枚举名不同——code 段位由 docs/05 全局错误码表定义,message 文案区分语义;此设计与 docs/04 § 1.3 错误码段位划分一致。 - `PasswordConfig` 提供 `@Bean PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); }` - [ ] **Step 1.1 写失败断言** - 在 `ApiResponseTest#errorCode_constantsMatchDocs05Spec` 末尾追加 3 行: ``` assertThat(ErrorCode.STAFF_NOT_FOUND.getCode()).isEqualTo(40421); assertThat(ErrorCode.PERM_CATEGORY_NOT_FOUND.getCode()).isEqualTo(40422); assertThat(ErrorCode.USR_USER_NAME_OR_NO_DUP.getCode()).isEqualTo(40921); ``` - 子会话: FAIL - [ ] **Step 1.2 实现 ErrorCode + PasswordConfig** - 子会话: PASS(ApiResponseTest 5/5;上下文重启 PasswordConfig bean 注入由后续 IT 验证) - [ ] **Step 1.3 提交** - `git commit -m "feat(common): error codes + PasswordConfig REQ-USR-001"` --- ### Task 2: 4 张表 entity + mapper + Mapper smoke IT **Files:** - Create: `backend/src/main/java/com/xly/erp/module/usr/entity/{UserEntity,StaffEntity,PermissionCategoryEntity,UserPermissionEntity}.java` - Create: `backend/src/main/java/com/xly/erp/module/usr/mapper/{UserMapper,StaffMapper,PermissionCategoryMapper,UserPermissionMapper}.java` - Test: `backend/src/test/java/com/xly/erp/module/usr/mapper/UsrMappersIT.java` **API shape**(每个 entity 严格按 docs/03 字段;`@TableField` 显式声明列名;保留匈牙利前缀;主键 `iIncrement` `IdType.AUTO`;与 `ModuleEntity` 同范式): | Entity | 表 | 关键字段(不含 5 个标准列) | |---|---|---| | `UserEntity` | tUser | sUserNo / sUserName / iStaffId / sUserType / sLanguage / bCanModifyDocs / sPasswordHash / tLastLoginDate / sCreatedBy / bDeleted / tDeletedDate / sDeletedBy | | `StaffEntity` | tStaff | sStaffNo / sStaffName / sDepartment / sCreatedBy / bDeleted / tDeletedDate / sDeletedBy | | `PermissionCategoryEntity` | tPermissionCategory | sCategoryCode / sCategoryName / iParentId / iSortOrder / sCreatedBy / bDeleted / tDeletedDate / sDeletedBy | | `UserPermissionEntity` | tUserPermission | iUserId / iCategoryId / sCreatedBy(docs/03 修订版无 bSelected 列) | 每个 mapper `extends BaseMapper`,无自定义 SQL。 > **注意**:spec § Service 实现依赖 `tUser.iStaffId` 设置为 NULL 时不应被 IGNORED 策略影响(参考 REQ-MOD-002 经验)。本期 UserEntity 字段 **不**给 iStaffId 加 `FieldStrategy.IGNORED`——因为 user create 走 insert(默认 NOT_NULL 策略对 insert 没问题:null 字段被跳过,DB 列默认 NULL),不会触发 update path 上的副作用。如未来 REQ-USR-002 需要 update 中清空 iStaffId,再按 REQ-MOD-003 经验用 `LambdaUpdateWrapper.set(...)` 处理。 - [ ] **Step 2.1 写失败 IT** - `UsrMappersIT#allFourMappers_insertAndSelect_smoke`:用 4 个 mapper 各 insert 一条最小字段记录(构造 entity → insert → selectById 断言字段往返) - `@SpringBootTest @ActiveProfiles("test") @Transactional @Rollback` - 子会话: FAIL(entity / mapper 不存在) - [ ] **Step 2.2 实现 4 entity + 4 mapper** - 子会话: PASS - [ ] **Step 2.3 提交** - `git commit -m "feat(usr): user/staff/permission/userPermission entities + mappers REQ-USR-001"` --- ### Task 3: UserCreateDTO + UserVO + Validation **Files:** - Create: `backend/src/main/java/com/xly/erp/module/usr/dto/UserCreateDTO.java` - Create: `backend/src/main/java/com/xly/erp/module/usr/vo/UserVO.java` - Test: `backend/src/test/java/com/xly/erp/module/usr/dto/UserCreateDTOValidationTest.java` **API shape:** `UserCreateDTO`: - `@NotBlank @Size(max=50) String sUserNo` - `@NotBlank @Size(max=50) String sUserName` - `Integer iStaffId`(可空) - `@NotBlank @Pattern(regexp="^(普通用户|超级管理员)$") String sUserType` - `@NotBlank @Pattern(regexp="^(zh|en|zh-TW)$") String sLanguage` - `Boolean bCanModifyDocs`(可空,service 层 default false) - `List permissionCategoryIds`(可空 / 空数组) `UserVO` 字段 10 个(spec § 输出列表;不含 sPasswordHash),含静态工厂 `from(UserEntity entity, List permissionCategoryIds)`。 - [ ] **Step 3.1 写失败测试(5 个)** - `UserCreateDTOValidationTest#allValidFields_yieldsNoViolations` - `UserCreateDTOValidationTest#blankRequiredFields_yieldsViolations`(4 个 @NotBlank) - `UserCreateDTOValidationTest#invalidUserTypeEnum_yieldsViolation` - `UserCreateDTOValidationTest#invalidLanguageEnum_yieldsViolation` - `UserCreateDTOValidationTest#overSizedFields_yieldsViolations` - 子会话: FAIL - [ ] **Step 3.2 实现 DTO + VO** - 子会话: PASS - [ ] **Step 3.3 提交** - `git commit -m "feat(usr): user create DTO and VO REQ-USR-001"` --- ### Task 4: UserService.create + Mockito 单元测试 **Files:** - Create: `backend/src/main/java/com/xly/erp/module/usr/service/UserService.java` - Create: `backend/src/main/java/com/xly/erp/module/usr/service/impl/UserServiceImpl.java` - Test: `backend/src/test/java/com/xly/erp/module/usr/service/UserServiceImplTest.java` **API shape:** - `interface UserService { UserVO create(UserCreateDTO dto); }` - `@Service @RequiredArgsConstructor class UserServiceImpl`,依赖 `UserMapper` / `StaffMapper` / `PermissionCategoryMapper` / `UserPermissionMapper` / `PasswordEncoder`。 - `create(dto)` 步骤(plan 锁定): 1. **唯一性预检**:`userMapper.selectCount(eq(sUserName, dto.sUserName).eq(bDeleted, false))` > 0 → `BizException(USR_USER_NAME_OR_NO_DUP)`;同理 sUserNo。 2. **iStaffId 校验**:dto.iStaffId 非空 → `staffMapper.selectById(...)`;null 或 bDeleted=true → `BizException(STAFF_NOT_FOUND)`。 3. **权限分类校验**:dto.permissionCategoryIds 非空 → `permissionCategoryMapper.selectBatchIds(ids)`;返回的 list 长度 < ids 长度 OR 任一 bDeleted=true → `BizException(PERM_CATEGORY_NOT_FOUND)`。 4. **构造 UserEntity**:复制 dto;`bCanModifyDocs` null → false;`sPasswordHash = passwordEncoder.encode("666666")`;`tCreateDate = now`;`bDeleted = false`;其他字段 null。 5. **Insert user**:`userMapper.insert(user)`,捕 `DuplicateKeyException` → `USR_USER_NAME_OR_NO_DUP`。MyBatis-Plus 回写 `iIncrement` 到 entity。 6. **批量 insert UserPermission**:dto.permissionCategoryIds 非空 → 循环逐条 `userPermissionMapper.insert(...)`(每条 `iUserId = user.iIncrement` / `iCategoryId = id` / `tCreateDate = now`;无 bSelected 列)。 7. **返回 VO**:`UserVO.from(user, dto.permissionCategoryIds 或 [])`。 - 标 `@Transactional(rollbackFor = Exception.class)` **初始密码常量**(写在 plan 锁定): ``` private static final String INITIAL_PASSWORD = "666666"; ``` 后续若策略变化,service 单点修改。 - [ ] **Step 4.1 写失败测试(9 个)** - `create_minimalFields_returnsVOWithBCryptHash`:mock 全部 selectCount=0 / passwordEncoder.encode → "$2a$bcrypt";insert 设 iIncrement;断言 VO + 断言传给 userMapper.insert 的 entity.sPasswordHash 等于 mock 返回值 - `create_withStaffAndPermissions_writesAssociation`:mock staff / batch ids 校验通过;断言 userPermissionMapper.insert 被调 N 次 + 每次 entity 字段 - `create_duplicateUserName_throws40921`:selectCount(sUserName)>0 - `create_duplicateUserNo_throws40921`:selectCount(sUserNo)>0 - `create_staffNotFound_throws40421`:staffMapper.selectById → null - `create_staffSoftDeleted_throws40421`:staff.bDeleted=true - `create_permissionCategoryNotFound_throws40422`:selectBatchIds 返回比 ids 短 - `create_emptyPermissionCategoryIds_doesNotInsertAssociation`:permissionCategoryIds=[],断言 userPermissionMapper.insert 从未被调 - `create_concurrentDuplicate_dupKeyException_mappedTo40921`:mock userMapper.insert 抛 DuplicateKeyException - 测试方式:`@ExtendWith(MockitoExtension.class)` + `ArgumentCaptor` / `ArgumentCaptor` - 子会话: FAIL - [ ] **Step 4.2 实现 UserService + Impl** - 子会话: PASS - [ ] **Step 4.3 提交** - `git commit -m "feat(usr): create user service REQ-USR-001"` --- ### Task 5: UserController + 端到端 IT **Files:** - Create: `backend/src/main/java/com/xly/erp/module/usr/controller/UserController.java` - Test: `backend/src/test/java/com/xly/erp/module/usr/controller/UserControllerIT.java` **API shape:** - `@RestController @RequestMapping("/api/users") @RequiredArgsConstructor class UserController` - `@PostMapping ApiResponse create(@Valid @RequestBody UserCreateDTO dto)` - Javadoc:`REQ-MOD-001 用户新增 — REQ-USR-004 完成后追加 @PreAuthorize("hasAuthority('USR:CREATE')")` - [ ] **Step 5.1 写失败测试(7 个)** - `post_minimalFields_returns200`:仅必填字段;断言 200 + data.sUserName + data.bCanModifyDocs=false + data.permissionCategoryIds=[] - `post_withStaffAndPermissions_returns200_andDbAssociated`:先 mapper.insert 一条 staff + 3 条 permissionCategory,POST 用户附 permissionCategoryIds;断言 DB tUserPermission 有 3 条匹配 - `post_duplicateUserName_returns40921`:先 POST 一次,再 POST 同 sUserName - `post_staffNotFound_returns40421`:iStaffId=999999 - `post_permissionCategoryNotFound_returns40422`:permissionCategoryIds=[999999] - `post_passwordHashedInDb_notPlaintext`:POST 后 selectById;断言 sPasswordHash 以 "$2a$" 或 "$2b$" 开头,且不含明文 "666666" - `post_responseExcludesSPasswordHash`:jsonPath `$.data.sPasswordHash` doesNotExist - 测试方式:`@SpringBootTest @AutoConfigureMockMvc @Transactional @Rollback` + `@Autowired UserMapper / StaffMapper / PermissionCategoryMapper / UserPermissionMapper` - 子会话: FAIL(端点不存在) - [ ] **Step 5.2 实现 UserController** - 子会话: PASS - [ ] **Step 5.3 跑全量 backend 测试** - `cd backend && mvn -B test` - 期望累计 76(module_mod 现有)+ 1(ApiResponse 错误码扩展) + 4(MapperIT) + 5(DTO Valid) + 9(service unit) + 7(controller IT) = 102 测试,全绿。 - [ ] **Step 5.4 提交** - `git commit -m "feat(usr): POST /api/users controller REQ-USR-001"` --- ## 提交计划 - `feat(common): error codes + PasswordConfig REQ-USR-001`(Task 1) - `feat(usr): user/staff/permission/userPermission entities + mappers REQ-USR-001`(Task 2) - `feat(usr): user create DTO and VO REQ-USR-001`(Task 3) - `feat(usr): create user service REQ-USR-001`(Task 4) - `feat(usr): POST /api/users controller REQ-USR-001`(Task 5)