Commit bd83f4c29bf260100364993d7e2fd6c76dadf429
1 parent
943857d0
chore(usr): REQ-USR-002 review approve + 归档 spec/plan/review
Showing
4 changed files
with
485 additions
and
1 deletions
docs/08-模块任务管理.md
docs/superpowers/plans/2026-05-15-REQ-USR-002.md
0 → 100644
| 1 | +--- | ||
| 2 | +req_id: REQ-USR-002 | ||
| 3 | +date: 2026-05-15 | ||
| 4 | +spec_ref: docs/superpowers/specs/2026-05-15-REQ-USR-002.md | ||
| 5 | +--- | ||
| 6 | + | ||
| 7 | +# REQ-USR-002 新增用户 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/v1/users` 接口,超级管理员新建用户(初始密码 `666666` 系统生成 + 哈希),同时落地 JWT 鉴权 HandlerInterceptor + `@RequireSuperAdmin` 角色守卫基础设施。 | ||
| 12 | + | ||
| 13 | +**Architecture:** | ||
| 14 | +- 鉴权:手写 `JwtHandlerInterceptor` 实现 `HandlerInterceptor`,通过 `WebMvcConfigurer` 注册,匹配 `/api/v1/**`,放行 `/api/v1/auth/login`;通过 `LoginContext` ThreadLocal 把当前用户上下文传递到 controller / service。 | ||
| 15 | +- 角色守卫:`@RequireSuperAdmin` 方法级注解;同一 interceptor 在 `handler instanceof HandlerMethod` 时检查注解 + `LoginContext.userType`。 | ||
| 16 | +- 业务:`UserCreateService` 单一职责(创建用户 + 写权限分类授权),事务边界一整个写入流程;唯一性预检 + DB 唯一索引兜底;外键存在性预检(employee + permissionCategory)。 | ||
| 17 | +- docs/05 同步:删除 CreateUserReq 的 `password` 字段与 `40002` 错误码(在 Task 1 一并完成)。 | ||
| 18 | + | ||
| 19 | +**Tech Stack:** 复用 REQ-USR-001 已建(Spring Boot 3 / MyBatis-Plus / BCrypt / JJWT);本 REQ 新增依赖:无。 | ||
| 20 | + | ||
| 21 | +--- | ||
| 22 | + | ||
| 23 | +## Schema 改动 | ||
| 24 | + | ||
| 25 | +无。V1 已建。 | ||
| 26 | + | ||
| 27 | +--- | ||
| 28 | + | ||
| 29 | +## 文件变更清单 | ||
| 30 | + | ||
| 31 | +**基础设施(鉴权 / 角色守卫)**: | ||
| 32 | +- `backend/src/main/java/com/xly/erp/common/security/LoginContext.java` — Create(ThreadLocal 工具) | ||
| 33 | +- `backend/src/main/java/com/xly/erp/common/security/JwtHandlerInterceptor.java` — Create | ||
| 34 | +- `backend/src/main/java/com/xly/erp/common/security/RequireSuperAdmin.java` — Create(注解) | ||
| 35 | +- `backend/src/main/java/com/xly/erp/common/config/WebMvcConfig.java` — Create(注册 interceptor) | ||
| 36 | +- `backend/src/main/resources/application.yml` — Modify:21(启用 `spring.jackson.deserialization.fail-on-unknown-properties: true`) | ||
| 37 | + | ||
| 38 | +**业务(REQ-USR-002 专属)**: | ||
| 39 | +- `backend/src/main/java/com/xly/erp/module/usr/dto/CreateUserReq.java` — Create | ||
| 40 | +- `backend/src/main/java/com/xly/erp/module/usr/vo/CreateUserVo.java` — Create | ||
| 41 | +- `backend/src/main/java/com/xly/erp/module/usr/mapper/SysUserMapper.java` — Modify(新增 `selectByUserCode` + `existsByUsername` + `existsByUserCode` 方法) | ||
| 42 | +- `backend/src/main/java/com/xly/erp/module/usr/mapper/SysPermissionCategoryMapper.java` — Create | ||
| 43 | +- `backend/src/main/java/com/xly/erp/module/usr/mapper/SysUserPermissionCategoryMapper.java` — Create | ||
| 44 | +- `backend/src/main/java/com/xly/erp/module/usr/entity/SysPermissionCategory.java` — Create | ||
| 45 | +- `backend/src/main/java/com/xly/erp/module/usr/entity/SysUserPermissionCategory.java` — Create | ||
| 46 | +- `backend/src/main/java/com/xly/erp/module/usr/service/UserCreateService.java` — Create(接口) | ||
| 47 | +- `backend/src/main/java/com/xly/erp/module/usr/service/impl/UserCreateServiceImpl.java` — Create | ||
| 48 | +- `backend/src/main/java/com/xly/erp/module/usr/controller/UserController.java` — Create | ||
| 49 | + | ||
| 50 | +**文档同步**: | ||
| 51 | +- `docs/05-API接口契约.md` — Modify(去掉 `password` + `40002`) | ||
| 52 | + | ||
| 53 | +**测试**: | ||
| 54 | +- `backend/src/test/java/com/xly/erp/common/security/JwtHandlerInterceptorTest.java` — Create(鉴权 / 角色守卫单测,含 MockMvc) | ||
| 55 | +- `backend/src/test/java/com/xly/erp/module/usr/service/UserCreateServiceImplTest.java` — Create(service 集成测) | ||
| 56 | +- `backend/src/test/java/com/xly/erp/module/usr/controller/UserControllerTest.java` — Create(controller 端到端) | ||
| 57 | +- `backend/src/test/java/com/xly/erp/module/usr/support/LoginTestSeeder.java` — Modify(扩展 seed:添加 SUPER_ADMIN 用户 + 2 个权限分类) | ||
| 58 | + | ||
| 59 | +--- | ||
| 60 | + | ||
| 61 | +## 约束常量 | ||
| 62 | + | ||
| 63 | +**错误码新增**(添加到 `ErrorCode`): | ||
| 64 | + | ||
| 65 | +| 常量 | 值 | HTTP | | ||
| 66 | +|---|---|---| | ||
| 67 | +| `FORBIDDEN` | `40301` | 403 | | ||
| 68 | +| `CONFLICT_USERNAME` | `40901` | 409 | | ||
| 69 | +| `CONFLICT_USERCODE` | `40902` | 409 | | ||
| 70 | + | ||
| 71 | +**初始密码**:常量 `UserCreateServiceImpl.INITIAL_PASSWORD = "666666"`。 | ||
| 72 | + | ||
| 73 | +**LoginContext API**(ThreadLocal 工具): | ||
| 74 | +- `LoginContext.set(int userId, String username, String userType, String companyCode)` | ||
| 75 | +- `LoginContext.current() : LoginUser`(含上述 4 字段,未登录返 null) | ||
| 76 | +- `LoginContext.clear()` | ||
| 77 | + | ||
| 78 | +**`@RequireSuperAdmin`**:方法级注解,无属性。 | ||
| 79 | + | ||
| 80 | +**`JwtHandlerInterceptor`** 行为序列(`preHandle`): | ||
| 81 | +1. handler 非 HandlerMethod → return true(静态资源等) | ||
| 82 | +2. method 标注 `@RequireSuperAdmin` 或在 `/api/v1/**` 路径下(非 login)→ 必须鉴权 | ||
| 83 | +3. 取 `Authorization` 头;缺失或无 `Bearer ` 前缀 → `BizException(40101, "未携带 token")` | ||
| 84 | +4. `jwtUtil.parse(token)` 取 claims | ||
| 85 | +5. `userMapper.selectByUsername(claims.username)` 查最新状态;不存在 / `iIsDeleted=1` / `tLockUntil > NOW()` → `BizException(40101, "token 关联用户不可用")` | ||
| 86 | +6. `LoginContext.set(...)` | ||
| 87 | +7. 若 method 标注 `@RequireSuperAdmin` 且 `userType != "SUPER_ADMIN"` → `BizException(40301, "权限不足,仅超级管理员可调用")` | ||
| 88 | +8. return true | ||
| 89 | +9. `afterCompletion` 调 `LoginContext.clear()` | ||
| 90 | + | ||
| 91 | +--- | ||
| 92 | + | ||
| 93 | +## 任务步骤 | ||
| 94 | + | ||
| 95 | +### Task 1: docs/05 同步 + ErrorCode 新增 3 个常量 | ||
| 96 | + | ||
| 97 | +**Files:** | ||
| 98 | +- Modify: `docs/05-API接口契约.md` § REQ-USR-002 — 删除 password 字段 + 40002 错误码 | ||
| 99 | +- Modify: `backend/src/main/java/com/xly/erp/common/response/ErrorCode.java` — 新增 FORBIDDEN / CONFLICT_USERNAME / CONFLICT_USERCODE 常量 + 在 `toHttpStatus` 对 40301 / 40901 / 40902 加映射 | ||
| 100 | +- Modify: `backend/src/test/java/com/xly/erp/common/response/ErrorCodeTest.java` — Create(覆盖新增 HTTP 映射) | ||
| 101 | + | ||
| 102 | +**API shape:** | ||
| 103 | +- `ErrorCode.FORBIDDEN = 40301` | ||
| 104 | +- `ErrorCode.CONFLICT_USERNAME = 40901` | ||
| 105 | +- `ErrorCode.CONFLICT_USERCODE = 40902` | ||
| 106 | +- `ErrorCode.toHttpStatus(40301) == 403`、`toHttpStatus(40901) == 409`、`toHttpStatus(40902) == 409` | ||
| 107 | + | ||
| 108 | +- [ ] **Step 1: 写失败测试** `ErrorCodeTest#httpMappings_coverNewCodes` | ||
| 109 | +- [ ] **Step 2: 实现最小代码** + 改 docs/05 | ||
| 110 | +- [ ] **Step 3: 子会话验证 PASS** | ||
| 111 | +- [ ] **Step 4: Commit** `chore(usr): docs/05 去 password 字段 + ErrorCode 新增 40301/40901/40902 REQ-USR-002` | ||
| 112 | + | ||
| 113 | +### Task 2: LoginContext ThreadLocal | ||
| 114 | + | ||
| 115 | +**Files:** | ||
| 116 | +- Create: `backend/src/main/java/com/xly/erp/common/security/LoginContext.java` | ||
| 117 | +- Create: `backend/src/test/java/com/xly/erp/common/security/LoginContextTest.java` | ||
| 118 | + | ||
| 119 | +**API shape:** | ||
| 120 | +- `LoginContext.LoginUser` — record(userId:Integer, username:String, userType:String, companyCode:String) | ||
| 121 | +- `LoginContext.set(LoginUser)` / `LoginContext.current()` / `LoginContext.clear()` | ||
| 122 | +- 用 `InheritableThreadLocal` 否,纯 `ThreadLocal`(避免子线程意外继承) | ||
| 123 | + | ||
| 124 | +- [ ] **Step 1: 写失败测试** | ||
| 125 | + - `LoginContextTest#setAndCurrent_isolatedPerThread` — 两个线程 set 不同值,互不影响 | ||
| 126 | + - `LoginContextTest#clear_returnsNullForCurrent` | ||
| 127 | +- [ ] **Step 2: 实现最小代码** | ||
| 128 | +- [ ] **Step 3: 子会话验证 PASS** | ||
| 129 | +- [ ] **Step 4: Commit** `feat(usr): LoginContext ThreadLocal REQ-USR-002` | ||
| 130 | + | ||
| 131 | +### Task 3: @RequireSuperAdmin 注解 + JwtHandlerInterceptor + WebMvcConfig | ||
| 132 | + | ||
| 133 | +**Files:** | ||
| 134 | +- Create: `backend/src/main/java/com/xly/erp/common/security/RequireSuperAdmin.java` | ||
| 135 | +- Create: `backend/src/main/java/com/xly/erp/common/security/JwtHandlerInterceptor.java` | ||
| 136 | +- Create: `backend/src/main/java/com/xly/erp/common/config/WebMvcConfig.java` | ||
| 137 | +- Create: `backend/src/test/java/com/xly/erp/common/security/JwtHandlerInterceptorTest.java` | ||
| 138 | + | ||
| 139 | +**API shape:** | ||
| 140 | +- `@RequireSuperAdmin` — `@Target(METHOD) @Retention(RUNTIME)` | ||
| 141 | +- `JwtHandlerInterceptor implements HandlerInterceptor` —— 行为见"约束常量" | ||
| 142 | +- `WebMvcConfig implements WebMvcConfigurer` —— `addInterceptors(registry)`:注册 JwtHandlerInterceptor,`.addPathPatterns("/api/v1/**").excludePathPatterns("/api/v1/auth/login")` | ||
| 143 | + | ||
| 144 | +测试用一个 `@TestController` 暴露 `/api/v1/_test/admin-only`(标 `@RequireSuperAdmin`)和 `/api/v1/_test/any-auth`(不标),MockMvc 调用: | ||
| 145 | + | ||
| 146 | +- [ ] **Step 1: 写失败测试** | ||
| 147 | + - `noAuthHeader_returns401_40101` | ||
| 148 | + - `invalidToken_returns401_40101` | ||
| 149 | + - `tokenForDeletedUser_returns401_40101` | ||
| 150 | + - `tokenForLockedUser_returns401_40101` | ||
| 151 | + - `validToken_normalUser_canAccessAnyAuthEndpoint` | ||
| 152 | + - `validToken_normalUser_cannotAccessAdminOnly_returns403_40301` | ||
| 153 | + - `validToken_superAdmin_canAccessAdminOnly` | ||
| 154 | + - `loginEndpointPath_skipsInterceptor`(鉴权未注入也能调 /api/v1/auth/login) | ||
| 155 | + - `loginContext_clearedAfterRequest`(请求结束后 ThreadLocal 应清空) | ||
| 156 | +- [ ] **Step 2: 实现最小代码** | ||
| 157 | +- [ ] **Step 3: 子会话验证 PASS** | ||
| 158 | +- [ ] **Step 4: Commit** `feat(usr): JwtHandlerInterceptor + @RequireSuperAdmin REQ-USR-002` | ||
| 159 | + | ||
| 160 | +### Task 4: SysPermissionCategory + SysUserPermissionCategory entity + mapper | ||
| 161 | + | ||
| 162 | +**Files:** | ||
| 163 | +- Create: `backend/src/main/java/com/xly/erp/module/usr/entity/SysPermissionCategory.java` | ||
| 164 | +- Create: `backend/src/main/java/com/xly/erp/module/usr/entity/SysUserPermissionCategory.java` | ||
| 165 | +- Create: `backend/src/main/java/com/xly/erp/module/usr/mapper/SysPermissionCategoryMapper.java` | ||
| 166 | +- Create: `backend/src/main/java/com/xly/erp/module/usr/mapper/SysUserPermissionCategoryMapper.java` | ||
| 167 | +- Modify: `backend/src/test/java/com/xly/erp/module/usr/support/LoginTestSeeder.java` — 扩展插入 SUPER_ADMIN 用户 + 2 个权限分类,并暴露 alice 的 ID + admin 的 ID + 权限分类 ID 给 Fixture record | ||
| 168 | + | ||
| 169 | +**API shape:** | ||
| 170 | +- entity 字段对齐 docs/03(与 SysUser 一致风格,匈牙利前缀 + Lombok @Data) | ||
| 171 | +- `SysPermissionCategoryMapper#countActiveByIds(List<Integer> ids) : int`(@Select 显式列,过滤 iIsDeleted=0) | ||
| 172 | +- `SysUserPermissionCategoryMapper extends BaseMapper<...>`,无自定义方法 | ||
| 173 | +- Fixture record 扩展:`Fixture(aliceId, bobDeletedId, employeeId, adminId, permissionCategoryIds)` 其中 permissionCategoryIds 是 List<Integer>,admin 用户名常量 `USER_ADMIN = "admin"` | ||
| 174 | + | ||
| 175 | +- [ ] **Step 1: 写失败测试** | ||
| 176 | + - `SysPermissionCategoryMapperTest#countActiveByIds_excludesDeleted` | ||
| 177 | + - `LoginTestSeederTest#fixture_includesAdminAndPermissionCategories` | ||
| 178 | +- [ ] **Step 2: 实现最小代码** | ||
| 179 | +- [ ] **Step 3: 子会话验证 PASS** | ||
| 180 | +- [ ] **Step 4: Commit** `feat(usr): sys_permission_category + sys_user_permission_category entity/mapper REQ-USR-002` | ||
| 181 | + | ||
| 182 | +### Task 5: SysUserMapper 唯一性查询方法 | ||
| 183 | + | ||
| 184 | +**Files:** | ||
| 185 | +- Modify: `backend/src/main/java/com/xly/erp/module/usr/mapper/SysUserMapper.java` — 新增 `existsByUsername / existsByUserCode` | ||
| 186 | +- Modify: `backend/src/test/java/com/xly/erp/module/usr/mapper/SysUserMapperTest.java` — 补测试 | ||
| 187 | + | ||
| 188 | +**API shape:** | ||
| 189 | +- `SysUserMapper#existsByUsername(String username) : boolean`(@Select `SELECT COUNT(*) > 0 FROM sys_user WHERE sUsername = #{username}`;MyBatis 把 int 0/1 自动转 boolean,或用 Integer 后 service 层做判断) | ||
| 190 | +- `SysUserMapper#existsByUserCode(String userCode) : boolean` | ||
| 191 | + | ||
| 192 | +- [ ] **Step 1: 写失败测试** | ||
| 193 | + - `SysUserMapperTest#existsByUsername_trueForExisting / falseForUnknown` | ||
| 194 | + - `SysUserMapperTest#existsByUserCode_trueForExisting / falseForUnknown` | ||
| 195 | +- [ ] **Step 2: 实现最小代码** | ||
| 196 | +- [ ] **Step 3: 子会话验证 PASS** | ||
| 197 | +- [ ] **Step 4: Commit** `feat(usr): SysUserMapper 用户名/用户号唯一性查询 REQ-USR-002` | ||
| 198 | + | ||
| 199 | +### Task 6: CreateUserReq + CreateUserVo | ||
| 200 | + | ||
| 201 | +**Files:** | ||
| 202 | +- Create: `backend/src/main/java/com/xly/erp/module/usr/dto/CreateUserReq.java` | ||
| 203 | +- Create: `backend/src/main/java/com/xly/erp/module/usr/vo/CreateUserVo.java` | ||
| 204 | +- Create: `backend/src/test/java/com/xly/erp/module/usr/dto/CreateUserReqValidationTest.java` | ||
| 205 | +- Modify: `backend/src/main/resources/application.yml` — 启用 `spring.jackson.deserialization.fail-on-unknown-properties: true` | ||
| 206 | +- Modify: `backend/src/main/resources/application-test.yml` — 同上 | ||
| 207 | + | ||
| 208 | +**API shape:** | ||
| 209 | +- `CreateUserReq { @NotBlank @Pattern(regexp="^[A-Za-z0-9_]{3,20}$") username, @NotBlank @Size(max=50) userCode, @NotBlank @Pattern(regexp="NORMAL|SUPER_ADMIN") userType, @NotBlank @Pattern(regexp="zh-CN|en-US|zh-TW") language, @NotNull Boolean canEditDocument, Integer employeeId, List<Integer> permissionCategoryIds }` | ||
| 210 | +- `CreateUserVo { Integer userId; String username; String userCode; }` + @Builder | ||
| 211 | + | ||
| 212 | +注意:不定义 `password` 字段;启用 fail-on-unknown-properties 后,请求带 password 会被 Jackson 直接拒绝。Jackson 反序列化异常 → 在 `GlobalExceptionHandler` 已有 `HttpMessageNotReadableException` handler(如果没有,本 Task 顺带补;同 round 1 review 推迟项) | ||
| 213 | + | ||
| 214 | +> 实际:round 1 没补 HttpMessageNotReadable handler。本 Task 顺手补到 `GlobalExceptionHandler`,让"包含未知字段"返 40001 / 400 而非 50000。 | ||
| 215 | + | ||
| 216 | +- [ ] **Step 1: 写失败测试** | ||
| 217 | + - `CreateUserReqValidationTest`(10 个用例):空 / 越长 / 用户名非法字符 / userType 非枚举 / language 非枚举 / canEditDocument 缺失 / employeeId 可空 / permissionCategoryIds 可空 / 全合法 / userCode 越长 | ||
| 218 | +- [ ] **Step 2: 实现最小代码** + 改两份 application yml + 补 HttpMessageNotReadable handler | ||
| 219 | +- [ ] **Step 3: 子会话验证 PASS** | ||
| 220 | +- [ ] **Step 4: Commit** `feat(usr): CreateUserReq/Vo + Jackson 严格反序列化 REQ-USR-002` | ||
| 221 | + | ||
| 222 | +### Task 7: UserCreateService 接口 + Impl 骨架(仅唯一性 / 外键校验) | ||
| 223 | + | ||
| 224 | +**Files:** | ||
| 225 | +- Create: `backend/src/main/java/com/xly/erp/module/usr/service/UserCreateService.java` | ||
| 226 | +- Create: `backend/src/main/java/com/xly/erp/module/usr/service/impl/UserCreateServiceImpl.java` | ||
| 227 | +- Create: `backend/src/test/java/com/xly/erp/module/usr/service/UserCreateServiceImplTest.java` | ||
| 228 | + | ||
| 229 | +**API shape:** | ||
| 230 | +- `UserCreateService#create(CreateUserReq req, String operatorUsername) : CreateUserVo` | ||
| 231 | +- `UserCreateServiceImpl` 注入:SysUserMapper / SysEmployeeMapper / SysPermissionCategoryMapper / SysUserPermissionCategoryMapper / BCryptPasswordEncoder | ||
| 232 | +- 常量 `INITIAL_PASSWORD = "666666"` | ||
| 233 | +- `@Transactional` | ||
| 234 | + | ||
| 235 | +本 Task 范围:实现 4 项校验(唯一用户名、唯一用户号、employeeId 存在、permissionCategoryIds 全部存在),但不实现实际写入——直接返回 dummy CreateUserVo(0, ..., ...);写入路径在 Task 8。 | ||
| 236 | + | ||
| 237 | +- [ ] **Step 1: 写失败测试**(service 集成测,复用扩展后的 LoginTestSeeder) | ||
| 238 | + - `create_usernameExists_throws40901` | ||
| 239 | + - `create_userCodeExists_throws40902` | ||
| 240 | + - `create_employeeIdNotFound_throws40004` | ||
| 241 | + - `create_employeeIdSoftDeleted_throws40004` | ||
| 242 | + - `create_permissionCategoryNotFound_throws40004`(含数组中混入不存在 ID) | ||
| 243 | +- [ ] **Step 2: 实现最小代码** | ||
| 244 | +- [ ] **Step 3: 子会话验证 PASS** | ||
| 245 | +- [ ] **Step 4: Commit** `feat(usr): UserCreateService 唯一性 + 外键校验 REQ-USR-002` | ||
| 246 | + | ||
| 247 | +### Task 8: UserCreateService 写入路径(sys_user + sys_user_permission_category 事务) | ||
| 248 | + | ||
| 249 | +**Files:** | ||
| 250 | +- Modify: `backend/src/main/java/com/xly/erp/module/usr/service/impl/UserCreateServiceImpl.java` | ||
| 251 | +- Modify: `backend/src/test/java/com/xly/erp/module/usr/service/UserCreateServiceImplTest.java` | ||
| 252 | + | ||
| 253 | +**API behavior:** | ||
| 254 | +- 校验全过 → BCrypt encode "666666" → 插入 sys_user(sCreatedBy = operatorUsername)→ 批量插入 sys_user_permission_category(每条 sGrantedBy = operatorUsername)→ 返回 CreateUserVo(新 userId, username, userCode) | ||
| 255 | +- 捕获 `DataIntegrityViolationException`(如并发同名)→ 抛 40901 / 40902(按异常消息含 `uk_sys_user_username` / `uk_sys_user_code` 判别) | ||
| 256 | + | ||
| 257 | +- [ ] **Step 1: 写失败测试** | ||
| 258 | + - `create_minimalFields_persistsUserWithInitialPassword` | ||
| 259 | + - `create_fullFields_persistsUserAndPermissionMappings` | ||
| 260 | + - `create_emptyPermissionCategories_persistsUserOnly`(permissionCategoryIds=空数组 / null 都允许) | ||
| 261 | + - `create_initialPasswordMatchesBcrypt666666`(用 BCryptPasswordEncoder.matches("666666", DB hash) == true) | ||
| 262 | + - `create_dataIntegrityViolation_username_throws40901`(先 select 返 false 但插入时报 DuplicateKey;用 spy / 模拟难,可在测试用并发场景模拟,或直接抛模拟异常验证转换) | ||
| 263 | +- [ ] **Step 2: 实现最小代码** | ||
| 264 | +- [ ] **Step 3: 子会话验证 PASS** | ||
| 265 | +- [ ] **Step 4: Commit** `feat(usr): UserCreateService 写入用户 + 权限分类授权 REQ-USR-002` | ||
| 266 | + | ||
| 267 | +### Task 9: UserController POST /api/v1/users + 端到端测试 | ||
| 268 | + | ||
| 269 | +**Files:** | ||
| 270 | +- Create: `backend/src/main/java/com/xly/erp/module/usr/controller/UserController.java` | ||
| 271 | +- Create: `backend/src/test/java/com/xly/erp/module/usr/controller/UserControllerTest.java` | ||
| 272 | + | ||
| 273 | +**API shape:** | ||
| 274 | +- `UserController` — `@RestController @RequestMapping("/api/v1/users")` | ||
| 275 | +- `POST /api/v1/users`:标注 `@RequireSuperAdmin`,方法签名 `Result<CreateUserVo> create(@RequestBody @Valid CreateUserReq req)`;调用 `userCreateService.create(req, LoginContext.current().username())`;返回 `ResponseEntity.status(201).body(Result.ok(vo))` | ||
| 276 | + | ||
| 277 | +端到端测试(`@SpringBootTest + @AutoConfigureMockMvc`):用 admin token 调用,覆盖: | ||
| 278 | + | ||
| 279 | +- [ ] **Step 1: 写失败测试** | ||
| 280 | + - `post_users_success_returns201_andCreatedVo` | ||
| 281 | + - `post_users_blankUsername_returns400_40001` | ||
| 282 | + - `post_users_invalidUserType_returns400_40001` | ||
| 283 | + - `post_users_unknownPropertyPassword_returns400_40001`(请求 body 含 `"password":"...".` 字段) | ||
| 284 | + - `post_users_noAuthHeader_returns401_40101` | ||
| 285 | + - `post_users_normalUserToken_returns403_40301` | ||
| 286 | + - `post_users_deletedUserToken_returns401_40101` | ||
| 287 | + - `post_users_duplicateUsername_returns409_40901` | ||
| 288 | + - `post_users_duplicateUserCode_returns409_40902` | ||
| 289 | + - `post_users_unknownEmployee_returns400_40004` | ||
| 290 | + - `post_users_unknownPermissionCategory_returns400_40004` | ||
| 291 | + - `post_users_success_canLoginWithInitialPassword`(创建后立即调 /auth/login 用 666666 应成功) | ||
| 292 | +- [ ] **Step 2: 实现最小代码** | ||
| 293 | +- [ ] **Step 3: 子会话验证 PASS** | ||
| 294 | +- [ ] **Step 4: Commit** `feat(usr): POST /api/v1/users controller + 端到端测试 REQ-USR-002` | ||
| 295 | + | ||
| 296 | +--- | ||
| 297 | + | ||
| 298 | +## 提交计划 | ||
| 299 | + | ||
| 300 | +| Task | Commit message | | ||
| 301 | +|---|---| | ||
| 302 | +| 1 | `chore(usr): docs/05 去 password 字段 + ErrorCode 新增 40301/40901/40902 REQ-USR-002` | | ||
| 303 | +| 2 | `feat(usr): LoginContext ThreadLocal REQ-USR-002` | | ||
| 304 | +| 3 | `feat(usr): JwtHandlerInterceptor + @RequireSuperAdmin REQ-USR-002` | | ||
| 305 | +| 4 | `feat(usr): sys_permission_category + sys_user_permission_category entity/mapper REQ-USR-002` | | ||
| 306 | +| 5 | `feat(usr): SysUserMapper 用户名/用户号唯一性查询 REQ-USR-002` | | ||
| 307 | +| 6 | `feat(usr): CreateUserReq/Vo + Jackson 严格反序列化 REQ-USR-002` | | ||
| 308 | +| 7 | `feat(usr): UserCreateService 唯一性 + 外键校验 REQ-USR-002` | | ||
| 309 | +| 8 | `feat(usr): UserCreateService 写入用户 + 权限分类授权 REQ-USR-002` | | ||
| 310 | +| 9 | `feat(usr): POST /api/v1/users controller + 端到端测试 REQ-USR-002` | |
docs/superpowers/reviews/2026-05-15-REQ-USR-002.md
0 → 100644
| 1 | +--- | ||
| 2 | +req_id: REQ-USR-002 | ||
| 3 | +date: 2026-05-15 | ||
| 4 | +round: 1 | ||
| 5 | +reviewer: superpower-code-reviewer | ||
| 6 | +--- | ||
| 7 | + | ||
| 8 | +# Review: REQ-USR-002 — round 1 | ||
| 9 | + | ||
| 10 | +## 结论 | ||
| 11 | +approve | ||
| 12 | + | ||
| 13 | +## Must-fix | ||
| 14 | +(无) | ||
| 15 | + | ||
| 16 | +## Nice-to-have | ||
| 17 | + | ||
| 18 | +- backend/src/main/java/com/xly/erp/module/usr/service/impl/UserCreateServiceImpl.java:82 — DataIntegrityViolationException 转 40901/40902 用 message 文本匹配,与驱动版本/locale 强耦合;建议改判 SQLState='23000' + 错误号 1062,并补 @SpyBean Mockito 回归测试(spec § 15 唯一索引并发兜底测试缺口) | ||
| 19 | +- backend/src/main/java/com/xly/erp/module/usr/service/impl/UserCreateServiceImpl.java:93 — 权限分类批量插入用 for + 单条 insert(N 次 IO);建议改 saveBatch 或在注释里写明 'N < 20 不批量' | ||
| 20 | +- backend/src/main/java/com/xly/erp/module/usr/service/impl/UserCreateServiceImpl.java:57 — countActiveByIds 用 COUNT(*) + size 比较,重复 ID(如 [1,1,2])会误判 40004;建议先 dedup 或改用 COUNT(DISTINCT iIncrement) | ||
| 21 | +- backend/src/main/java/com/xly/erp/module/usr/service/impl/UserCreateServiceImpl.java:52 — employee/permissionCategory 校验失败抛 COMPANY_NOT_FOUND 但 message 含义错位;后续可拆 RESOURCE_NOT_FOUND 段位或重命名 COMPANY_NOT_FOUND → REFERENCE_NOT_FOUND | ||
| 22 | +- backend/src/main/java/com/xly/erp/common/security/JwtHandlerInterceptor.java:48 — 每请求 1 次 selectByUsername DB 查询;后续可加 Caffeine 短期缓存(容量评估时再做) | ||
| 23 | +- backend/src/main/java/com/xly/erp/common/security/JwtHandlerInterceptor.java:60 — claims.get('companyCode') 未做 null 校验;建议在 LoginUser 处文档化或加 log.warn | ||
| 24 | +- backend/src/main/java/com/xly/erp/common/security/JwtHandlerInterceptor.java:66 — set LoginContext 早于角色守卫;建议加注释说明该顺序是有意的,afterCompletion 保证清理 | ||
| 25 | +- backend/src/main/java/com/xly/erp/common/security/JwtHandlerInterceptor.java:35 — plan 声明 'handler 非 HandlerMethod → return true',实际未实现该顶部短路;建议补齐对齐 plan | ||
| 26 | +- backend/src/test/java/com/xly/erp/common/security/JwtHandlerInterceptorTest.java:124 — plan 列出 `loginContext_clearedAfterRequest` 测试用例但未实现;建议补显式清理回归 | ||
| 27 | +- backend/src/main/java/com/xly/erp/module/usr/dto/CreateUserReq.java:24 — userType/language 用 @Pattern 枚举校验失类型安全;可改为 service 层 toEnum 模式 | ||
| 28 | +- backend/src/main/resources/application.yml:8 — fail-on-unknown-properties=true 是全局开关;建议在 docs/04 § 1.3 留痕说明所有 DTO 必须完整定义 | ||
| 29 | +- backend/src/main/java/com/xly/erp/module/usr/controller/UserController.java:28 — LoginContext.current() 未 null 校验;建议加防御性 Optional.ofNullable | ||
| 30 | +- backend/src/main/java/com/xly/erp/module/usr/entity/SysPermissionCategory.java:17 — entity 含未写入的多租户字段(sId/sBrandsId 等),与 SysUser 风格一致,可接受 | ||
| 31 | + | ||
| 32 | +## 反例 / 测试覆盖缺口 | ||
| 33 | + | ||
| 34 | +1. spec § 15「唯一索引兜底(DataIntegrityViolationException 路径)」未实测——UserCreateServiceImplTest 缺乏对 catch 分支的回归 | ||
| 35 | +2. plan § Step 1 列出 `loginContext_clearedAfterRequest` 但实际未实现,ThreadLocal 清理路径无显式回归 | ||
| 36 | +3. docs/04 § 1.3 段位(10xxx/20xxx/...)与代码 / docs/05 实际使用的 HTTP-aligned 段位(40001/40101/40301/...)冲突——round 1 REQ-USR-001 已记录,本 REQ 沿用未拉齐;建议单独 PR 修订 docs/04 § 1.3 | ||
| 37 | +4. `permissionCategoryIds` 含重复 ID 行为未定义(countActiveByIds 用 COUNT(*) + size 会 false-negative),无对应测试 | ||
| 38 | + | ||
| 39 | +## 总结 | ||
| 40 | + | ||
| 41 | +REQ-USR-002 整体实现质量较高:鉴权 / 角色守卫拦截器流程清晰、事务边界正确、唯一性预检 + DB 兜底双层防御、初始密码 BCrypt 哈希、Jackson 严格反序列化拒绝 password 字段、85 测试覆盖 spec 验收 1-14。文档同步(docs/05 删除 password+40002)已完成,错误码新增 40301/40901/40902 + HTTP 映射准确。功能、安全、文档与 plan/spec 三方一致,无高/中级阻塞问题。Approve。 |
docs/superpowers/specs/2026-05-15-REQ-USR-002.md
0 → 100644
| 1 | +--- | ||
| 2 | +req_id: REQ-USR-002 | ||
| 3 | +date: 2026-05-15 | ||
| 4 | +module: module_usr | ||
| 5 | +--- | ||
| 6 | + | ||
| 7 | +# Spec: REQ-USR-002 — 新增用户 | ||
| 8 | + | ||
| 9 | +## 目标 | ||
| 10 | + | ||
| 11 | +超级管理员通过 `POST /api/v1/users` 新建用户账号。账号立即生效;初始密码由系统统一设为 `666666`(哈希后存入);可选关联职员;同时按勾选写入权限分类授权关系。 | ||
| 12 | + | ||
| 13 | +## 输入 / 触发 | ||
| 14 | + | ||
| 15 | +HTTP 入口 `POST /api/v1/users`。要求请求头 `Authorization: Bearer <accessToken>`。 | ||
| 16 | + | ||
| 17 | +**请求体 CreateUserReq**(JSON): | ||
| 18 | + | ||
| 19 | +| 字段 | 类型 | 必填 | 校验规则 | | ||
| 20 | +|---|---|---|---| | ||
| 21 | +| `username` | string | 是 | 非空;3-20 位字母数字下划线(正则 `^[A-Za-z0-9_]{3,20}$`);系统内唯一(命中 `sys_user.sUsername` 返 40901) | | ||
| 22 | +| `userCode` | string | 是 | 非空;最大 50;系统内唯一(命中 `sys_user.sUserCode` 返 40902) | | ||
| 23 | +| `userType` | string | 是 | 枚举 `NORMAL` / `SUPER_ADMIN` | | ||
| 24 | +| `language` | string | 是 | 枚举 `zh-CN` / `en-US` / `zh-TW` | | ||
| 25 | +| `canEditDocument` | boolean | 是 | true / false | | ||
| 26 | +| `employeeId` | int | 否 | 若不为 null,必须命中 `sys_employee.iIncrement` AND `iIsDeleted=0`,否则返 40004 | | ||
| 27 | +| `permissionCategoryIds` | int[] | 否 | 可为空数组或省略;每个元素必须命中 `sys_permission_category.iIncrement` AND `iIsDeleted=0`,否则返 40004 | | ||
| 28 | + | ||
| 29 | +> **本 REQ 不接受 `password` 字段**:用户提交的任何 password 字段被忽略(或返 40001)。docs/05 应同步删除 `password` 与 `40002` —— 本 REQ 落地时由实现方在 docs/05 一并修订(spec 自审范畴)。 | ||
| 30 | + | ||
| 31 | +## 输出 / 结果 | ||
| 32 | + | ||
| 33 | +**成功 201 Created**:`Result<CreateUserVo>` | ||
| 34 | + | ||
| 35 | +```json | ||
| 36 | +{ "code": 200, "message": "操作成功", "data": { "userId": 42, "username": "alice", "userCode": "U001" }, "timestamp": ... } | ||
| 37 | +``` | ||
| 38 | + | ||
| 39 | +> HTTP 201 体现新资源创建;body 仍走统一 `Result` 包装(docs/04 § 1.3)。 | ||
| 40 | + | ||
| 41 | +副作用(同一事务): | ||
| 42 | +1. `sys_user` 插入一条新记录: | ||
| 43 | + - `sPasswordHash` = `BCryptPasswordEncoder.encode("666666")` | ||
| 44 | + - `iFailedLoginCount` = 0、`tLockUntil` = NULL、`tLastLoginDate` = NULL | ||
| 45 | + - `iIsDeleted` = 0 | ||
| 46 | + - `sCreatedBy` = 当前登录用户 username(来自 JWT claim) | ||
| 47 | +2. `sys_user_permission_category` 按 `permissionCategoryIds` 批量插入授权记录,`sGrantedBy` = 当前登录用户 username | ||
| 48 | + | ||
| 49 | +**失败**: | ||
| 50 | + | ||
| 51 | +| HTTP | code | 含义 | 触发条件 | | ||
| 52 | +|---|---|---|---| | ||
| 53 | +| 400 | 40001 | 必填字段缺失或格式错误 | jakarta 校验失败(@NotBlank / @Pattern / 枚举不合法);请求包含 `password` 字段(本 REQ 不允许) | | ||
| 54 | +| 400 | 40004 | 员工或权限分类不存在 | `employeeId` 或 `permissionCategoryIds` 中任一不命中 | | ||
| 55 | +| 401 | 40101 | 未携带或无效 Token | Authorization 缺失 / 解析失败 / 用户已作废 / 用户已锁定 | | ||
| 56 | +| 403 | 40301 | 非超级管理员调用 | JWT claim `userType != SUPER_ADMIN` | | ||
| 57 | +| 409 | 40901 | 用户名已存在 | sUsername 唯一冲突(DB 唯一键 `uk_sys_user_username` 兜底;service 层 select 预检以返友好错误) | | ||
| 58 | +| 409 | 40902 | 用户号已存在 | sUserCode 唯一冲突 | | ||
| 59 | + | ||
| 60 | +## 业务规则 | ||
| 61 | + | ||
| 62 | +1. **鉴权**:手写 `JwtHandlerInterceptor` 实现 `HandlerInterceptor`,注册到 `WebMvcConfigurer`,匹配 `/api/v1/**` 但排除 `/api/v1/auth/login`: | ||
| 63 | + - 读 `Authorization: Bearer <token>` 头;缺失 → 抛 `BizException(40101, "未携带 token")` | ||
| 64 | + - `JwtUtil.parse(token)` 抛 BizException 时透传(JwtUtil 内部已返 40101) | ||
| 65 | + - 解析 claims → 查 `sys_user.iIncrement = sub`;若不存在 / `iIsDeleted=1` / `tLockUntil > NOW()` → 抛 `BizException(40101, "token 关联用户不可用")` | ||
| 66 | + - 把 username / userType / userId / companyCode 放入 `LoginContext`(ThreadLocal 工具)供后续使用 | ||
| 67 | + - 业务方法结束时(`afterCompletion`)清理 ThreadLocal | ||
| 68 | +2. **角色守卫**:用自定义注解 `@RequireSuperAdmin`(标注在 controller 方法上)+ 同一 interceptor 检查 `handler instanceof HandlerMethod && method.isAnnotationPresent(...)` → 校验 `LoginContext.userType == SUPER_ADMIN`;不匹配 → 抛 `BizException(40301, "权限不足,仅超级管理员可调用")` | ||
| 69 | +3. **唯一性校验**:在 service 写入前用 `selectByUsername` / `selectByUserCode` 查重(友好返 40901 / 40902);DB 唯一索引兜底(捕获 `DataIntegrityViolationException` 转 40901 或 40902,避免堆栈泄漏) | ||
| 70 | +4. **外键校验**:employeeId / permissionCategoryIds 在写入前一次性 select 校验(SELECT iIncrement FROM ... WHERE iIncrement IN(...) AND iIsDeleted=0),数量不齐 → 40004 | ||
| 71 | +5. **初始密码**:固定字符串 `"666666"`,通过 `BCryptPasswordEncoder.encode` 哈希;常量定义在 `LoginServiceImpl.INITIAL_PASSWORD`(已存在)或新建 `UserCreateServiceImpl.INITIAL_PASSWORD` | ||
| 72 | +6. **事务**:`@Transactional`,sys_user 插入 + sys_user_permission_category 批量插入在同一事务;唯一冲突或 FK 不存在均回滚 | ||
| 73 | +7. **本 REQ 不接受 password 字段**:DTO 不定义 password 属性;若客户端误传,Jackson 默认配置(`FAIL_ON_UNKNOWN_PROPERTIES=false`)会忽略——但为防御性,建议 `application.yml` 设 `spring.jackson.deserialization.fail-on-unknown-properties: true`,确保多余字段返 40001 | ||
| 74 | + | ||
| 75 | +## 边界与约束 | ||
| 76 | + | ||
| 77 | +- **HTTPS / 鉴权 / 统一响应 / 异常**:复用 REQ-USR-001 已建的全部基础设施 | ||
| 78 | +- **HandlerInterceptor 位置**:放在 `backend/src/main/java/com/xly/erp/common/security/`,与 `JwtUtil` 同包 | ||
| 79 | +- **ThreadLocal 工具**:`LoginContext` 单例,提供 `current() / set() / clear()`;测试场景可手工 set 以模拟登录态 | ||
| 80 | +- **@RequireSuperAdmin 注解**:放在 `backend/src/main/java/com/xly/erp/common/security/` | ||
| 81 | +- **不实现**: | ||
| 82 | + - 修改密码 / 重置密码(推迟到后续 REQ) | ||
| 83 | + - 用户软删除接口(推迟到 REQ-USR-003 的"作废"路径) | ||
| 84 | + - 权限分类的新增 / 编辑(运营模块) | ||
| 85 | + - 员工的 CRUD(HR 模块) | ||
| 86 | + - JWT 黑名单 / refresh token / 多端互踢(推迟) | ||
| 87 | +- **docs/05 同步修订**:本 REQ 实现时需同步修订 docs/05 § REQ-USR-002,去掉 `password` 字段与 `40002` 错误码(与 REQ 卡片对齐为"系统生成初始密码") | ||
| 88 | + | ||
| 89 | +## 依赖的 schema 表 / 字段 | ||
| 90 | + | ||
| 91 | +写 `sys_user`(V1 已建): | ||
| 92 | +- 写入:`sUsername`, `sUserCode`, `sPasswordHash`, `iEmployeeId`, `sUserType`, `sLanguage`, `iCanEditDocument`, `iIsDeleted=0`, `iFailedLoginCount=0`, `sCreatedBy` | ||
| 93 | + | ||
| 94 | +写 `sys_user_permission_category`(V1 已建): | ||
| 95 | +- 写入:`iUserId`, `iPermissionCategoryId`, `sGrantedBy` | ||
| 96 | + | ||
| 97 | +只读 `sys_employee`(V1 已建): | ||
| 98 | +- 读:`iIncrement`, `iIsDeleted`(校验 employeeId 存在) | ||
| 99 | + | ||
| 100 | +只读 `sys_permission_category`(V1 已建): | ||
| 101 | +- 读:`iIncrement`, `iIsDeleted`(校验 permissionCategoryIds 全部存在) | ||
| 102 | + | ||
| 103 | +**本 REQ 不需要新增 migration**。 | ||
| 104 | + | ||
| 105 | +## 依赖的接口 | ||
| 106 | + | ||
| 107 | +- 本 REQ 提供:`POST /api/v1/users` | ||
| 108 | +- 鉴权前置依赖:JWT 由 REQ-USR-001 签发 | ||
| 109 | +- 下游会消费但本 REQ 不实现的接口: | ||
| 110 | + - `GET /api/v1/employees`(员工下拉,后续 HR 模块) | ||
| 111 | + - `GET /api/v1/permission-categories`(权限分类下拉,后续运营模块) | ||
| 112 | + | ||
| 113 | +## 验收标准 | ||
| 114 | + | ||
| 115 | +后端集成测试: | ||
| 116 | + | ||
| 117 | +1. **正常路径(最小字段)**:管理员 token + 合法 username / userCode / userType / language / canEditDocument,不带 employeeId / permissionCategoryIds → 201;DB 出现新用户记录,`sPasswordHash` 非空且可被 `BCryptPasswordEncoder.matches("666666", ...)` 验证;`iIsDeleted=0`,`iFailedLoginCount=0`,`sCreatedBy` = 管理员 username | ||
| 118 | +2. **正常路径(完整字段)**:带 employeeId + permissionCategoryIds(2 条)→ 201;DB sys_user 一行 + sys_user_permission_category 两行;`sGrantedBy` 正确 | ||
| 119 | +3. **新用户立刻可登录**:调用 `POST /api/v1/auth/login` 用初始密码 `666666` + 任意有效公司 → 200 + token | ||
| 120 | +4. **缺 Authorization 头** → 401 / 40101 | ||
| 121 | +5. **Token 无效(篡改)** → 401 / 40101 | ||
| 122 | +6. **Token 关联用户已作废** → 401 / 40101 | ||
| 123 | +7. **NORMAL 用户 token 调用** → 403 / 40301 | ||
| 124 | +8. **username 重复** → 409 / 40901;DB 没有新用户被插入 | ||
| 125 | +9. **userCode 重复** → 409 / 40902;DB 没有新用户被插入 | ||
| 126 | +10. **employeeId 不存在 / 已软删** → 400 / 40004 | ||
| 127 | +11. **permissionCategoryIds 含不存在 ID** → 400 / 40004;事务回滚(sys_user 也未插入) | ||
| 128 | +12. **请求体缺 username / username 含非法字符** → 400 / 40001 | ||
| 129 | +13. **请求体携带 password 字段** → 400 / 40001(Jackson 严格反序列化) | ||
| 130 | +14. **userType / language 非枚举值** → 400 / 40001 | ||
| 131 | +15. **唯一索引兜底**:模拟绕过 service 层预检的并发场景(DataIntegrityViolationException 路径),断言返 40901 而非 50000 | ||
| 132 | + | ||
| 133 | +测试基础:复用 `LoginTestSeeder` + 扩展 `UserCreateTestSeeder`(管理员 token 注入 + 权限分类 fixture)。 |