2026-05-15-REQ-USR-002.md 18.1 KB

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 标注 @RequireSuperAdminuserType != "SUPER_ADMIN"BizException(40301, "权限不足,仅超级管理员可调用")
  8. return true
  9. afterCompletionLoginContext.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) == 403toHttpStatus(40901) == 409toHttpStatus(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<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_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<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_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<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_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