--- req_id: REQ-USR-002 date: 2026-05-15 spec_ref: docs/superpowers/specs/2026-05-15-REQ-USR-002.md --- # REQ-USR-002 新增用户 Implementation Plan > **Execution:** Parent skill `feature-tdd` executes this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** `POST /api/v1/users` 接口,超级管理员新建用户(初始密码 `666666` 系统生成 + 哈希),同时落地 JWT 鉴权 HandlerInterceptor + `@RequireSuperAdmin` 角色守卫基础设施。 **Architecture:** - 鉴权:手写 `JwtHandlerInterceptor` 实现 `HandlerInterceptor`,通过 `WebMvcConfigurer` 注册,匹配 `/api/v1/**`,放行 `/api/v1/auth/login`;通过 `LoginContext` ThreadLocal 把当前用户上下文传递到 controller / service。 - 角色守卫:`@RequireSuperAdmin` 方法级注解;同一 interceptor 在 `handler instanceof HandlerMethod` 时检查注解 + `LoginContext.userType`。 - 业务:`UserCreateService` 单一职责(创建用户 + 写权限分类授权),事务边界一整个写入流程;唯一性预检 + DB 唯一索引兜底;外键存在性预检(employee + permissionCategory)。 - docs/05 同步:删除 CreateUserReq 的 `password` 字段与 `40002` 错误码(在 Task 1 一并完成)。 **Tech Stack:** 复用 REQ-USR-001 已建(Spring Boot 3 / MyBatis-Plus / BCrypt / JJWT);本 REQ 新增依赖:无。 --- ## Schema 改动 无。V1 已建。 --- ## 文件变更清单 **基础设施(鉴权 / 角色守卫)**: - `backend/src/main/java/com/xly/erp/common/security/LoginContext.java` — Create(ThreadLocal 工具) - `backend/src/main/java/com/xly/erp/common/security/JwtHandlerInterceptor.java` — Create - `backend/src/main/java/com/xly/erp/common/security/RequireSuperAdmin.java` — Create(注解) - `backend/src/main/java/com/xly/erp/common/config/WebMvcConfig.java` — Create(注册 interceptor) - `backend/src/main/resources/application.yml` — Modify:21(启用 `spring.jackson.deserialization.fail-on-unknown-properties: true`) **业务(REQ-USR-002 专属)**: - `backend/src/main/java/com/xly/erp/module/usr/dto/CreateUserReq.java` — Create - `backend/src/main/java/com/xly/erp/module/usr/vo/CreateUserVo.java` — Create - `backend/src/main/java/com/xly/erp/module/usr/mapper/SysUserMapper.java` — Modify(新增 `selectByUserCode` + `existsByUsername` + `existsByUserCode` 方法) - `backend/src/main/java/com/xly/erp/module/usr/mapper/SysPermissionCategoryMapper.java` — Create - `backend/src/main/java/com/xly/erp/module/usr/mapper/SysUserPermissionCategoryMapper.java` — Create - `backend/src/main/java/com/xly/erp/module/usr/entity/SysPermissionCategory.java` — Create - `backend/src/main/java/com/xly/erp/module/usr/entity/SysUserPermissionCategory.java` — Create - `backend/src/main/java/com/xly/erp/module/usr/service/UserCreateService.java` — Create(接口) - `backend/src/main/java/com/xly/erp/module/usr/service/impl/UserCreateServiceImpl.java` — Create - `backend/src/main/java/com/xly/erp/module/usr/controller/UserController.java` — Create **文档同步**: - `docs/05-API接口契约.md` — Modify(去掉 `password` + `40002`) **测试**: - `backend/src/test/java/com/xly/erp/common/security/JwtHandlerInterceptorTest.java` — Create(鉴权 / 角色守卫单测,含 MockMvc) - `backend/src/test/java/com/xly/erp/module/usr/service/UserCreateServiceImplTest.java` — Create(service 集成测) - `backend/src/test/java/com/xly/erp/module/usr/controller/UserControllerTest.java` — Create(controller 端到端) - `backend/src/test/java/com/xly/erp/module/usr/support/LoginTestSeeder.java` — Modify(扩展 seed:添加 SUPER_ADMIN 用户 + 2 个权限分类) --- ## 约束常量 **错误码新增**(添加到 `ErrorCode`): | 常量 | 值 | HTTP | |---|---|---| | `FORBIDDEN` | `40301` | 403 | | `CONFLICT_USERNAME` | `40901` | 409 | | `CONFLICT_USERCODE` | `40902` | 409 | **初始密码**:常量 `UserCreateServiceImpl.INITIAL_PASSWORD = "666666"`。 **LoginContext API**(ThreadLocal 工具): - `LoginContext.set(int userId, String username, String userType, String companyCode)` - `LoginContext.current() : LoginUser`(含上述 4 字段,未登录返 null) - `LoginContext.clear()` **`@RequireSuperAdmin`**:方法级注解,无属性。 **`JwtHandlerInterceptor`** 行为序列(`preHandle`): 1. handler 非 HandlerMethod → return true(静态资源等) 2. method 标注 `@RequireSuperAdmin` 或在 `/api/v1/**` 路径下(非 login)→ 必须鉴权 3. 取 `Authorization` 头;缺失或无 `Bearer ` 前缀 → `BizException(40101, "未携带 token")` 4. `jwtUtil.parse(token)` 取 claims 5. `userMapper.selectByUsername(claims.username)` 查最新状态;不存在 / `iIsDeleted=1` / `tLockUntil > NOW()` → `BizException(40101, "token 关联用户不可用")` 6. `LoginContext.set(...)` 7. 若 method 标注 `@RequireSuperAdmin` 且 `userType != "SUPER_ADMIN"` → `BizException(40301, "权限不足,仅超级管理员可调用")` 8. return true 9. `afterCompletion` 调 `LoginContext.clear()` --- ## 任务步骤 ### Task 1: docs/05 同步 + ErrorCode 新增 3 个常量 **Files:** - Modify: `docs/05-API接口契约.md` § REQ-USR-002 — 删除 password 字段 + 40002 错误码 - Modify: `backend/src/main/java/com/xly/erp/common/response/ErrorCode.java` — 新增 FORBIDDEN / CONFLICT_USERNAME / CONFLICT_USERCODE 常量 + 在 `toHttpStatus` 对 40301 / 40901 / 40902 加映射 - Modify: `backend/src/test/java/com/xly/erp/common/response/ErrorCodeTest.java` — Create(覆盖新增 HTTP 映射) **API shape:** - `ErrorCode.FORBIDDEN = 40301` - `ErrorCode.CONFLICT_USERNAME = 40901` - `ErrorCode.CONFLICT_USERCODE = 40902` - `ErrorCode.toHttpStatus(40301) == 403`、`toHttpStatus(40901) == 409`、`toHttpStatus(40902) == 409` - [ ] **Step 1: 写失败测试** `ErrorCodeTest#httpMappings_coverNewCodes` - [ ] **Step 2: 实现最小代码** + 改 docs/05 - [ ] **Step 3: 子会话验证 PASS** - [ ] **Step 4: Commit** `chore(usr): docs/05 去 password 字段 + ErrorCode 新增 40301/40901/40902 REQ-USR-002` ### Task 2: LoginContext ThreadLocal **Files:** - Create: `backend/src/main/java/com/xly/erp/common/security/LoginContext.java` - Create: `backend/src/test/java/com/xly/erp/common/security/LoginContextTest.java` **API shape:** - `LoginContext.LoginUser` — record(userId:Integer, username:String, userType:String, companyCode:String) - `LoginContext.set(LoginUser)` / `LoginContext.current()` / `LoginContext.clear()` - 用 `InheritableThreadLocal` 否,纯 `ThreadLocal`(避免子线程意外继承) - [ ] **Step 1: 写失败测试** - `LoginContextTest#setAndCurrent_isolatedPerThread` — 两个线程 set 不同值,互不影响 - `LoginContextTest#clear_returnsNullForCurrent` - [ ] **Step 2: 实现最小代码** - [ ] **Step 3: 子会话验证 PASS** - [ ] **Step 4: Commit** `feat(usr): LoginContext ThreadLocal REQ-USR-002` ### Task 3: @RequireSuperAdmin 注解 + JwtHandlerInterceptor + WebMvcConfig **Files:** - Create: `backend/src/main/java/com/xly/erp/common/security/RequireSuperAdmin.java` - Create: `backend/src/main/java/com/xly/erp/common/security/JwtHandlerInterceptor.java` - Create: `backend/src/main/java/com/xly/erp/common/config/WebMvcConfig.java` - Create: `backend/src/test/java/com/xly/erp/common/security/JwtHandlerInterceptorTest.java` **API shape:** - `@RequireSuperAdmin` — `@Target(METHOD) @Retention(RUNTIME)` - `JwtHandlerInterceptor implements HandlerInterceptor` —— 行为见"约束常量" - `WebMvcConfig implements WebMvcConfigurer` —— `addInterceptors(registry)`:注册 JwtHandlerInterceptor,`.addPathPatterns("/api/v1/**").excludePathPatterns("/api/v1/auth/login")` 测试用一个 `@TestController` 暴露 `/api/v1/_test/admin-only`(标 `@RequireSuperAdmin`)和 `/api/v1/_test/any-auth`(不标),MockMvc 调用: - [ ] **Step 1: 写失败测试** - `noAuthHeader_returns401_40101` - `invalidToken_returns401_40101` - `tokenForDeletedUser_returns401_40101` - `tokenForLockedUser_returns401_40101` - `validToken_normalUser_canAccessAnyAuthEndpoint` - `validToken_normalUser_cannotAccessAdminOnly_returns403_40301` - `validToken_superAdmin_canAccessAdminOnly` - `loginEndpointPath_skipsInterceptor`(鉴权未注入也能调 /api/v1/auth/login) - `loginContext_clearedAfterRequest`(请求结束后 ThreadLocal 应清空) - [ ] **Step 2: 实现最小代码** - [ ] **Step 3: 子会话验证 PASS** - [ ] **Step 4: Commit** `feat(usr): JwtHandlerInterceptor + @RequireSuperAdmin REQ-USR-002` ### Task 4: SysPermissionCategory + SysUserPermissionCategory entity + mapper **Files:** - Create: `backend/src/main/java/com/xly/erp/module/usr/entity/SysPermissionCategory.java` - Create: `backend/src/main/java/com/xly/erp/module/usr/entity/SysUserPermissionCategory.java` - Create: `backend/src/main/java/com/xly/erp/module/usr/mapper/SysPermissionCategoryMapper.java` - Create: `backend/src/main/java/com/xly/erp/module/usr/mapper/SysUserPermissionCategoryMapper.java` - Modify: `backend/src/test/java/com/xly/erp/module/usr/support/LoginTestSeeder.java` — 扩展插入 SUPER_ADMIN 用户 + 2 个权限分类,并暴露 alice 的 ID + admin 的 ID + 权限分类 ID 给 Fixture record **API shape:** - entity 字段对齐 docs/03(与 SysUser 一致风格,匈牙利前缀 + Lombok @Data) - `SysPermissionCategoryMapper#countActiveByIds(List ids) : int`(@Select 显式列,过滤 iIsDeleted=0) - `SysUserPermissionCategoryMapper extends BaseMapper<...>`,无自定义方法 - Fixture record 扩展:`Fixture(aliceId, bobDeletedId, employeeId, adminId, permissionCategoryIds)` 其中 permissionCategoryIds 是 List,admin 用户名常量 `USER_ADMIN = "admin"` - [ ] **Step 1: 写失败测试** - `SysPermissionCategoryMapperTest#countActiveByIds_excludesDeleted` - `LoginTestSeederTest#fixture_includesAdminAndPermissionCategories` - [ ] **Step 2: 实现最小代码** - [ ] **Step 3: 子会话验证 PASS** - [ ] **Step 4: Commit** `feat(usr): sys_permission_category + sys_user_permission_category entity/mapper REQ-USR-002` ### Task 5: SysUserMapper 唯一性查询方法 **Files:** - Modify: `backend/src/main/java/com/xly/erp/module/usr/mapper/SysUserMapper.java` — 新增 `existsByUsername / existsByUserCode` - Modify: `backend/src/test/java/com/xly/erp/module/usr/mapper/SysUserMapperTest.java` — 补测试 **API shape:** - `SysUserMapper#existsByUsername(String username) : boolean`(@Select `SELECT COUNT(*) > 0 FROM sys_user WHERE sUsername = #{username}`;MyBatis 把 int 0/1 自动转 boolean,或用 Integer 后 service 层做判断) - `SysUserMapper#existsByUserCode(String userCode) : boolean` - [ ] **Step 1: 写失败测试** - `SysUserMapperTest#existsByUsername_trueForExisting / falseForUnknown` - `SysUserMapperTest#existsByUserCode_trueForExisting / falseForUnknown` - [ ] **Step 2: 实现最小代码** - [ ] **Step 3: 子会话验证 PASS** - [ ] **Step 4: Commit** `feat(usr): SysUserMapper 用户名/用户号唯一性查询 REQ-USR-002` ### Task 6: CreateUserReq + CreateUserVo **Files:** - Create: `backend/src/main/java/com/xly/erp/module/usr/dto/CreateUserReq.java` - Create: `backend/src/main/java/com/xly/erp/module/usr/vo/CreateUserVo.java` - Create: `backend/src/test/java/com/xly/erp/module/usr/dto/CreateUserReqValidationTest.java` - Modify: `backend/src/main/resources/application.yml` — 启用 `spring.jackson.deserialization.fail-on-unknown-properties: true` - Modify: `backend/src/main/resources/application-test.yml` — 同上 **API shape:** - `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 permissionCategoryIds }` - `CreateUserVo { Integer userId; String username; String userCode; }` + @Builder 注意:不定义 `password` 字段;启用 fail-on-unknown-properties 后,请求带 password 会被 Jackson 直接拒绝。Jackson 反序列化异常 → 在 `GlobalExceptionHandler` 已有 `HttpMessageNotReadableException` handler(如果没有,本 Task 顺带补;同 round 1 review 推迟项) > 实际:round 1 没补 HttpMessageNotReadable handler。本 Task 顺手补到 `GlobalExceptionHandler`,让"包含未知字段"返 40001 / 400 而非 50000。 - [ ] **Step 1: 写失败测试** - `CreateUserReqValidationTest`(10 个用例):空 / 越长 / 用户名非法字符 / userType 非枚举 / language 非枚举 / canEditDocument 缺失 / employeeId 可空 / permissionCategoryIds 可空 / 全合法 / userCode 越长 - [ ] **Step 2: 实现最小代码** + 改两份 application yml + 补 HttpMessageNotReadable handler - [ ] **Step 3: 子会话验证 PASS** - [ ] **Step 4: Commit** `feat(usr): CreateUserReq/Vo + Jackson 严格反序列化 REQ-USR-002` ### Task 7: UserCreateService 接口 + Impl 骨架(仅唯一性 / 外键校验) **Files:** - Create: `backend/src/main/java/com/xly/erp/module/usr/service/UserCreateService.java` - Create: `backend/src/main/java/com/xly/erp/module/usr/service/impl/UserCreateServiceImpl.java` - Create: `backend/src/test/java/com/xly/erp/module/usr/service/UserCreateServiceImplTest.java` **API shape:** - `UserCreateService#create(CreateUserReq req, String operatorUsername) : CreateUserVo` - `UserCreateServiceImpl` 注入:SysUserMapper / SysEmployeeMapper / SysPermissionCategoryMapper / SysUserPermissionCategoryMapper / BCryptPasswordEncoder - 常量 `INITIAL_PASSWORD = "666666"` - `@Transactional` 本 Task 范围:实现 4 项校验(唯一用户名、唯一用户号、employeeId 存在、permissionCategoryIds 全部存在),但不实现实际写入——直接返回 dummy CreateUserVo(0, ..., ...);写入路径在 Task 8。 - [ ] **Step 1: 写失败测试**(service 集成测,复用扩展后的 LoginTestSeeder) - `create_usernameExists_throws40901` - `create_userCodeExists_throws40902` - `create_employeeIdNotFound_throws40004` - `create_employeeIdSoftDeleted_throws40004` - `create_permissionCategoryNotFound_throws40004`(含数组中混入不存在 ID) - [ ] **Step 2: 实现最小代码** - [ ] **Step 3: 子会话验证 PASS** - [ ] **Step 4: Commit** `feat(usr): UserCreateService 唯一性 + 外键校验 REQ-USR-002` ### Task 8: UserCreateService 写入路径(sys_user + sys_user_permission_category 事务) **Files:** - Modify: `backend/src/main/java/com/xly/erp/module/usr/service/impl/UserCreateServiceImpl.java` - Modify: `backend/src/test/java/com/xly/erp/module/usr/service/UserCreateServiceImplTest.java` **API behavior:** - 校验全过 → BCrypt encode "666666" → 插入 sys_user(sCreatedBy = operatorUsername)→ 批量插入 sys_user_permission_category(每条 sGrantedBy = operatorUsername)→ 返回 CreateUserVo(新 userId, username, userCode) - 捕获 `DataIntegrityViolationException`(如并发同名)→ 抛 40901 / 40902(按异常消息含 `uk_sys_user_username` / `uk_sys_user_code` 判别) - [ ] **Step 1: 写失败测试** - `create_minimalFields_persistsUserWithInitialPassword` - `create_fullFields_persistsUserAndPermissionMappings` - `create_emptyPermissionCategories_persistsUserOnly`(permissionCategoryIds=空数组 / null 都允许) - `create_initialPasswordMatchesBcrypt666666`(用 BCryptPasswordEncoder.matches("666666", DB hash) == true) - `create_dataIntegrityViolation_username_throws40901`(先 select 返 false 但插入时报 DuplicateKey;用 spy / 模拟难,可在测试用并发场景模拟,或直接抛模拟异常验证转换) - [ ] **Step 2: 实现最小代码** - [ ] **Step 3: 子会话验证 PASS** - [ ] **Step 4: Commit** `feat(usr): UserCreateService 写入用户 + 权限分类授权 REQ-USR-002` ### Task 9: UserController POST /api/v1/users + 端到端测试 **Files:** - Create: `backend/src/main/java/com/xly/erp/module/usr/controller/UserController.java` - Create: `backend/src/test/java/com/xly/erp/module/usr/controller/UserControllerTest.java` **API shape:** - `UserController` — `@RestController @RequestMapping("/api/v1/users")` - `POST /api/v1/users`:标注 `@RequireSuperAdmin`,方法签名 `Result create(@RequestBody @Valid CreateUserReq req)`;调用 `userCreateService.create(req, LoginContext.current().username())`;返回 `ResponseEntity.status(201).body(Result.ok(vo))` 端到端测试(`@SpringBootTest + @AutoConfigureMockMvc`):用 admin token 调用,覆盖: - [ ] **Step 1: 写失败测试** - `post_users_success_returns201_andCreatedVo` - `post_users_blankUsername_returns400_40001` - `post_users_invalidUserType_returns400_40001` - `post_users_unknownPropertyPassword_returns400_40001`(请求 body 含 `"password":"...".` 字段) - `post_users_noAuthHeader_returns401_40101` - `post_users_normalUserToken_returns403_40301` - `post_users_deletedUserToken_returns401_40101` - `post_users_duplicateUsername_returns409_40901` - `post_users_duplicateUserCode_returns409_40902` - `post_users_unknownEmployee_returns400_40004` - `post_users_unknownPermissionCategory_returns400_40004` - `post_users_success_canLoginWithInitialPassword`(创建后立即调 /auth/login 用 666666 应成功) - [ ] **Step 2: 实现最小代码** - [ ] **Step 3: 子会话验证 PASS** - [ ] **Step 4: Commit** `feat(usr): POST /api/v1/users controller + 端到端测试 REQ-USR-002` --- ## 提交计划 | Task | Commit message | |---|---| | 1 | `chore(usr): docs/05 去 password 字段 + ErrorCode 新增 40301/40901/40902 REQ-USR-002` | | 2 | `feat(usr): LoginContext ThreadLocal REQ-USR-002` | | 3 | `feat(usr): JwtHandlerInterceptor + @RequireSuperAdmin REQ-USR-002` | | 4 | `feat(usr): sys_permission_category + sys_user_permission_category entity/mapper REQ-USR-002` | | 5 | `feat(usr): SysUserMapper 用户名/用户号唯一性查询 REQ-USR-002` | | 6 | `feat(usr): CreateUserReq/Vo + Jackson 严格反序列化 REQ-USR-002` | | 7 | `feat(usr): UserCreateService 唯一性 + 外键校验 REQ-USR-002` | | 8 | `feat(usr): UserCreateService 写入用户 + 权限分类授权 REQ-USR-002` | | 9 | `feat(usr): POST /api/v1/users controller + 端到端测试 REQ-USR-002` |