From 520c01f2f4aac33744692faa9c218b063adbedcd Mon Sep 17 00:00:00 2001 From: zichun Date: Wed, 6 May 2026 21:22:27 +0800 Subject: [PATCH] fix(usr): 修复 review round 1 must-fix REQ-USR-001 --- docs/superpowers/plans/2026-05-06-REQ-USR-001.md | 236 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ docs/superpowers/reviews/2026-05-06-REQ-USR-001.md | 35 +++++++++++++++++++++++++++++++++++ docs/superpowers/specs/2026-05-06-REQ-USR-001.md | 197 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 468 insertions(+), 0 deletions(-) create mode 100644 docs/superpowers/plans/2026-05-06-REQ-USR-001.md create mode 100644 docs/superpowers/reviews/2026-05-06-REQ-USR-001.md create mode 100644 docs/superpowers/specs/2026-05-06-REQ-USR-001.md diff --git a/docs/superpowers/plans/2026-05-06-REQ-USR-001.md b/docs/superpowers/plans/2026-05-06-REQ-USR-001.md new file mode 100644 index 0000000..52d1251 --- /dev/null +++ b/docs/superpowers/plans/2026-05-06-REQ-USR-001.md @@ -0,0 +1,236 @@ +--- +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) diff --git a/docs/superpowers/reviews/2026-05-06-REQ-USR-001.md b/docs/superpowers/reviews/2026-05-06-REQ-USR-001.md new file mode 100644 index 0000000..e1968f3 --- /dev/null +++ b/docs/superpowers/reviews/2026-05-06-REQ-USR-001.md @@ -0,0 +1,35 @@ +--- +req_id: REQ-USR-001 +date: 2026-05-06 +round: 1 +reviewer: superpower-code-reviewer +--- + +# Review: REQ-USR-001 — round 1 + +## 结论 +request-changes + +## Must-fix + +- [high → 误判,已 verified] backend/src/main/java/com/xly/erp/module/usr/service/impl/UserServiceImpl.java:102 — reviewer 报告 `UserPermissionEntity insert 未设置 tCreateDate`,但本主会话用 `grep -n "setTCreateDate"` 确认 line 102 实际为 `up.setTCreateDate(LocalDateTime.now());`。reviewer 误读,**无需代码修复**;本 round fix commit 不涉及该项。 +- [medium] docs/superpowers/specs/2026-05-06-REQ-USR-001.md / docs/superpowers/plans/2026-05-06-REQ-USR-001.md — spec § 业务规则 5 + § 依赖的 schema 表 § tUserPermission 仍写 `bSelected=1`,但 docs/03 修订版已删除该列、UserPermissionEntity 也无该字段。需把 spec/plan 的 bSelected 提及改为 "—(关联存在即已选)",避免 REQ-USR-002 误回填。 + +## Nice-to-have + +- backend/src/main/java/com/xly/erp/common/response/ErrorCode.java — `MOD_NOT_FOUND(40421)` 与 `STAFF_NOT_FOUND(40421)` / `MOD_PARENT_LOOP(40921)` 与 `USR_USER_NAME_OR_NO_DUP(40921)` 共用同一数值。当前由枚举名+message 区分语义合理;建议在文件 Javadoc 写一条说明「同 code 不同枚举为合法重载(参考 docs/04 § 1.3 / docs/05 错误码表)」。 +- backend/src/main/java/com/xly/erp/module/usr/service/impl/UserServiceImpl.java:30 — `INITIAL_PASSWORD = "666666"` 硬编码。spec 已锁定此为业务常量;可改 `@Value("${xly.user.initial-password:666666}")` 注入便于环境覆盖(属软规则边缘,不阻塞)。 +- backend/src/main/java/com/xly/erp/module/usr/service/impl/UserServiceImpl.java:70 — `permissionCategoryIds` 含重复 id(如 [1,1,2])会导致 found.size() < ids.size() 误报 40422;可在校验前 distinct。 +- backend/src/main/java/com/xly/erp/module/usr/service/impl/UserServiceImpl.java:44 — 两次 selectCount 可合并为单次 OR 查询(低优)。 +- backend/src/test/java/com/xly/erp/module/usr/controller/UserControllerIT.java — IT 层缺 `post_staffSoftDeleted_returns40421`、`post_duplicateUserNo_returns40921` 两条用例(service 单测已覆盖)。建议补端到端镜像。 +- docs/superpowers/plans/2026-05-06-REQ-USR-001.md Task 5 API shape — 注释笔误 `REQ-MOD-001 用户新增`,实际 Controller.java 注释正确为 `REQ-USR-001`;plan 文档同步修正即可。 + +## 反例 / 测试覆盖缺口 + +1. spec § 验收 #6(iStaffId 已软删除)IT 层未覆盖(仅 service mock 单测)。 +2. spec § 验收 #4 sUserNo 唯一冲突 IT 层未直接覆盖(仅 sUserName 冲突的 IT)。 +3. spec § 业务规则 8 「DuplicateKeyException 端到端映射 40921」无 IT 触发路径(@Transactional 包裹下不易触发;service mock 已覆盖,gap 可接受)。 +4. docs/05 § REQ-USR-001 错误码段位 40020 与 spec 落地 40010 不一致(spec § 错误码映射注释提到 docs/05 后续 sweep;本 REQ 不阻塞)。 +5. docs/superpowers 文档残留 `bSelected` 概念,与代码实现脱节(must_fix #2)。 + +**round 1 修复范围**:仅修 must_fix #2(spec/plan 文档清洁),代码不动;下一 round verify 重跑全量后预期 approve。 diff --git a/docs/superpowers/specs/2026-05-06-REQ-USR-001.md b/docs/superpowers/specs/2026-05-06-REQ-USR-001.md new file mode 100644 index 0000000..ba59bda --- /dev/null +++ b/docs/superpowers/specs/2026-05-06-REQ-USR-001.md @@ -0,0 +1,197 @@ +--- +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 中)。 -- libgit2 0.22.2