Commit 797e9fb579616d4f3430bd1c0a099bd69d3f9142
1 parent
0e79763c
docs(usr): spec + plan REQ-USR-001
Showing
2 changed files
with
396 additions
and
0 deletions
docs/superpowers/plans/2026-04-30-REQ-USR-001.md
0 → 100644
| 1 | +--- | ||
| 2 | +req_id: REQ-USR-001 | ||
| 3 | +date: 2026-04-30 | ||
| 4 | +spec_ref: docs/superpowers/specs/2026-04-30-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. | ||
| 10 | + | ||
| 11 | +**Goal:** 在 MOD 模块已建工程基础上扩展 USR 模块树,实现 `POST /api/usr/users`:新增 `tUser` 行 + 默认 BCrypt 密码哈希 + `tUserPermission` 多对多关联,含 `iStaffId` / `permissionCategoryIds` 存在性校验。 | ||
| 12 | + | ||
| 13 | +**Architecture:** 新建 `module/usr/` 平行模块树(entity/dto/mapper/service/controller)+ 在 `common/security/SecurityConfig` 加 `/api/usr/**` permitAll + 注册 `BCryptPasswordEncoder` bean。Staff / PermissionCategory 仅做存在性校验(最小化 mapper,不建 entity)。事务包"3 项校验 + INSERT user + INSERT user_permission × N"。 | ||
| 14 | + | ||
| 15 | +**Tech Stack:** 复用(Spring Boot 3.3.5 / MyBatis-Plus / Spring Security / JJWT);本 REQ 引入 `BCryptPasswordEncoder`(spring-security-crypto 已通过 starter-security 引入,无需新依赖)。 | ||
| 16 | + | ||
| 17 | +--- | ||
| 18 | + | ||
| 19 | +## Schema 改动 | ||
| 20 | + | ||
| 21 | +无(`tUser` / `tStaff` / `tPermissionCategory` / `tUserPermission` 均在 V1 就位)。 | ||
| 22 | + | ||
| 23 | +## 文件变更清单 | ||
| 24 | + | ||
| 25 | +### 新增 | ||
| 26 | + | ||
| 27 | +- `backend/src/main/java/com/xly/erp/module/usr/entity/User.java` | ||
| 28 | +- `backend/src/main/java/com/xly/erp/module/usr/entity/UserPermission.java` | ||
| 29 | +- `backend/src/main/java/com/xly/erp/module/usr/dto/CreateUserDTO.java` | ||
| 30 | +- `backend/src/main/java/com/xly/erp/module/usr/mapper/UserMapper.java` | ||
| 31 | +- `backend/src/main/java/com/xly/erp/module/usr/mapper/UserPermissionMapper.java` | ||
| 32 | +- `backend/src/main/java/com/xly/erp/module/usr/mapper/StaffMapper.java` | ||
| 33 | +- `backend/src/main/java/com/xly/erp/module/usr/mapper/PermissionCategoryMapper.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/module/usr/mapper/UserMapperIT.java` | ||
| 38 | +- `backend/src/test/java/com/xly/erp/module/usr/mapper/StaffMapperIT.java` | ||
| 39 | +- `backend/src/test/java/com/xly/erp/module/usr/mapper/PermissionCategoryMapperIT.java` | ||
| 40 | +- `backend/src/test/java/com/xly/erp/module/usr/service/UserServiceImplTest.java` | ||
| 41 | +- `backend/src/test/java/com/xly/erp/module/usr/controller/UserControllerIT.java` | ||
| 42 | + | ||
| 43 | +### 修改 | ||
| 44 | + | ||
| 45 | +- `backend/src/main/java/com/xly/erp/common/security/SecurityConfig.java` — 加 `/api/usr/**` permitAll + `@Bean BCryptPasswordEncoder` | ||
| 46 | + | ||
| 47 | +## 任务步骤 | ||
| 48 | + | ||
| 49 | +> 全局:每 commit `<type>(usr): <subject> REQ-USR-001`;测试派发子会话;现有 67 用例全程绿。 | ||
| 50 | + | ||
| 51 | +### Task 1: SecurityConfig 扩展 + BCryptPasswordEncoder bean | ||
| 52 | + | ||
| 53 | +**Files:** | ||
| 54 | +- Modify: `backend/src/main/java/com/xly/erp/common/security/SecurityConfig.java` | ||
| 55 | + | ||
| 56 | +**API shape:** | ||
| 57 | +- 在 `authorizeHttpRequests` 的现有 `requestMatchers("/api/mod/**").permitAll()` 后追加 `requestMatchers("/api/usr/**").permitAll()` | ||
| 58 | +- 类内追加 `@Bean public BCryptPasswordEncoder bCryptPasswordEncoder() { return new BCryptPasswordEncoder(); }` | ||
| 59 | +- stub 注释保持 `// REQ-MOD-001 stub: see USR-004 follow-up` | ||
| 60 | + | ||
| 61 | +- [ ] **Step 1: 修改 SecurityConfig** | ||
| 62 | +- [ ] **Step 2: 子会话验证 PASS** | ||
| 63 | + - 命令:`cd backend && mvn -B test` | ||
| 64 | + - 期望:现有 67 用例全绿(路径扩范围不收紧) | ||
| 65 | +- [ ] **Step 3: Commit** | ||
| 66 | + - `git commit -m "refactor(usr): widen permitAll to /api/usr/** + bcrypt bean REQ-USR-001"` | ||
| 67 | + | ||
| 68 | +### Task 2: tUser entity + UserMapper + IT | ||
| 69 | + | ||
| 70 | +**Files:** | ||
| 71 | +- Create: `backend/src/main/java/com/xly/erp/module/usr/entity/User.java` | ||
| 72 | +- Create: `backend/src/main/java/com/xly/erp/module/usr/mapper/UserMapper.java` | ||
| 73 | +- Create: `backend/src/test/java/com/xly/erp/module/usr/mapper/UserMapperIT.java` | ||
| 74 | + | ||
| 75 | +**API shape:** | ||
| 76 | +- `User` PO:`@TableName("tUser")` + 17 字段 1:1 映射(参 docs/03 § tUser),全部 `@TableField` 显式列名(与 MOD `Module.java` 风格一致),`iIncrement` 用 `@TableId(IdType.AUTO)` | ||
| 77 | +- `UserMapper extends BaseMapper<User>`:暂只用 BaseMapper 的 insert/selectById(唯一冲突走 DB 索引兜底,无需自定义 exists 方法) | ||
| 78 | + | ||
| 79 | +- [ ] **Step 1: 写失败测试 `UserMapperIT`(2 用例)** | ||
| 80 | + - `insertAndSelectById_persistsAllStandardCols` — 构造 User 实例(含 sPasswordHash="bcrypt-stub")插入 → selectById 比较;断言 sBrandsId="XLY"、bDeleted=false 等 | ||
| 81 | + - `uniqueUserNoConstraint_rejectsDuplicate` — 插入两条同 sUserNo(不同 sUserName)→ 第二次抛 `DuplicateKeyException` | ||
| 82 | + - 测试隔离:`@BeforeEach @AfterEach` 用 `DELETE FROM tUserPermission WHERE iUserId IN (SELECT iIncrement FROM tUser WHERE sUserNo LIKE 'sp_test_%')` + `DELETE FROM tUser WHERE sUserNo LIKE 'sp_test_%'`(避免外键孤儿,但 USR-001 的 tUserPermission 与 tUser 在本 IT 不会被插入;此句保留为防御) | ||
| 83 | + | ||
| 84 | +- [ ] **Step 2: 实现 entity + mapper** | ||
| 85 | +- [ ] **Step 3: 子会话验证 PASS** | ||
| 86 | + - 命令:`cd backend && mvn -B test -Dtest=UserMapperIT` | ||
| 87 | +- [ ] **Step 4: Commit** | ||
| 88 | + - `git commit -m "feat(usr): tUser entity + mapper REQ-USR-001"` | ||
| 89 | + | ||
| 90 | +### Task 3: StaffMapper + PermissionCategoryMapper(最小存在性查询) | ||
| 91 | + | ||
| 92 | +**Files:** | ||
| 93 | +- Create: `backend/src/main/java/com/xly/erp/module/usr/mapper/StaffMapper.java` | ||
| 94 | +- Create: `backend/src/main/java/com/xly/erp/module/usr/mapper/PermissionCategoryMapper.java` | ||
| 95 | +- Create: `backend/src/test/java/com/xly/erp/module/usr/mapper/StaffMapperIT.java` | ||
| 96 | +- Create: `backend/src/test/java/com/xly/erp/module/usr/mapper/PermissionCategoryMapperIT.java` | ||
| 97 | + | ||
| 98 | +**API shape:** | ||
| 99 | +- `StaffMapper`(**不**继承 BaseMapper,仅注解 SELECT;本 REQ 不建 Staff entity): | ||
| 100 | + - `@Select("SELECT 1 FROM tStaff WHERE iIncrement = #{id} AND bDeleted = 0 LIMIT 1") Integer findActiveStaffFlag(@Param("id") Integer id)` | ||
| 101 | + - `default boolean existsActiveById(Integer id) { return findActiveStaffFlag(id) != null; }` | ||
| 102 | +- `PermissionCategoryMapper`(同上): | ||
| 103 | + - `@Select("<script>SELECT COUNT(1) FROM tPermissionCategory WHERE bDeleted = 0 AND iIncrement IN <foreach collection='ids' item='id' open='(' separator=',' close=')'>#{id}</foreach></script>") int countActiveByIds(@Param("ids") List<Integer> ids)`(mybatis 动态 SQL);空 list 调用方先短路,不调 mapper | ||
| 104 | + | ||
| 105 | +- [ ] **Step 1: 写失败测试** | ||
| 106 | + - `StaffMapperIT#existsActiveById_handlesAliveDeletedMissing`:JdbcTemplate 直插 alive staff + deleted staff;断言三种 id(alive/deleted/不存在)的返回值 | ||
| 107 | + - `PermissionCategoryMapperIT#countActiveByIds_returnsCorrectCount`:JdbcTemplate 直插 cat1(alive) + cat2(alive) + cat3(deleted);查 [cat1,cat2,cat3] → count=2;查 [cat1, 99999] → count=1;查 [99999] → count=0 | ||
| 108 | + | ||
| 109 | +- [ ] **Step 2: 实现 mapper** | ||
| 110 | +- [ ] **Step 3: 子会话验证 PASS** | ||
| 111 | + - 命令:`cd backend && mvn -B test -Dtest='StaffMapperIT,PermissionCategoryMapperIT'` | ||
| 112 | +- [ ] **Step 4: Commit** | ||
| 113 | + - `git commit -m "feat(usr): staff + permission-category existence mappers REQ-USR-001"` | ||
| 114 | + | ||
| 115 | +### Task 4: tUserPermission entity + UserPermissionMapper + IT | ||
| 116 | + | ||
| 117 | +**Files:** | ||
| 118 | +- Create: `backend/src/main/java/com/xly/erp/module/usr/entity/UserPermission.java` | ||
| 119 | +- Create: `backend/src/main/java/com/xly/erp/module/usr/mapper/UserPermissionMapper.java` | ||
| 120 | +- 复用 `UserMapperIT`(追加用例)或独立 `UserPermissionMapperIT`,本 plan 选择追加到 UserMapperIT | ||
| 121 | + | ||
| 122 | +**API shape:** | ||
| 123 | +- `UserPermission` PO:`@TableName("tUserPermission")` + 字段 `iIncrement(@TableId AUTO)` / `sId` / `sBrandsId` / `sSubsidiaryId` / `tCreateDate` / `iUserId` / `iCategoryId` / `sCreatedBy`(参 docs/03 § tUserPermission) | ||
| 124 | +- `UserPermissionMapper extends BaseMapper<UserPermission>`,仅用 BaseMapper.insert | ||
| 125 | + | ||
| 126 | +- [ ] **Step 1: 写失败测试(追加到 `UserMapperIT`)** | ||
| 127 | + - `userPermissionInsert_persistsRowWithUserAndCategory` — 先插一行 user,再插一行 userPermission(iUserId=user.id, iCategoryId=10);JdbcTemplate 验 row 存在 | ||
| 128 | + | ||
| 129 | +- [ ] **Step 2: 实现 entity + mapper** | ||
| 130 | +- [ ] **Step 3: 子会话验证 PASS** | ||
| 131 | + - 命令:`cd backend && mvn -B test -Dtest=UserMapperIT` | ||
| 132 | +- [ ] **Step 4: Commit** | ||
| 133 | + - `git commit -m "feat(usr): tUserPermission entity + mapper REQ-USR-001"` | ||
| 134 | + | ||
| 135 | +### Task 5: CreateUserDTO + UserService.create 主流程(合法 + 标准列) | ||
| 136 | + | ||
| 137 | +**Files:** | ||
| 138 | +- Create: `backend/src/main/java/com/xly/erp/module/usr/dto/CreateUserDTO.java` | ||
| 139 | +- Create: `backend/src/main/java/com/xly/erp/module/usr/service/UserService.java` | ||
| 140 | +- Create: `backend/src/main/java/com/xly/erp/module/usr/service/impl/UserServiceImpl.java` | ||
| 141 | +- Create: `backend/src/test/java/com/xly/erp/module/usr/service/UserServiceImplTest.java` | ||
| 142 | + | ||
| 143 | +**API shape:** | ||
| 144 | +- `CreateUserDTO` 字段(带 `@JsonProperty` + Bean Validation): | ||
| 145 | + - `@NotBlank @Size(max=50) String sUserNo` | ||
| 146 | + - `@NotBlank @Size(max=50) String sUserName` | ||
| 147 | + - `Integer iStaffId`(可空) | ||
| 148 | + - `@NotBlank String sUserType` | ||
| 149 | + - `@NotBlank String sLanguage` | ||
| 150 | + - `Boolean bCanModifyDocs`(可空) | ||
| 151 | + - `List<Integer> permissionCategoryIds`(可空,null/空均按"无权限组"处理) | ||
| 152 | +- `UserService#create(CreateUserDTO dto) : Map<String, Object>`(返回 `{iIncrement, sUserNo}`);本 plan 用 `Map<String, Object>` 而非新 VO,与 MOD 控制器响应风格保持一致 | ||
| 153 | +- `UserServiceImpl` 依赖:`UserMapper` / `UserPermissionMapper` / `StaffMapper` / `PermissionCategoryMapper` / `TenantProperties` / `StubSecurityProperties` / `BCryptPasswordEncoder` | ||
| 154 | +- `@Transactional(rollbackFor = Exception.class)` | ||
| 155 | +- 流程主路径(仅本 task 实现合法 + 标准列): | ||
| 156 | + 1. 构造 `User entity`:DTO 透传 + 标准列(sBrandsId/sSubsidiaryId/tCreateDate/sCreatedBy 同 MOD 模块策略) + `sPasswordHash = encoder.encode("666666")` + `bDeleted=false` | ||
| 157 | + 2. `userMapper.insert(entity)` | ||
| 158 | + 3. 若 `permissionCategoryIds` 非空:for-loop 插 `UserPermission` 行 | ||
| 159 | + 4. 返回 `Map.of("iIncrement", entity.getIIncrement(), "sUserNo", entity.getSUserNo())` | ||
| 160 | + | ||
| 161 | +- [ ] **Step 1: 写失败测试(2 用例)** | ||
| 162 | + - `createWithValidDto_persistsUser_andUserPermissions` — Mock mappers + encoder.encode 返回 "$2a$10$stub";ArgumentCaptor 抓 `userMapper.insert` + `userPermissionMapper.insert`;断言 user.sBrandsId="XLY"、sCreatedBy="STUB_ADMIN"、sPasswordHash 以 "$2a$" 开头;N 条权限关联含正确 iUserId / iCategoryId | ||
| 163 | + - `createWithoutPermissionCategoryIds_skipsUserPermissionInserts` — `permissionCategoryIds=null`;`userPermissionMapper.insert` 永不调用 | ||
| 164 | + | ||
| 165 | +- [ ] **Step 2: 实现 DTO + service 主流程** | ||
| 166 | + - 仅覆盖本 task 两用例所需逻辑(异常分支留 Task 6) | ||
| 167 | +- [ ] **Step 3: 子会话验证 PASS** | ||
| 168 | + - 命令:`cd backend && mvn -B test -Dtest=UserServiceImplTest` | ||
| 169 | +- [ ] **Step 4: Commit** | ||
| 170 | + - `git commit -m "feat(usr): user create dto + service happy path REQ-USR-001"` | ||
| 171 | + | ||
| 172 | +### Task 6: Service 异常分支补全 | ||
| 173 | + | ||
| 174 | +**Files:** | ||
| 175 | +- Modify: `backend/src/main/java/com/xly/erp/module/usr/service/impl/UserServiceImpl.java` | ||
| 176 | +- Modify: `backend/src/test/java/com/xly/erp/module/usr/service/UserServiceImplTest.java` | ||
| 177 | + | ||
| 178 | +**API shape:** 不变(仅在 service 头部补 4 类校验 + 异常翻译) | ||
| 179 | + | ||
| 180 | +**校验顺序(service 实现):** | ||
| 181 | +1. 枚举校验 `sUserType ∈ {普通用户, 超级管理员}` + `sLanguage ∈ {zh, en, zh-TW}` → 任一非法 `BizException(40001, "<字段>: 取值非法")` | ||
| 182 | +2. `iStaffId != null` 且 `!staffMapper.existsActiveById(iStaffId)` → `BizException(40022, "职员不存在或已删除")` | ||
| 183 | +3. `permissionCategoryIds` 非空:`int n = permissionCategoryMapper.countActiveByIds(ids); if (n != ids.size()) throw new BizException(40023, "权限分类含无效 id")` | ||
| 184 | +4. `userMapper.insert(...)` 用 try/catch 捕获 `DuplicateKeyException` → `BizException(40020, "用户号或用户名已存在")` | ||
| 185 | +5. `sCreatedBy` 优先 SecurityContextHelper.currentUserNo(),回退 stub | ||
| 186 | + | ||
| 187 | +- [ ] **Step 1: 在 `UserServiceImplTest` 追加 6 用例** | ||
| 188 | + - `createWithInvalidUserType_throws40001` | ||
| 189 | + - `createWithInvalidLanguage_throws40001` | ||
| 190 | + - `createWithStaffNotFound_throws40022` — Mock `staffMapper.existsActiveById(...) → false`;`userMapper.insert` 永不调用 | ||
| 191 | + - `createWithSomeInvalidPermissionIds_throws40023` — Mock `permissionCategoryMapper.countActiveByIds([1,2,3]) → 2`;`userMapper.insert` 永不调用 | ||
| 192 | + - `createWithDuplicateUserNo_throws40020` — Mock `userMapper.insert` 抛 `DuplicateKeyException` | ||
| 193 | + - `createUsesAuthenticatedUserNoAsCreatedBy` — `SecurityContextHolder` 注 "ALICE";ArgumentCaptor `sCreatedBy="ALICE"` | ||
| 194 | + | ||
| 195 | +- [ ] **Step 2: 在 ServiceImpl 补 4 类校验 + 异常翻译** | ||
| 196 | +- [ ] **Step 3: 子会话验证 PASS** | ||
| 197 | + - 命令:`cd backend && mvn -B test -Dtest=UserServiceImplTest` | ||
| 198 | + - 期望:2 + 6 = 8 用例全绿 | ||
| 199 | +- [ ] **Step 4: Commit** | ||
| 200 | + - `git commit -m "feat(usr): user create error branches REQ-USR-001"` | ||
| 201 | + | ||
| 202 | +### Task 7: UserController POST + IT(9 用例)+ 全量回归 | ||
| 203 | + | ||
| 204 | +**Files:** | ||
| 205 | +- Create: `backend/src/main/java/com/xly/erp/module/usr/controller/UserController.java` | ||
| 206 | +- Create: `backend/src/test/java/com/xly/erp/module/usr/controller/UserControllerIT.java` | ||
| 207 | + | ||
| 208 | +**API shape:** | ||
| 209 | +- `@RestController @RequestMapping("/api/usr")` | ||
| 210 | +- `@PostMapping("/users") public Result<Map<String, Object>> create(@Valid @RequestBody CreateUserDTO dto) { return Result.ok(userService.create(dto)); }` | ||
| 211 | + | ||
| 212 | +- [ ] **Step 1: 写失败 IT(9 用例)** | ||
| 213 | + - `postValidBody_with_jwt_returns200_andPersists` — 前置:JdbcTemplate 直插一行 staff + 两行 permission_category;POST 完整 body 带 JWT;`code=0` / `data.iIncrement>0` / `data.sUserNo == 请求值`;JdbcTemplate 验 tUser 行存在 + tUserPermission 行数 == permissionCategoryIds.size() | ||
| 214 | + - `postEmptyBody_returns40001` | ||
| 215 | + - `postInvalidUserType_returns40001` — `sUserType="火星"` | ||
| 216 | + - `postInvalidLanguage_returns40001` — `sLanguage="ja"` | ||
| 217 | + - `postDuplicateUserNo_returns40020` — 先 POST 一次成功,再 POST 同 sUserNo(不同 sUserName)→ `code=40020` | ||
| 218 | + - `postStaffNotFound_returns40022` — `iStaffId=99999990` → `code=40022` | ||
| 219 | + - `postPermissionCategoryNotFound_returns40023` — `permissionCategoryIds=[99999991]` → `code=40023` | ||
| 220 | + - `postWithoutJwt_permitAllStub_returns200_andCreatedBySTUBADMIN` — DB 验 sCreatedBy="STUB_ADMIN" | ||
| 221 | + - `postTamperedJwt_returns20001` — Authorization "Bearer not.a.real.jwt";DB 无新增行 | ||
| 222 | + - 测试隔离:`@BeforeEach @AfterEach` 清理 `tUserPermission` + `tUser`(按 sUserNo LIKE 'sp_test_%')+ 清理本 IT 创建的 tStaff / tPermissionCategory(按 sStaffNo / sCategoryCode LIKE 'sp_test_%');按外键依赖顺序删(tUserPermission → tUser → tStaff / tPermissionCategory) | ||
| 223 | + | ||
| 224 | +- [ ] **Step 2: 实现 controller** | ||
| 225 | +- [ ] **Step 3: 子会话跑全量回归** | ||
| 226 | + - 命令:`cd backend && mvn -B test` | ||
| 227 | + - 期望:MOD 67 + USR-001 新增 2(UserMapperIT) + 1(UserMapperIT 追加) + 1(StaffMapperIT) + 1(PermissionCategoryMapperIT) + 8(UserServiceImplTest) + 9(UserControllerIT) = 89 用例全绿 | ||
| 228 | +- [ ] **Step 4: Commit** | ||
| 229 | + - `git commit -m "test(usr): user create integration coverage REQ-USR-001"` | ||
| 230 | + | ||
| 231 | +## 提交计划 | ||
| 232 | + | ||
| 233 | +| commit | 覆盖 | | ||
| 234 | +|---|---| | ||
| 235 | +| `refactor(usr): widen permitAll to /api/usr/** + bcrypt bean REQ-USR-001` | Task 1 | | ||
| 236 | +| `feat(usr): tUser entity + mapper REQ-USR-001` | Task 2 | | ||
| 237 | +| `feat(usr): staff + permission-category existence mappers REQ-USR-001` | Task 3 | | ||
| 238 | +| `feat(usr): tUserPermission entity + mapper REQ-USR-001` | Task 4 | | ||
| 239 | +| `feat(usr): user create dto + service happy path REQ-USR-001` | Task 5 | | ||
| 240 | +| `feat(usr): user create error branches REQ-USR-001` | Task 6 | | ||
| 241 | +| `test(usr): user create integration coverage REQ-USR-001` | Task 7 | |
docs/superpowers/specs/2026-04-30-REQ-USR-001.md
0 → 100644
| 1 | +--- | ||
| 2 | +req_id: REQ-USR-001 | ||
| 3 | +date: 2026-04-30 | ||
| 4 | +module: module_usr | ||
| 5 | +--- | ||
| 6 | + | ||
| 7 | +# Spec: REQ-USR-001 — 用户新增 | ||
| 8 | + | ||
| 9 | +## 目标 | ||
| 10 | + | ||
| 11 | +录入新用户基本信息 + 初始化默认密码(`666666` 的 BCrypt 哈希)+ 同步建立用户与权限分类的多对多关联,返回新用户 `iIncrement` + `sUserNo`。 | ||
| 12 | + | ||
| 13 | +## 输入 / 触发 | ||
| 14 | + | ||
| 15 | +### HTTP 接口(docs/05 § REQ-USR-001) | ||
| 16 | + | ||
| 17 | +- Method / Path: `POST /api/usr/users` | ||
| 18 | +- Auth: 必需(沿用 MOD 模块 stub:本 REQ 在 SecurityConfig 加 `/api/usr/**` permitAll,USR-004 闭环时统一改为 `hasAuthority('SUPER_ADMIN')`) | ||
| 19 | +- Permission: 仅超级管理员(stub 期不强制) | ||
| 20 | + | ||
| 21 | +### 请求 DTO `CreateUserDTO` | ||
| 22 | + | ||
| 23 | +| JSON 字段 | Java 类型 | 必填 | 校验 | 业务校验 | | ||
| 24 | +|---|---|---|---|---| | ||
| 25 | +| `sUserNo` | `String` | 是 | `@NotBlank @Size(max=50)` | 系统内唯一(依赖 `tUser.uk_user_no`);冲突 → `40020` | | ||
| 26 | +| `sUserName` | `String` | 是 | `@NotBlank @Size(max=50)` | 系统内唯一(依赖 `tUser.uk_user_name`);冲突 → `40020` | | ||
| 27 | +| `iStaffId` | `Integer` | 否 | — | 非 null 时必须命中 `tStaff` 中存在且 `bDeleted=0` 的记录;不存在 / 已软删 → `40022` | | ||
| 28 | +| `sUserType` | `String` | 是 | `@NotBlank` | 必须在枚举 `[普通用户, 超级管理员]` 内;非法 → `40001`(沿用 docs/05 错误码列表) | | ||
| 29 | +| `sLanguage` | `String` | 是 | `@NotBlank` | 必须在枚举 `[zh, en, zh-TW]`(代码值,前端做 i18n 标签映射)内;非法 → `40001` | | ||
| 30 | +| `bCanModifyDocs` | `Boolean` | 否 | — | 缺省 `false` | | ||
| 31 | +| `permissionCategoryIds` | `List<Integer>` | 否 | — | 非空时所有 id 必须在 `tPermissionCategory` 中存在 + `bDeleted=0`;任一不合法 → `40023`。空 list / null → 不建关联 | | ||
| 32 | + | ||
| 33 | +### 鉴权与上下文 | ||
| 34 | + | ||
| 35 | +JWT Filter 解析 token 写 `principal=sUserNo`;伪造 token → `code=20001`;缺失 token → permitAll 透传。`sCreatedBy` 取 `SecurityContextHelper.currentUserNo()`,匿名状态回退 `stubProps.stubUserNo`(与 MOD 模块同策略)。 | ||
| 36 | + | ||
| 37 | +## 输出 / 结果 | ||
| 38 | + | ||
| 39 | +### 成功响应 | ||
| 40 | + | ||
| 41 | +```json | ||
| 42 | +{ "code": 0, "msg": "ok", "data": { "iIncrement": 456, "sUserNo": "u001" } } | ||
| 43 | +``` | ||
| 44 | + | ||
| 45 | +### 持久化效果 | ||
| 46 | + | ||
| 47 | +事务内两步: | ||
| 48 | + | ||
| 49 | +1. INSERT `tUser`:DTO 字段 + 标准列 + `sPasswordHash = bcrypt("666666")` + `tLastLoginDate=NULL` + `bDeleted=0` | ||
| 50 | +2. 对 `permissionCategoryIds` 中的每个 id:INSERT `tUserPermission(iUserId=新用户.iIncrement, iCategoryId=id, sCreatedBy=同上, tCreateDate=NOW(), sBrandsId/sSubsidiaryId=配置默认)` | ||
| 51 | + | ||
| 52 | +| `tUser` 字段 | 来源 | | ||
| 53 | +|---|---| | ||
| 54 | +| `iIncrement` | DB 自增 | | ||
| 55 | +| `sId` | NULL | | ||
| 56 | +| `sBrandsId` / `sSubsidiaryId` | `TenantProperties`(XLY/XLY) | | ||
| 57 | +| `tCreateDate` | `LocalDateTime.now()` | | ||
| 58 | +| `sUserNo` / `sUserName` / `iStaffId` / `sUserType` / `sLanguage` / `bCanModifyDocs` | DTO 透传 | | ||
| 59 | +| `sPasswordHash` | `BCryptPasswordEncoder.encode("666666")`(每次 hash salt 不同) | | ||
| 60 | +| `tLastLoginDate` | NULL(USR-004 登录时更新) | | ||
| 61 | +| `sCreatedBy` | JWT principal 或 stub | | ||
| 62 | +| `bDeleted` | `0` | | ||
| 63 | +| `tDeletedDate` / `sDeletedBy` | NULL | | ||
| 64 | + | ||
| 65 | +## 业务规则 | ||
| 66 | + | ||
| 67 | +1. **唯一性策略**:DB `uk_user_no` + `uk_user_name` 包含已软删行;本期不支持"软删后用户号复用"——若 sUserNo 历史上有过删除记录,再次创建会被 DB 唯一索引拒绝。docs/03 § tUser 业务注记说"应用层保证(仅约束未删除部分)"是设计意向,本 REQ 实现保持与 V1 schema 一致:依赖 DB 唯一索引兜底,service 层捕获 `DuplicateKeyException` → `BizException(40020,"用户号或用户名已存在")`,不再做"先查后插"二次校验(避免竞态)。 | ||
| 68 | +2. **iStaffId 校验**:非 null 时调 `staffMapper.existsActiveById(iStaffId)`;false → `BizException(40022,"职员不存在或已删除")`。 | ||
| 69 | +3. **permissionCategoryIds 校验**:非空时一次性查 `permissionCategoryMapper.countActiveByIds(ids)`,若返回数 != ids.size → `BizException(40023,"权限分类含无效 id")`。**避免 N+1**(一条 IN 查询)。 | ||
| 70 | +4. **sUserType / sLanguage 枚举**:service 入口处用 `Set.contains` 校验;非法 → `BizException(40001, "<字段名>: 取值非法")`。 | ||
| 71 | +5. **密码哈希**:使用 Spring Security 的 `BCryptPasswordEncoder`(已通过 starter-security 引入),强度默认 10。`@Bean BCryptPasswordEncoder` 在 `SecurityConfig` 注册(仅本 REQ 引入,避免循环依赖,参 § 实现范围抉择)。 | ||
| 72 | +6. **事务**:service 上 `@Transactional(rollbackFor = Exception.class)`,包"校验 + INSERT user + INSERT user_permission * N"。任一步骤失败回滚,不留残行。 | ||
| 73 | +7. **批量插入策略**:本 REQ 用 `for (Integer id : ids) userPermissionMapper.insert(rec)` 简单循环。permissionCategoryIds 典型 < 50,N+1 影响可接受。后续若性能瓶颈再改 batch INSERT。 | ||
| 74 | + | ||
| 75 | +## 边界与约束 | ||
| 76 | + | ||
| 77 | +- **必填项缺失** → `40001` | ||
| 78 | +- **`sUserType` / `sLanguage` 非枚举** → `40001` | ||
| 79 | +- **`sUserNo` / `sUserName` 唯一冲突** → `40020` | ||
| 80 | +- **`iStaffId` 不存在 / 已软删** → `40022` | ||
| 81 | +- **`permissionCategoryIds` 含无效 id / 已软删** → `40023` | ||
| 82 | +- **JWT 伪造** → `20001` | ||
| 83 | +- **JWT 缺失** → permitAll stub(USR-004 后改 401) | ||
| 84 | +- **不返回 `sPasswordHash`**:响应 data 仅含 `iIncrement` + `sUserNo`,避免哈希泄漏 | ||
| 85 | + | ||
| 86 | +## 实现范围与边界抉择 | ||
| 87 | + | ||
| 88 | +1. **复用 MOD 模块工程**:无新增 pom 依赖;`backend/src/main/java/com/xly/erp/module/usr/` 全新模块树,与 `module/mod/` 平行。 | ||
| 89 | +2. **SecurityConfig 路径扩展**:在现有 `/api/mod/**` permitAll 同位加 `/api/usr/**` permitAll,stub 注释保持 `// REQ-MOD-001 stub: see USR-004 follow-up`(USR-004 时整段一次性收紧)。 | ||
| 90 | +3. **`BCryptPasswordEncoder` 注册位置**:本 REQ 在 `SecurityConfig` 加 `@Bean BCryptPasswordEncoder`,service 通过构造器注入。USR-004 登录接口同样依赖此 bean,无重复定义。 | ||
| 91 | +4. **Staff / PermissionCategory 仅做存在性校验**:本 REQ 不建 `Staff` / `PermissionCategory` 完整 entity,仅建 `StaffMapper` / `PermissionCategoryMapper` 两个最小化接口(注解 SELECT 1 / SELECT COUNT)。后续 USR-002/003 真正用到完整字段时再补 entity。 | ||
| 92 | +5. **唯一冲突处理走 DB 索引兜底**:与 MOD-001 `sProcedureName` 风格一致;不做"先查后插"避免竞态。 | ||
| 93 | +6. **批量插入 `tUserPermission` 暂用循环**:本 REQ 数据量小,YAGNI;性能问题后续 REQ 出现时再优化。 | ||
| 94 | +7. **测试用 stub JWT 注入**:复用 `TestJwtHelper`,无需新建。 | ||
| 95 | + | ||
| 96 | +## 依赖的 schema 表 / 字段 | ||
| 97 | + | ||
| 98 | +写入: | ||
| 99 | +- `tUser`:14 个字段(除 `tDeletedDate` / `sDeletedBy`) | ||
| 100 | +- `tUserPermission`:`iUserId` / `iCategoryId` / `sCreatedBy` / `tCreateDate` / `sBrandsId` / `sSubsidiaryId` | ||
| 101 | + | ||
| 102 | +读取(仅校验存在性): | ||
| 103 | +- `tStaff`:`iIncrement` + `bDeleted` | ||
| 104 | +- `tPermissionCategory`:`iIncrement` + `bDeleted` | ||
| 105 | + | ||
| 106 | +依赖索引:`tUser.uk_user_no` / `uk_user_name` 兜底唯一冲突;`tStaff.iIncrement` PK;`tPermissionCategory.iIncrement` PK。 | ||
| 107 | + | ||
| 108 | +外键:`fk_user_staff: iStaffId → tStaff.iIncrement (ON DELETE SET NULL)` 在 INSERT 时由 DB 兜底(service 提前校验给更友好错误码)。 | ||
| 109 | + | ||
| 110 | +## 依赖的接口 | ||
| 111 | + | ||
| 112 | +无(USR-001 是用户域 CRUD 起点)。 | ||
| 113 | + | ||
| 114 | +## 验收标准 | ||
| 115 | + | ||
| 116 | +### 单元测试(`UserServiceImplTest`,Mockito) | ||
| 117 | + | ||
| 118 | +- [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` | ||
| 119 | +- [x] `createWithoutPermissionCategoryIds_skipsUserPermissionInserts` — `permissionCategoryIds=null` / 空 list → `userPermissionMapper.insert` 永不调用 | ||
| 120 | +- [x] `createWithInvalidUserType_throws40001` | ||
| 121 | +- [x] `createWithInvalidLanguage_throws40001` | ||
| 122 | +- [x] `createWithStaffNotFound_throws40022` — `staffMapper.existsActiveById(...)=false` | ||
| 123 | +- [x] `createWithSomeInvalidPermissionIds_throws40023` — `permissionCategoryMapper.countActiveByIds([1,2,3])=2`,期望抛 40023;`userMapper.insert` 永不调用 | ||
| 124 | +- [x] `createWithDuplicateUserNo_throws40020` — `userMapper.insert` 抛 `DuplicateKeyException` | ||
| 125 | +- [x] `createUsesAuthenticatedUserNoAsCreatedBy` — SecurityContextHolder 注 "ALICE",断言 `sCreatedBy="ALICE"` | ||
| 126 | + | ||
| 127 | +### Mapper IT(`UserMapperIT`,真实 DB) | ||
| 128 | + | ||
| 129 | +- [x] `insertAndSelectById_persistsAllStandardCols` — 构造 User 实例插入,`selectById` 比较;`sPasswordHash` 非空且 BCrypt 格式 | ||
| 130 | +- [x] `uniqueUserNoConstraint_rejectsDuplicate` — 插入两条同 sUserNo(不同 sUserName)→ 第二次 `DuplicateKeyException` | ||
| 131 | + | ||
| 132 | +### Mapper IT(`StaffMapperIT` + `PermissionCategoryMapperIT`,最小化) | ||
| 133 | + | ||
| 134 | +- [x] `staffMapper#existsActiveById_handlesAliveDeletedMissing` | ||
| 135 | +- [x] `permissionCategoryMapper#countActiveByIds_returnsCorrectCount` | ||
| 136 | + | ||
| 137 | +### 集成测试(`UserControllerIT`) | ||
| 138 | + | ||
| 139 | +- [x] `postValidBody_with_jwt_returns200_andPersists` — 直插一条职员 + 两条权限分类作为前置数据;POST 完整 body 带 JWT;`code=0` / `data.iIncrement>0` / `data.sUserNo` 等于请求;JdbcTemplate 验证 `tUser` + `tUserPermission` 行 | ||
| 140 | +- [x] `postEmptyBody_returns40001` | ||
| 141 | +- [x] `postInvalidUserType_returns40001` | ||
| 142 | +- [x] `postInvalidLanguage_returns40001` | ||
| 143 | +- [x] `postDuplicateUserNo_returns40020` | ||
| 144 | +- [x] `postStaffNotFound_returns40022` | ||
| 145 | +- [x] `postPermissionCategoryNotFound_returns40023` | ||
| 146 | +- [x] `postWithoutJwt_permitAllStub_returns200_andCreatedBySTUBADMIN` | ||
| 147 | +- [x] `postTamperedJwt_returns20001` | ||
| 148 | + | ||
| 149 | +### 工程验收 | ||
| 150 | + | ||
| 151 | +- [x] `cd backend && mvn -B test` 全绿(67 + USR-001 新增 8(svc) + 4(mapperIT) + 9(controllerIT) = 88 用例) | ||
| 152 | +- [x] `BCryptPasswordEncoder` bean 在 `SecurityConfig` 注册,service 通过构造器注入 | ||
| 153 | +- [x] SecurityConfig 路径白名单含 `/api/usr/**` | ||
| 154 | +- [x] `// REQ-MOD-001 stub: see USR-004 follow-up` 锚点保持 | ||
| 155 | +- [x] `tUser.sPasswordHash` 在响应中**不**回显 |