2026-05-13-REQ-USR-001.md 41.1 KB

req_id: REQ-USR-001 date: 2026-05-13

spec_ref: docs/superpowers/specs/2026-05-13-REQ-USR-001.md

REQ-USR-001 增加用户 Implementation Plan

Execution: Parent skill feature-tdd executes this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: 实现 POST /api/usr/user 增加用户接口;本 REQ 作为后端阶段第一个落地任务,同步建立 Spring Boot 工程骨架与通用基础设施(Result/异常/Security/JWT/MyBatis-Plus)供后续 REQ 复用。

Architecture: 分三段实现 — (A) 工程骨架 + Flyway V2 种子;(B) common 层基础设施(统一响应/异常/JWT/Security);(C) USR 模块 createUser 业务实现 + Controller 集成测试。每段任务遵循 RED→GREEN→Commit;Service 用 Mockito 单元测试 + Controller 用 @SpringBootTest + MockMvc 集成测试。

Tech Stack: Spring Boot 3.3.x(含 Web/Security/Validation)、MyBatis-Plus 3.5.x、Flyway 10.x、jjwt 0.12.x、MapStruct + Lombok、MySQL 8、JDK 21(.env.local JAVA_HOME=/opt/homebrew/opt/openjdk@21/...)、Maven 3.9。


Schema 改动

需要 migration:sql/migrations/V2__seed_admin_and_permissions.sql,作用:种子 4 条权限分类(usr:user:create / update / list / assign-role)+ 1 条种子超管 admin(哈希密码:admin)+ 4 条 admin × 权限授权。

完整 DDL(写死,schema 是事实源):

-- V2__seed_admin_and_permissions.sql
-- 种子权限分类(覆盖 USR 模块全部 REQ 所需)
INSERT INTO tPermission (sCategory, sCategoryName, sDescription, iIsDisabled)
VALUES
  ('usr:user:create',       '新增用户',       'REQ-USR-001 增加用户',           0),
  ('usr:user:update',       '修改用户',       'REQ-USR-002 修改用户',           0),
  ('usr:user:list',         '查询用户',       'REQ-USR-003 查询用户',           0),
  ('usr:user:assign-role',  '分配用户角色',   '修改用户类型 NORMAL/ADMIN 用',   0);

-- 种子超级管理员(密码明文 'admin',下方哈希在 Task 2 执行前用 BCryptPasswordEncoder.encode('admin') 本地生成一次,
--   长度 60、以 $2a$10$ 开头;占位 BCRYPT_ADMIN_HASH 在 Task 2 中实际替换为真实 BCrypt 字符串)
INSERT INTO tUser
  (sUserCode, sUserName, iEmployeeId, sUserType, sLanguage, iCanEditDoc,
   sPasswordHash, iIsDisabled, iLoginFailCount,
   sBrandsId, sSubsidiaryId, sCreatedBy)
VALUES
  ('ADMIN001', 'admin', NULL, 'ADMIN', 'zh-CN', 1,
   '{BCRYPT_ADMIN_HASH}', 0, 0,
   'BR-DEFAULT', 'SUB-DEFAULT', 'system');

-- admin × 4 个权限授权
INSERT INTO tUserPermission (iUserId, iPermissionId, sGrantedBy, sBrandsId, sSubsidiaryId)
SELECT u.iIncrement, p.iIncrement, 'system', 'BR-DEFAULT', 'SUB-DEFAULT'
FROM tUser u
CROSS JOIN tPermission p
WHERE u.sUserName = 'admin'
  AND p.sCategory IN ('usr:user:create','usr:user:update','usr:user:list','usr:user:assign-role');

合约级常量(跨任务复用,写死)

  • 错误码(数字字符串,作为 Result.code: int,与 docs/05 一致):
    • 200 成功
    • 40001 必填字段缺失或格式不合法
    • 40002 userName 已存在
    • 40003 userCode 已存在
    • 40004 employeeId 非法(不存在或已作废)
    • 40005 permissionIds 含非法项
    • 40101 未认证
    • 40301 权限不足
    • 50000 系统内部错误
  • JWT claim 名(HS256,secret 取 ${JWT_SECRET}):
    • sub = userName
    • uid = userId(int)
    • bid = brandsId
    • sid = subsidiaryId
    • auth = authorities(List<String>,如 ["usr:user:create"]
    • exp = now + 24h
  • BCrypt 前缀:$2a$$2b$,长度 60
  • DB 列名匈牙利前缀映射:User.userNametUser.sUserNameUser.iIncrement 主键标 @TableId(type = IdType.AUTO)

文件变更清单

全部为 create(项目根 backend/ 目录此前不存在)。

工程根

  • backend/pom.xml — Maven POM;Spring Boot parent + 业务/测试依赖(create)
  • backend/.gitignoretarget/ *.iml .idea/(create)

main/java

  • backend/src/main/java/com/xly/test4/Application.java@SpringBootApplication 入口(create)
  • backend/src/main/java/com/xly/test4/common/response/Result.java — 统一响应包装(create)
  • backend/src/main/java/com/xly/test4/common/response/ResultCode.java — 错误码常量(create)
  • backend/src/main/java/com/xly/test4/common/exception/BusinessException.java — 业务异常(create)
  • backend/src/main/java/com/xly/test4/common/exception/GlobalExceptionHandler.java@RestControllerAdvice 全局异常(create)
  • backend/src/main/java/com/xly/test4/common/security/CurrentUser.java — JWT 解析后的 principal 值对象(create)
  • backend/src/main/java/com/xly/test4/common/security/CurrentUserContext.java — 静态工具读 SecurityContext(create)
  • backend/src/main/java/com/xly/test4/common/security/JwtTokenProvider.java — 签发/解析 JWT(create)
  • backend/src/main/java/com/xly/test4/common/security/JwtAuthenticationFilter.javaOncePerRequestFilter(create)
  • backend/src/main/java/com/xly/test4/common/security/RestAuthenticationEntryPoint.java — 401 → Result(40101)(create)
  • backend/src/main/java/com/xly/test4/common/security/RestAccessDeniedHandler.java — 403 → Result(40301)(create)
  • backend/src/main/java/com/xly/test4/common/security/SecurityConfig.java@EnableMethodSecurity + SecurityFilterChain(create)
  • backend/src/main/java/com/xly/test4/common/config/MybatisPlusConfig.java — 分页插件(create)
  • backend/src/main/java/com/xly/test4/module/usr/entity/User.javatUser 映射(create)
  • backend/src/main/java/com/xly/test4/module/usr/entity/UserPermission.javatUserPermission 映射(create)
  • backend/src/main/java/com/xly/test4/module/usr/entity/Employee.javatEmployee 映射(create)
  • backend/src/main/java/com/xly/test4/module/usr/entity/Permission.javatPermission 映射(create)
  • backend/src/main/java/com/xly/test4/module/usr/mapper/UserMapper.javaextends BaseMapper<User>(create)
  • backend/src/main/java/com/xly/test4/module/usr/mapper/UserPermissionMapper.javaextends BaseMapper<UserPermission>(create)
  • backend/src/main/java/com/xly/test4/module/usr/mapper/EmployeeMapper.javaextends BaseMapper<Employee>(create)
  • backend/src/main/java/com/xly/test4/module/usr/mapper/PermissionMapper.javaextends BaseMapper<Permission>(create)
  • backend/src/main/java/com/xly/test4/module/usr/dto/UserCreateDTO.java — 入参(Bean Validation)(create)
  • backend/src/main/java/com/xly/test4/module/usr/vo/UserCreateVO.java — 出参(create)
  • backend/src/main/java/com/xly/test4/module/usr/converter/UserConverter.java — MapStruct(create)
  • backend/src/main/java/com/xly/test4/module/usr/service/UserService.java — Service 接口(create)
  • backend/src/main/java/com/xly/test4/module/usr/service/impl/UserServiceImpl.java — 实现(create)
  • backend/src/main/java/com/xly/test4/module/usr/controller/UserController.java — Controller(create)

main/resources

  • backend/src/main/resources/application.yml — 主配置(create)
  • backend/src/main/resources/application-dev.yml — dev profile(create)

test/java

  • backend/src/test/java/com/xly/test4/ApplicationContextIT.java — 上下文加载 + Flyway 校验(create)
  • backend/src/test/java/com/xly/test4/common/response/ResultTest.java — 单元(create)
  • backend/src/test/java/com/xly/test4/common/exception/GlobalExceptionHandlerTest.java — 单元(create)
  • backend/src/test/java/com/xly/test4/common/security/JwtTokenProviderTest.java — 单元(create)
  • backend/src/test/java/com/xly/test4/common/security/SecurityIntegrationIT.java — 集成(create)
  • backend/src/test/java/com/xly/test4/module/usr/dto/UserCreateDTOValidationTest.java — Bean Validation 单元(create)
  • backend/src/test/java/com/xly/test4/module/usr/service/impl/UserServiceImplTest.java — Service 单元(Mockito)(create)
  • backend/src/test/java/com/xly/test4/module/usr/controller/UserControllerIT.java — Controller 集成(MockMvc)(create)
  • backend/src/test/java/com/xly/test4/support/TestJwtFactory.java — 测试 JWT 签发工具(create)

sql

  • sql/migrations/V2__seed_admin_and_permissions.sql — 种子(create)

任务步骤

Task 1: 工程骨架 — pom.xml + Application + application.yml

Files:

  • Create: backend/pom.xml
  • Create: backend/.gitignore
  • Create: backend/src/main/java/com/xly/test4/Application.java
  • Create: backend/src/main/resources/application.yml
  • Create: backend/src/main/resources/application-dev.yml
  • Test: backend/src/test/java/com/xly/test4/ApplicationContextIT.java

API shape / 关键配置:

  • pom.xml <parent> = spring-boot-starter-parent 3.3.x;依赖:spring-boot-starter-webspring-boot-starter-securityspring-boot-starter-validationcom.baomidou:mybatis-plus-spring-boot3-starter:3.5.7org.flywaydb:flyway-coreorg.flywaydb:flyway-mysqlcom.mysql:mysql-connector-jio.jsonwebtoken:jjwt-api:0.12.5 + jjwt-impl + jjwt-jackson(runtime)、org.mapstruct:mapstruct:1.5.5.Final + org.projectlombok:lombok:1.18.32 + org.projectlombok:lombok-mapstruct-bindingorg.springdoc:springdoc-openapi-starter-webmvc-ui:2.6.0。test 域:spring-boot-starter-testspring-security-test
  • Application.java@SpringBootApplication + @MapperScan("com.xly.test4.module.*.mapper")
  • application.yml 关键键:
    • spring.datasource.url=jdbc:mysql://${DB_HOST}:${DB_PORT}/${DB_SCHEMA}?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai
    • spring.datasource.username=${DB_USER}spring.datasource.password=${DB_PASSWORD}
    • spring.flyway.enabled=truespring.flyway.locations=filesystem:../sql/migrationsspring.flyway.baseline-on-migrate=false
    • mybatis-plus.mapper-locations=classpath:mapper/**/*.xmlmybatis-plus.global-config.db-config.id-type=AUTO
    • app.security.default-password=666666app.security.max-login-fail=5app.security.lock-minutes=30
    • app.security.jwt.secret=${JWT_SECRET}app.security.jwt.access-ttl-hours=24
    • server.port=8080spring.profiles.active=dev
  • application-dev.ymllogging.level.com.xly.test4=DEBUG

  • Step 1: 写失败测试

    • 测试文件: backend/src/test/java/com/xly/test4/ApplicationContextIT.java
    • 测试名: ApplicationContextIT#contextLoads
    • 意图: @SpringBootTest 启动整个 Spring 上下文,断言 ApplicationContext 非空(即工程能跑起来 + Flyway 能 apply 已有的 V1)
    • 期望 FAIL 原因:Application 类、application.yml、pom.xml 都不存在 → 编译/启动失败
  • Step 2: 实现最小代码

    • 创建 backend/pom.xml 含上述全部依赖
    • 创建 backend/.gitignore
    • 创建 backend/src/main/java/com/xly/test4/Application.java(仅 @SpringBootApplication + @MapperScan + main 方法)
    • 创建 backend/src/main/resources/application.yml + application-dev.yml
    • 启动前确保仓库根 sql/migrations/V1__initial_schema.sql 已存在(事实如此),Flyway 会自动 apply V1
  • Step 3: 子会话验证 PASS

    • 子会话执行:bash scripts/setup-test-db.sh && cd backend && mvn -DfailIfNoTests=false -Dtest=ApplicationContextIT test
    • 期望:BUILD SUCCESS,ApplicationContextIT PASS
  • Step 4: Commit

    • git add backend/pom.xml backend/.gitignore backend/src/main/java/com/xly/test4/Application.java backend/src/main/resources/application.yml backend/src/main/resources/application-dev.yml backend/src/test/java/com/xly/test4/ApplicationContextIT.java
    • git commit -m "feat(usr): 后端工程骨架 (Spring Boot 3.3 + MyBatis-Plus + Flyway) REQ-USR-001"

Task 2: Flyway V2 种子 migration

Files:

  • Create: sql/migrations/V2__seed_admin_and_permissions.sql
  • Modify: backend/src/test/java/com/xly/test4/ApplicationContextIT.java(增加种子断言)

API shape:

  • migration 全文见 § Schema 改动;{BCRYPT_ADMIN_HASH} 占位实现时本地执行一次 new BCryptPasswordEncoder().encode("admin") 取真实哈希字符串(长度 60、以 $2a$10$ 开头)落 SQL
  • 断言:tPermission 至少含 usr:user:create / usr:user:update / usr:user:list / usr:user:assign-role 4 行;tUsersUserName='admin' AND sUserType='ADMIN' AND iIsDisabled=0tUserPermission 该 admin 关联 4 个权限

  • Step 1: 写失败测试

    • 测试名: ApplicationContextIT#flywayMigrationsApplied_seedDataPresent
    • 意图: 用 JdbcTemplatetPermission 行数 ≥ 4 + 4 个权限码全部存在;tUsersUserName='admin' 行存在且 sUserType='ADMIN'tUserPermission 中 admin × 4 个权限关联存在
    • 期望 FAIL 原因:V2 文件不存在,DB 中只有 V1 创建的空表
  • Step 2: 实现最小代码

    • 本地 spawn Java 一次性脚本(或 mvn exec)执行 new BCryptPasswordEncoder().encode("admin"),记录哈希字符串
    • 创建 sql/migrations/V2__seed_admin_and_permissions.sql,将 {BCRYPT_ADMIN_HASH} 替换为真实哈希
  • Step 3: 子会话验证 PASS

    • 子会话执行:bash scripts/setup-test-db.sh && cd backend && mvn -Dtest=ApplicationContextIT test
    • 期望:两个测试方法均 PASS(contextLoads + flywayMigrationsApplied_seedDataPresent)
  • Step 4: Commit

    • git add sql/migrations/V2__seed_admin_and_permissions.sql backend/src/test/java/com/xly/test4/ApplicationContextIT.java
    • git commit -m "feat(usr): V2 种子超管 + USR 权限分类 REQ-USR-001"

Task 3: MyBatis-Plus 分页配置

Files:

  • Create: backend/src/main/java/com/xly/test4/common/config/MybatisPlusConfig.java

API shape:

  • MybatisPlusConfig@Configuration):暴露 MybatisPlusInterceptor bean,内含 PaginationInnerInterceptor(DbType.MYSQL)

  • Step 1: 写失败测试

    • 测试文件: 复用 ApplicationContextIT,新增方法 paginationInterceptorRegistered
    • 意图: @Autowired MybatisPlusInterceptor,断言其 inner interceptors 含 PaginationInnerInterceptor
    • 期望 FAIL 原因:MybatisPlusInterceptor bean 不存在 → 注入失败
  • Step 2: 实现最小代码

    • 创建 MybatisPlusConfig.java,注册分页拦截器
  • Step 3: 子会话验证 PASS

    • cd backend && mvn -Dtest=ApplicationContextIT test
  • Step 4: Commit

    • git add backend/src/main/java/com/xly/test4/common/config/MybatisPlusConfig.java backend/src/test/java/com/xly/test4/ApplicationContextIT.java
    • git commit -m "feat(usr): MyBatis-Plus 分页拦截器配置 REQ-USR-001"

Task 4: Result + ResultCode 统一响应

Files:

  • Create: backend/src/main/java/com/xly/test4/common/response/Result.java
  • Create: backend/src/main/java/com/xly/test4/common/response/ResultCode.java
  • Test: backend/src/test/java/com/xly/test4/common/response/ResultTest.java

API shape:

  • Result<T> 字段:int codeString messageT datalong timestamp
  • 静态方法:
    • public static <T> Result<T> success(T data)code=200, message="操作成功"
    • public static <T> Result<T> fail(int code, String message)data=null
    • 构造时 timestamp = System.currentTimeMillis()
  • ResultCodepublic final class,全大写常量):OK=200PARAM_INVALID=40001USER_NAME_DUPLICATE=40002USER_CODE_DUPLICATE=40003EMPLOYEE_INVALID=40004PERMISSION_INVALID=40005UNAUTHENTICATED=40101FORBIDDEN=40301INTERNAL_ERROR=50000

  • Step 1: 写失败测试

    • 测试名: ResultTest#successContainsDataAndCode200#failNullsDataAndSetsCustomCode
    • 期望 FAIL 原因:Result / ResultCode 不存在
  • Step 2: 实现最小代码

  • Step 3: 子会话验证 PASS

    • cd backend && mvn -Dtest=ResultTest test
  • Step 4: Commit

    • git commit -m "feat(usr): 统一响应 Result + 错误码常量 REQ-USR-001"

Task 5: BusinessException + GlobalExceptionHandler

Files:

  • Create: backend/src/main/java/com/xly/test4/common/exception/BusinessException.java
  • Create: backend/src/main/java/com/xly/test4/common/exception/GlobalExceptionHandler.java
  • Test: backend/src/test/java/com/xly/test4/common/exception/GlobalExceptionHandlerTest.java

API shape:

  • BusinessException extends RuntimeException:构造 BusinessException(int code, String message);getter getCode() / 继承 getMessage()
  • GlobalExceptionHandler@RestControllerAdvice):

    • @ExceptionHandler(BusinessException.class)Result.fail(e.getCode(), e.getMessage())
    • @ExceptionHandler(MethodArgumentNotValidException.class)Result.fail(40001, firstFieldError.getDefaultMessage())
    • @ExceptionHandler(AccessDeniedException.class)Result.fail(40301, "权限不足")
    • @ExceptionHandler(DuplicateKeyException.class) → 解析 e.getCause().getMessage() 中包含 uk_tUser_sUserName → 40002;含 uk_tUser_sUserCode → 40003;否则 50000
    • @ExceptionHandler(Exception.class) 兜底 → Result.fail(50000, "系统繁忙,请稍后重试"),堆栈进 SLF4J log.error
  • Step 1: 写失败测试

    • 测试名: 在 GlobalExceptionHandlerTest 中:
    • #handleBusinessException_returnsFailResultWithCodeAndMessage — 构造 BusinessException(40002,"用户名已存在") 调 handler,断言返回 Result.code==40002 && message=="用户名已存在"
    • #handleDuplicateKey_userNameIndex_returns40002 — 构造 cause 含 uk_tUser_sUserNameDuplicateKeyException,断言 40002
    • #handleDuplicateKey_userCodeIndex_returns40003
    • #handleAccessDenied_returns40301
    • #handleGenericException_returns50000_noStackInResponse
    • 期望 FAIL 原因:类不存在
  • Step 2: 实现最小代码

  • Step 3: 子会话验证 PASS

    • cd backend && mvn -Dtest=GlobalExceptionHandlerTest test
  • Step 4: Commit

    • git commit -m "feat(usr): 全局异常处理器 + BusinessException REQ-USR-001"

Task 6: JwtTokenProvider + CurrentUser + CurrentUserContext

Files:

  • Create: backend/src/main/java/com/xly/test4/common/security/CurrentUser.java
  • Create: backend/src/main/java/com/xly/test4/common/security/CurrentUserContext.java
  • Create: backend/src/main/java/com/xly/test4/common/security/JwtTokenProvider.java
  • Test: backend/src/test/java/com/xly/test4/common/security/JwtTokenProviderTest.java

API shape:

  • CurrentUser(Lombok @Data + @Builder):Integer userId; String userName; String brandsId; String subsidiaryId; List<String> authorities;
  • CurrentUserContext:静态方法 public static CurrentUser current()SecurityContextHolder.getContext().getAuthentication().getPrincipal() 强转为 CurrentUser;未认证(principal 不是 CurrentUser)抛 BusinessException(40101,"未认证")
  • JwtTokenProvider@Component,构造注入 @Value("${app.security.jwt.secret}") String secret + @Value("${app.security.jwt.access-ttl-hours}") long ttlHours):

    • String issue(CurrentUser user) — HS256 签名,claims 含 sub/uid/bid/sid/auth/exp(见 § 合约级常量)
    • CurrentUser parse(String token) — 解析失败/过期抛 BusinessException(40101,"未认证")
  • Step 1: 写失败测试

    • 测试名:
    • JwtTokenProviderTest#issueThenParse_roundTripsAllClaims — issue + parse 后 userName/userId/brandsId/subsidiaryId/authorities 与原值相等
    • JwtTokenProviderTest#parseExpiredToken_throws40101
    • JwtTokenProviderTest#parseTamperedSignature_throws40101
    • 测试构造方式:直接 new JwtTokenProvider("test-secret-pure-ascii-at-least-32-bytes-long-xxxx", 24)(HS256 要求 secret ≥ 32 字节,纯 ASCII 字节数 == 字符数)
    • 期望 FAIL 原因:类不存在
  • Step 2: 实现最小代码

  • Step 3: 子会话验证 PASS

    • cd backend && mvn -Dtest=JwtTokenProviderTest test
  • Step 4: Commit

    • git commit -m "feat(usr): JWT 签发/解析 + CurrentUser 上下文 REQ-USR-001"

Task 7: Security 链 — Filter + EntryPoint + AccessDeniedHandler + SecurityConfig

Files:

  • Create: backend/src/main/java/com/xly/test4/common/security/JwtAuthenticationFilter.java
  • Create: backend/src/main/java/com/xly/test4/common/security/RestAuthenticationEntryPoint.java
  • Create: backend/src/main/java/com/xly/test4/common/security/RestAccessDeniedHandler.java
  • Create: backend/src/main/java/com/xly/test4/common/security/SecurityConfig.java
  • Create: backend/src/test/java/com/xly/test4/support/TestJwtFactory.java
  • Test: backend/src/test/java/com/xly/test4/common/security/SecurityIntegrationIT.java

API shape:

  • JwtAuthenticationFilter extends OncePerRequestFilter
    • Authorization 头;缺失 → 直接放行(交给后续链/EntryPoint 决定)
    • Bearer 前缀 → 直接放行
    • jwtTokenProvider.parse(token)CurrentUser;构造 UsernamePasswordAuthenticationToken(currentUser, null, authoritiesAsSimpleGrantedAuthority) 塞入 SecurityContextHolder
    • 解析失败(捕获 BusinessException)→ 清 context,继续 chain
  • RestAuthenticationEntryPoint.commence(...) → 写 HTTP 401 + Result.fail(40101,"未认证") JSON
  • RestAccessDeniedHandler.handle(...) → 写 HTTP 403 + Result.fail(40301,"权限不足") JSON
  • SecurityConfig@Configuration @EnableMethodSecurity):
    • SecurityFilterChain/api/auth/login/v3/api-docs/**/swagger-ui/**/actuator/health 白名单;其余 /api/** .authenticated()csrf().disable()sessionCreationPolicy=STATELESSexceptionHandling() 注入两个 handler;JwtAuthenticationFilterUsernamePasswordAuthenticationFilter 之前
    • PasswordEncoder bean = BCryptPasswordEncoder
  • TestJwtFactory(test scope util):
    • static String adminToken(JwtTokenProvider provider) — 签发种子 admin 的 token(userId=可从 DB 查,userName="admin", brandsId="BR-DEFAULT", subsidiaryId="SUB-DEFAULT", authorities=["usr:user:create","usr:user:update","usr:user:list","usr:user:assign-role"])
    • static String normalUserToken(JwtTokenProvider provider, Integer userId, String userName) — 普通用户 token,authorities=空

控制器桩(为本任务集成测试服务):在 SecurityIntegrationIT 内用 @RestController + @PreAuthorize("hasAuthority('usr:user:create')") 注解一个 /api/_probe/secured 端点(仅测试用,不入主源),返回 200。

  • Step 1: 写失败测试

    • 测试名 (SecurityIntegrationIT@SpringBootTest + MockMvc):
    • #unauthenticatedRequestToSecuredEndpoint_returns401AndCode40101
    • #invalidJwt_returns401AndCode40101
    • #validJwtMissingRequiredAuthority_returns403AndCode40301
    • #validAdminJwt_returns200
    • 期望 FAIL 原因:filter / config 类不存在 → Spring Security 默认行为,body 不是 Result JSON
  • Step 2: 实现最小代码

    • 实现 4 个类 + TestJwtFactory
  • Step 3: 子会话验证 PASS

    • bash scripts/setup-test-db.sh && cd backend && mvn -Dtest=SecurityIntegrationIT test
  • Step 4: Commit

    • git commit -m "feat(usr): Spring Security + JWT filter 链 + 401/403 Result 转译 REQ-USR-001"

Task 8: USR 模块 4 张表 entity

Files:

  • Create: backend/src/main/java/com/xly/test4/module/usr/entity/User.java
  • Create: backend/src/main/java/com/xly/test4/module/usr/entity/UserPermission.java
  • Create: backend/src/main/java/com/xly/test4/module/usr/entity/Employee.java
  • Create: backend/src/main/java/com/xly/test4/module/usr/entity/Permission.java
  • Create: backend/src/main/java/com/xly/test4/module/usr/mapper/UserMapper.java
  • Create: backend/src/main/java/com/xly/test4/module/usr/mapper/UserPermissionMapper.java
  • Create: backend/src/main/java/com/xly/test4/module/usr/mapper/EmployeeMapper.java
  • Create: backend/src/main/java/com/xly/test4/module/usr/mapper/PermissionMapper.java
  • Test: backend/src/test/java/com/xly/test4/module/usr/mapper/UserMapperIT.java

API shape:

  • entity 字段命名按 docs/03(匈牙利前缀);通过 MyBatis-Plus 注解显式映射:
    • User@TableName("tUser") @TableId(value="iIncrement", type=IdType.AUTO) 私有 Integer iIncrement;其余字段:sIdsBrandsIdsSubsidiaryIdtCreateDatesUserCodesUserNameiEmployeeIdsUserTypesLanguageiCanEditDocsPasswordHashiIsDisabledtLastLoginDateiLoginFailCounttLockedUntilsCreatedBytUpdateDate(类型与 docs/03 对齐:int/String/LocalDateTime/Integer
    • UserPermission@TableName("tUserPermission") 字段 iIncrementsIdsBrandsIdsSubsidiaryIdtCreateDateiUserIdiPermissionIdsGrantedBy
    • Employee@TableName("tEmployee") 字段含 iIncrementsEmployeeNameiIsDisabled 等(本 REQ 仅读这 3 个,但 entity 字段完整)
    • Permission@TableName("tPermission") 字段含 iIncrementsCategorysCategoryNameiIsDisabled
  • Lombok @Data + @NoArgsConstructor + @AllArgsConstructor + @Builder
  • mapper:仅 public interface XxxMapper extends BaseMapper<Xxx> { }

  • Step 1: 写失败测试

    • 测试名 (UserMapperIT@SpringBootTest 真连测试库):
    • #insertAndSelectById_columnsMappedCorrectly — 构造 User 含全部字段,userMapper.insert(user) 后用 selectById(iIncrement) 取回,断言所有非 null 字段一致(验证驼峰→匈牙利前缀映射)
    • #seedAdminLoadable_byUserNamenew LambdaQueryWrapper<User>().eq(User::getSUserName,"admin") 取回种子 admin,断言 sUserType="ADMIN"sBrandsId="BR-DEFAULT"
    • 期望 FAIL 原因:entity / mapper 不存在
  • Step 2: 实现最小代码

  • Step 3: 子会话验证 PASS

    • bash scripts/setup-test-db.sh && cd backend && mvn -Dtest=UserMapperIT test
  • Step 4: Commit

    • git commit -m "feat(usr): USR 模块 entity + mapper (User/UserPermission/Employee/Permission) REQ-USR-001"

Task 9: UserCreateDTO + Bean Validation

Files:

  • Create: backend/src/main/java/com/xly/test4/module/usr/dto/UserCreateDTO.java
  • Test: backend/src/test/java/com/xly/test4/module/usr/dto/UserCreateDTOValidationTest.java

API shape:

  • 字段(Lombok @Data):

    • @NotBlank @Size(max=32) String userCode
    • @NotBlank @Size(max=50) String userName
    • Integer employeeId(可空)
    • @NotBlank @Pattern(regexp="NORMAL|ADMIN") String userType
    • @NotBlank @Pattern(regexp="zh-CN|en|zh-TW") String language
    • @NotNull Boolean canEditDoc
    • @Size(min=1, max=64) String password(可空,留给 service 用默认)
    • List<Integer> permissionIds(可空)
  • Step 1: 写失败测试

    • 测试名 (UserCreateDTOValidationTest,用 Validation.buildDefaultValidatorFactory().getValidator()):
    • #allRequiredFieldsValid_zeroViolations — happy path
    • #userNameBlank_violatesNotBlank
    • #userTypeOutOfEnum_violatesPattern — 传 "FOO"
    • #languageOutOfEnum_violatesPattern — 传 "fr"
    • #userCodeOver32Chars_violatesSize
    • #canEditDocNull_violatesNotNull
    • #passwordOver64Chars_violatesSize
    • 期望 FAIL 原因:DTO 不存在
  • Step 2: 实现最小代码

  • Step 3: 子会话验证 PASS

    • cd backend && mvn -Dtest=UserCreateDTOValidationTest test
  • Step 4: Commit

    • git commit -m "feat(usr): UserCreateDTO + Bean Validation REQ-USR-001"

Task 10: UserCreateVO + UserConverter (MapStruct)

Files:

  • Create: backend/src/main/java/com/xly/test4/module/usr/vo/UserCreateVO.java
  • Create: backend/src/main/java/com/xly/test4/module/usr/converter/UserConverter.java
  • Test: backend/src/test/java/com/xly/test4/module/usr/converter/UserConverterTest.java

API shape:

  • UserCreateVOInteger userId; String userCode;(Lombok @Data + @Builder
  • UserConverter@Mapper(componentModel="spring")):

    • User toEntity(UserCreateDTO dto) — 字段名映射:dto.userCode → user.sUserCode、dto.userName → user.sUserName、dto.employeeId → user.iEmployeeId、dto.userType → user.sUserType、dto.language → user.sLanguage、dto.canEditDoc(true→1, false→0) → user.iCanEditDoc(用 @Mapping(target="iCanEditDoc", expression="java(dto.getCanEditDoc() ? 1 : 0)"));password / permissionIds 不映射(service 单独处理)
    • UserCreateVO toVO(User user) — user.iIncrement → vo.userId、user.sUserCode → vo.userCode
  • Step 1: 写失败测试

    • 测试名:
    • UserConverterTest#toEntity_mapsAllFieldsExceptPasswordAndPermissions
    • UserConverterTest#toEntity_canEditDocTrueMapsTo1
    • UserConverterTest#toEntity_canEditDocFalseMapsTo0
    • UserConverterTest#toVO_mapsIncrementToUserIdAndUserCode
    • 期望 FAIL 原因:converter 不存在
  • Step 2: 实现最小代码

  • Step 3: 子会话验证 PASS

    • cd backend && mvn -Dtest=UserConverterTest test
  • Step 4: Commit

    • git commit -m "feat(usr): UserConverter (MapStruct) + UserCreateVO REQ-USR-001"

Task 11: UserService 接口 + Impl 主插入逻辑(含隐式字段 + 密码哈希)

Files:

  • Create: backend/src/main/java/com/xly/test4/module/usr/service/UserService.java
  • Create: backend/src/main/java/com/xly/test4/module/usr/service/impl/UserServiceImpl.java
  • Test: backend/src/test/java/com/xly/test4/module/usr/service/impl/UserServiceImplTest.java

API shape:

  • UserServiceUserCreateVO createUser(UserCreateDTO dto)
  • UserServiceImpl@Service,构造注入 UserMapperUserPermissionMapperEmployeeMapperPermissionMapperUserConverterPasswordEncoder@Value("${app.security.default-password}") String defaultPassword):

    • @Transactional(rollbackFor = Exception.class) 标注 createUser
    • 本任务实现:
    • converter.toEntity(dto)User
    • CurrentUserContext.current() → 写 user.sCreatedBy = current.userNameuser.sBrandsId = current.brandsIduser.sSubsidiaryId = current.subsidiaryId
    • 密码:String raw = dto.getPassword() != null ? dto.getPassword() : defaultPassword;user.sPasswordHash = passwordEncoder.encode(raw)
    • user.iIsDisabled = 0; user.iLoginFailCount = 0;
    • userMapper.insert(user) → MyBatis-Plus 自动回填 iIncrement
    • 暂不处理 permissionIds、唯一性、外键校验(留给 Task 12-13)
    • return converter.toVO(user)
  • Step 1: 写失败测试

    • 测试名 (UserServiceImplTest,Mockito mock 全部 mapper + converter;用 @MockBean 或纯 MockitoExtension):
    • #createUser_minimalDTO_writesSecurityContextFieldsAndHashesPassword
      • 准备:MockedStatic<CurrentUserContext> mock current() 返回 admin CurrentUser;mock userMapper.insert(any()) 同时塞 iIncrement=42;mock converter 返回 entity 与 VO
      • 断言:userMapper.insert(captor) 捕获参数 User,断言 sCreatedBy="admin"sBrandsId="BR-DEFAULT"sSubsidiaryId="SUB-DEFAULT"sPasswordHash.startsWith("$2")sPasswordHash.length()==60iIsDisabled==0iLoginFailCount==0
      • 返回 VO userId==42
    • #createUser_noPasswordInDTO_usesDefaultPassword666666
      • 断言 passwordEncoder.matches("666666", capturedUser.sPasswordHash)
    • 期望 FAIL 原因:service / impl 不存在
  • Step 2: 实现最小代码

  • Step 3: 子会话验证 PASS

    • cd backend && mvn -Dtest=UserServiceImplTest test
  • Step 4: Commit

    • git commit -m "feat(usr): UserService.createUser 主插入 + 隐式字段 + BCrypt 哈希 REQ-USR-001"

Task 12: 唯一性预检 + DuplicateKey 兜底

Files:

  • Modify: backend/src/main/java/com/xly/test4/module/usr/service/impl/UserServiceImpl.java
  • Modify: backend/src/test/java/com/xly/test4/module/usr/service/impl/UserServiceImplTest.java

API shape:

  • createUser 开头插入唯一性预检(DB 兜底由 GlobalExceptionHandler 在 Task 5 已处理):

    • if (userMapper.selectCount(new LambdaQueryWrapper<User>().eq(User::getSUserName, dto.getUserName())) > 0) throw new BusinessException(40002, "用户名已存在");
    • if (userMapper.selectCount(new LambdaQueryWrapper<User>().eq(User::getSUserCode, dto.getUserCode())) > 0) throw new BusinessException(40003, "用户号已存在");
  • Step 1: 写失败测试

    • 新增方法:
    • #createUser_duplicateUserName_throws40002 — mock userMapper.selectCount 对 userName 查询返回 1,断言抛 BusinessExceptioncode==40002
    • #createUser_duplicateUserCode_throws40003
    • 期望 FAIL 原因:service 无预检逻辑 → 不抛 / 抛错误异常
  • Step 2: 实现最小代码

  • Step 3: 子会话验证 PASS

    • cd backend && mvn -Dtest=UserServiceImplTest test
  • Step 4: Commit

    • git commit -m "feat(usr): createUser 唯一性预检 (40002/40003) REQ-USR-001"

Task 13: employeeId + permissionIds 存在性校验

Files:

  • Modify: backend/src/main/java/com/xly/test4/module/usr/service/impl/UserServiceImpl.java
  • Modify: backend/src/test/java/com/xly/test4/module/usr/service/impl/UserServiceImplTest.java

API shape:

  • createUser 唯一性预检之后、userMapper.insert(user) 之前:

    • employeeId 非空 → employeeMapper.selectOne(new LambdaQueryWrapper<Employee>().eq(Employee::getIIncrement, dto.getEmployeeId()).eq(Employee::getIIsDisabled, 0)),null → throw new BusinessException(40004, "员工不存在或已作废")
    • permissionIds 非空 → List<Permission> found = permissionMapper.selectList(new LambdaQueryWrapper<Permission>().in(Permission::getIIncrement, dto.getPermissionIds()).eq(Permission::getIIsDisabled, 0));若 found.size() != dto.getPermissionIds().size()throw new BusinessException(40005, "权限分类含非法项")
  • Step 1: 写失败测试

    • 新增方法:
    • #createUser_invalidEmployeeId_throws40004
    • #createUser_disabledEmployee_throws40004
    • #createUser_invalidPermissionIds_throws40005 — 传 [1,2,3],mock permissionMapper.selectList 仅返回 2 行 → 40005
    • #createUser_permissionIdsNullOrEmpty_skipsCheck
    • 期望 FAIL 原因:service 无该校验
  • Step 2: 实现最小代码

  • Step 3: 子会话验证 PASS

    • cd backend && mvn -Dtest=UserServiceImplTest test
  • Step 4: Commit

    • git commit -m "feat(usr): createUser 外键校验 (40004/40005) REQ-USR-001"

Task 14: tUserPermission 批量写入 + 事务回滚

Files:

  • Modify: backend/src/main/java/com/xly/test4/module/usr/service/impl/UserServiceImpl.java
  • Modify: backend/src/test/java/com/xly/test4/module/usr/service/impl/UserServiceImplTest.java

API shape:

  • userMapper.insert(user) 之后(main 主键已回填)、return toVO 之前:

    • permissionIds 非空 → 循环 dto.getPermissionIds():构造 UserPermissioniUserId = user.getIIncrement()iPermissionId = pidsGrantedBy = current.userNamesBrandsId = current.brandsIdsSubsidiaryId = current.subsidiaryId),调 userPermissionMapper.insert(up)
    • 任一 insert 抛异常 → @Transactional 已自动回滚(断言 userMapper.insert 也回滚 = tUser 行最终不存在;service 层只需信任 Spring)
  • Step 1: 写失败测试

    • 新增方法:
    • #createUser_withPermissionIds_insertsOneUserPermissionPerId — 传 permissionIds=[10,20,30],断言 userPermissionMapper.insert 调用 3 次;用 ArgumentCaptor 捕获,断言每行 iUserId == user.iIncrementiPermissionId 依次 10/20/30、sGrantedBy=="admin"sBrandsId/sSubsidiaryId 与 current 一致
    • #createUser_emptyPermissionIds_doesNotCallUserPermissionInsert
  • Step 2: 实现最小代码

  • Step 3: 子会话验证 PASS

    • cd backend && mvn -Dtest=UserServiceImplTest test
  • Step 4: Commit

    • git commit -m "feat(usr): tUserPermission 批量写入 + 事务边界 REQ-USR-001"

Task 15: UserController + 集成测试 happy path

Files:

  • Create: backend/src/main/java/com/xly/test4/module/usr/controller/UserController.java
  • Test: backend/src/test/java/com/xly/test4/module/usr/controller/UserControllerIT.java

API shape:

  • UserController@RestController @RequestMapping("/api/usr/user")):
    • @PostMapping @PreAuthorize("hasAuthority('usr:user:create')") public Result<UserCreateVO> createUser(@Valid @RequestBody UserCreateDTO dto)
    • body:return Result.success(userService.createUser(dto));
  • UserControllerIT@SpringBootTest @AutoConfigureMockMvc,真连 DB):

    • @BeforeEach:注入 TestJwtFactory + JwtTokenProvider,签发 admin token;可选 bash scripts/setup-test-db.sh 之后再启动(test.sh 会做)
  • Step 1: 写失败测试

    • 测试名: UserControllerIT#createUser_validRequestWithAdminToken_returns200WithUserIdAndUserCode
    • body 示例: json { "userCode": "U-IT-001", "userName": "it-user-001", "employeeId": null, "userType": "NORMAL", "language": "zh-CN", "canEditDoc": false, "password": "Pass1234", "permissionIds": [] }
    • 断言:HTTP 200;$.code==200$.data.userId 为正整数;$.data.userCode=="U-IT-001";JdbcTemplate 查 tUser WHERE sUserName='it-user-001' 存在且 sCreatedBy='admin'sBrandsId='BR-DEFAULT'sPasswordHash 长度 60 起 $2
    • 期望 FAIL 原因:Controller 不存在 → 404
  • Step 2: 实现最小代码

  • Step 3: 子会话验证 PASS

    • bash scripts/setup-test-db.sh && cd backend && mvn -Dtest=UserControllerIT test
  • Step 4: Commit

    • git commit -m "feat(usr): UserController.createUser + happy path 集成测试 REQ-USR-001"

Task 16: Controller 错误路径集成测试

Files:

  • Modify: backend/src/test/java/com/xly/test4/module/usr/controller/UserControllerIT.java

API shape:

  • UserControllerIT 内追加 7 个错误路径测试方法(共享 admin token / normal token)

  • Step 1: 写失败测试

    • 新增方法(每个 @Test 一对断言):
    • #createUser_duplicateUserName_returns40002 — 先 POST 写一个,再 POST 同 userName,第二次 $.code==40002
    • #createUser_duplicateUserCode_returns40003 — 同上,复用 userCode
    • #createUser_invalidEmployeeId_returns40004 — 传 employeeId=99999(不存在)
    • #createUser_invalidPermissionIds_returns40005 — 传 permissionIds=[99999]
    • #createUser_missingUserName_returns40001 — body 不带 userName
    • #createUser_normalUserToken_returns40301 — 用 TestJwtFactory.normalUserToken(..., authorities=[]) 签发 token,HTTP 403 + $.code==40301
    • #createUser_noAuthHeader_returns40101 — 不带 Authorization,HTTP 401 + $.code==40101
    • 期望 FAIL 原因:上一任务只覆盖 happy path
    • 说明:此处"上一任务"指 Task 15;不要复用 Task 15 测试方法
  • Step 2: 实现最小代码

    • 实际上业务逻辑在 Task 11-14 已完成;此 task 只验证它们透过 Controller 链路联通
    • 如果有任何断言挂红 → 回溯到 Task 11-14 修复对应 Service/Handler
  • Step 3: 子会话验证 PASS

    • bash scripts/setup-test-db.sh && cd backend && mvn -Dtest=UserControllerIT test
    • 全 8 个测试方法(含 Task 15 的 happy path)PASS
  • Step 4: Commit

    • git commit -m "test(usr): createUser 错误路径集成测试 (40001-40005/40101/40301) REQ-USR-001"

Task 17: 全量测试闸门

Files: (无)

  • Step 1: 子会话执行全量测试

    • bash scripts/test.sh(项目根脚本,含 setup-test-db + mvn test)
    • 期望:BUILD SUCCESS,全部测试通过;若失败 → 回溯具体 task
  • Step 2: (无需 commit) — 测试通过即代表本 REQ TDD 阶段完成;下游 feature-verify / feature-review 负责剩余流程


提交计划

Task Commit message
1 feat(usr): 后端工程骨架 (Spring Boot 3.3 + MyBatis-Plus + Flyway) REQ-USR-001
2 feat(usr): V2 种子超管 + USR 权限分类 REQ-USR-001
3 feat(usr): MyBatis-Plus 分页拦截器配置 REQ-USR-001
4 feat(usr): 统一响应 Result + 错误码常量 REQ-USR-001
5 feat(usr): 全局异常处理器 + BusinessException REQ-USR-001
6 feat(usr): JWT 签发/解析 + CurrentUser 上下文 REQ-USR-001
7 feat(usr): Spring Security + JWT filter 链 + 401/403 Result 转译 REQ-USR-001
8 feat(usr): USR 模块 entity + mapper (User/UserPermission/Employee/Permission) REQ-USR-001
9 feat(usr): UserCreateDTO + Bean Validation REQ-USR-001
10 feat(usr): UserConverter (MapStruct) + UserCreateVO REQ-USR-001
11 feat(usr): UserService.createUser 主插入 + 隐式字段 + BCrypt 哈希 REQ-USR-001
12 feat(usr): createUser 唯一性预检 (40002/40003) REQ-USR-001
13 feat(usr): createUser 外键校验 (40004/40005) REQ-USR-001
14 feat(usr): tUserPermission 批量写入 + 事务边界 REQ-USR-001
15 feat(usr): UserController.createUser + happy path 集成测试 REQ-USR-001
16 test(usr): createUser 错误路径集成测试 (40001-40005/40101/40301) REQ-USR-001
17 (无 commit,整体测试闸门)