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-tddexecutes 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;通过LoginContextThreadLocal 把当前用户上下文传递到 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):
- handler 非 HandlerMethod → return true(静态资源等)
- method 标注
@RequireSuperAdmin或在/api/v1/**路径下(非 login)→ 必须鉴权 - 取
Authorization头;缺失或无Bearer前缀 →BizException(40101, "未携带 token") -
jwtUtil.parse(token)取 claims -
userMapper.selectByUsername(claims.username)查最新状态;不存在 /iIsDeleted=1/tLockUntil > NOW()→BizException(40101, "token 关联用户不可用") LoginContext.set(...)- 若 method 标注
@RequireSuperAdmin且userType != "SUPER_ADMIN"→BizException(40301, "权限不足,仅超级管理员可调用") - return true
-
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 = 40301ErrorCode.CONFLICT_USERNAME = 40901ErrorCode.CONFLICT_USERCODE = 40902ErrorCode.toHttpStatus(40301) == 403、toHttpStatus(40901) == 409、toHttpStatus(40902) == 409Step 1: 写失败测试
ErrorCodeTest#httpMappings_coverNewCodesStep 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_40101invalidToken_returns401_40101tokenForDeletedUser_returns401_40101tokenForLockedUser_returns401_40101validToken_normalUser_canAccessAnyAuthEndpointvalidToken_normalUser_cannotAccessAdminOnly_returns403_40301validToken_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<Integer> 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_excludesDeletedLoginTestSeederTest#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(@SelectSELECT 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 / falseForUnknownSysUserMapperTest#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<Integer> 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_throws40901create_userCodeExists_throws40902create_employeeIdNotFound_throws40004create_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_persistsUserWithInitialPasswordcreate_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<CreateUserVo> 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_andCreatedVopost_users_blankUsername_returns400_40001post_users_invalidUserType_returns400_40001-
post_users_unknownPropertyPassword_returns400_40001(请求 body 含"password":"...".字段) post_users_noAuthHeader_returns401_40101post_users_normalUserToken_returns403_40301post_users_deletedUserToken_returns401_40101post_users_duplicateUsername_returns409_40901post_users_duplicateUserCode_returns409_40902post_users_unknownEmployee_returns400_40004post_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 |