diff --git a/docs/superpowers/plans/2026-05-13-REQ-USR-001.md b/docs/superpowers/plans/2026-05-13-REQ-USR-001.md new file mode 100644 index 0000000..dcec4de --- /dev/null +++ b/docs/superpowers/plans/2026-05-13-REQ-USR-001.md @@ -0,0 +1,719 @@ +--- +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,整体测试闸门) | diff --git a/docs/superpowers/specs/2026-05-13-REQ-USR-001.md b/docs/superpowers/specs/2026-05-13-REQ-USR-001.md new file mode 100644 index 0000000..bce7441 --- /dev/null +++ b/docs/superpowers/specs/2026-05-13-REQ-USR-001.md @@ -0,0 +1,240 @@ +--- +req_id: REQ-USR-001 +date: 2026-05-13 +module: USR-用户管理 +--- + +# Spec: REQ-USR-001 — 增加用户 + +## 目标 + +实现 `POST /api/usr/user` 增加用户接口:管理员(或被授予 `usr:user:create` 权限的账号)提交合法字段后,写入 `tUser` 主记录 + `tUserPermission` 关联授权,初始密码以哈希形式存储,账号立即生效可用。 + +本 REQ 作为后端阶段第一个落地的功能,**附带一次性建立后端 Spring Boot 工程骨架与通用基础设施**(详见 § 附带基础设施),供本模块后续 REQ-USR-002/003/004 复用。 + +## 输入 / 触发 + +### HTTP 入口 +- Method: `POST` +- Path: `/api/usr/user` +- Auth: 需要(JWT, `Authorization: Bearer `) +- Permission: `usr:user:create` + +### 请求体 `UserCreateDTO`(JSON) + +| 字段 | 类型 | 必填 | 校验 | 说明 | +|---|---|---|---|---| +| `userCode` | String | 是 | 非空、长度 ≤ 32、与已有不重复 | 用户号(业务唯一编码),写 `tUser.sUserCode` | +| `userName` | String | 是 | 非空、长度 ≤ 50、与已有不重复 | 用户名(登录标识,全局唯一),写 `tUser.sUserName` | +| `employeeId` | Integer | 否 | 非空时需存在于 `tEmployee` 且 `iIsDisabled=0` | 写 `tUser.iEmployeeId` | +| `userType` | String | 是 | 枚举 `NORMAL` / `ADMIN` | 写 `tUser.sUserType` | +| `language` | String | 是 | 枚举 `zh-CN` / `en` / `zh-TW` | 写 `tUser.sLanguage` | +| `canEditDoc` | Boolean | 是 | — | 写 `tUser.iCanEditDoc`(true→1, false→0) | +| `password` | String | 否 | 非空时长度 1–64 | 明文密码;缺省 → 用配置 `app.security.default-password`(默认 `666666`)。后端 BCrypt 哈希后写 `tUser.sPasswordHash` | +| `permissionIds` | Integer[] | 否 | 元素需存在于 `tPermission` 且 `iIsDisabled=0` | 权限分类主键数组,对应写入 `tUserPermission` | + +### 隐式上下文(来自 JWT/SecurityContext) + +- 当前操作员的 `userName` → 写 `tUser.sCreatedBy` +- 当前操作员的 `brandsId` / `subsidiaryId` → 写 `tUser.sBrandsId` / `tUser.sSubsidiaryId`(**多租户继承策略**:新用户默认归属创建者所在品牌+子公司) +- 当前操作员的 authorities 必须包含 `usr:user:create`,否则返回 `40301` + +## 输出 / 结果 + +成功响应(HTTP 200): + +```json +{ + "code": 200, + "message": "操作成功", + "data": { + "userId": 12, + "userCode": "U2026051301" + }, + "timestamp": 1715619600000 +} +``` + +- `data.userId` = 新写入 `tUser.iIncrement`(int) +- `data.userCode` = 写入的 `sUserCode`,方便前端立刻回显(对应 REQ 输出表 1「用户号」) +- **password 字段在任何响应、日志、堆栈中都不得出现** + +### 副作用 + +- `tUser` 新增 1 行 +- `tUserPermission` 新增 `len(permissionIds)` 行(permissionIds 为空/未传时不写入关联) + +## 业务规则 + +### B-001 唯一性 +- `userName` 命中 `uk_tUser_sUserName` → `40002` +- `userCode` 命中 `uk_tUser_sUserCode` → `40003` +- 唯一性优先在 Service 层 `selectCount` 预检(更友好的错误信息),但 DB 唯一索引兜底;并发场景下捕获 `DuplicateKeyException` 转译为 `40002` / `40003`(按命中索引名区分) + +### B-002 外键 / 字典存在性 +- `employeeId` 非空且 (`tEmployee` 中无此 ID 或 `iIsDisabled=1`) → `40004` +- `permissionIds` 中任一元素 (`tPermission` 中无此 ID 或 `iIsDisabled=1`) → `40005` + +### B-003 密码处理 +- 明文密码:DTO 传入或取 `app.security.default-password` +- 用 Spring Security `BCryptPasswordEncoder.encode(password)` 哈希 → 写 `sPasswordHash` +- 不存明文,不打日志,不回显 + +### B-004 隐式字段写入 +- `sCreatedBy` = SecurityContext 当前操作员 `userName` +- `sBrandsId` / `sSubsidiaryId` = SecurityContext 当前操作员的同名字段 +- `iIsDisabled` = 0 +- `iLoginFailCount` = 0 +- `tLockedUntil` = NULL +- `tLastLoginDate` = NULL +- `tCreateDate` / `tUpdateDate` 由 MySQL DEFAULT / ON UPDATE CURRENT_TIMESTAMP 自动赋值 +- `sId` 标准列暂置 NULL(业务 ID 后续可由全局 ID 生成器补;本 REQ 不强求) + +### B-005 事务边界 +- Service 实现类方法标 `@Transactional(rollbackFor = Exception.class)` +- `tUser` 插入 → 获取自增主键 `iIncrement` → 循环/批量插入 `tUserPermission`,任一失败整体回滚 +- Controller 不打事务 + +### B-006 权限校验 +- 用 Spring Security 的 `@PreAuthorize("hasAuthority('usr:user:create')")` 注解放在 Controller 方法 +- 当前账号 authorities 不含该权限 → Spring Security 抛 `AccessDeniedException`,全局异常处理器转译为 `40301` + +### B-007 参数校验 +- DTO 用 Jakarta Bean Validation 注解(`@NotBlank` / `@Size` / `@Pattern` / `@NotNull` 等) +- `@Valid` 触发,未通过 → `MethodArgumentNotValidException` → 全局异常处理器转译为 `40001`,`message` 取首个 fieldError 信息 + +## 边界与约束 + +- 包路径:`com.xly.test4.module.usr.{controller,service,service.impl,mapper,entity,dto,vo}` +- Mapper XML:`backend/src/main/resources/mapper/usr/{UserMapper.xml, UserPermissionMapper.xml}` +- DTO/VO 字段用小驼峰;entity 字段与表列名 1:1(匈牙利前缀保留,如 `iIncrement` / `sUserName`)。通过 MyBatis-Plus `@TableField` 注解显式映射 +- DTO ↔ Entity ↔ VO 转换走 MapStruct(`UserConverter` 接口) +- 接口响应统一 `Result` 包装(code/message/data/timestamp);错误响应禁止回显堆栈,堆栈只进 Logback +- 唯一性预检 + DB 兜底两道防线(避免单纯依赖 SELECT 再 INSERT 的 TOCTOU 漏洞) +- 测试时通过 `JwtTokenProvider` 直接签发带种子 admin 上下文的 token;不依赖 REQ-USR-004 登录接口 +- 错误码体系**以 docs/05 为准**(数字字符串如 `"40001"`),与 docs/04 § 1.3 的 `-` 描述存在差异,本 REQ 不修订 docs/04,后续可在统一文档时处理 + +## 依赖的 schema 表 / 字段 + +### 写入 + +- `tUser`:插入新行,字段见 § B-004 +- `tUserPermission`:每个 permissionId 一行,字段 `iUserId` / `iPermissionId` / `sGrantedBy`(取当前操作员 userName)/ `sBrandsId` / `sSubsidiaryId` + +### 读取(校验用) + +- `tEmployee`:按 `iIncrement` 查存在性 + `iIsDisabled=0` +- `tPermission`:按 `iIncrement IN (…)` 批量查存在性 + `iIsDisabled=0` + +### 不涉及 + +- `tCompany`:仅 REQ-USR-004 登录页用作版本下拉,本 REQ 不读不写 + +## 依赖的接口 + +- **本 REQ 提供**:`POST /api/usr/user`(见 docs/05 § REQ-USR-001) +- **本 REQ 不依赖任何其他业务接口**(鉴权链路通过 Spring Security filter 自动消费 JWT,不算接口依赖) + +## 附带基础设施 + +> 这些基础组件本 REQ 一次性建好,供 REQ-USR-002/003/004 直接复用。后续 REQ 的 spec 不再重复声明这些。 + +### 工程骨架 + +- `backend/pom.xml` + - parent: `spring-boot-starter-parent` 3.x + - 依赖:`spring-boot-starter-web`、`spring-boot-starter-security`、`spring-boot-starter-validation`、`mybatis-plus-spring-boot3-starter`、`flyway-core` + `flyway-mysql`、`mysql-connector-j`、`io.jsonwebtoken:jjwt-api`+`jjwt-impl`+`jjwt-jackson`(或 `nimbus-jose-jwt`,二选一;本设计选 jjwt 0.12.x)、`mapstruct` + `lombok` + `lombok-mapstruct-binding`、`springdoc-openapi-starter-webmvc-ui`、`spring-boot-starter-test` + `spring-security-test`(test scope) +- `backend/src/main/java/com/xly/test4/Application.java`:`@SpringBootApplication` 入口 +- `backend/src/main/resources/application.yml`: + - `spring.datasource.url/username/password`:从环境变量注入(`${DB_HOST}/${DB_PORT}/${DB_USER}/${DB_PASSWORD}/${DB_SCHEMA}`),匹配 `.env.local` + - `spring.flyway.enabled=true`、`spring.flyway.locations=filesystem:../sql/migrations`(直接指向仓库根 `sql/migrations/`,与 setup-test-db.sh 注释及 CLAUDE.md § Schema 演化规约 的 SSoT 路径一致;Maven 启动 / IDE 启动工作目录均为 `backend/`,相对路径成立) + - `mybatis-plus.mapper-locations=classpath:mapper/**/*.xml` + - `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` +- `backend/src/main/resources/application-dev.yml`:dev profile 覆盖(如 Logback DEBUG) + +### Common 层 + +- `common/response/Result.java` + - 字段:`code: int`、`message: String`、`data: T`、`timestamp: long` + - 静态方法:`Result.success(T data)` / `Result.fail(int code, String message)` +- `common/response/ResultCode.java`:常量集合(200 / 40001 / 40002 / 40003 / 40004 / 40005 / 40301 / 50000) +- `common/exception/BusinessException.java extends RuntimeException`:字段 `code: int`、`message: String` +- `common/exception/GlobalExceptionHandler.java`(`@RestControllerAdvice`): + - `BusinessException` → `Result.fail(e.code, e.message)` + - `MethodArgumentNotValidException` → `Result.fail(40001, firstFieldErrorMessage)` + - `AccessDeniedException` → `Result.fail(40301, "权限不足")` + - `DuplicateKeyException` → 按命中索引名识别(`uk_tUser_sUserName` / `uk_tUser_sUserCode`)转 40002/40003;否则 50000 + - 兜底 `Exception` → `Result.fail(50000, "系统繁忙,请稍后重试")`,堆栈进 Logback +- `common/security/JwtTokenProvider.java`: + - 签发:输入 `userName` / `userId` / `brandsId` / `subsidiaryId` / `authorities: List` → 返回 `accessToken`,HS256 签名,`exp = now + 24h` + - 解析:输入 `token` → 返回 `Claims`(含上述字段),过期/篡改抛 `JwtAuthenticationException`(自定义) +- `common/security/JwtAuthenticationFilter.java extends OncePerRequestFilter`: + - 从 `Authorization: Bearer ` 头解析,构造 `UsernamePasswordAuthenticationToken` 并塞入 `SecurityContextHolder` + - principal 用自定义 `CurrentUser` 对象(持有 userId/userName/brandsId/subsidiaryId) + - 失败 → 不直接拒绝,交给 Security 链后续 `AuthenticationEntryPoint` 决定 +- `common/security/CurrentUser.java`:值对象,字段 `userId/userName/brandsId/subsidiaryId/authorities` +- `common/security/CurrentUserContext.java`:静态工具,封装 `SecurityContextHolder.getContext().getAuthentication().getPrincipal()` 转 `CurrentUser` +- `common/security/RestAuthenticationEntryPoint.java implements AuthenticationEntryPoint`:未认证(无 token / token 非法)→ HTTP 401 + Result body `code=40101`、`message="未认证"` +- `common/security/RestAccessDeniedHandler.java implements AccessDeniedHandler`:已认证但无权限 → HTTP 403 + Result body `code=40301`、`message="权限不足"`(与 § B-006 一致;Spring Security `@PreAuthorize` 抛出的 `AccessDeniedException` 走全局异常 handler,本处仅兜底 filter 层抛出的同类异常) +- `common/security/SecurityConfig.java`(`@EnableMethodSecurity`): + - `SecurityFilterChain`:`/api/auth/login`、`/v3/api-docs/**`、`/swagger-ui/**` 白名单,其余 `/api/**` 需鉴权;`csrf().disable()`、`sessionCreationPolicy=STATELESS`、注册 `JwtAuthenticationFilter` 在 `UsernamePasswordAuthenticationFilter` 之前;`exceptionHandling().authenticationEntryPoint(RestAuthenticationEntryPoint).accessDeniedHandler(RestAccessDeniedHandler)` + - 暴露 `PasswordEncoder` bean = `BCryptPasswordEncoder` +- `common/config/MybatisPlusConfig.java`:注册分页插件 `PaginationInnerInterceptor`(后续 REQ-USR-003 用) + +### Migration + +- `sql/migrations/V2__seed_admin_and_permissions.sql`: + - 插入 `tPermission` 行(≥ 4 行,覆盖本模块所有 REQ 需要的权限码): + - (sCategory='usr:user:create', sCategoryName='新增用户') + - (sCategory='usr:user:update', sCategoryName='修改用户') + - (sCategory='usr:user:list', sCategoryName='查询用户') + - (sCategory='usr:user:assign-role', sCategoryName='分配用户角色') + - 插入 `tUser` 行:种子超管 + - `sUserName='admin'`、`sUserCode='ADMIN001'`、`sUserType='ADMIN'`、`sLanguage='zh-CN'`、`iCanEditDoc=1`、`iIsDisabled=0` + - `sBrandsId='BR-DEFAULT'`、`sSubsidiaryId='SUB-DEFAULT'` + - `sPasswordHash` = 预先 BCrypt 哈希过的 `admin` 字面值(migration 内硬编码 BCrypt 字符串,生成方式由 spec 调用方决定;推荐用本地 `BCryptPasswordEncoder` 提前生成一次落 SQL) + - 插入 `tUserPermission` 行:admin × 上述 4 个权限分类,全部授权 + +> **migration 命名说明**:项目当前已有 `V1__initial_schema.sql`(A4 生成),新增的种子 migration 命名为 `V2__seed_admin_and_permissions.sql`,与 CLAUDE.md § Schema 演化规约 一致。 + +### 验证策略(与测试无关,但属于本 REQ 配套) + +- 在 `Application.java` 启动后通过 Spring Boot Actuator `/actuator/health`(可选)或集成测试 `@SpringBootTest` 启动验证 Flyway apply 通过 + tUser/admin 行存在 + +## 验收标准 + +### 接口契约 + +1. ✅ 合法请求 POST `/api/usr/user`(带 admin token)→ HTTP 200,`code=200`,`data.userId` 为正整数,`data.userCode` 与入参一致;`tUser` 多 1 行、`tUserPermission` 多 `len(permissionIds)` 行 +2. ✅ 入参 `userName` 与已有重复 → HTTP 200,`code=40002`,`tUser` 行数不变 +3. ✅ 入参 `userCode` 与已有重复 → HTTP 200,`code=40003`,`tUser` 行数不变 +4. ✅ 入参 `employeeId` 为不存在的 ID → `code=40004` +5. ✅ 入参 `employeeId` 指向已作废员工(`iIsDisabled=1`)→ `code=40004` +6. ✅ 入参 `permissionIds` 含不存在的 ID → `code=40005`,`tUser` 不写入(整体事务回滚) +7. ✅ 入参缺 `userName`(必填)→ `code=40001`,`message` 含字段错误信息 +8. ✅ 入参 `userType` 取值不合法(如 `"FOO"`)→ `code=40001` +9. ✅ 带普通用户 token(authorities 不含 `usr:user:create`)→ `code=40301` +10. ✅ 不带 Authorization 头 → HTTP 401 + Result body `code=40101`、`message="未认证"`(由自定义 `AuthenticationEntryPoint` 统一转译为 Result 包装;docs/05 § 错误码 401xx 段位语义) + +### 数据正确性 + +11. ✅ 写入的 `sPasswordHash` 以 `$2a$` / `$2b$` BCrypt 前缀开头,长度 60,不等于明文 +12. ✅ 入参不带 `password` → 写入的 hash 用 BCrypt 哈希 `666666` 后能 `matches` 通过 +13. ✅ 写入的 `sCreatedBy` 等于 token 中的 admin userName +14. ✅ 写入的 `sBrandsId` / `sSubsidiaryId` 等于 token 中的对应字段 +15. ✅ 响应 body、错误响应、应用日志中不出现明文 `password` 字段值 + +### 基础设施 + +16. ✅ `mvn spring-boot:run` 启动成功,Flyway 自动 apply V1 + V2,启动日志见 `tUser` 中存在 `admin` 行 +17. ✅ `mvn test` 测试套件通过(含本 REQ 集成测试) +18. ✅ `GET /v3/api-docs` 或 `/swagger-ui.html` 可访问(springdoc 暴露),看到 `POST /api/usr/user` 接口签名 + +### 错误响应格式 + +19. ✅ 任何错误响应 body 不含 stacktrace / class name / SQL 文本;只含 `code` / `message` / `data:null` / `timestamp`