Commit 073c6d26697fb8bd95f7e9fcae5dfc4c45741357

Authored by zichun
1 parent 1559e913

docs(usr): REQ-USR-001 spec + plan 归档 REQ-USR-001

docs/superpowers/plans/2026-05-13-REQ-USR-001.md 0 → 100644
  1 +---
  2 +req_id: REQ-USR-001
  3 +date: 2026-05-13
  4 +spec_ref: docs/superpowers/specs/2026-05-13-REQ-USR-001.md
  5 +---
  6 +
  7 +# REQ-USR-001 增加用户 Implementation Plan
  8 +
  9 +> **Execution:** Parent skill `feature-tdd` executes this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
  10 +
  11 +**Goal:** 实现 `POST /api/usr/user` 增加用户接口;本 REQ 作为后端阶段第一个落地任务,同步建立 Spring Boot 工程骨架与通用基础设施(Result/异常/Security/JWT/MyBatis-Plus)供后续 REQ 复用。
  12 +
  13 +**Architecture:** 分三段实现 — (A) 工程骨架 + Flyway V2 种子;(B) common 层基础设施(统一响应/异常/JWT/Security);(C) USR 模块 createUser 业务实现 + Controller 集成测试。每段任务遵循 RED→GREEN→Commit;Service 用 Mockito 单元测试 + Controller 用 `@SpringBootTest + MockMvc` 集成测试。
  14 +
  15 +**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。
  16 +
  17 +---
  18 +
  19 +## Schema 改动
  20 +
  21 +需要 migration:`sql/migrations/V2__seed_admin_and_permissions.sql`,作用:种子 4 条权限分类(usr:user:create / update / list / assign-role)+ 1 条种子超管 `admin`(哈希密码:admin)+ 4 条 admin × 权限授权。
  22 +
  23 +完整 DDL(写死,schema 是事实源):
  24 +
  25 +```sql
  26 +-- V2__seed_admin_and_permissions.sql
  27 +-- 种子权限分类(覆盖 USR 模块全部 REQ 所需)
  28 +INSERT INTO tPermission (sCategory, sCategoryName, sDescription, iIsDisabled)
  29 +VALUES
  30 + ('usr:user:create', '新增用户', 'REQ-USR-001 增加用户', 0),
  31 + ('usr:user:update', '修改用户', 'REQ-USR-002 修改用户', 0),
  32 + ('usr:user:list', '查询用户', 'REQ-USR-003 查询用户', 0),
  33 + ('usr:user:assign-role', '分配用户角色', '修改用户类型 NORMAL/ADMIN 用', 0);
  34 +
  35 +-- 种子超级管理员(密码明文 'admin',下方哈希在 Task 2 执行前用 BCryptPasswordEncoder.encode('admin') 本地生成一次,
  36 +-- 长度 60、以 $2a$10$ 开头;占位 BCRYPT_ADMIN_HASH 在 Task 2 中实际替换为真实 BCrypt 字符串)
  37 +INSERT INTO tUser
  38 + (sUserCode, sUserName, iEmployeeId, sUserType, sLanguage, iCanEditDoc,
  39 + sPasswordHash, iIsDisabled, iLoginFailCount,
  40 + sBrandsId, sSubsidiaryId, sCreatedBy)
  41 +VALUES
  42 + ('ADMIN001', 'admin', NULL, 'ADMIN', 'zh-CN', 1,
  43 + '{BCRYPT_ADMIN_HASH}', 0, 0,
  44 + 'BR-DEFAULT', 'SUB-DEFAULT', 'system');
  45 +
  46 +-- admin × 4 个权限授权
  47 +INSERT INTO tUserPermission (iUserId, iPermissionId, sGrantedBy, sBrandsId, sSubsidiaryId)
  48 +SELECT u.iIncrement, p.iIncrement, 'system', 'BR-DEFAULT', 'SUB-DEFAULT'
  49 +FROM tUser u
  50 +CROSS JOIN tPermission p
  51 +WHERE u.sUserName = 'admin'
  52 + AND p.sCategory IN ('usr:user:create','usr:user:update','usr:user:list','usr:user:assign-role');
  53 +```
  54 +
  55 +## 合约级常量(跨任务复用,写死)
  56 +
  57 +- 错误码(数字字符串,作为 `Result.code: int`,与 docs/05 一致):
  58 + - `200` 成功
  59 + - `40001` 必填字段缺失或格式不合法
  60 + - `40002` userName 已存在
  61 + - `40003` userCode 已存在
  62 + - `40004` employeeId 非法(不存在或已作废)
  63 + - `40005` permissionIds 含非法项
  64 + - `40101` 未认证
  65 + - `40301` 权限不足
  66 + - `50000` 系统内部错误
  67 +- JWT claim 名(HS256,secret 取 `${JWT_SECRET}`):
  68 + - `sub` = userName
  69 + - `uid` = userId(int)
  70 + - `bid` = brandsId
  71 + - `sid` = subsidiaryId
  72 + - `auth` = authorities(`List<String>`,如 `["usr:user:create"]`)
  73 + - `exp` = now + 24h
  74 +- BCrypt 前缀:`$2a$` 或 `$2b$`,长度 60
  75 +- DB 列名匈牙利前缀映射:`User.userName` ↔ `tUser.sUserName`、`User.iIncrement` 主键标 `@TableId(type = IdType.AUTO)`
  76 +
  77 +## 文件变更清单
  78 +
  79 +全部为 create(项目根 `backend/` 目录此前不存在)。
  80 +
  81 +### 工程根
  82 +
  83 +- `backend/pom.xml` — Maven POM;Spring Boot parent + 业务/测试依赖(create)
  84 +- `backend/.gitignore` — `target/` `*.iml` `.idea/`(create)
  85 +
  86 +### main/java
  87 +
  88 +- `backend/src/main/java/com/xly/test4/Application.java` — `@SpringBootApplication` 入口(create)
  89 +- `backend/src/main/java/com/xly/test4/common/response/Result.java` — 统一响应包装(create)
  90 +- `backend/src/main/java/com/xly/test4/common/response/ResultCode.java` — 错误码常量(create)
  91 +- `backend/src/main/java/com/xly/test4/common/exception/BusinessException.java` — 业务异常(create)
  92 +- `backend/src/main/java/com/xly/test4/common/exception/GlobalExceptionHandler.java` — `@RestControllerAdvice` 全局异常(create)
  93 +- `backend/src/main/java/com/xly/test4/common/security/CurrentUser.java` — JWT 解析后的 principal 值对象(create)
  94 +- `backend/src/main/java/com/xly/test4/common/security/CurrentUserContext.java` — 静态工具读 SecurityContext(create)
  95 +- `backend/src/main/java/com/xly/test4/common/security/JwtTokenProvider.java` — 签发/解析 JWT(create)
  96 +- `backend/src/main/java/com/xly/test4/common/security/JwtAuthenticationFilter.java` — `OncePerRequestFilter`(create)
  97 +- `backend/src/main/java/com/xly/test4/common/security/RestAuthenticationEntryPoint.java` — 401 → Result(40101)(create)
  98 +- `backend/src/main/java/com/xly/test4/common/security/RestAccessDeniedHandler.java` — 403 → Result(40301)(create)
  99 +- `backend/src/main/java/com/xly/test4/common/security/SecurityConfig.java` — `@EnableMethodSecurity` + SecurityFilterChain(create)
  100 +- `backend/src/main/java/com/xly/test4/common/config/MybatisPlusConfig.java` — 分页插件(create)
  101 +- `backend/src/main/java/com/xly/test4/module/usr/entity/User.java` — `tUser` 映射(create)
  102 +- `backend/src/main/java/com/xly/test4/module/usr/entity/UserPermission.java` — `tUserPermission` 映射(create)
  103 +- `backend/src/main/java/com/xly/test4/module/usr/entity/Employee.java` — `tEmployee` 映射(create)
  104 +- `backend/src/main/java/com/xly/test4/module/usr/entity/Permission.java` — `tPermission` 映射(create)
  105 +- `backend/src/main/java/com/xly/test4/module/usr/mapper/UserMapper.java` — `extends BaseMapper<User>`(create)
  106 +- `backend/src/main/java/com/xly/test4/module/usr/mapper/UserPermissionMapper.java` — `extends BaseMapper<UserPermission>`(create)
  107 +- `backend/src/main/java/com/xly/test4/module/usr/mapper/EmployeeMapper.java` — `extends BaseMapper<Employee>`(create)
  108 +- `backend/src/main/java/com/xly/test4/module/usr/mapper/PermissionMapper.java` — `extends BaseMapper<Permission>`(create)
  109 +- `backend/src/main/java/com/xly/test4/module/usr/dto/UserCreateDTO.java` — 入参(Bean Validation)(create)
  110 +- `backend/src/main/java/com/xly/test4/module/usr/vo/UserCreateVO.java` — 出参(create)
  111 +- `backend/src/main/java/com/xly/test4/module/usr/converter/UserConverter.java` — MapStruct(create)
  112 +- `backend/src/main/java/com/xly/test4/module/usr/service/UserService.java` — Service 接口(create)
  113 +- `backend/src/main/java/com/xly/test4/module/usr/service/impl/UserServiceImpl.java` — 实现(create)
  114 +- `backend/src/main/java/com/xly/test4/module/usr/controller/UserController.java` — Controller(create)
  115 +
  116 +### main/resources
  117 +
  118 +- `backend/src/main/resources/application.yml` — 主配置(create)
  119 +- `backend/src/main/resources/application-dev.yml` — dev profile(create)
  120 +
  121 +### test/java
  122 +
  123 +- `backend/src/test/java/com/xly/test4/ApplicationContextIT.java` — 上下文加载 + Flyway 校验(create)
  124 +- `backend/src/test/java/com/xly/test4/common/response/ResultTest.java` — 单元(create)
  125 +- `backend/src/test/java/com/xly/test4/common/exception/GlobalExceptionHandlerTest.java` — 单元(create)
  126 +- `backend/src/test/java/com/xly/test4/common/security/JwtTokenProviderTest.java` — 单元(create)
  127 +- `backend/src/test/java/com/xly/test4/common/security/SecurityIntegrationIT.java` — 集成(create)
  128 +- `backend/src/test/java/com/xly/test4/module/usr/dto/UserCreateDTOValidationTest.java` — Bean Validation 单元(create)
  129 +- `backend/src/test/java/com/xly/test4/module/usr/service/impl/UserServiceImplTest.java` — Service 单元(Mockito)(create)
  130 +- `backend/src/test/java/com/xly/test4/module/usr/controller/UserControllerIT.java` — Controller 集成(MockMvc)(create)
  131 +- `backend/src/test/java/com/xly/test4/support/TestJwtFactory.java` — 测试 JWT 签发工具(create)
  132 +
  133 +### sql
  134 +
  135 +- `sql/migrations/V2__seed_admin_and_permissions.sql` — 种子(create)
  136 +
  137 +---
  138 +
  139 +## 任务步骤
  140 +
  141 +### Task 1: 工程骨架 — pom.xml + Application + application.yml
  142 +
  143 +**Files:**
  144 +- Create: `backend/pom.xml`
  145 +- Create: `backend/.gitignore`
  146 +- Create: `backend/src/main/java/com/xly/test4/Application.java`
  147 +- Create: `backend/src/main/resources/application.yml`
  148 +- Create: `backend/src/main/resources/application-dev.yml`
  149 +- Test: `backend/src/test/java/com/xly/test4/ApplicationContextIT.java`
  150 +
  151 +**API shape / 关键配置:**
  152 +- `pom.xml` `<parent>` = `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`
  153 +- `Application.java`:`@SpringBootApplication` + `@MapperScan("com.xly.test4.module.*.mapper")`
  154 +- `application.yml` 关键键:
  155 + - `spring.datasource.url=jdbc:mysql://${DB_HOST}:${DB_PORT}/${DB_SCHEMA}?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai`
  156 + - `spring.datasource.username=${DB_USER}`、`spring.datasource.password=${DB_PASSWORD}`
  157 + - `spring.flyway.enabled=true`、`spring.flyway.locations=filesystem:../sql/migrations`、`spring.flyway.baseline-on-migrate=false`
  158 + - `mybatis-plus.mapper-locations=classpath:mapper/**/*.xml`、`mybatis-plus.global-config.db-config.id-type=AUTO`
  159 + - `app.security.default-password=666666`、`app.security.max-login-fail=5`、`app.security.lock-minutes=30`
  160 + - `app.security.jwt.secret=${JWT_SECRET}`、`app.security.jwt.access-ttl-hours=24`
  161 + - `server.port=8080`、`spring.profiles.active=dev`
  162 +- `application-dev.yml`:`logging.level.com.xly.test4=DEBUG`
  163 +
  164 +- [ ] **Step 1: 写失败测试**
  165 + - 测试文件: `backend/src/test/java/com/xly/test4/ApplicationContextIT.java`
  166 + - 测试名: `ApplicationContextIT#contextLoads`
  167 + - 意图: `@SpringBootTest` 启动整个 Spring 上下文,断言 `ApplicationContext` 非空(即工程能跑起来 + Flyway 能 apply 已有的 V1)
  168 + - 期望 FAIL 原因:`Application` 类、`application.yml`、pom.xml 都不存在 → 编译/启动失败
  169 +
  170 +- [ ] **Step 2: 实现最小代码**
  171 + - 创建 `backend/pom.xml` 含上述全部依赖
  172 + - 创建 `backend/.gitignore`
  173 + - 创建 `backend/src/main/java/com/xly/test4/Application.java`(仅 `@SpringBootApplication` + `@MapperScan` + main 方法)
  174 + - 创建 `backend/src/main/resources/application.yml` + `application-dev.yml`
  175 + - 启动前确保仓库根 `sql/migrations/V1__initial_schema.sql` 已存在(事实如此),Flyway 会自动 apply V1
  176 +
  177 +- [ ] **Step 3: 子会话验证 PASS**
  178 + - 子会话执行:`bash scripts/setup-test-db.sh && cd backend && mvn -DfailIfNoTests=false -Dtest=ApplicationContextIT test`
  179 + - 期望:BUILD SUCCESS,`ApplicationContextIT` PASS
  180 +
  181 +- [ ] **Step 4: Commit**
  182 + - `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`
  183 + - `git commit -m "feat(usr): 后端工程骨架 (Spring Boot 3.3 + MyBatis-Plus + Flyway) REQ-USR-001"`
  184 +
  185 +---
  186 +
  187 +### Task 2: Flyway V2 种子 migration
  188 +
  189 +**Files:**
  190 +- Create: `sql/migrations/V2__seed_admin_and_permissions.sql`
  191 +- Modify: `backend/src/test/java/com/xly/test4/ApplicationContextIT.java`(增加种子断言)
  192 +
  193 +**API shape:**
  194 +- migration 全文见 § Schema 改动;`{BCRYPT_ADMIN_HASH}` 占位实现时本地执行一次 `new BCryptPasswordEncoder().encode("admin")` 取真实哈希字符串(长度 60、以 `$2a$10$` 开头)落 SQL
  195 +- 断言:`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 个权限
  196 +
  197 +- [ ] **Step 1: 写失败测试**
  198 + - 测试名: `ApplicationContextIT#flywayMigrationsApplied_seedDataPresent`
  199 + - 意图: 用 `JdbcTemplate` 查 `tPermission` 行数 ≥ 4 + 4 个权限码全部存在;`tUser` 中 `sUserName='admin'` 行存在且 `sUserType='ADMIN'`;`tUserPermission` 中 admin × 4 个权限关联存在
  200 + - 期望 FAIL 原因:V2 文件不存在,DB 中只有 V1 创建的空表
  201 +
  202 +- [ ] **Step 2: 实现最小代码**
  203 + - 本地 spawn Java 一次性脚本(或 `mvn exec`)执行 `new BCryptPasswordEncoder().encode("admin")`,记录哈希字符串
  204 + - 创建 `sql/migrations/V2__seed_admin_and_permissions.sql`,将 `{BCRYPT_ADMIN_HASH}` 替换为真实哈希
  205 +
  206 +- [ ] **Step 3: 子会话验证 PASS**
  207 + - 子会话执行:`bash scripts/setup-test-db.sh && cd backend && mvn -Dtest=ApplicationContextIT test`
  208 + - 期望:两个测试方法均 PASS(contextLoads + flywayMigrationsApplied_seedDataPresent)
  209 +
  210 +- [ ] **Step 4: Commit**
  211 + - `git add sql/migrations/V2__seed_admin_and_permissions.sql backend/src/test/java/com/xly/test4/ApplicationContextIT.java`
  212 + - `git commit -m "feat(usr): V2 种子超管 + USR 权限分类 REQ-USR-001"`
  213 +
  214 +---
  215 +
  216 +### Task 3: MyBatis-Plus 分页配置
  217 +
  218 +**Files:**
  219 +- Create: `backend/src/main/java/com/xly/test4/common/config/MybatisPlusConfig.java`
  220 +
  221 +**API shape:**
  222 +- `MybatisPlusConfig`(`@Configuration`):暴露 `MybatisPlusInterceptor` bean,内含 `PaginationInnerInterceptor(DbType.MYSQL)`
  223 +
  224 +- [ ] **Step 1: 写失败测试**
  225 + - 测试文件: 复用 `ApplicationContextIT`,新增方法 `paginationInterceptorRegistered`
  226 + - 意图: `@Autowired MybatisPlusInterceptor`,断言其 inner interceptors 含 `PaginationInnerInterceptor`
  227 + - 期望 FAIL 原因:`MybatisPlusInterceptor` bean 不存在 → 注入失败
  228 +
  229 +- [ ] **Step 2: 实现最小代码**
  230 + - 创建 `MybatisPlusConfig.java`,注册分页拦截器
  231 +
  232 +- [ ] **Step 3: 子会话验证 PASS**
  233 + - `cd backend && mvn -Dtest=ApplicationContextIT test`
  234 +
  235 +- [ ] **Step 4: Commit**
  236 + - `git add backend/src/main/java/com/xly/test4/common/config/MybatisPlusConfig.java backend/src/test/java/com/xly/test4/ApplicationContextIT.java`
  237 + - `git commit -m "feat(usr): MyBatis-Plus 分页拦截器配置 REQ-USR-001"`
  238 +
  239 +---
  240 +
  241 +### Task 4: Result + ResultCode 统一响应
  242 +
  243 +**Files:**
  244 +- Create: `backend/src/main/java/com/xly/test4/common/response/Result.java`
  245 +- Create: `backend/src/main/java/com/xly/test4/common/response/ResultCode.java`
  246 +- Test: `backend/src/test/java/com/xly/test4/common/response/ResultTest.java`
  247 +
  248 +**API shape:**
  249 +- `Result<T>` 字段:`int code`、`String message`、`T data`、`long timestamp`
  250 +- 静态方法:
  251 + - `public static <T> Result<T> success(T data)` → `code=200, message="操作成功"`
  252 + - `public static <T> Result<T> fail(int code, String message)` → `data=null`
  253 + - 构造时 `timestamp = System.currentTimeMillis()`
  254 +- `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`
  255 +
  256 +- [ ] **Step 1: 写失败测试**
  257 + - 测试名: `ResultTest#successContainsDataAndCode200`、`#failNullsDataAndSetsCustomCode`
  258 + - 期望 FAIL 原因:`Result` / `ResultCode` 不存在
  259 +
  260 +- [ ] **Step 2: 实现最小代码**
  261 +
  262 +- [ ] **Step 3: 子会话验证 PASS**
  263 + - `cd backend && mvn -Dtest=ResultTest test`
  264 +
  265 +- [ ] **Step 4: Commit**
  266 + - `git commit -m "feat(usr): 统一响应 Result + 错误码常量 REQ-USR-001"`
  267 +
  268 +---
  269 +
  270 +### Task 5: BusinessException + GlobalExceptionHandler
  271 +
  272 +**Files:**
  273 +- Create: `backend/src/main/java/com/xly/test4/common/exception/BusinessException.java`
  274 +- Create: `backend/src/main/java/com/xly/test4/common/exception/GlobalExceptionHandler.java`
  275 +- Test: `backend/src/test/java/com/xly/test4/common/exception/GlobalExceptionHandlerTest.java`
  276 +
  277 +**API shape:**
  278 +- `BusinessException extends RuntimeException`:构造 `BusinessException(int code, String message)`;getter `getCode()` / 继承 `getMessage()`
  279 +- `GlobalExceptionHandler`(`@RestControllerAdvice`):
  280 + - `@ExceptionHandler(BusinessException.class)` → `Result.fail(e.getCode(), e.getMessage())`
  281 + - `@ExceptionHandler(MethodArgumentNotValidException.class)` → `Result.fail(40001, firstFieldError.getDefaultMessage())`
  282 + - `@ExceptionHandler(AccessDeniedException.class)` → `Result.fail(40301, "权限不足")`
  283 + - `@ExceptionHandler(DuplicateKeyException.class)` → 解析 `e.getCause().getMessage()` 中包含 `uk_tUser_sUserName` → 40002;含 `uk_tUser_sUserCode` → 40003;否则 50000
  284 + - `@ExceptionHandler(Exception.class)` 兜底 → `Result.fail(50000, "系统繁忙,请稍后重试")`,堆栈进 SLF4J `log.error`
  285 +
  286 +- [ ] **Step 1: 写失败测试**
  287 + - 测试名: 在 `GlobalExceptionHandlerTest` 中:
  288 + - `#handleBusinessException_returnsFailResultWithCodeAndMessage` — 构造 `BusinessException(40002,"用户名已存在")` 调 handler,断言返回 `Result.code==40002 && message=="用户名已存在"`
  289 + - `#handleDuplicateKey_userNameIndex_returns40002` — 构造 cause 含 `uk_tUser_sUserName` 的 `DuplicateKeyException`,断言 40002
  290 + - `#handleDuplicateKey_userCodeIndex_returns40003`
  291 + - `#handleAccessDenied_returns40301`
  292 + - `#handleGenericException_returns50000_noStackInResponse`
  293 + - 期望 FAIL 原因:类不存在
  294 +
  295 +- [ ] **Step 2: 实现最小代码**
  296 +
  297 +- [ ] **Step 3: 子会话验证 PASS**
  298 + - `cd backend && mvn -Dtest=GlobalExceptionHandlerTest test`
  299 +
  300 +- [ ] **Step 4: Commit**
  301 + - `git commit -m "feat(usr): 全局异常处理器 + BusinessException REQ-USR-001"`
  302 +
  303 +---
  304 +
  305 +### Task 6: JwtTokenProvider + CurrentUser + CurrentUserContext
  306 +
  307 +**Files:**
  308 +- Create: `backend/src/main/java/com/xly/test4/common/security/CurrentUser.java`
  309 +- Create: `backend/src/main/java/com/xly/test4/common/security/CurrentUserContext.java`
  310 +- Create: `backend/src/main/java/com/xly/test4/common/security/JwtTokenProvider.java`
  311 +- Test: `backend/src/test/java/com/xly/test4/common/security/JwtTokenProviderTest.java`
  312 +
  313 +**API shape:**
  314 +- `CurrentUser`(Lombok `@Data + @Builder`):`Integer userId; String userName; String brandsId; String subsidiaryId; List<String> authorities;`
  315 +- `CurrentUserContext`:静态方法 `public static CurrentUser current()` 从 `SecurityContextHolder.getContext().getAuthentication().getPrincipal()` 强转为 `CurrentUser`;未认证(principal 不是 `CurrentUser`)抛 `BusinessException(40101,"未认证")`
  316 +- `JwtTokenProvider`(`@Component`,构造注入 `@Value("${app.security.jwt.secret}") String secret` + `@Value("${app.security.jwt.access-ttl-hours}") long ttlHours`):
  317 + - `String issue(CurrentUser user)` — HS256 签名,claims 含 sub/uid/bid/sid/auth/exp(见 § 合约级常量)
  318 + - `CurrentUser parse(String token)` — 解析失败/过期抛 `BusinessException(40101,"未认证")`
  319 +
  320 +- [ ] **Step 1: 写失败测试**
  321 + - 测试名:
  322 + - `JwtTokenProviderTest#issueThenParse_roundTripsAllClaims` — issue + parse 后 userName/userId/brandsId/subsidiaryId/authorities 与原值相等
  323 + - `JwtTokenProviderTest#parseExpiredToken_throws40101`
  324 + - `JwtTokenProviderTest#parseTamperedSignature_throws40101`
  325 + - 测试构造方式:直接 `new JwtTokenProvider("test-secret-pure-ascii-at-least-32-bytes-long-xxxx", 24)`(HS256 要求 secret ≥ 32 字节,纯 ASCII 字节数 == 字符数)
  326 + - 期望 FAIL 原因:类不存在
  327 +
  328 +- [ ] **Step 2: 实现最小代码**
  329 +
  330 +- [ ] **Step 3: 子会话验证 PASS**
  331 + - `cd backend && mvn -Dtest=JwtTokenProviderTest test`
  332 +
  333 +- [ ] **Step 4: Commit**
  334 + - `git commit -m "feat(usr): JWT 签发/解析 + CurrentUser 上下文 REQ-USR-001"`
  335 +
  336 +---
  337 +
  338 +### Task 7: Security 链 — Filter + EntryPoint + AccessDeniedHandler + SecurityConfig
  339 +
  340 +**Files:**
  341 +- Create: `backend/src/main/java/com/xly/test4/common/security/JwtAuthenticationFilter.java`
  342 +- Create: `backend/src/main/java/com/xly/test4/common/security/RestAuthenticationEntryPoint.java`
  343 +- Create: `backend/src/main/java/com/xly/test4/common/security/RestAccessDeniedHandler.java`
  344 +- Create: `backend/src/main/java/com/xly/test4/common/security/SecurityConfig.java`
  345 +- Create: `backend/src/test/java/com/xly/test4/support/TestJwtFactory.java`
  346 +- Test: `backend/src/test/java/com/xly/test4/common/security/SecurityIntegrationIT.java`
  347 +
  348 +**API shape:**
  349 +- `JwtAuthenticationFilter extends OncePerRequestFilter`:
  350 + - 读 `Authorization` 头;缺失 → 直接放行(交给后续链/EntryPoint 决定)
  351 + - 非 `Bearer ` 前缀 → 直接放行
  352 + - 调 `jwtTokenProvider.parse(token)` 得 `CurrentUser`;构造 `UsernamePasswordAuthenticationToken(currentUser, null, authoritiesAsSimpleGrantedAuthority)` 塞入 `SecurityContextHolder`
  353 + - 解析失败(捕获 `BusinessException`)→ 清 context,继续 chain
  354 +- `RestAuthenticationEntryPoint.commence(...)` → 写 HTTP 401 + `Result.fail(40101,"未认证")` JSON
  355 +- `RestAccessDeniedHandler.handle(...)` → 写 HTTP 403 + `Result.fail(40301,"权限不足")` JSON
  356 +- `SecurityConfig`(`@Configuration @EnableMethodSecurity`):
  357 + - `SecurityFilterChain`:`/api/auth/login`、`/v3/api-docs/**`、`/swagger-ui/**`、`/actuator/health` 白名单;其余 `/api/**` `.authenticated()`;`csrf().disable()`;`sessionCreationPolicy=STATELESS`;`exceptionHandling()` 注入两个 handler;`JwtAuthenticationFilter` 在 `UsernamePasswordAuthenticationFilter` 之前
  358 + - `PasswordEncoder` bean = `BCryptPasswordEncoder`
  359 +- `TestJwtFactory`(test scope util):
  360 + - `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"])
  361 + - `static String normalUserToken(JwtTokenProvider provider, Integer userId, String userName)` — 普通用户 token,authorities=空
  362 +
  363 +**控制器桩**(为本任务集成测试服务):在 `SecurityIntegrationIT` 内用 `@RestController` + `@PreAuthorize("hasAuthority('usr:user:create')")` 注解一个 `/api/_probe/secured` 端点(仅测试用,不入主源),返回 200。
  364 +
  365 +- [ ] **Step 1: 写失败测试**
  366 + - 测试名 (`SecurityIntegrationIT`,`@SpringBootTest + MockMvc`):
  367 + - `#unauthenticatedRequestToSecuredEndpoint_returns401AndCode40101`
  368 + - `#invalidJwt_returns401AndCode40101`
  369 + - `#validJwtMissingRequiredAuthority_returns403AndCode40301`
  370 + - `#validAdminJwt_returns200`
  371 + - 期望 FAIL 原因:filter / config 类不存在 → Spring Security 默认行为,body 不是 Result JSON
  372 +
  373 +- [ ] **Step 2: 实现最小代码**
  374 + - 实现 4 个类 + `TestJwtFactory`
  375 +
  376 +- [ ] **Step 3: 子会话验证 PASS**
  377 + - `bash scripts/setup-test-db.sh && cd backend && mvn -Dtest=SecurityIntegrationIT test`
  378 +
  379 +- [ ] **Step 4: Commit**
  380 + - `git commit -m "feat(usr): Spring Security + JWT filter 链 + 401/403 Result 转译 REQ-USR-001"`
  381 +
  382 +---
  383 +
  384 +### Task 8: USR 模块 4 张表 entity
  385 +
  386 +**Files:**
  387 +- Create: `backend/src/main/java/com/xly/test4/module/usr/entity/User.java`
  388 +- Create: `backend/src/main/java/com/xly/test4/module/usr/entity/UserPermission.java`
  389 +- Create: `backend/src/main/java/com/xly/test4/module/usr/entity/Employee.java`
  390 +- Create: `backend/src/main/java/com/xly/test4/module/usr/entity/Permission.java`
  391 +- Create: `backend/src/main/java/com/xly/test4/module/usr/mapper/UserMapper.java`
  392 +- Create: `backend/src/main/java/com/xly/test4/module/usr/mapper/UserPermissionMapper.java`
  393 +- Create: `backend/src/main/java/com/xly/test4/module/usr/mapper/EmployeeMapper.java`
  394 +- Create: `backend/src/main/java/com/xly/test4/module/usr/mapper/PermissionMapper.java`
  395 +- Test: `backend/src/test/java/com/xly/test4/module/usr/mapper/UserMapperIT.java`
  396 +
  397 +**API shape:**
  398 +- entity 字段命名按 docs/03(匈牙利前缀);通过 MyBatis-Plus 注解显式映射:
  399 + - `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`)
  400 + - `UserPermission`:`@TableName("tUserPermission")` 字段 `iIncrement`、`sId`、`sBrandsId`、`sSubsidiaryId`、`tCreateDate`、`iUserId`、`iPermissionId`、`sGrantedBy`
  401 + - `Employee`:`@TableName("tEmployee")` 字段含 `iIncrement`、`sEmployeeName`、`iIsDisabled` 等(本 REQ 仅读这 3 个,但 entity 字段完整)
  402 + - `Permission`:`@TableName("tPermission")` 字段含 `iIncrement`、`sCategory`、`sCategoryName`、`iIsDisabled` 等
  403 +- Lombok `@Data + @NoArgsConstructor + @AllArgsConstructor + @Builder`
  404 +- mapper:仅 `public interface XxxMapper extends BaseMapper<Xxx> { }`
  405 +
  406 +- [ ] **Step 1: 写失败测试**
  407 + - 测试名 (`UserMapperIT`,`@SpringBootTest` 真连测试库):
  408 + - `#insertAndSelectById_columnsMappedCorrectly` — 构造 `User` 含全部字段,`userMapper.insert(user)` 后用 `selectById(iIncrement)` 取回,断言所有非 null 字段一致(验证驼峰→匈牙利前缀映射)
  409 + - `#seedAdminLoadable_byUserName` — `new LambdaQueryWrapper<User>().eq(User::getSUserName,"admin")` 取回种子 admin,断言 `sUserType="ADMIN"`、`sBrandsId="BR-DEFAULT"`
  410 + - 期望 FAIL 原因:entity / mapper 不存在
  411 +
  412 +- [ ] **Step 2: 实现最小代码**
  413 +
  414 +- [ ] **Step 3: 子会话验证 PASS**
  415 + - `bash scripts/setup-test-db.sh && cd backend && mvn -Dtest=UserMapperIT test`
  416 +
  417 +- [ ] **Step 4: Commit**
  418 + - `git commit -m "feat(usr): USR 模块 entity + mapper (User/UserPermission/Employee/Permission) REQ-USR-001"`
  419 +
  420 +---
  421 +
  422 +### Task 9: UserCreateDTO + Bean Validation
  423 +
  424 +**Files:**
  425 +- Create: `backend/src/main/java/com/xly/test4/module/usr/dto/UserCreateDTO.java`
  426 +- Test: `backend/src/test/java/com/xly/test4/module/usr/dto/UserCreateDTOValidationTest.java`
  427 +
  428 +**API shape:**
  429 +- 字段(Lombok `@Data`):
  430 + - `@NotBlank @Size(max=32) String userCode`
  431 + - `@NotBlank @Size(max=50) String userName`
  432 + - `Integer employeeId`(可空)
  433 + - `@NotBlank @Pattern(regexp="NORMAL|ADMIN") String userType`
  434 + - `@NotBlank @Pattern(regexp="zh-CN|en|zh-TW") String language`
  435 + - `@NotNull Boolean canEditDoc`
  436 + - `@Size(min=1, max=64) String password`(可空,留给 service 用默认)
  437 + - `List<Integer> permissionIds`(可空)
  438 +
  439 +- [ ] **Step 1: 写失败测试**
  440 + - 测试名 (`UserCreateDTOValidationTest`,用 `Validation.buildDefaultValidatorFactory().getValidator()`):
  441 + - `#allRequiredFieldsValid_zeroViolations` — happy path
  442 + - `#userNameBlank_violatesNotBlank`
  443 + - `#userTypeOutOfEnum_violatesPattern` — 传 "FOO"
  444 + - `#languageOutOfEnum_violatesPattern` — 传 "fr"
  445 + - `#userCodeOver32Chars_violatesSize`
  446 + - `#canEditDocNull_violatesNotNull`
  447 + - `#passwordOver64Chars_violatesSize`
  448 + - 期望 FAIL 原因:DTO 不存在
  449 +
  450 +- [ ] **Step 2: 实现最小代码**
  451 +
  452 +- [ ] **Step 3: 子会话验证 PASS**
  453 + - `cd backend && mvn -Dtest=UserCreateDTOValidationTest test`
  454 +
  455 +- [ ] **Step 4: Commit**
  456 + - `git commit -m "feat(usr): UserCreateDTO + Bean Validation REQ-USR-001"`
  457 +
  458 +---
  459 +
  460 +### Task 10: UserCreateVO + UserConverter (MapStruct)
  461 +
  462 +**Files:**
  463 +- Create: `backend/src/main/java/com/xly/test4/module/usr/vo/UserCreateVO.java`
  464 +- Create: `backend/src/main/java/com/xly/test4/module/usr/converter/UserConverter.java`
  465 +- Test: `backend/src/test/java/com/xly/test4/module/usr/converter/UserConverterTest.java`
  466 +
  467 +**API shape:**
  468 +- `UserCreateVO`:`Integer userId; String userCode;`(Lombok `@Data + @Builder`)
  469 +- `UserConverter`(`@Mapper(componentModel="spring")`):
  470 + - `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 单独处理)
  471 + - `UserCreateVO toVO(User user)` — user.iIncrement → vo.userId、user.sUserCode → vo.userCode
  472 +
  473 +- [ ] **Step 1: 写失败测试**
  474 + - 测试名:
  475 + - `UserConverterTest#toEntity_mapsAllFieldsExceptPasswordAndPermissions`
  476 + - `UserConverterTest#toEntity_canEditDocTrueMapsTo1`
  477 + - `UserConverterTest#toEntity_canEditDocFalseMapsTo0`
  478 + - `UserConverterTest#toVO_mapsIncrementToUserIdAndUserCode`
  479 + - 期望 FAIL 原因:converter 不存在
  480 +
  481 +- [ ] **Step 2: 实现最小代码**
  482 +
  483 +- [ ] **Step 3: 子会话验证 PASS**
  484 + - `cd backend && mvn -Dtest=UserConverterTest test`
  485 +
  486 +- [ ] **Step 4: Commit**
  487 + - `git commit -m "feat(usr): UserConverter (MapStruct) + UserCreateVO REQ-USR-001"`
  488 +
  489 +---
  490 +
  491 +### Task 11: UserService 接口 + Impl 主插入逻辑(含隐式字段 + 密码哈希)
  492 +
  493 +**Files:**
  494 +- Create: `backend/src/main/java/com/xly/test4/module/usr/service/UserService.java`
  495 +- Create: `backend/src/main/java/com/xly/test4/module/usr/service/impl/UserServiceImpl.java`
  496 +- Test: `backend/src/test/java/com/xly/test4/module/usr/service/impl/UserServiceImplTest.java`
  497 +
  498 +**API shape:**
  499 +- `UserService`:`UserCreateVO createUser(UserCreateDTO dto)`
  500 +- `UserServiceImpl`(`@Service`,构造注入 `UserMapper`、`UserPermissionMapper`、`EmployeeMapper`、`PermissionMapper`、`UserConverter`、`PasswordEncoder`、`@Value("${app.security.default-password}") String defaultPassword`):
  501 + - `@Transactional(rollbackFor = Exception.class)` 标注 `createUser`
  502 + - 本任务实现:
  503 + 1. `converter.toEntity(dto)` 得 `User`
  504 + 2. 取 `CurrentUserContext.current()` → 写 `user.sCreatedBy = current.userName`、`user.sBrandsId = current.brandsId`、`user.sSubsidiaryId = current.subsidiaryId`
  505 + 3. 密码:`String raw = dto.getPassword() != null ? dto.getPassword() : defaultPassword;` → `user.sPasswordHash = passwordEncoder.encode(raw)`
  506 + 4. `user.iIsDisabled = 0; user.iLoginFailCount = 0;`
  507 + 5. `userMapper.insert(user)` → MyBatis-Plus 自动回填 `iIncrement`
  508 + 6. 暂不处理 permissionIds、唯一性、外键校验(留给 Task 12-13)
  509 + 7. `return converter.toVO(user)`
  510 +
  511 +- [ ] **Step 1: 写失败测试**
  512 + - 测试名 (`UserServiceImplTest`,Mockito mock 全部 mapper + converter;用 `@MockBean` 或纯 `MockitoExtension`):
  513 + - `#createUser_minimalDTO_writesSecurityContextFieldsAndHashesPassword`
  514 + - 准备:`MockedStatic<CurrentUserContext>` mock `current()` 返回 admin CurrentUser;mock `userMapper.insert(any())` 同时塞 iIncrement=42;mock converter 返回 entity 与 VO
  515 + - 断言:`userMapper.insert(captor)` 捕获参数 `User`,断言 `sCreatedBy="admin"`、`sBrandsId="BR-DEFAULT"`、`sSubsidiaryId="SUB-DEFAULT"`、`sPasswordHash.startsWith("$2")`、`sPasswordHash.length()==60`、`iIsDisabled==0`、`iLoginFailCount==0`
  516 + - 返回 VO `userId==42`
  517 + - `#createUser_noPasswordInDTO_usesDefaultPassword666666`
  518 + - 断言 `passwordEncoder.matches("666666", capturedUser.sPasswordHash)`
  519 + - 期望 FAIL 原因:service / impl 不存在
  520 +
  521 +- [ ] **Step 2: 实现最小代码**
  522 +
  523 +- [ ] **Step 3: 子会话验证 PASS**
  524 + - `cd backend && mvn -Dtest=UserServiceImplTest test`
  525 +
  526 +- [ ] **Step 4: Commit**
  527 + - `git commit -m "feat(usr): UserService.createUser 主插入 + 隐式字段 + BCrypt 哈希 REQ-USR-001"`
  528 +
  529 +---
  530 +
  531 +### Task 12: 唯一性预检 + DuplicateKey 兜底
  532 +
  533 +**Files:**
  534 +- Modify: `backend/src/main/java/com/xly/test4/module/usr/service/impl/UserServiceImpl.java`
  535 +- Modify: `backend/src/test/java/com/xly/test4/module/usr/service/impl/UserServiceImplTest.java`
  536 +
  537 +**API shape:**
  538 +- 在 `createUser` 开头插入唯一性预检(DB 兜底由 GlobalExceptionHandler 在 Task 5 已处理):
  539 + - `if (userMapper.selectCount(new LambdaQueryWrapper<User>().eq(User::getSUserName, dto.getUserName())) > 0) throw new BusinessException(40002, "用户名已存在");`
  540 + - `if (userMapper.selectCount(new LambdaQueryWrapper<User>().eq(User::getSUserCode, dto.getUserCode())) > 0) throw new BusinessException(40003, "用户号已存在");`
  541 +
  542 +- [ ] **Step 1: 写失败测试**
  543 + - 新增方法:
  544 + - `#createUser_duplicateUserName_throws40002` — mock `userMapper.selectCount` 对 userName 查询返回 1,断言抛 `BusinessException` 且 `code==40002`
  545 + - `#createUser_duplicateUserCode_throws40003`
  546 + - 期望 FAIL 原因:service 无预检逻辑 → 不抛 / 抛错误异常
  547 +
  548 +- [ ] **Step 2: 实现最小代码**
  549 +
  550 +- [ ] **Step 3: 子会话验证 PASS**
  551 + - `cd backend && mvn -Dtest=UserServiceImplTest test`
  552 +
  553 +- [ ] **Step 4: Commit**
  554 + - `git commit -m "feat(usr): createUser 唯一性预检 (40002/40003) REQ-USR-001"`
  555 +
  556 +---
  557 +
  558 +### Task 13: employeeId + permissionIds 存在性校验
  559 +
  560 +**Files:**
  561 +- Modify: `backend/src/main/java/com/xly/test4/module/usr/service/impl/UserServiceImpl.java`
  562 +- Modify: `backend/src/test/java/com/xly/test4/module/usr/service/impl/UserServiceImplTest.java`
  563 +
  564 +**API shape:**
  565 +- `createUser` 唯一性预检之后、`userMapper.insert(user)` 之前:
  566 + - employeeId 非空 → `employeeMapper.selectOne(new LambdaQueryWrapper<Employee>().eq(Employee::getIIncrement, dto.getEmployeeId()).eq(Employee::getIIsDisabled, 0))`,null → `throw new BusinessException(40004, "员工不存在或已作废")`
  567 + - 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, "权限分类含非法项")`
  568 +
  569 +- [ ] **Step 1: 写失败测试**
  570 + - 新增方法:
  571 + - `#createUser_invalidEmployeeId_throws40004`
  572 + - `#createUser_disabledEmployee_throws40004`
  573 + - `#createUser_invalidPermissionIds_throws40005` — 传 [1,2,3],mock permissionMapper.selectList 仅返回 2 行 → 40005
  574 + - `#createUser_permissionIdsNullOrEmpty_skipsCheck`
  575 + - 期望 FAIL 原因:service 无该校验
  576 +
  577 +- [ ] **Step 2: 实现最小代码**
  578 +
  579 +- [ ] **Step 3: 子会话验证 PASS**
  580 + - `cd backend && mvn -Dtest=UserServiceImplTest test`
  581 +
  582 +- [ ] **Step 4: Commit**
  583 + - `git commit -m "feat(usr): createUser 外键校验 (40004/40005) REQ-USR-001"`
  584 +
  585 +---
  586 +
  587 +### Task 14: tUserPermission 批量写入 + 事务回滚
  588 +
  589 +**Files:**
  590 +- Modify: `backend/src/main/java/com/xly/test4/module/usr/service/impl/UserServiceImpl.java`
  591 +- Modify: `backend/src/test/java/com/xly/test4/module/usr/service/impl/UserServiceImplTest.java`
  592 +
  593 +**API shape:**
  594 +- `userMapper.insert(user)` 之后(main 主键已回填)、`return toVO` 之前:
  595 + - permissionIds 非空 → 循环 `dto.getPermissionIds()`:构造 `UserPermission`(`iUserId = user.getIIncrement()`、`iPermissionId = pid`、`sGrantedBy = current.userName`、`sBrandsId = current.brandsId`、`sSubsidiaryId = current.subsidiaryId`),调 `userPermissionMapper.insert(up)`
  596 + - 任一 insert 抛异常 → `@Transactional` 已自动回滚(断言 `userMapper.insert` 也回滚 = tUser 行最终不存在;service 层只需信任 Spring)
  597 +
  598 +- [ ] **Step 1: 写失败测试**
  599 + - 新增方法:
  600 + - `#createUser_withPermissionIds_insertsOneUserPermissionPerId` — 传 permissionIds=[10,20,30],断言 `userPermissionMapper.insert` 调用 3 次;用 `ArgumentCaptor` 捕获,断言每行 `iUserId == user.iIncrement`、`iPermissionId` 依次 10/20/30、`sGrantedBy=="admin"`、`sBrandsId/sSubsidiaryId` 与 current 一致
  601 + - `#createUser_emptyPermissionIds_doesNotCallUserPermissionInsert`
  602 +
  603 +- [ ] **Step 2: 实现最小代码**
  604 +
  605 +- [ ] **Step 3: 子会话验证 PASS**
  606 + - `cd backend && mvn -Dtest=UserServiceImplTest test`
  607 +
  608 +- [ ] **Step 4: Commit**
  609 + - `git commit -m "feat(usr): tUserPermission 批量写入 + 事务边界 REQ-USR-001"`
  610 +
  611 +---
  612 +
  613 +### Task 15: UserController + 集成测试 happy path
  614 +
  615 +**Files:**
  616 +- Create: `backend/src/main/java/com/xly/test4/module/usr/controller/UserController.java`
  617 +- Test: `backend/src/test/java/com/xly/test4/module/usr/controller/UserControllerIT.java`
  618 +
  619 +**API shape:**
  620 +- `UserController`(`@RestController @RequestMapping("/api/usr/user")`):
  621 + - `@PostMapping @PreAuthorize("hasAuthority('usr:user:create')") public Result<UserCreateVO> createUser(@Valid @RequestBody UserCreateDTO dto)`
  622 + - body:`return Result.success(userService.createUser(dto));`
  623 +- `UserControllerIT`(`@SpringBootTest @AutoConfigureMockMvc`,真连 DB):
  624 + - `@BeforeEach`:注入 `TestJwtFactory` + `JwtTokenProvider`,签发 admin token;可选 `bash scripts/setup-test-db.sh` 之后再启动(test.sh 会做)
  625 +
  626 +- [ ] **Step 1: 写失败测试**
  627 + - 测试名: `UserControllerIT#createUser_validRequestWithAdminToken_returns200WithUserIdAndUserCode`
  628 + - body 示例:
  629 + ```json
  630 + {
  631 + "userCode": "U-IT-001",
  632 + "userName": "it-user-001",
  633 + "employeeId": null,
  634 + "userType": "NORMAL",
  635 + "language": "zh-CN",
  636 + "canEditDoc": false,
  637 + "password": "Pass1234",
  638 + "permissionIds": []
  639 + }
  640 + ```
  641 + - 断言: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`
  642 + - 期望 FAIL 原因:Controller 不存在 → 404
  643 +
  644 +- [ ] **Step 2: 实现最小代码**
  645 +
  646 +- [ ] **Step 3: 子会话验证 PASS**
  647 + - `bash scripts/setup-test-db.sh && cd backend && mvn -Dtest=UserControllerIT test`
  648 +
  649 +- [ ] **Step 4: Commit**
  650 + - `git commit -m "feat(usr): UserController.createUser + happy path 集成测试 REQ-USR-001"`
  651 +
  652 +---
  653 +
  654 +### Task 16: Controller 错误路径集成测试
  655 +
  656 +**Files:**
  657 +- Modify: `backend/src/test/java/com/xly/test4/module/usr/controller/UserControllerIT.java`
  658 +
  659 +**API shape:**
  660 +- 在 `UserControllerIT` 内追加 7 个错误路径测试方法(共享 admin token / normal token)
  661 +
  662 +- [ ] **Step 1: 写失败测试**
  663 + - 新增方法(每个 `@Test` 一对断言):
  664 + - `#createUser_duplicateUserName_returns40002` — 先 POST 写一个,再 POST 同 userName,第二次 `$.code==40002`
  665 + - `#createUser_duplicateUserCode_returns40003` — 同上,复用 userCode
  666 + - `#createUser_invalidEmployeeId_returns40004` — 传 `employeeId=99999`(不存在)
  667 + - `#createUser_invalidPermissionIds_returns40005` — 传 `permissionIds=[99999]`
  668 + - `#createUser_missingUserName_returns40001` — body 不带 userName
  669 + - `#createUser_normalUserToken_returns40301` — 用 `TestJwtFactory.normalUserToken(..., authorities=[])` 签发 token,HTTP 403 + `$.code==40301`
  670 + - `#createUser_noAuthHeader_returns40101` — 不带 Authorization,HTTP 401 + `$.code==40101`
  671 + - 期望 FAIL 原因:上一任务只覆盖 happy path
  672 + - **说明**:此处"上一任务"指 Task 15;不要复用 Task 15 测试方法
  673 +
  674 +- [ ] **Step 2: 实现最小代码**
  675 + - 实际上业务逻辑在 Task 11-14 已完成;此 task 只验证它们透过 Controller 链路联通
  676 + - 如果有任何断言挂红 → 回溯到 Task 11-14 修复对应 Service/Handler
  677 +
  678 +- [ ] **Step 3: 子会话验证 PASS**
  679 + - `bash scripts/setup-test-db.sh && cd backend && mvn -Dtest=UserControllerIT test`
  680 + - 全 8 个测试方法(含 Task 15 的 happy path)PASS
  681 +
  682 +- [ ] **Step 4: Commit**
  683 + - `git commit -m "test(usr): createUser 错误路径集成测试 (40001-40005/40101/40301) REQ-USR-001"`
  684 +
  685 +---
  686 +
  687 +### Task 17: 全量测试闸门
  688 +
  689 +**Files:** (无)
  690 +
  691 +- [ ] **Step 1: 子会话执行全量测试**
  692 + - `bash scripts/test.sh`(项目根脚本,含 setup-test-db + mvn test)
  693 + - 期望:BUILD SUCCESS,全部测试通过;若失败 → 回溯具体 task
  694 +
  695 +- [ ] **Step 2: (无需 commit)** — 测试通过即代表本 REQ TDD 阶段完成;下游 feature-verify / feature-review 负责剩余流程
  696 +
  697 +---
  698 +
  699 +## 提交计划
  700 +
  701 +| Task | Commit message |
  702 +|------|---------------|
  703 +| 1 | `feat(usr): 后端工程骨架 (Spring Boot 3.3 + MyBatis-Plus + Flyway) REQ-USR-001` |
  704 +| 2 | `feat(usr): V2 种子超管 + USR 权限分类 REQ-USR-001` |
  705 +| 3 | `feat(usr): MyBatis-Plus 分页拦截器配置 REQ-USR-001` |
  706 +| 4 | `feat(usr): 统一响应 Result + 错误码常量 REQ-USR-001` |
  707 +| 5 | `feat(usr): 全局异常处理器 + BusinessException REQ-USR-001` |
  708 +| 6 | `feat(usr): JWT 签发/解析 + CurrentUser 上下文 REQ-USR-001` |
  709 +| 7 | `feat(usr): Spring Security + JWT filter 链 + 401/403 Result 转译 REQ-USR-001` |
  710 +| 8 | `feat(usr): USR 模块 entity + mapper (User/UserPermission/Employee/Permission) REQ-USR-001` |
  711 +| 9 | `feat(usr): UserCreateDTO + Bean Validation REQ-USR-001` |
  712 +| 10 | `feat(usr): UserConverter (MapStruct) + UserCreateVO REQ-USR-001` |
  713 +| 11 | `feat(usr): UserService.createUser 主插入 + 隐式字段 + BCrypt 哈希 REQ-USR-001` |
  714 +| 12 | `feat(usr): createUser 唯一性预检 (40002/40003) REQ-USR-001` |
  715 +| 13 | `feat(usr): createUser 外键校验 (40004/40005) REQ-USR-001` |
  716 +| 14 | `feat(usr): tUserPermission 批量写入 + 事务边界 REQ-USR-001` |
  717 +| 15 | `feat(usr): UserController.createUser + happy path 集成测试 REQ-USR-001` |
  718 +| 16 | `test(usr): createUser 错误路径集成测试 (40001-40005/40101/40301) REQ-USR-001` |
  719 +| 17 | (无 commit,整体测试闸门) |
docs/superpowers/specs/2026-05-13-REQ-USR-001.md 0 → 100644
  1 +---
  2 +req_id: REQ-USR-001
  3 +date: 2026-05-13
  4 +module: USR-用户管理
  5 +---
  6 +
  7 +# Spec: REQ-USR-001 — 增加用户
  8 +
  9 +## 目标
  10 +
  11 +实现 `POST /api/usr/user` 增加用户接口:管理员(或被授予 `usr:user:create` 权限的账号)提交合法字段后,写入 `tUser` 主记录 + `tUserPermission` 关联授权,初始密码以哈希形式存储,账号立即生效可用。
  12 +
  13 +本 REQ 作为后端阶段第一个落地的功能,**附带一次性建立后端 Spring Boot 工程骨架与通用基础设施**(详见 § 附带基础设施),供本模块后续 REQ-USR-002/003/004 复用。
  14 +
  15 +## 输入 / 触发
  16 +
  17 +### HTTP 入口
  18 +- Method: `POST`
  19 +- Path: `/api/usr/user`
  20 +- Auth: 需要(JWT, `Authorization: Bearer <accessToken>`)
  21 +- Permission: `usr:user:create`
  22 +
  23 +### 请求体 `UserCreateDTO`(JSON)
  24 +
  25 +| 字段 | 类型 | 必填 | 校验 | 说明 |
  26 +|---|---|---|---|---|
  27 +| `userCode` | String | 是 | 非空、长度 ≤ 32、与已有不重复 | 用户号(业务唯一编码),写 `tUser.sUserCode` |
  28 +| `userName` | String | 是 | 非空、长度 ≤ 50、与已有不重复 | 用户名(登录标识,全局唯一),写 `tUser.sUserName` |
  29 +| `employeeId` | Integer | 否 | 非空时需存在于 `tEmployee` 且 `iIsDisabled=0` | 写 `tUser.iEmployeeId` |
  30 +| `userType` | String | 是 | 枚举 `NORMAL` / `ADMIN` | 写 `tUser.sUserType` |
  31 +| `language` | String | 是 | 枚举 `zh-CN` / `en` / `zh-TW` | 写 `tUser.sLanguage` |
  32 +| `canEditDoc` | Boolean | 是 | — | 写 `tUser.iCanEditDoc`(true→1, false→0) |
  33 +| `password` | String | 否 | 非空时长度 1–64 | 明文密码;缺省 → 用配置 `app.security.default-password`(默认 `666666`)。后端 BCrypt 哈希后写 `tUser.sPasswordHash` |
  34 +| `permissionIds` | Integer[] | 否 | 元素需存在于 `tPermission` 且 `iIsDisabled=0` | 权限分类主键数组,对应写入 `tUserPermission` |
  35 +
  36 +### 隐式上下文(来自 JWT/SecurityContext)
  37 +
  38 +- 当前操作员的 `userName` → 写 `tUser.sCreatedBy`
  39 +- 当前操作员的 `brandsId` / `subsidiaryId` → 写 `tUser.sBrandsId` / `tUser.sSubsidiaryId`(**多租户继承策略**:新用户默认归属创建者所在品牌+子公司)
  40 +- 当前操作员的 authorities 必须包含 `usr:user:create`,否则返回 `40301`
  41 +
  42 +## 输出 / 结果
  43 +
  44 +成功响应(HTTP 200):
  45 +
  46 +```json
  47 +{
  48 + "code": 200,
  49 + "message": "操作成功",
  50 + "data": {
  51 + "userId": 12,
  52 + "userCode": "U2026051301"
  53 + },
  54 + "timestamp": 1715619600000
  55 +}
  56 +```
  57 +
  58 +- `data.userId` = 新写入 `tUser.iIncrement`(int)
  59 +- `data.userCode` = 写入的 `sUserCode`,方便前端立刻回显(对应 REQ 输出表 1「用户号」)
  60 +- **password 字段在任何响应、日志、堆栈中都不得出现**
  61 +
  62 +### 副作用
  63 +
  64 +- `tUser` 新增 1 行
  65 +- `tUserPermission` 新增 `len(permissionIds)` 行(permissionIds 为空/未传时不写入关联)
  66 +
  67 +## 业务规则
  68 +
  69 +### B-001 唯一性
  70 +- `userName` 命中 `uk_tUser_sUserName` → `40002`
  71 +- `userCode` 命中 `uk_tUser_sUserCode` → `40003`
  72 +- 唯一性优先在 Service 层 `selectCount` 预检(更友好的错误信息),但 DB 唯一索引兜底;并发场景下捕获 `DuplicateKeyException` 转译为 `40002` / `40003`(按命中索引名区分)
  73 +
  74 +### B-002 外键 / 字典存在性
  75 +- `employeeId` 非空且 (`tEmployee` 中无此 ID 或 `iIsDisabled=1`) → `40004`
  76 +- `permissionIds` 中任一元素 (`tPermission` 中无此 ID 或 `iIsDisabled=1`) → `40005`
  77 +
  78 +### B-003 密码处理
  79 +- 明文密码:DTO 传入或取 `app.security.default-password`
  80 +- 用 Spring Security `BCryptPasswordEncoder.encode(password)` 哈希 → 写 `sPasswordHash`
  81 +- 不存明文,不打日志,不回显
  82 +
  83 +### B-004 隐式字段写入
  84 +- `sCreatedBy` = SecurityContext 当前操作员 `userName`
  85 +- `sBrandsId` / `sSubsidiaryId` = SecurityContext 当前操作员的同名字段
  86 +- `iIsDisabled` = 0
  87 +- `iLoginFailCount` = 0
  88 +- `tLockedUntil` = NULL
  89 +- `tLastLoginDate` = NULL
  90 +- `tCreateDate` / `tUpdateDate` 由 MySQL DEFAULT / ON UPDATE CURRENT_TIMESTAMP 自动赋值
  91 +- `sId` 标准列暂置 NULL(业务 ID 后续可由全局 ID 生成器补;本 REQ 不强求)
  92 +
  93 +### B-005 事务边界
  94 +- Service 实现类方法标 `@Transactional(rollbackFor = Exception.class)`
  95 +- `tUser` 插入 → 获取自增主键 `iIncrement` → 循环/批量插入 `tUserPermission`,任一失败整体回滚
  96 +- Controller 不打事务
  97 +
  98 +### B-006 权限校验
  99 +- 用 Spring Security 的 `@PreAuthorize("hasAuthority('usr:user:create')")` 注解放在 Controller 方法
  100 +- 当前账号 authorities 不含该权限 → Spring Security 抛 `AccessDeniedException`,全局异常处理器转译为 `40301`
  101 +
  102 +### B-007 参数校验
  103 +- DTO 用 Jakarta Bean Validation 注解(`@NotBlank` / `@Size` / `@Pattern` / `@NotNull` 等)
  104 +- `@Valid` 触发,未通过 → `MethodArgumentNotValidException` → 全局异常处理器转译为 `40001`,`message` 取首个 fieldError 信息
  105 +
  106 +## 边界与约束
  107 +
  108 +- 包路径:`com.xly.test4.module.usr.{controller,service,service.impl,mapper,entity,dto,vo}`
  109 +- Mapper XML:`backend/src/main/resources/mapper/usr/{UserMapper.xml, UserPermissionMapper.xml}`
  110 +- DTO/VO 字段用小驼峰;entity 字段与表列名 1:1(匈牙利前缀保留,如 `iIncrement` / `sUserName`)。通过 MyBatis-Plus `@TableField` 注解显式映射
  111 +- DTO ↔ Entity ↔ VO 转换走 MapStruct(`UserConverter` 接口)
  112 +- 接口响应统一 `Result<T>` 包装(code/message/data/timestamp);错误响应禁止回显堆栈,堆栈只进 Logback
  113 +- 唯一性预检 + DB 兜底两道防线(避免单纯依赖 SELECT 再 INSERT 的 TOCTOU 漏洞)
  114 +- 测试时通过 `JwtTokenProvider` 直接签发带种子 admin 上下文的 token;不依赖 REQ-USR-004 登录接口
  115 +- 错误码体系**以 docs/05 为准**(数字字符串如 `"40001"`),与 docs/04 § 1.3 的 `<MOD>-<NNN>` 描述存在差异,本 REQ 不修订 docs/04,后续可在统一文档时处理
  116 +
  117 +## 依赖的 schema 表 / 字段
  118 +
  119 +### 写入
  120 +
  121 +- `tUser`:插入新行,字段见 § B-004
  122 +- `tUserPermission`:每个 permissionId 一行,字段 `iUserId` / `iPermissionId` / `sGrantedBy`(取当前操作员 userName)/ `sBrandsId` / `sSubsidiaryId`
  123 +
  124 +### 读取(校验用)
  125 +
  126 +- `tEmployee`:按 `iIncrement` 查存在性 + `iIsDisabled=0`
  127 +- `tPermission`:按 `iIncrement IN (…)` 批量查存在性 + `iIsDisabled=0`
  128 +
  129 +### 不涉及
  130 +
  131 +- `tCompany`:仅 REQ-USR-004 登录页用作版本下拉,本 REQ 不读不写
  132 +
  133 +## 依赖的接口
  134 +
  135 +- **本 REQ 提供**:`POST /api/usr/user`(见 docs/05 § REQ-USR-001)
  136 +- **本 REQ 不依赖任何其他业务接口**(鉴权链路通过 Spring Security filter 自动消费 JWT,不算接口依赖)
  137 +
  138 +## 附带基础设施
  139 +
  140 +> 这些基础组件本 REQ 一次性建好,供 REQ-USR-002/003/004 直接复用。后续 REQ 的 spec 不再重复声明这些。
  141 +
  142 +### 工程骨架
  143 +
  144 +- `backend/pom.xml`
  145 + - parent: `spring-boot-starter-parent` 3.x
  146 + - 依赖:`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)
  147 +- `backend/src/main/java/com/xly/test4/Application.java`:`@SpringBootApplication` 入口
  148 +- `backend/src/main/resources/application.yml`:
  149 + - `spring.datasource.url/username/password`:从环境变量注入(`${DB_HOST}/${DB_PORT}/${DB_USER}/${DB_PASSWORD}/${DB_SCHEMA}`),匹配 `.env.local`
  150 + - `spring.flyway.enabled=true`、`spring.flyway.locations=filesystem:../sql/migrations`(直接指向仓库根 `sql/migrations/`,与 setup-test-db.sh 注释及 CLAUDE.md § Schema 演化规约 的 SSoT 路径一致;Maven 启动 / IDE 启动工作目录均为 `backend/`,相对路径成立)
  151 + - `mybatis-plus.mapper-locations=classpath:mapper/**/*.xml`
  152 + - `app.security.default-password=666666`
  153 + - `app.security.max-login-fail=5`
  154 + - `app.security.lock-minutes=30`
  155 + - `app.security.jwt.secret=${JWT_SECRET}`
  156 + - `app.security.jwt.access-ttl-hours=24`
  157 + - `server.port=8080`
  158 +- `backend/src/main/resources/application-dev.yml`:dev profile 覆盖(如 Logback DEBUG)
  159 +
  160 +### Common 层
  161 +
  162 +- `common/response/Result.java`
  163 + - 字段:`code: int`、`message: String`、`data: T`、`timestamp: long`
  164 + - 静态方法:`Result.success(T data)` / `Result.fail(int code, String message)`
  165 +- `common/response/ResultCode.java`:常量集合(200 / 40001 / 40002 / 40003 / 40004 / 40005 / 40301 / 50000)
  166 +- `common/exception/BusinessException.java extends RuntimeException`:字段 `code: int`、`message: String`
  167 +- `common/exception/GlobalExceptionHandler.java`(`@RestControllerAdvice`):
  168 + - `BusinessException` → `Result.fail(e.code, e.message)`
  169 + - `MethodArgumentNotValidException` → `Result.fail(40001, firstFieldErrorMessage)`
  170 + - `AccessDeniedException` → `Result.fail(40301, "权限不足")`
  171 + - `DuplicateKeyException` → 按命中索引名识别(`uk_tUser_sUserName` / `uk_tUser_sUserCode`)转 40002/40003;否则 50000
  172 + - 兜底 `Exception` → `Result.fail(50000, "系统繁忙,请稍后重试")`,堆栈进 Logback
  173 +- `common/security/JwtTokenProvider.java`:
  174 + - 签发:输入 `userName` / `userId` / `brandsId` / `subsidiaryId` / `authorities: List<String>` → 返回 `accessToken`,HS256 签名,`exp = now + 24h`
  175 + - 解析:输入 `token` → 返回 `Claims`(含上述字段),过期/篡改抛 `JwtAuthenticationException`(自定义)
  176 +- `common/security/JwtAuthenticationFilter.java extends OncePerRequestFilter`:
  177 + - 从 `Authorization: Bearer <token>` 头解析,构造 `UsernamePasswordAuthenticationToken` 并塞入 `SecurityContextHolder`
  178 + - principal 用自定义 `CurrentUser` 对象(持有 userId/userName/brandsId/subsidiaryId)
  179 + - 失败 → 不直接拒绝,交给 Security 链后续 `AuthenticationEntryPoint` 决定
  180 +- `common/security/CurrentUser.java`:值对象,字段 `userId/userName/brandsId/subsidiaryId/authorities`
  181 +- `common/security/CurrentUserContext.java`:静态工具,封装 `SecurityContextHolder.getContext().getAuthentication().getPrincipal()` 转 `CurrentUser`
  182 +- `common/security/RestAuthenticationEntryPoint.java implements AuthenticationEntryPoint`:未认证(无 token / token 非法)→ HTTP 401 + Result body `code=40101`、`message="未认证"`
  183 +- `common/security/RestAccessDeniedHandler.java implements AccessDeniedHandler`:已认证但无权限 → HTTP 403 + Result body `code=40301`、`message="权限不足"`(与 § B-006 一致;Spring Security `@PreAuthorize` 抛出的 `AccessDeniedException` 走全局异常 handler,本处仅兜底 filter 层抛出的同类异常)
  184 +- `common/security/SecurityConfig.java`(`@EnableMethodSecurity`):
  185 + - `SecurityFilterChain`:`/api/auth/login`、`/v3/api-docs/**`、`/swagger-ui/**` 白名单,其余 `/api/**` 需鉴权;`csrf().disable()`、`sessionCreationPolicy=STATELESS`、注册 `JwtAuthenticationFilter` 在 `UsernamePasswordAuthenticationFilter` 之前;`exceptionHandling().authenticationEntryPoint(RestAuthenticationEntryPoint).accessDeniedHandler(RestAccessDeniedHandler)`
  186 + - 暴露 `PasswordEncoder` bean = `BCryptPasswordEncoder`
  187 +- `common/config/MybatisPlusConfig.java`:注册分页插件 `PaginationInnerInterceptor`(后续 REQ-USR-003 用)
  188 +
  189 +### Migration
  190 +
  191 +- `sql/migrations/V2__seed_admin_and_permissions.sql`:
  192 + - 插入 `tPermission` 行(≥ 4 行,覆盖本模块所有 REQ 需要的权限码):
  193 + - (sCategory='usr:user:create', sCategoryName='新增用户')
  194 + - (sCategory='usr:user:update', sCategoryName='修改用户')
  195 + - (sCategory='usr:user:list', sCategoryName='查询用户')
  196 + - (sCategory='usr:user:assign-role', sCategoryName='分配用户角色')
  197 + - 插入 `tUser` 行:种子超管
  198 + - `sUserName='admin'`、`sUserCode='ADMIN001'`、`sUserType='ADMIN'`、`sLanguage='zh-CN'`、`iCanEditDoc=1`、`iIsDisabled=0`
  199 + - `sBrandsId='BR-DEFAULT'`、`sSubsidiaryId='SUB-DEFAULT'`
  200 + - `sPasswordHash` = 预先 BCrypt 哈希过的 `admin` 字面值(migration 内硬编码 BCrypt 字符串,生成方式由 spec 调用方决定;推荐用本地 `BCryptPasswordEncoder` 提前生成一次落 SQL)
  201 + - 插入 `tUserPermission` 行:admin × 上述 4 个权限分类,全部授权
  202 +
  203 +> **migration 命名说明**:项目当前已有 `V1__initial_schema.sql`(A4 生成),新增的种子 migration 命名为 `V2__seed_admin_and_permissions.sql`,与 CLAUDE.md § Schema 演化规约 一致。
  204 +
  205 +### 验证策略(与测试无关,但属于本 REQ 配套)
  206 +
  207 +- 在 `Application.java` 启动后通过 Spring Boot Actuator `/actuator/health`(可选)或集成测试 `@SpringBootTest` 启动验证 Flyway apply 通过 + tUser/admin 行存在
  208 +
  209 +## 验收标准
  210 +
  211 +### 接口契约
  212 +
  213 +1. ✅ 合法请求 POST `/api/usr/user`(带 admin token)→ HTTP 200,`code=200`,`data.userId` 为正整数,`data.userCode` 与入参一致;`tUser` 多 1 行、`tUserPermission` 多 `len(permissionIds)` 行
  214 +2. ✅ 入参 `userName` 与已有重复 → HTTP 200,`code=40002`,`tUser` 行数不变
  215 +3. ✅ 入参 `userCode` 与已有重复 → HTTP 200,`code=40003`,`tUser` 行数不变
  216 +4. ✅ 入参 `employeeId` 为不存在的 ID → `code=40004`
  217 +5. ✅ 入参 `employeeId` 指向已作废员工(`iIsDisabled=1`)→ `code=40004`
  218 +6. ✅ 入参 `permissionIds` 含不存在的 ID → `code=40005`,`tUser` 不写入(整体事务回滚)
  219 +7. ✅ 入参缺 `userName`(必填)→ `code=40001`,`message` 含字段错误信息
  220 +8. ✅ 入参 `userType` 取值不合法(如 `"FOO"`)→ `code=40001`
  221 +9. ✅ 带普通用户 token(authorities 不含 `usr:user:create`)→ `code=40301`
  222 +10. ✅ 不带 Authorization 头 → HTTP 401 + Result body `code=40101`、`message="未认证"`(由自定义 `AuthenticationEntryPoint` 统一转译为 Result 包装;docs/05 § 错误码 401xx 段位语义)
  223 +
  224 +### 数据正确性
  225 +
  226 +11. ✅ 写入的 `sPasswordHash` 以 `$2a$` / `$2b$` BCrypt 前缀开头,长度 60,不等于明文
  227 +12. ✅ 入参不带 `password` → 写入的 hash 用 BCrypt 哈希 `666666` 后能 `matches` 通过
  228 +13. ✅ 写入的 `sCreatedBy` 等于 token 中的 admin userName
  229 +14. ✅ 写入的 `sBrandsId` / `sSubsidiaryId` 等于 token 中的对应字段
  230 +15. ✅ 响应 body、错误响应、应用日志中不出现明文 `password` 字段值
  231 +
  232 +### 基础设施
  233 +
  234 +16. ✅ `mvn spring-boot:run` 启动成功,Flyway 自动 apply V1 + V2,启动日志见 `tUser` 中存在 `admin` 行
  235 +17. ✅ `mvn test` 测试套件通过(含本 REQ 集成测试)
  236 +18. ✅ `GET /v3/api-docs` 或 `/swagger-ui.html` 可访问(springdoc 暴露),看到 `POST /api/usr/user` 接口签名
  237 +
  238 +### 错误响应格式
  239 +
  240 +19. ✅ 任何错误响应 body 不含 stacktrace / class name / SQL 文本;只含 `code` / `message` / `data:null` / `timestamp`