Commit 073c6d26697fb8bd95f7e9fcae5dfc4c45741357
1 parent
1559e913
docs(usr): REQ-USR-001 spec + plan 归档 REQ-USR-001
Showing
2 changed files
with
959 additions
and
0 deletions
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` |