--- 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 是事实源): ```sql -- 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`,如 `["usr:user:create"]`) - `exp` = now + 24h - BCrypt 前缀:`$2a$` 或 `$2b$`,长度 60 - DB 列名匈牙利前缀映射:`User.userName` ↔ `tUser.sUserName`、`User.iIncrement` 主键标 `@TableId(type = IdType.AUTO)` ## 文件变更清单 全部为 create(项目根 `backend/` 目录此前不存在)。 ### 工程根 - `backend/pom.xml` — Maven POM;Spring Boot parent + 业务/测试依赖(create) - `backend/.gitignore` — `target/` `*.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.java` — `OncePerRequestFilter`(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.java` — `tUser` 映射(create) - `backend/src/main/java/com/xly/test4/module/usr/entity/UserPermission.java` — `tUserPermission` 映射(create) - `backend/src/main/java/com/xly/test4/module/usr/entity/Employee.java` — `tEmployee` 映射(create) - `backend/src/main/java/com/xly/test4/module/usr/entity/Permission.java` — `tPermission` 映射(create) - `backend/src/main/java/com/xly/test4/module/usr/mapper/UserMapper.java` — `extends BaseMapper`(create) - `backend/src/main/java/com/xly/test4/module/usr/mapper/UserPermissionMapper.java` — `extends BaseMapper`(create) - `backend/src/main/java/com/xly/test4/module/usr/mapper/EmployeeMapper.java` — `extends BaseMapper`(create) - `backend/src/main/java/com/xly/test4/module/usr/mapper/PermissionMapper.java` — `extends BaseMapper`(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` `` = `spring-boot-starter-parent` 3.3.x;依赖:`spring-boot-starter-web`、`spring-boot-starter-security`、`spring-boot-starter-validation`、`com.baomidou:mybatis-plus-spring-boot3-starter:3.5.7`、`org.flywaydb:flyway-core`、`org.flywaydb:flyway-mysql`、`com.mysql:mysql-connector-j`、`io.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-binding`、`org.springdoc:springdoc-openapi-starter-webmvc-ui:2.6.0`。test 域:`spring-boot-starter-test`、`spring-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=true`、`spring.flyway.locations=filesystem:../sql/migrations`、`spring.flyway.baseline-on-migrate=false` - `mybatis-plus.mapper-locations=classpath:mapper/**/*.xml`、`mybatis-plus.global-config.db-config.id-type=AUTO` - `app.security.default-password=666666`、`app.security.max-login-fail=5`、`app.security.lock-minutes=30` - `app.security.jwt.secret=${JWT_SECRET}`、`app.security.jwt.access-ttl-hours=24` - `server.port=8080`、`spring.profiles.active=dev` - `application-dev.yml`:`logging.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 行;`tUser` 含 `sUserName='admin' AND sUserType='ADMIN' AND iIsDisabled=0`;`tUserPermission` 该 admin 关联 4 个权限 - [ ] **Step 1: 写失败测试** - 测试名: `ApplicationContextIT#flywayMigrationsApplied_seedDataPresent` - 意图: 用 `JdbcTemplate` 查 `tPermission` 行数 ≥ 4 + 4 个权限码全部存在;`tUser` 中 `sUserName='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` 字段:`int code`、`String message`、`T data`、`long timestamp` - 静态方法: - `public static Result success(T data)` → `code=200, message="操作成功"` - `public static Result fail(int code, String message)` → `data=null` - 构造时 `timestamp = System.currentTimeMillis()` - `ResultCode`(`public final class`,全大写常量):`OK=200`、`PARAM_INVALID=40001`、`USER_NAME_DUPLICATE=40002`、`USER_CODE_DUPLICATE=40003`、`EMPLOYEE_INVALID=40004`、`PERMISSION_INVALID=40005`、`UNAUTHENTICATED=40101`、`FORBIDDEN=40301`、`INTERNAL_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_sUserName` 的 `DuplicateKeyException`,断言 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 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=STATELESS`;`exceptionHandling()` 注入两个 handler;`JwtAuthenticationFilter` 在 `UsernamePasswordAuthenticationFilter` 之前 - `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`;其余字段:`sId`、`sBrandsId`、`sSubsidiaryId`、`tCreateDate`、`sUserCode`、`sUserName`、`iEmployeeId`、`sUserType`、`sLanguage`、`iCanEditDoc`、`sPasswordHash`、`iIsDisabled`、`tLastLoginDate`、`iLoginFailCount`、`tLockedUntil`、`sCreatedBy`、`tUpdateDate`(类型与 docs/03 对齐:`int`/`String`/`LocalDateTime`/`Integer`) - `UserPermission`:`@TableName("tUserPermission")` 字段 `iIncrement`、`sId`、`sBrandsId`、`sSubsidiaryId`、`tCreateDate`、`iUserId`、`iPermissionId`、`sGrantedBy` - `Employee`:`@TableName("tEmployee")` 字段含 `iIncrement`、`sEmployeeName`、`iIsDisabled` 等(本 REQ 仅读这 3 个,但 entity 字段完整) - `Permission`:`@TableName("tPermission")` 字段含 `iIncrement`、`sCategory`、`sCategoryName`、`iIsDisabled` 等 - Lombok `@Data + @NoArgsConstructor + @AllArgsConstructor + @Builder` - mapper:仅 `public interface XxxMapper extends BaseMapper { }` - [ ] **Step 1: 写失败测试** - 测试名 (`UserMapperIT`,`@SpringBootTest` 真连测试库): - `#insertAndSelectById_columnsMappedCorrectly` — 构造 `User` 含全部字段,`userMapper.insert(user)` 后用 `selectById(iIncrement)` 取回,断言所有非 null 字段一致(验证驼峰→匈牙利前缀映射) - `#seedAdminLoadable_byUserName` — `new LambdaQueryWrapper().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 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:** - `UserCreateVO`:`Integer 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:** - `UserService`:`UserCreateVO createUser(UserCreateDTO dto)` - `UserServiceImpl`(`@Service`,构造注入 `UserMapper`、`UserPermissionMapper`、`EmployeeMapper`、`PermissionMapper`、`UserConverter`、`PasswordEncoder`、`@Value("${app.security.default-password}") String defaultPassword`): - `@Transactional(rollbackFor = Exception.class)` 标注 `createUser` - 本任务实现: 1. `converter.toEntity(dto)` 得 `User` 2. 取 `CurrentUserContext.current()` → 写 `user.sCreatedBy = current.userName`、`user.sBrandsId = current.brandsId`、`user.sSubsidiaryId = current.subsidiaryId` 3. 密码:`String raw = dto.getPassword() != null ? dto.getPassword() : defaultPassword;` → `user.sPasswordHash = passwordEncoder.encode(raw)` 4. `user.iIsDisabled = 0; user.iLoginFailCount = 0;` 5. `userMapper.insert(user)` → MyBatis-Plus 自动回填 `iIncrement` 6. 暂不处理 permissionIds、唯一性、外键校验(留给 Task 12-13) 7. `return converter.toVO(user)` - [ ] **Step 1: 写失败测试** - 测试名 (`UserServiceImplTest`,Mockito mock 全部 mapper + converter;用 `@MockBean` 或纯 `MockitoExtension`): - `#createUser_minimalDTO_writesSecurityContextFieldsAndHashesPassword` - 准备:`MockedStatic` 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()==60`、`iIsDisabled==0`、`iLoginFailCount==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().eq(User::getSUserName, dto.getUserName())) > 0) throw new BusinessException(40002, "用户名已存在");` - `if (userMapper.selectCount(new LambdaQueryWrapper().eq(User::getSUserCode, dto.getUserCode())) > 0) throw new BusinessException(40003, "用户号已存在");` - [ ] **Step 1: 写失败测试** - 新增方法: - `#createUser_duplicateUserName_throws40002` — mock `userMapper.selectCount` 对 userName 查询返回 1,断言抛 `BusinessException` 且 `code==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().eq(Employee::getIIncrement, dto.getEmployeeId()).eq(Employee::getIIsDisabled, 0))`,null → `throw new BusinessException(40004, "员工不存在或已作废")` - permissionIds 非空 → `List found = permissionMapper.selectList(new LambdaQueryWrapper().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()`:构造 `UserPermission`(`iUserId = user.getIIncrement()`、`iPermissionId = pid`、`sGrantedBy = current.userName`、`sBrandsId = current.brandsId`、`sSubsidiaryId = 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.iIncrement`、`iPermissionId` 依次 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 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,整体测试闸门) |