Commit 520c01f2f4aac33744692faa9c218b063adbedcd
1 parent
8e0ddfdc
fix(usr): 修复 review round 1 must-fix REQ-USR-001
清理 spec/plan 中残留的 bSelected 字段提及——docs/03 修订版无该列, 关联记录存在即「已选」。代码 UserPermissionEntity 已正确不含该字段; 本 commit 仅清洁文档使 SSoT 一致。 reviewer round 1 报告的 high『tCreateDate 未设置』是误判: UserServiceImpl.java:102 实际已含 setTCreateDate(LocalDateTime.now()), 本 fix 不动代码。
Showing
3 changed files
with
468 additions
and
0 deletions
docs/superpowers/plans/2026-05-06-REQ-USR-001.md
0 → 100644
| 1 | +--- | ||
| 2 | +req_id: REQ-USR-001 | ||
| 3 | +date: 2026-05-06 | ||
| 4 | +spec_ref: docs/superpowers/specs/2026-05-06-REQ-USR-001.md | ||
| 5 | +--- | ||
| 6 | + | ||
| 7 | +# REQ-USR-001 用户新增 Implementation Plan | ||
| 8 | + | ||
| 9 | +> **Execution:** Parent skill `feature-tdd` executes this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. | ||
| 10 | + | ||
| 11 | +**Goal:** 实现 `POST /api/users`:录入用户基本信息 + 可选员工关联 + 权限组关联,密码 `666666` 经 BCrypt 哈希落库;返回 UserVO(不含哈希)。 | ||
| 12 | + | ||
| 13 | +**Architecture:** 复用 module_mod 已建立的 common / config / 异常 / Jackson / Security 体系。新建 4 个 entity + 4 个 mapper(tUser / tStaff / tPermissionCategory / tUserPermission),UserService 协调跨表写入并用 `@Transactional` 包裹整体一致性。BCryptPasswordEncoder 注册为 Spring bean 供 REQ-USR-004 复用。 | ||
| 14 | + | ||
| 15 | +**Tech Stack:** Spring Boot 3.2.5 + Spring Security 6(BCryptPasswordEncoder)+ MyBatis-Plus 3.5.7 + JUnit 5 + Mockito。 | ||
| 16 | + | ||
| 17 | +--- | ||
| 18 | + | ||
| 19 | +## Schema 改动 | ||
| 20 | + | ||
| 21 | +无(V1 已建 5 张表 + FK + UNIQUE 索引)。 | ||
| 22 | + | ||
| 23 | +## 文件变更清单 | ||
| 24 | + | ||
| 25 | +- 修改: `backend/src/main/java/com/xly/erp/common/response/ErrorCode.java` — 追加 3 个常量 | ||
| 26 | +- 创建: `backend/src/main/java/com/xly/erp/config/PasswordConfig.java` — BCryptPasswordEncoder bean | ||
| 27 | +- 创建: `backend/src/main/java/com/xly/erp/module/usr/entity/UserEntity.java` | ||
| 28 | +- 创建: `backend/src/main/java/com/xly/erp/module/usr/entity/StaffEntity.java` | ||
| 29 | +- 创建: `backend/src/main/java/com/xly/erp/module/usr/entity/PermissionCategoryEntity.java` | ||
| 30 | +- 创建: `backend/src/main/java/com/xly/erp/module/usr/entity/UserPermissionEntity.java` | ||
| 31 | +- 创建: `backend/src/main/java/com/xly/erp/module/usr/mapper/{UserMapper,StaffMapper,PermissionCategoryMapper,UserPermissionMapper}.java` | ||
| 32 | +- 创建: `backend/src/main/java/com/xly/erp/module/usr/dto/UserCreateDTO.java` | ||
| 33 | +- 创建: `backend/src/main/java/com/xly/erp/module/usr/vo/UserVO.java` | ||
| 34 | +- 创建: `backend/src/main/java/com/xly/erp/module/usr/service/UserService.java` | ||
| 35 | +- 创建: `backend/src/main/java/com/xly/erp/module/usr/service/impl/UserServiceImpl.java` | ||
| 36 | +- 创建: `backend/src/main/java/com/xly/erp/module/usr/controller/UserController.java` | ||
| 37 | +- 修改: `backend/src/test/java/com/xly/erp/common/response/ApiResponseTest.java` — 追加 3 个新错误码断言 | ||
| 38 | +- 创建: `backend/src/test/java/com/xly/erp/module/usr/dto/UserCreateDTOValidationTest.java` | ||
| 39 | +- 创建: `backend/src/test/java/com/xly/erp/module/usr/mapper/UsrMappersIT.java` — 4 张表 insert/select smoke test | ||
| 40 | +- 创建: `backend/src/test/java/com/xly/erp/module/usr/service/UserServiceImplTest.java` — 9 个 mock 单测 | ||
| 41 | +- 创建: `backend/src/test/java/com/xly/erp/module/usr/controller/UserControllerIT.java` — 7 个 MockMvc 集成测试 | ||
| 42 | + | ||
| 43 | +--- | ||
| 44 | + | ||
| 45 | +## 任务步骤 | ||
| 46 | + | ||
| 47 | +### Task 1: 错误码 + PasswordConfig | ||
| 48 | + | ||
| 49 | +**Files:** | ||
| 50 | +- Modify: `backend/src/main/java/com/xly/erp/common/response/ErrorCode.java` | ||
| 51 | +- Create: `backend/src/main/java/com/xly/erp/config/PasswordConfig.java` | ||
| 52 | +- Modify: `backend/src/test/java/com/xly/erp/common/response/ApiResponseTest.java` | ||
| 53 | + | ||
| 54 | +**API shape:** | ||
| 55 | +- 新增 ErrorCode 常量(注意:与 MOD 段位共享但枚举名不同): | ||
| 56 | + - `STAFF_NOT_FOUND(40421, "职员不存在或已删除")` | ||
| 57 | + - `PERM_CATEGORY_NOT_FOUND(40422, "权限分类不存在或已删除")` | ||
| 58 | + - `USR_USER_NAME_OR_NO_DUP(40921, "用户名或用户号已存在")` | ||
| 59 | + | ||
| 60 | + > 注:`MOD_NOT_FOUND(40421)` 与 `STAFF_NOT_FOUND(40421)` code 相同但枚举名不同——code 段位由 docs/05 全局错误码表定义,message 文案区分语义;此设计与 docs/04 § 1.3 错误码段位划分一致。 | ||
| 61 | +- `PasswordConfig` 提供 `@Bean PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); }` | ||
| 62 | + | ||
| 63 | +- [ ] **Step 1.1 写失败断言** | ||
| 64 | + - 在 `ApiResponseTest#errorCode_constantsMatchDocs05Spec` 末尾追加 3 行: | ||
| 65 | + ``` | ||
| 66 | + assertThat(ErrorCode.STAFF_NOT_FOUND.getCode()).isEqualTo(40421); | ||
| 67 | + assertThat(ErrorCode.PERM_CATEGORY_NOT_FOUND.getCode()).isEqualTo(40422); | ||
| 68 | + assertThat(ErrorCode.USR_USER_NAME_OR_NO_DUP.getCode()).isEqualTo(40921); | ||
| 69 | + ``` | ||
| 70 | + - 子会话: FAIL | ||
| 71 | + | ||
| 72 | +- [ ] **Step 1.2 实现 ErrorCode + PasswordConfig** | ||
| 73 | + - 子会话: PASS(ApiResponseTest 5/5;上下文重启 PasswordConfig bean 注入由后续 IT 验证) | ||
| 74 | + | ||
| 75 | +- [ ] **Step 1.3 提交** | ||
| 76 | + - `git commit -m "feat(common): error codes + PasswordConfig REQ-USR-001"` | ||
| 77 | + | ||
| 78 | +--- | ||
| 79 | + | ||
| 80 | +### Task 2: 4 张表 entity + mapper + Mapper smoke IT | ||
| 81 | + | ||
| 82 | +**Files:** | ||
| 83 | +- Create: `backend/src/main/java/com/xly/erp/module/usr/entity/{UserEntity,StaffEntity,PermissionCategoryEntity,UserPermissionEntity}.java` | ||
| 84 | +- Create: `backend/src/main/java/com/xly/erp/module/usr/mapper/{UserMapper,StaffMapper,PermissionCategoryMapper,UserPermissionMapper}.java` | ||
| 85 | +- Test: `backend/src/test/java/com/xly/erp/module/usr/mapper/UsrMappersIT.java` | ||
| 86 | + | ||
| 87 | +**API shape**(每个 entity 严格按 docs/03 字段;`@TableField` 显式声明列名;保留匈牙利前缀;主键 `iIncrement` `IdType.AUTO`;与 `ModuleEntity` 同范式): | ||
| 88 | + | ||
| 89 | +| Entity | 表 | 关键字段(不含 5 个标准列) | | ||
| 90 | +|---|---|---| | ||
| 91 | +| `UserEntity` | tUser | sUserNo / sUserName / iStaffId / sUserType / sLanguage / bCanModifyDocs / sPasswordHash / tLastLoginDate / sCreatedBy / bDeleted / tDeletedDate / sDeletedBy | | ||
| 92 | +| `StaffEntity` | tStaff | sStaffNo / sStaffName / sDepartment / sCreatedBy / bDeleted / tDeletedDate / sDeletedBy | | ||
| 93 | +| `PermissionCategoryEntity` | tPermissionCategory | sCategoryCode / sCategoryName / iParentId / iSortOrder / sCreatedBy / bDeleted / tDeletedDate / sDeletedBy | | ||
| 94 | +| `UserPermissionEntity` | tUserPermission | iUserId / iCategoryId / sCreatedBy(docs/03 修订版无 bSelected 列) | | ||
| 95 | + | ||
| 96 | +每个 mapper `extends BaseMapper<XxxEntity>`,无自定义 SQL。 | ||
| 97 | + | ||
| 98 | +> **注意**: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(...)` 处理。 | ||
| 99 | + | ||
| 100 | +- [ ] **Step 2.1 写失败 IT** | ||
| 101 | + - `UsrMappersIT#allFourMappers_insertAndSelect_smoke`:用 4 个 mapper 各 insert 一条最小字段记录(构造 entity → insert → selectById 断言字段往返) | ||
| 102 | + - `@SpringBootTest @ActiveProfiles("test") @Transactional @Rollback` | ||
| 103 | + - 子会话: FAIL(entity / mapper 不存在) | ||
| 104 | + | ||
| 105 | +- [ ] **Step 2.2 实现 4 entity + 4 mapper** | ||
| 106 | + - 子会话: PASS | ||
| 107 | + | ||
| 108 | +- [ ] **Step 2.3 提交** | ||
| 109 | + - `git commit -m "feat(usr): user/staff/permission/userPermission entities + mappers REQ-USR-001"` | ||
| 110 | + | ||
| 111 | +--- | ||
| 112 | + | ||
| 113 | +### Task 3: UserCreateDTO + UserVO + Validation | ||
| 114 | + | ||
| 115 | +**Files:** | ||
| 116 | +- Create: `backend/src/main/java/com/xly/erp/module/usr/dto/UserCreateDTO.java` | ||
| 117 | +- Create: `backend/src/main/java/com/xly/erp/module/usr/vo/UserVO.java` | ||
| 118 | +- Test: `backend/src/test/java/com/xly/erp/module/usr/dto/UserCreateDTOValidationTest.java` | ||
| 119 | + | ||
| 120 | +**API shape:** | ||
| 121 | + | ||
| 122 | +`UserCreateDTO`: | ||
| 123 | +- `@NotBlank @Size(max=50) String sUserNo` | ||
| 124 | +- `@NotBlank @Size(max=50) String sUserName` | ||
| 125 | +- `Integer iStaffId`(可空) | ||
| 126 | +- `@NotBlank @Pattern(regexp="^(普通用户|超级管理员)$") String sUserType` | ||
| 127 | +- `@NotBlank @Pattern(regexp="^(zh|en|zh-TW)$") String sLanguage` | ||
| 128 | +- `Boolean bCanModifyDocs`(可空,service 层 default false) | ||
| 129 | +- `List<Integer> permissionCategoryIds`(可空 / 空数组) | ||
| 130 | + | ||
| 131 | +`UserVO` 字段 10 个(spec § 输出列表;不含 sPasswordHash),含静态工厂 `from(UserEntity entity, List<Integer> permissionCategoryIds)`。 | ||
| 132 | + | ||
| 133 | +- [ ] **Step 3.1 写失败测试(5 个)** | ||
| 134 | + - `UserCreateDTOValidationTest#allValidFields_yieldsNoViolations` | ||
| 135 | + - `UserCreateDTOValidationTest#blankRequiredFields_yieldsViolations`(4 个 @NotBlank) | ||
| 136 | + - `UserCreateDTOValidationTest#invalidUserTypeEnum_yieldsViolation` | ||
| 137 | + - `UserCreateDTOValidationTest#invalidLanguageEnum_yieldsViolation` | ||
| 138 | + - `UserCreateDTOValidationTest#overSizedFields_yieldsViolations` | ||
| 139 | + - 子会话: FAIL | ||
| 140 | + | ||
| 141 | +- [ ] **Step 3.2 实现 DTO + VO** | ||
| 142 | + - 子会话: PASS | ||
| 143 | + | ||
| 144 | +- [ ] **Step 3.3 提交** | ||
| 145 | + - `git commit -m "feat(usr): user create DTO and VO REQ-USR-001"` | ||
| 146 | + | ||
| 147 | +--- | ||
| 148 | + | ||
| 149 | +### Task 4: UserService.create + Mockito 单元测试 | ||
| 150 | + | ||
| 151 | +**Files:** | ||
| 152 | +- Create: `backend/src/main/java/com/xly/erp/module/usr/service/UserService.java` | ||
| 153 | +- Create: `backend/src/main/java/com/xly/erp/module/usr/service/impl/UserServiceImpl.java` | ||
| 154 | +- Test: `backend/src/test/java/com/xly/erp/module/usr/service/UserServiceImplTest.java` | ||
| 155 | + | ||
| 156 | +**API shape:** | ||
| 157 | +- `interface UserService { UserVO create(UserCreateDTO dto); }` | ||
| 158 | +- `@Service @RequiredArgsConstructor class UserServiceImpl`,依赖 `UserMapper` / `StaffMapper` / `PermissionCategoryMapper` / `UserPermissionMapper` / `PasswordEncoder`。 | ||
| 159 | +- `create(dto)` 步骤(plan 锁定): | ||
| 160 | + 1. **唯一性预检**:`userMapper.selectCount(eq(sUserName, dto.sUserName).eq(bDeleted, false))` > 0 → `BizException(USR_USER_NAME_OR_NO_DUP)`;同理 sUserNo。 | ||
| 161 | + 2. **iStaffId 校验**:dto.iStaffId 非空 → `staffMapper.selectById(...)`;null 或 bDeleted=true → `BizException(STAFF_NOT_FOUND)`。 | ||
| 162 | + 3. **权限分类校验**:dto.permissionCategoryIds 非空 → `permissionCategoryMapper.selectBatchIds(ids)`;返回的 list 长度 < ids 长度 OR 任一 bDeleted=true → `BizException(PERM_CATEGORY_NOT_FOUND)`。 | ||
| 163 | + 4. **构造 UserEntity**:复制 dto;`bCanModifyDocs` null → false;`sPasswordHash = passwordEncoder.encode("666666")`;`tCreateDate = now`;`bDeleted = false`;其他字段 null。 | ||
| 164 | + 5. **Insert user**:`userMapper.insert(user)`,捕 `DuplicateKeyException` → `USR_USER_NAME_OR_NO_DUP`。MyBatis-Plus 回写 `iIncrement` 到 entity。 | ||
| 165 | + 6. **批量 insert UserPermission**:dto.permissionCategoryIds 非空 → 循环逐条 `userPermissionMapper.insert(...)`(每条 `iUserId = user.iIncrement` / `iCategoryId = id` / `tCreateDate = now`;无 bSelected 列)。 | ||
| 166 | + 7. **返回 VO**:`UserVO.from(user, dto.permissionCategoryIds 或 [])`。 | ||
| 167 | +- 标 `@Transactional(rollbackFor = Exception.class)` | ||
| 168 | + | ||
| 169 | +**初始密码常量**(写在 plan 锁定): | ||
| 170 | +``` | ||
| 171 | +private static final String INITIAL_PASSWORD = "666666"; | ||
| 172 | +``` | ||
| 173 | +后续若策略变化,service 单点修改。 | ||
| 174 | + | ||
| 175 | +- [ ] **Step 4.1 写失败测试(9 个)** | ||
| 176 | + - `create_minimalFields_returnsVOWithBCryptHash`:mock 全部 selectCount=0 / passwordEncoder.encode → "$2a$bcrypt";insert 设 iIncrement;断言 VO + 断言传给 userMapper.insert 的 entity.sPasswordHash 等于 mock 返回值 | ||
| 177 | + - `create_withStaffAndPermissions_writesAssociation`:mock staff / batch ids 校验通过;断言 userPermissionMapper.insert 被调 N 次 + 每次 entity 字段 | ||
| 178 | + - `create_duplicateUserName_throws40921`:selectCount(sUserName)>0 | ||
| 179 | + - `create_duplicateUserNo_throws40921`:selectCount(sUserNo)>0 | ||
| 180 | + - `create_staffNotFound_throws40421`:staffMapper.selectById → null | ||
| 181 | + - `create_staffSoftDeleted_throws40421`:staff.bDeleted=true | ||
| 182 | + - `create_permissionCategoryNotFound_throws40422`:selectBatchIds 返回比 ids 短 | ||
| 183 | + - `create_emptyPermissionCategoryIds_doesNotInsertAssociation`:permissionCategoryIds=[],断言 userPermissionMapper.insert 从未被调 | ||
| 184 | + - `create_concurrentDuplicate_dupKeyException_mappedTo40921`:mock userMapper.insert 抛 DuplicateKeyException | ||
| 185 | + - 测试方式:`@ExtendWith(MockitoExtension.class)` + `ArgumentCaptor<UserEntity>` / `ArgumentCaptor<UserPermissionEntity>` | ||
| 186 | + - 子会话: FAIL | ||
| 187 | + | ||
| 188 | +- [ ] **Step 4.2 实现 UserService + Impl** | ||
| 189 | + - 子会话: PASS | ||
| 190 | + | ||
| 191 | +- [ ] **Step 4.3 提交** | ||
| 192 | + - `git commit -m "feat(usr): create user service REQ-USR-001"` | ||
| 193 | + | ||
| 194 | +--- | ||
| 195 | + | ||
| 196 | +### Task 5: UserController + 端到端 IT | ||
| 197 | + | ||
| 198 | +**Files:** | ||
| 199 | +- Create: `backend/src/main/java/com/xly/erp/module/usr/controller/UserController.java` | ||
| 200 | +- Test: `backend/src/test/java/com/xly/erp/module/usr/controller/UserControllerIT.java` | ||
| 201 | + | ||
| 202 | +**API shape:** | ||
| 203 | +- `@RestController @RequestMapping("/api/users") @RequiredArgsConstructor class UserController` | ||
| 204 | +- `@PostMapping ApiResponse<UserVO> create(@Valid @RequestBody UserCreateDTO dto)` | ||
| 205 | +- Javadoc:`REQ-MOD-001 用户新增 — REQ-USR-004 完成后追加 @PreAuthorize("hasAuthority('USR:CREATE')")` | ||
| 206 | + | ||
| 207 | +- [ ] **Step 5.1 写失败测试(7 个)** | ||
| 208 | + - `post_minimalFields_returns200`:仅必填字段;断言 200 + data.sUserName + data.bCanModifyDocs=false + data.permissionCategoryIds=[] | ||
| 209 | + - `post_withStaffAndPermissions_returns200_andDbAssociated`:先 mapper.insert 一条 staff + 3 条 permissionCategory,POST 用户附 permissionCategoryIds;断言 DB tUserPermission 有 3 条匹配 | ||
| 210 | + - `post_duplicateUserName_returns40921`:先 POST 一次,再 POST 同 sUserName | ||
| 211 | + - `post_staffNotFound_returns40421`:iStaffId=999999 | ||
| 212 | + - `post_permissionCategoryNotFound_returns40422`:permissionCategoryIds=[999999] | ||
| 213 | + - `post_passwordHashedInDb_notPlaintext`:POST 后 selectById;断言 sPasswordHash 以 "$2a$" 或 "$2b$" 开头,且不含明文 "666666" | ||
| 214 | + - `post_responseExcludesSPasswordHash`:jsonPath `$.data.sPasswordHash` doesNotExist | ||
| 215 | + - 测试方式:`@SpringBootTest @AutoConfigureMockMvc @Transactional @Rollback` + `@Autowired UserMapper / StaffMapper / PermissionCategoryMapper / UserPermissionMapper` | ||
| 216 | + - 子会话: FAIL(端点不存在) | ||
| 217 | + | ||
| 218 | +- [ ] **Step 5.2 实现 UserController** | ||
| 219 | + - 子会话: PASS | ||
| 220 | + | ||
| 221 | +- [ ] **Step 5.3 跑全量 backend 测试** | ||
| 222 | + - `cd backend && mvn -B test` | ||
| 223 | + - 期望累计 76(module_mod 现有)+ 1(ApiResponse 错误码扩展) + 4(MapperIT) + 5(DTO Valid) + 9(service unit) + 7(controller IT) = 102 测试,全绿。 | ||
| 224 | + | ||
| 225 | +- [ ] **Step 5.4 提交** | ||
| 226 | + - `git commit -m "feat(usr): POST /api/users controller REQ-USR-001"` | ||
| 227 | + | ||
| 228 | +--- | ||
| 229 | + | ||
| 230 | +## 提交计划 | ||
| 231 | + | ||
| 232 | +- `feat(common): error codes + PasswordConfig REQ-USR-001`(Task 1) | ||
| 233 | +- `feat(usr): user/staff/permission/userPermission entities + mappers REQ-USR-001`(Task 2) | ||
| 234 | +- `feat(usr): user create DTO and VO REQ-USR-001`(Task 3) | ||
| 235 | +- `feat(usr): create user service REQ-USR-001`(Task 4) | ||
| 236 | +- `feat(usr): POST /api/users controller REQ-USR-001`(Task 5) |
docs/superpowers/reviews/2026-05-06-REQ-USR-001.md
0 → 100644
| 1 | +--- | ||
| 2 | +req_id: REQ-USR-001 | ||
| 3 | +date: 2026-05-06 | ||
| 4 | +round: 1 | ||
| 5 | +reviewer: superpower-code-reviewer | ||
| 6 | +--- | ||
| 7 | + | ||
| 8 | +# Review: REQ-USR-001 — round 1 | ||
| 9 | + | ||
| 10 | +## 结论 | ||
| 11 | +request-changes | ||
| 12 | + | ||
| 13 | +## Must-fix | ||
| 14 | + | ||
| 15 | +- [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 不涉及该项。 | ||
| 16 | +- [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 误回填。 | ||
| 17 | + | ||
| 18 | +## Nice-to-have | ||
| 19 | + | ||
| 20 | +- 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 错误码表)」。 | ||
| 21 | +- backend/src/main/java/com/xly/erp/module/usr/service/impl/UserServiceImpl.java:30 — `INITIAL_PASSWORD = "666666"` 硬编码。spec 已锁定此为业务常量;可改 `@Value("${xly.user.initial-password:666666}")` 注入便于环境覆盖(属软规则边缘,不阻塞)。 | ||
| 22 | +- 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。 | ||
| 23 | +- backend/src/main/java/com/xly/erp/module/usr/service/impl/UserServiceImpl.java:44 — 两次 selectCount 可合并为单次 OR 查询(低优)。 | ||
| 24 | +- backend/src/test/java/com/xly/erp/module/usr/controller/UserControllerIT.java — IT 层缺 `post_staffSoftDeleted_returns40421`、`post_duplicateUserNo_returns40921` 两条用例(service 单测已覆盖)。建议补端到端镜像。 | ||
| 25 | +- docs/superpowers/plans/2026-05-06-REQ-USR-001.md Task 5 API shape — 注释笔误 `REQ-MOD-001 用户新增`,实际 Controller.java 注释正确为 `REQ-USR-001`;plan 文档同步修正即可。 | ||
| 26 | + | ||
| 27 | +## 反例 / 测试覆盖缺口 | ||
| 28 | + | ||
| 29 | +1. spec § 验收 #6(iStaffId 已软删除)IT 层未覆盖(仅 service mock 单测)。 | ||
| 30 | +2. spec § 验收 #4 sUserNo 唯一冲突 IT 层未直接覆盖(仅 sUserName 冲突的 IT)。 | ||
| 31 | +3. spec § 业务规则 8 「DuplicateKeyException 端到端映射 40921」无 IT 触发路径(@Transactional 包裹下不易触发;service mock 已覆盖,gap 可接受)。 | ||
| 32 | +4. docs/05 § REQ-USR-001 错误码段位 40020 与 spec 落地 40010 不一致(spec § 错误码映射注释提到 docs/05 后续 sweep;本 REQ 不阻塞)。 | ||
| 33 | +5. docs/superpowers 文档残留 `bSelected` 概念,与代码实现脱节(must_fix #2)。 | ||
| 34 | + | ||
| 35 | +**round 1 修复范围**:仅修 must_fix #2(spec/plan 文档清洁),代码不动;下一 round verify 重跑全量后预期 approve。 |
docs/superpowers/specs/2026-05-06-REQ-USR-001.md
0 → 100644
| 1 | +--- | ||
| 2 | +req_id: REQ-USR-001 | ||
| 3 | +date: 2026-05-06 | ||
| 4 | +module: module_usr | ||
| 5 | +--- | ||
| 6 | + | ||
| 7 | +# Spec: REQ-USR-001 — 用户新增 | ||
| 8 | + | ||
| 9 | +## 目标 | ||
| 10 | + | ||
| 11 | +实现后端 `POST /api/users` 接口:录入新用户基本信息 + 员工关联(可选)+ 权限组关联,密码默认 `666666` 经 BCrypt 哈希后落库;返回 `iIncrement` + 用户 VO(不含密码哈希)。 | ||
| 12 | + | ||
| 13 | +## 输入 / 触发 | ||
| 14 | + | ||
| 15 | +**接口**:`POST /api/users`,Content-Type `application/json`。 | ||
| 16 | + | ||
| 17 | +**Request body**(`UserCreateDTO`)字段: | ||
| 18 | + | ||
| 19 | +| 字段 | 类型 | 必填 | 校验 / 取值 | 落库列 | | ||
| 20 | +|---|---|---|---|---| | ||
| 21 | +| `sUserNo` | String | 是 | 长度 1-50;`bDeleted=0` 范围内系统内唯一 | `tUser.sUserNo` | | ||
| 22 | +| `sUserName` | String | 是 | 长度 1-50;`bDeleted=0` 范围内系统内唯一(登录账号) | `tUser.sUserName` | | ||
| 23 | +| `iStaffId` | Integer | 否 | 必须指向存在且未软删除的 `tStaff.iIncrement` | `tUser.iStaffId` | | ||
| 24 | +| `sUserType` | String | 是 | 枚举:`普通用户` / `超级管理员` | `tUser.sUserType` | | ||
| 25 | +| `sLanguage` | String | 是 | 枚举:`zh` / `en` / `zh-TW` | `tUser.sLanguage` | | ||
| 26 | +| `bCanModifyDocs` | Boolean | 否 | 默认 `false` | `tUser.bCanModifyDocs` | | ||
| 27 | +| `permissionCategoryIds` | List<Integer> | 否 | 每个元素须指向存在且未软删除的 `tPermissionCategory.iIncrement`;可空数组(无授权) | 写入 `tUserPermission` 关联表 | | ||
| 28 | + | ||
| 29 | +**鉴权**:契约要求 `Authorization: Bearer <accessToken>` + `USR:CREATE`。沿用 module_mod 的 SecurityConfig permitAll;Controller Javadoc:`REQ-USR-004 完成后追加 @PreAuthorize("hasAuthority('USR:CREATE')")`。 | ||
| 30 | + | ||
| 31 | +> **密码不在 DTO 里**:默认 `666666` 经 `org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder` 哈希后落库。BCrypt 已经在 spring-boot-starter-security 中包含,无需新增依赖。 | ||
| 32 | + | ||
| 33 | +## 输出 / 结果 | ||
| 34 | + | ||
| 35 | +**HTTP 200,响应体**: | ||
| 36 | + | ||
| 37 | +```json | ||
| 38 | +{ | ||
| 39 | + "code": 200, | ||
| 40 | + "message": "操作成功", | ||
| 41 | + "data": { | ||
| 42 | + "iIncrement": 12, | ||
| 43 | + "sUserNo": "u001", | ||
| 44 | + "sUserName": "alice", | ||
| 45 | + "iStaffId": 7, | ||
| 46 | + "sUserType": "普通用户", | ||
| 47 | + "sLanguage": "zh", | ||
| 48 | + "bCanModifyDocs": false, | ||
| 49 | + "tCreateDate": "2026-05-06T10:30:00", | ||
| 50 | + "bDeleted": false, | ||
| 51 | + "permissionCategoryIds": [1, 2, 3] | ||
| 52 | + }, | ||
| 53 | + "timestamp": 1746528600000 | ||
| 54 | +} | ||
| 55 | +``` | ||
| 56 | + | ||
| 57 | +新建 VO `UserVO`:字段 `iIncrement` / `sUserNo` / `sUserName` / `iStaffId` / `sUserType` / `sLanguage` / `bCanModifyDocs` / `tCreateDate` / `bDeleted` / `permissionCategoryIds`(聚合自 tUserPermission)。 | ||
| 58 | + | ||
| 59 | +**不返回**:`sPasswordHash` / `sId` / `sBrandsId` / `sSubsidiaryId` / `sCreatedBy` / `tLastLoginDate` / `tDeletedDate` / `sDeletedBy`。 | ||
| 60 | + | ||
| 61 | +## 业务规则 | ||
| 62 | + | ||
| 63 | +1. **唯一性**:`sUserNo` 与 `sUserName` 在 `bDeleted=0` 范围内系统内全局唯一。冲突 → `BizException(USR_USER_NAME_OR_NO_DUP)` (40921)。 | ||
| 64 | +2. **职员校验**:若 `iStaffId` 非空,必须 `selectById(iStaffId)` 存在且 `bDeleted=0`;不存在或已删 → `BizException(STAFF_NOT_FOUND)` (40421)。 | ||
| 65 | +3. **权限分类校验**:若 `permissionCategoryIds` 非空,每个 id 都要存在且未软删除(一次 `selectBatchIds` 一次性校验);任一不存在 → `BizException(PERM_CATEGORY_NOT_FOUND)` (40422)。 | ||
| 66 | +4. **密码哈希**:固定初始密码字符串 `"666666"` → `BCryptPasswordEncoder().encode("666666")` → 落 `tUser.sPasswordHash`。`BCryptPasswordEncoder` 注册为 Spring Bean(`PasswordConfig`)便于 REQ-USR-004 复用。 | ||
| 67 | +5. **关联表写入**:`tUserPermission` 按 `permissionCategoryIds` 逐条 `insert`(每条 `iUserId=新用户 id` / `iCategoryId=对应分类 id` / `tCreateDate=now`;docs/03 修订版无 bSelected 列,**关联记录存在即「已选」**)。 | ||
| 68 | +6. **新建记录初始状态**:`bDeleted=0`、`tDeletedDate=NULL`、`sDeletedBy=NULL`、`tCreateDate=LocalDateTime.now()`、`tLastLoginDate=NULL`、`sCreatedBy=NULL`(多租户/登录上下文未引入)、`sBrandsId=NULL`、`sSubsidiaryId=NULL`、`sId=NULL`。 | ||
| 69 | +7. **事务边界**:`@Transactional(rollbackFor = Exception.class)` 包住 用户校验 + 用户 insert + 权限关联批量 insert 三步;任一失败整体回滚。 | ||
| 70 | +8. **并发兜底**:DB 唯一索引 `uk_user_no` / `uk_user_name` 兜底唯一性;service 捕 `DuplicateKeyException` 映射为 `USR_USER_NAME_OR_NO_DUP`。 | ||
| 71 | + | ||
| 72 | +## 边界与约束 | ||
| 73 | + | ||
| 74 | +### 鉴权策略 | ||
| 75 | + | ||
| 76 | +沿用 module_mod 的 SecurityConfig permitAll。注释 `// REQ-USR-004 完成后追加 @PreAuthorize("hasAuthority('USR:CREATE')")`。 | ||
| 77 | + | ||
| 78 | +### 错误码映射 | ||
| 79 | + | ||
| 80 | +| 场景 | 错误码 | ErrorCode 枚举 | | ||
| 81 | +|---|---|---| | ||
| 82 | +| 必填缺失 / 类型 / 长度 / 枚举非法 | 40010 | `PARAM_INVALID`(已存在) | | ||
| 83 | +| `iStaffId` 不存在 / 已删除 | 40421 | `STAFF_NOT_FOUND`(**新增**) | | ||
| 84 | +| `permissionCategoryIds` 任一不存在 / 已删除 | 40422 | `PERM_CATEGORY_NOT_FOUND`(**新增**) | | ||
| 85 | +| `sUserNo` / `sUserName` 唯一冲突 | 40921 | `USR_USER_NAME_OR_NO_DUP`(**新增**) | | ||
| 86 | +| 服务端兜底 | 50000 | `INTERNAL_ERROR` | | ||
| 87 | + | ||
| 88 | +> docs/05 § REQ-USR-001 中列的错误码 `40020` / `40921` / `40421` / `40422` —— 段位约定 `40020` 是 USR 模块的参数错(非 MOD 模块的 `40010`)。本 spec **统一沿用 `40010` 作为参数错(与 GlobalExceptionHandler 现有映射一致)**,避免在两套段位里折腾。docs/05 后续 sweep 时再统一对齐。 | ||
| 89 | + | ||
| 90 | +### 性能 / 并发 | ||
| 91 | + | ||
| 92 | +- 单条用户 + 至多 N 条权限关联 insert,预期低并发。`uk_user_no` / `uk_user_name` 唯一约束兜底。 | ||
| 93 | +- `permissionCategoryIds` 批量校验用 `selectBatchIds` 单次 SQL,O(1) round-trip。 | ||
| 94 | + | ||
| 95 | +### 字符集 / 长度 | ||
| 96 | + | ||
| 97 | +- utf8mb4,允许中文姓名 / 用户名(虽然多数业务侧用英文)。 | ||
| 98 | +- 长度超 schema 上限 → 视为参数错 40010。 | ||
| 99 | + | ||
| 100 | +### 与 docs/04 § 1.4 / 3.5 一致性 | ||
| 101 | + | ||
| 102 | +- 异常走 GlobalExceptionHandler。 | ||
| 103 | +- BCryptPasswordEncoder bean 不硬编码 strength(默认 10),从 application.yml 读取 strength(暂留默认)。 | ||
| 104 | + | ||
| 105 | +### 已知技术债 | ||
| 106 | + | ||
| 107 | +- **`sCreatedBy=NULL`**:REQ-USR-004 引入登录上下文后回填。 | ||
| 108 | +- **多租户字段 NULL**:与 module_mod 一致,REQ-USR-004 后由拦截器注入。 | ||
| 109 | + | ||
| 110 | +## 依赖的 schema 表 / 字段 | ||
| 111 | + | ||
| 112 | +**写表**:`tUser`、`tUserPermission` | ||
| 113 | + | ||
| 114 | +**读表**:`tStaff`(关联校验 / 后续 REQ-USR-003 列表 join)、`tPermissionCategory`(关联校验 / 列表只读字典) | ||
| 115 | + | ||
| 116 | +| `tUser` 字段 | 落库逻辑 | | ||
| 117 | +|---|---| | ||
| 118 | +| `iIncrement` | DB AUTO_INCREMENT | | ||
| 119 | +| `sUserNo` / `sUserName` | 入参(必填,唯一) | | ||
| 120 | +| `iStaffId` | 入参(可选;FK 校验通过的 `tStaff.iIncrement`) | | ||
| 121 | +| `sUserType` / `sLanguage` | 入参(必填,枚举) | | ||
| 122 | +| `bCanModifyDocs` | 入参(可选,默认 false) | | ||
| 123 | +| `sPasswordHash` | BCrypt("666666") | | ||
| 124 | +| `tCreateDate` | LocalDateTime.now() | | ||
| 125 | +| `tLastLoginDate` / `sCreatedBy` / 多租户 / `sId` / `bDeleted` 三件套 | 见 § 业务规则 6 | | ||
| 126 | + | ||
| 127 | +| `tUserPermission` 字段 | 落库逻辑 | | ||
| 128 | +|---|---| | ||
| 129 | +| `iIncrement` | DB AUTO_INCREMENT | | ||
| 130 | +| `iUserId` | 新用户 iIncrement | | ||
| 131 | +| `iCategoryId` | dto.permissionCategoryIds[i] | | ||
| 132 | +| `tCreateDate` | LocalDateTime.now() | | ||
| 133 | +| `sCreatedBy` | NULL(REQ-USR-004 后回填) | | ||
| 134 | +| 多租户 / `sId` | NULL | | ||
| 135 | + | ||
| 136 | +**索引利用**: | ||
| 137 | +- `uk_user_no` / `uk_user_name`(UNIQUE):用户唯一性预检 + 兜底 | ||
| 138 | +- `uk_user_perm` (UNIQUE iUserId+iCategoryId):防重复授权(应用层不会触发,DB 兜底) | ||
| 139 | + | ||
| 140 | +**外键**: | ||
| 141 | +- `fk_user_staff`(tUser.iStaffId → tStaff.iIncrement):应用层先查再 insert,避免直接抛 SQL 完整性异常 | ||
| 142 | +- `fk_up_user`(tUserPermission.iUserId → tUser.iIncrement):CASCADE,本接口无需关心 | ||
| 143 | +- `fk_up_category`(tUserPermission.iCategoryId → tPermissionCategory.iIncrement):应用层先 selectBatchIds 再 insert | ||
| 144 | + | ||
| 145 | +## 依赖的接口 | ||
| 146 | + | ||
| 147 | +无(本接口独立工作)。 | ||
| 148 | + | ||
| 149 | +REQ-USR-002 / 003 / 004 都会读 tUser,但不依赖本接口运行时 — 仅依赖本接口建立的数据。 | ||
| 150 | + | ||
| 151 | +## 验收标准 | ||
| 152 | + | ||
| 153 | +### 功能正确性 | ||
| 154 | + | ||
| 155 | +1. **正向 — 最小字段(无 staff、无权限)**:传入 sUserNo / sUserName / sUserType / sLanguage 必填,返回 200 + `data.iIncrement` + 默认 bCanModifyDocs=false / permissionCategoryIds=[];DB 中 sPasswordHash 为 BCrypt 哈希(不等于 "666666" 明文)。 | ||
| 156 | +2. **正向 — 含 staff + 权限**:传入合法 iStaffId + 3 个 permissionCategoryIds,返回 200;DB tUser.iStaffId 等于入参;tUserPermission 中存在 3 条关联(iUserId=新用户)。 | ||
| 157 | +3. **唯一性冲突 — sUserName**:先建一个 sUserName=alice 的用户,再用同 sUserName 提交,返回 40921。 | ||
| 158 | +4. **唯一性冲突 — sUserNo**:同上,sUserNo 冲突。 | ||
| 159 | +5. **iStaffId 不存在**:传入 iStaffId=999999,返回 40421。 | ||
| 160 | +6. **iStaffId 已软删除**:先建 staff 后置 bDeleted=1,再 POST,返回 40421。 | ||
| 161 | +7. **permissionCategoryIds 任一不存在**:传入 [1, 999999],返回 40422,且 DB 中 tUser 与 tUserPermission 都未写入(事务回滚)。 | ||
| 162 | +8. **必填缺失 / 枚举非法 / 长度超限**:返回 40010。 | ||
| 163 | +9. **空 permissionCategoryIds**:传 `[]` 或不传该字段,正向通过(无关联记录)。 | ||
| 164 | +10. **密码哈希不可逆**:直接读 DB sPasswordHash,断言以 `$2a$` 或 `$2b$` 开头(BCrypt 标准前缀),且不含 "666666" 明文。 | ||
| 165 | +11. **响应不暴露 sPasswordHash**:jsonPath `$.data.sPasswordHash` doesNotExist。 | ||
| 166 | + | ||
| 167 | +### 接口契约一致性 | ||
| 168 | + | ||
| 169 | +- 响应格式 `{code, message, data, timestamp}`。 | ||
| 170 | +- 不回显堆栈。 | ||
| 171 | + | ||
| 172 | +### 测试覆盖 | ||
| 173 | + | ||
| 174 | +- **单元测试** `UserServiceImplTest`:mock UserMapper / StaffMapper / PermissionCategoryMapper / UserPermissionMapper / BCryptPasswordEncoder | ||
| 175 | + - create_minimalFields_returnsVOWithBCryptHash | ||
| 176 | + - create_withStaffAndPermissions_writesAssociation | ||
| 177 | + - create_duplicateUserName_throws40921 | ||
| 178 | + - create_duplicateUserNo_throws40921 | ||
| 179 | + - create_staffNotFound_throws40421 | ||
| 180 | + - create_staffSoftDeleted_throws40421 | ||
| 181 | + - create_permissionCategoryNotFound_throws40422 | ||
| 182 | + - create_emptyPermissionCategoryIds_doesNotInsertAssociation | ||
| 183 | + - create_concurrentDuplicate_dupKeyException_mappedTo40921 | ||
| 184 | +- **集成测试** `UserControllerIT`: | ||
| 185 | + - post_minimalFields_returns200 | ||
| 186 | + - post_withStaffAndPermissions_returns200_andDbAssociated | ||
| 187 | + - post_duplicateUserName_returns40921 | ||
| 188 | + - post_staffNotFound_returns40421 | ||
| 189 | + - post_permissionCategoryNotFound_returns40422 | ||
| 190 | + - post_passwordHashedInDb_notPlaintext | ||
| 191 | + - post_responseExcludesSPasswordHash | ||
| 192 | + | ||
| 193 | +### 代码与文档 | ||
| 194 | + | ||
| 195 | +- `// REQ-USR-001` 注释贴在 Controller / Service / 新增 ErrorCode / DTO / VO。 | ||
| 196 | +- 提交按 `feat(usr): <subject> REQ-USR-001` 规范。 | ||
| 197 | +- 不引入 docs/04 § 零 技术栈外的依赖(BCryptPasswordEncoder 已在 spring-boot-starter-security 中)。 |