fix: harden authentication, privacy, search, and scoring infrastructure #1
Merged
zhangshun
merged 28 commits from refactor/r01-structure-performance into master 1 month ago
394 changed files with 45012 additions and 2230 deletions
@ -0,0 +1,161 @@ |
|||
# 账号注销测试夹具隔离修复报告 |
|||
|
|||
日期:2026-09-17 |
|||
分支:`refactor/r01-structure-performance` |
|||
基线 HEAD:`d5c37128e84b4fc83bf5334254dd8efeb88ccf64` |
|||
性质:**纯测试修复** —— 生产代码、Flyway、配置与接口零改动 |
|||
|
|||
--- |
|||
|
|||
## 1. 结论 |
|||
|
|||
> `AccountDeletionControllerTest` 与关联测试组合的**既有交叉污染已消除**;修复后两种执行顺序各 5 次全部 F=0 / E=0 / S=0。 |
|||
|
|||
--- |
|||
|
|||
## 2. 稳定复现(修复前) |
|||
|
|||
**致冲突组合**(连续运行 5 次,**5/5 稳定复现**): |
|||
|
|||
``` |
|||
mvn -Dtest="*Deletion*Test,*Verification*Test,*Risk*Test,*Report*Test,*Block*Test, |
|||
*Notification*Test,*Recommendation*Test,*RequestContext*Test,*RequestId*Test" test |
|||
``` |
|||
|
|||
5 次运行**全部**失败于同一位置: |
|||
|
|||
``` |
|||
AccountDeletionControllerTest.lazyTransition_firstGet_returnsCompleted_andConsistent:228 |
|||
-> createUser:75 |
|||
DataIntegrityViolation: Unique index or primary key violation: |
|||
USERS(PHONE) = '930000000' |
|||
``` |
|||
|
|||
### 诊断结论(逐项回答) |
|||
|
|||
| 问题 | 结论 | |
|||
|---|---| |
|||
| **谁先插入 `phone='930000000'`** | `com.mydreamjob.common.logging.RequestContextMigrationIntegrationTest`(**HEAD 既有类**)。其 `@BeforeEach` 用 `AtomicLong(930_000_000L)` 创建 user | |
|||
| **冲突发生在哪个方法** | `AccountDeletionControllerTest#createUser:75`(由 `lazyTransition_firstGet_returnsCompleted_andConsistent:228` 调用) | |
|||
| **共享 H2 数据库名** | `integrationdb`(`jdbc:h2:mem:integrationdb;MODE=MySQL;DB_CLOSE_DELAY=-1`)—— 同一 JVM 内所有 `@SpringBootTest` 共享 | |
|||
| **static 计数器何时重置** | **不重置** —— `PHONE` 为 per-class static,类加载时初始化为 `930_000_000`;冲突**不是**计数器重置所致,而是**号段被残留记录占用** | |
|||
| **未清理的记录** | `users`(`phone=930000000`);`user_roles` 随 users 级联删除 | |
|||
|
|||
### 根因链 |
|||
|
|||
1. **号段撞车**:`common/logging/RequestContextMigrationIntegrationTest` 与 `deletion/AccountDeletionControllerTest` **都使用基数 `930_000_000`** |
|||
2. **污染源不清理**:前者 `@AfterEach` 只清 `RequestContextProbe` 与 `MDC`,**不删 users** → 每个测试方法残留 1 条 `930000000+N` |
|||
3. **受害者删除失败**:后者的 `@AfterEach` 用 `userRepository.deleteAll()`(**全表删除**),会因**其他测试类残留 user**(被 `notifications`/`offers`/`resume_versions` 等**无 CASCADE** 的外键引用)而失败 → 自身 user 也未删净 |
|||
4. **执行顺序**:诊断运行中 `common.logging.RequestContextMigrationIntegrationTest` 排在第 **6** 位、`AccountDeletionControllerTest` 排在第 **13** 位 → 前者残留必然影响后者 |
|||
|
|||
### 与 A2/A3 的关系(如实记录) |
|||
|
|||
A2/A3 新建的测试类**均不创建 user**,故**无污染**: |
|||
- `common/r01a2/RequestContextMigrationIntegrationTest`:无 `AtomicLong`、无 `userRepository` 注入 |
|||
- A3 的三个测试类:均使用 `@MockBean UserRepository`(不实际写库) |
|||
|
|||
**该缺陷完全存在于基线**(污染源文件为 HEAD 既有),与 A2/A3 无关;此前用 HEAD 隔离 worktree 的归因结论**正确**。 |
|||
|
|||
> 同号段还有 `candidatescore/CandidateScoringClockBoundaryIntegrationTest`,但其标注 `@Transactional`(每方法自动回滚)→ **不残留**,无风险。 |
|||
|
|||
--- |
|||
|
|||
## 3. 修复内容 |
|||
|
|||
### 3.1 `common/logging/RequestContextMigrationIntegrationTest.java`(污染源) |
|||
|
|||
| 改动 | 内容 | |
|||
|---|---| |
|||
| 号段独占 | `930_000_000L` → **`986_000_000L`**(已确认全仓未占用) | |
|||
| **清理** | `@AfterEach` 增加:按自身 `studentId` **精确删除**本测试创建的 user(`findById(...).ifPresent(delete)`),并补 javadoc 说明隔离原因 | |
|||
|
|||
### 3.2 `deletion/AccountDeletionControllerTest.java`(受害者) |
|||
|
|||
| 改动 | 内容 | |
|||
|---|---| |
|||
| 号段独占 | `930_000_000L` → **`987_000_000L`**(已确认全仓未占用) | |
|||
| 记录归属 | 新增 `List<Long> createdUserIds`,`createUser()` 登记每个自建 userId | |
|||
| **精确清理** | `@AfterEach` 由 `deleteAll()`(整表)改为**按本测试记录**删除:`account_deletions`(按 userId)→ `users`(级联 `user_roles`),各步 `flush()`,遵循外键顺序;`@BeforeEach` 清空记录 | |
|||
| **防掩盖断言** | `createUser()` 内先断言该号在共享库中**不存在**,存在即抛出可读错误(暴露上轮清理失效),而不是被唯一键异常吞掉 | |
|||
|
|||
**未使用**:未改唯一约束、未捕获忽略 `DataIntegrityViolationException`、未用 `@DirtiesContext`、未清空整表、未 `DELETE FROM users` 无条件删除、未 sleep、未放宽任何业务断言、未依赖测试执行顺序。 |
|||
|
|||
### 3.3 新增反证测试 `deletion/AccountDeletionFixtureIsolationTest.java`(5 例) |
|||
|
|||
| 反证 | 断言 | |
|||
|---|---| |
|||
| 1 | 同一 Spring 上下文内连续创建 5 个夹具**不冲突**且 ID 不重复 | |
|||
| 2 | 注销记录写入后,按本测试路径清理 → `account_deletions` / `users` / `user_roles` 残留**均为 0** | |
|||
| 3 | **变异探测**:预置同号记录(等价于"上轮未清理")→ 防掩盖断言**必须失败**并检出该号,证明残留不会被静默吞掉 | |
|||
| 4 | 独占号段 `987_000_000` 在测试开始前为空(不与任何他类重叠) | |
|||
| 5 | 清理**精确到本测试记录**:预置的"他类"记录必须保留,证明不是整表删除 | |
|||
|
|||
--- |
|||
|
|||
## 4. 验证结果 |
|||
|
|||
### 4.1 两种执行顺序各 5 次 |
|||
|
|||
| | 组合 | 结果(5 次) | |
|||
|---|---|---| |
|||
| 顺序 A | `*Deletion*Test,*RequestContext*Test,*RequestId*Test` | **95/95,F=0 E=0 S=0** ×5 ✔ | |
|||
| 顺序 B | `*RequestContext*Test,*Deletion*Test,*RequestId*Test` | **95/95,F=0 E=0 S=0** ×5 ✔ | |
|||
|
|||
(修复前该组合稳定失败 5/5;修复后 10/10 全绿) |
|||
|
|||
### 4.2 单类与反证 |
|||
|
|||
| 命令 | 结果 | |
|||
|---|---| |
|||
| `mvn -Dtest="AccountDeletionControllerTest" test` | **14 / 14** ✔ | |
|||
| `mvn -Dtest="AccountDeletionFixtureIsolationTest,AccountDeletionControllerTest" test` | **19 / 19**(5 反证 + 14 原)✔ | |
|||
|
|||
### 4.3 全量 |
|||
|
|||
| 命令 | 结果 | |
|||
|---|---| |
|||
| `mvn clean verify` | **2225 / 2225**,Failures=0、Errors=0、**Skipped=0**,BUILD SUCCESS(≥ 2220 ✔) | |
|||
| `python docs\verify_openapi.py` | **176 paths / 214 operations**,All checks passed ✔ | |
|||
|
|||
较修复前(2220)新增 **5** 个测试,全部为反证测试。 |
|||
|
|||
### 4.4 边界核验 |
|||
|
|||
| 项 | 结果 | |
|||
|---|---| |
|||
| 生产源码 `git diff --stat -- src/main/` | **空**(零改动)✔ | |
|||
| V1–V45 迁移 | **零修改** ✔ | |
|||
| Flyway / 配置 / 接口 | 零改动 ✔ | |
|||
| `GlobalExceptionHandler.java` | 仍为工作区 stat 标记(内容零差异),**未处理** ✔ | |
|||
| Docker | 未操作 ✔ | |
|||
| 是否进入 A4 | 否 ✔ | |
|||
|
|||
--- |
|||
|
|||
## 5. 精确修改文件 |
|||
|
|||
``` |
|||
修改(2) |
|||
mydreamjob-server/src/test/java/com/mydreamjob/common/logging/RequestContextMigrationIntegrationTest.java |
|||
mydreamjob-server/src/test/java/com/mydreamjob/deletion/AccountDeletionControllerTest.java |
|||
|
|||
新增(1) |
|||
mydreamjob-server/src/test/java/com/mydreamjob/deletion/AccountDeletionFixtureIsolationTest.java |
|||
|
|||
文档(1) |
|||
docs/account-deletion-test-isolation-fix-20260917.md |
|||
``` |
|||
|
|||
--- |
|||
|
|||
## 6. 提交 |
|||
|
|||
``` |
|||
提交信息:test: isolate account deletion controller fixtures |
|||
``` |
|||
|
|||
(精确暂存上述测试文件与报告,敏感扫描后独立提交;推送当前 R01 功能分支) |
|||
|
|||
--- |
|||
|
|||
> 账号注销测试夹具隔离修复完成;测试交叉污染已消除,生产代码零改动。 |
|||
@ -0,0 +1,224 @@ |
|||
# DEF-01 定向整改:`GET /jobs` 公开列表关键词规范化 |
|||
|
|||
日期:2026-09-20 |
|||
分支:`refactor/r01-structure-performance` |
|||
基线 HEAD:`2319f97203a2cafcf8012869fda8c30599ffb305`(整改前的 upstream 同点) |
|||
方案:**方案 A**(服务入口局部规范化),**不采用方案 B** |
|||
本轮性质:定向整改 + 独立复验;未暂存、未提交、未推送 |
|||
|
|||
--- |
|||
|
|||
## 1. 修复前最小复现 |
|||
|
|||
真实 HTTP(整改前,`mvn` 于 `2319f97`): |
|||
|
|||
``` |
|||
GET /api/v1/jobs?keyword=%20AlphaEngineer%20 → items=0 (期望 1:trim 后应命中) |
|||
GET /api/v1/jobs?keyword=%20%20%20 → items=0 (期望 20:空白应等同未传) |
|||
GET /api/v1/jobs/ch?keyword=AlphaEngineer → items=1 |
|||
GET /api/v1/jobs → items=20 |
|||
GET /api/v1/jobs/search?keyword=%20AlphaEngineer%20 → 1 (正确) |
|||
GET /api/v1/jobs/search?keyword=%20%20%20 → 20 (正确) |
|||
``` |
|||
|
|||
## 2. 两个端点的调用链 |
|||
|
|||
| 端点 | 调用链 | 关键词处理 | |
|||
|---|---|---| |
|||
| `GET /jobs` | `JobController.listJobs:97` → `JobService.listPublishedJobs:123` → `JobRepository.findPublishedJobs`(JPQL) | 只判 `:keyword IS NULL`,**无 trim、无 blank 归一** | |
|||
| `GET /jobs/search` | `JobController.searchJobs:134` → `JobService.searchJobs` → `JobSearchSpecification:52-53` | `!keyword.isBlank()` 判空 + `keyword.trim()` | |
|||
|
|||
## 3. 根因 |
|||
|
|||
`JobRepository.findPublishedJobs` 的 JPQL 为: |
|||
|
|||
```sql |
|||
AND (:keyword IS NULL OR j.title LIKE %:keyword% OR j.description LIKE %:keyword%) |
|||
``` |
|||
|
|||
- `" AlphaEngineer "` **不是 null** → 生成 `LIKE '% AlphaEngineer %'` → 前导/尾随空格进入匹配串 → **0 条**; |
|||
- `" "` **不是 null** → 生成 `LIKE '% %'` → 无意义的过滤条件 → **0 条**; |
|||
- `/jobs/search` 已做 `trim()` 与 `isBlank()` 归一 → 两条**公开**查询路径的输入语义不一致。 |
|||
|
|||
## 4. 为什么选方案 A |
|||
|
|||
- 缺陷是**输入规范化缺失**,不是查询实现缺陷; |
|||
- 方案 A 把规范化放在**唯一入口**(`listPublishedJobs`),一处生效、语义显式、可被单测直接覆盖; |
|||
- 改动面最小(1 个文件、+7/−1),**不触碰** Repository / Specification / Controller / DTO / OpenAPI / |
|||
排序 / 分页 / 薪资 / 城市 / 状态,回归风险最低。 |
|||
|
|||
## 5. 为什么不选方案 B |
|||
|
|||
- 方案 B 让 `/jobs` 改走 `JobSearchSpecification`,会改变 `/jobs` 的**分页 DTO 形态、排序实现与 |
|||
查询计划**(`JobSearchItem` 与现有 `toPublicView` 字段并不相同),属于超出缺陷范围的语义扩张; |
|||
- 两套实现并存是**既有事实**(`/jobs` 与 `/jobs/search` 本就服务不同调用方), |
|||
合并应作为独立重构批次评估,不应与本次缺陷修复捆绑。 |
|||
|
|||
## 6. 精确生产修改 |
|||
|
|||
**唯一文件**:`mydreamjob-server/src/main/java/com/mydreamjob/job/JobService.java` |
|||
(`git diff --stat`:**1 file changed, 7 insertions(+), 1 deletion(-)**) |
|||
|
|||
```java |
|||
public Page<Map<String, Object>> listPublishedJobs(String keyword, String city, ...) { |
|||
if (salaryMin != null && salaryMax != null && salaryMin > salaryMax) { |
|||
throw new BusinessException(ErrorCode.BAD_REQUEST); |
|||
} |
|||
String normalizedKeyword = keyword == null || keyword.isBlank() ? null : keyword.trim(); // ← 新增 |
|||
Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt")); |
|||
Page<Job> result = jobRepository.findPublishedJobs(normalizedKeyword, city, employmentType, // ← 原为 keyword |
|||
salaryMin, salaryMax, pageable); |
|||
return result.map(this::toPublicView); |
|||
} |
|||
``` |
|||
|
|||
(另含一段 Javadoc 说明 DEF-01 的语义契约;不改变任何行为。) |
|||
|
|||
**未新增通用工具类**:项目内不存在语义完全相同的既有规范化工具,故保持这段局部逻辑。 |
|||
|
|||
## 7. 输入规范化决策表 |
|||
|
|||
| 输入 | 规范化结果 | |
|||
|---|---| |
|||
| `null` | `null` | |
|||
| `""` | `null` | |
|||
| `" "` | `null` | |
|||
| `"AlphaEngineer"` | `"AlphaEngineer"` | |
|||
| `" AlphaEngineer "` | `"AlphaEngineer"` | |
|||
| `"Alpha Engineer"` | `"Alpha Engineer"`(中间空格**不折叠**) | |
|||
|
|||
未做:大小写转换、中间空格折叠、SQL 拼接、Repository 改动、Specification 改动、Controller 改动、 |
|||
排序/分页/薪资/城市/状态语义改动、公开岗位范围改动。 |
|||
|
|||
## 8. 新增测试方法名 |
|||
|
|||
`mydreamjob-server/src/test/java/com/mydreamjob/job/JobListKeywordNormalizationIntegrationTest.java` |
|||
(9 项,走**真实链路** HTTP → SecurityFilterChain → JobController → JobService → JobRepository): |
|||
|
|||
`plainKeywordFindsPublishedJob`、`trimmedKeywordFindsSamePublishedJob`、`blankKeywordBehavesAsNoKeyword`、 |
|||
`emptyKeywordBehavesAsNoKeyword`、`nullOrMissingKeywordListsPublishedJobs`、 |
|||
`draftJobNeverAppearsAfterNormalization`、`normalizationDoesNotChangePagination`、 |
|||
`normalizationDoesNotChangeExistingSortContract`、`listJobsIsReadOnly` |
|||
|
|||
夹具:1 条标题含 `AlphaEngineer` 的 PUBLISHED + 2 条其它 PUBLISHED + 1 条 DRAFT + 1 条 CLOSED; |
|||
唯一前缀 `DEF01KEYWORD_`;清理**只按显式登记的主键**,不使用 `deleteAll()`;不依赖执行顺序。 |
|||
|
|||
## 9. 修复前失败结果 |
|||
|
|||
以**变异验证**给出权威的修复前证据(把 `normalizedKeyword` 改回 `keyword`,其余不动): |
|||
|
|||
``` |
|||
Tests run: 9, Failures: 4, Errors: 0, Skipped: 0 |
|||
JobListKeywordNormalizationIntegrationTest.trimmedKeywordFindsSamePublishedJob FAIL |
|||
JobListKeywordNormalizationIntegrationTest.blankKeywordBehavesAsNoKeyword FAIL |
|||
JobListKeywordNormalizationIntegrationTest.normalizationDoesNotChangePagination FAIL |
|||
JobListKeywordNormalizationIntegrationTest.normalizationDoesNotChangeExistingSortContract FAIL |
|||
``` |
|||
|
|||
即 **trim 与 blank 两类断言在缺失修复时必然失败**(≥2 项,满足要求)。 |
|||
|
|||
## 10. 修复后结果 |
|||
|
|||
| 命令 | 结果 | |
|||
|---|---| |
|||
| `mvn -Dtest="JobListKeywordNormalizationIntegrationTest" test` | **9 / 0 / 0 / 0** | |
|||
| `mvn -Dtest="*Job*Test,*JobSearch*Test" test` | **219 / 0 / 0 / 0** | |
|||
|
|||
## 11. 真实 MySQL HTTP 结果 |
|||
|
|||
最新 JAR(`mvn clean verify` 产物)+ 真实 MySQL/Redis/RabbitMQ,前缀 `DEF01_20260920_`: |
|||
**11 / 11 通过**。 |
|||
|
|||
| 断言 | 实测 | |
|||
|---|---| |
|||
| 普通关键词 HTTP 200 且只命中目标 | `200, [target]` ✔ | |
|||
| 两侧空白与普通关键词:ID/顺序/数量一致 | `pad=[T] plain=[T]` ✔ | |
|||
| 纯空白与无关键词:ID/顺序/总数一致 | `blank == none` ✔ | |
|||
| 空字符串与无关键词:一致 | `empty == none` ✔ | |
|||
| DRAFT 不进入公开结果 | `statuses=['PUBLISHED']` ✔ | |
|||
| 分页 metadata 不因规范化变化 | `total=1,page=0,size=1,totalPages=1` ✔ | |
|||
| 默认排序不变(夹具 createdAt 降序) | `none == blank`,desc ✔ | |
|||
| 不返回 4xx/5xx | `[200]×7` ✔ | |
|||
| 响应不泄露 SQL/异常/路径/堆栈 | `leaks=[]` ✔ | |
|||
| 对照 `/jobs/search` 的 trim/blank 语义一致 | `pad==plain`、`blank==全部` ✔ | |
|||
| 请求前后数据库逐字段零变化 | `changed=none` ✔ | |
|||
|
|||
## 12. 变异测试结果 |
|||
|
|||
- 保存 `JobService.java` **原始字节**(sha256 前 16 位 `b4fab0034b34d569`,14 699 字节); |
|||
- 变异:`findPublishedJobs(normalizedKeyword, …)` → `findPublishedJobs(keyword, …)`; |
|||
- 结果:**4 项失败**(见 §9); |
|||
- **按原始字节精确恢复**(**未使用** `git restore` / `git checkout`),恢复后 sha 一致; |
|||
- 复跑新增测试 **9/9 绿**;校验无变异残留(`findPublishedJobs(keyword,` 计数 = 0, |
|||
`String normalizedKeyword =` 计数 = 1)。 |
|||
|
|||
## 13. 岗位关联回归 |
|||
|
|||
`mvn -Dtest="*Job*Test,*JobSearch*Test" test` → **219 / 0 / 0 / 0**(未修改或删除任何既有断言)。 |
|||
|
|||
## 14. 全量测试数 |
|||
|
|||
| 命令 | Tests | Failures | Errors | Skipped | BUILD | |
|||
|---|---|---|---|---|---| |
|||
| `mvn test` | **2797** | 0 | 0 | 0 | SUCCESS | |
|||
| `mvn clean verify` | **2797** | 0 | 0 | 0 | SUCCESS | |
|||
|
|||
`2797 = 2788(基线)+ 9(本轮新增)`,满足「不低于 2788 + 新增数」。 |
|||
Testcontainers MySQL 用例实际执行(`Skipped=0`)。 |
|||
|
|||
## 15. OpenAPI |
|||
|
|||
``` |
|||
python ..\docs\verify_openapi.py → paths: 176 / operations: 214 / All checks passed! |
|||
``` |
|||
|
|||
`docs/openapi-rc.json` **未修改**(`git diff` 为空)。 |
|||
|
|||
## 16. 数据副作用 |
|||
|
|||
- 黑盒前后 7 张表(jobs / companies / users / user_roles / audit_logs / notifications / applications) |
|||
**逐字段零变化**; |
|||
- 单测 `listJobsIsReadOnly` 对 5 张表逐字段零变化; |
|||
- 夹具残留 **0**;历史未跟踪文件与历史数据(含 `users.id` 7938/7939 的 `user_roles`)完好; |
|||
- `audit_logs` **未删除任何行**。 |
|||
|
|||
## 17. 精确修改文件 |
|||
|
|||
``` |
|||
M mydreamjob-server/src/main/java/com/mydreamjob/job/JobService.java (+7 −1) |
|||
?? mydreamjob-server/src/test/java/com/mydreamjob/job/JobListKeywordNormalizationIntegrationTest.java (新增,9 项) |
|||
?? docs/def-01-job-keyword-normalization-remediation-20260920.md (本报告) |
|||
?? docs/final-independent-acceptance-20260918.md (追加 DEF-01 章节) |
|||
``` |
|||
|
|||
## 18. Git 状态(整改与复验阶段) |
|||
|
|||
| 项 | 结果 | |
|||
|---|---| |
|||
| 生产代码改动 | **仅 `JobService.java` 1 个文件** | |
|||
| `JobRepository` / `JobSearchSpecification` / `JobController` | **零修改** | |
|||
| 其他生产模块 / Flyway / `pom.xml` / `openapi-rc.json` | **零修改** | |
|||
| 验收脚本与原始产物 | **零修改** | |
|||
|
|||
## 19. 未涉及范围 |
|||
|
|||
未进入:简历隐私矩阵、管理员初始化矩阵、Q01 H2/MySQL 对账与 EXPLAIN 分表、写侧刷新链路 |
|||
(四项 SKIP 仍待验收)。 |
|||
|
|||
## 20. 本轮测试自身缺陷(非产品缺陷,已修正并记录) |
|||
|
|||
| # | 现象 | 判定 | 修正 | |
|||
|---|---|---|---| |
|||
| T-1 | 「带空白关键词」用例恒返回空 | **测试缺陷**:MockMvc 对 URL 模板二次编码(`%20` → `%2520`),测的是字面量 `%20` 而非空白参数 | 改用 `.param()` 传参 | |
|||
| T-2 | 分页/排序断言随更大范围运行而失败 | **测试缺陷**:断言了既有契约**不保证**的顺序 —— 默认排序 `createdAt DESC` 无 tiebreaker,并列时入选子集与顺序均不稳定 | 夹具改用**显式互异 `created_at`**;分页用例只断言确定性元数据 | |
|||
| T-3 | 黑盒夹具未创建 | **脚本缺陷**:`seed_sms_codes/login` 使用 `lib.phone()`(`199000` 号段),而建公司语句另写了 `199002` 号段,两者是**不同号码**,公司被挂到不存在的用户上(号码为本地验收自建夹具,报告中脱敏) | 统一为 `lib.phone(CO)` | |
|||
| T-4 | 黑盒「默认排序」断言失败 | **脚本缺陷**:把「关键词过滤后的结果」与「无关键词的完整结果」做相等比较,语义不同 | 只比较同为未过滤的 `none` 与 `blank` | |
|||
|
|||
## 21. 明确声明 |
|||
|
|||
**未修改** `/jobs/search`、`JobSearchSpecification`、`JobRepository` JPQL、Controller 路由、 |
|||
排序/分页/薪资/城市/状态过滤、DTO、OpenAPI、`ErrorCode`、`SecurityConfig`、V1–V45、`pom.xml`; |
|||
**未新增 V46**;**未修改任何既有测试断言**;**未**执行 `git reset/restore/checkout/clean`; |
|||
**未**使用 `git add -A` / `git add .` / 目录级暂存;**未**删除或移动历史未跟踪文件。 |
|||
|
|||
本报告与 `docs/final-independent-acceptance-20260918.md` 的 DEF-01 追加章节于提交前保持**待提交**状态。 |
|||
File diff suppressed because it is too large
@ -0,0 +1,303 @@ |
|||
# Q01 MySQL 执行计划证据(由 JobSearchQueryExplainMySqlIntegrationTest 生成) |
|||
|
|||
- 数据库:`mysql:8.4` Testcontainers 独立容器,正式 Flyway 迁移,`ddl-auto=validate` |
|||
- EXPLAIN 形式:`EXPLAIN FORMAT=JSON`,与 Criteria 路径绑定同一组参数 |
|||
- 夹具规模:每个城市分别 1 / 20 / 50 条 PUBLISHED 岗位(刻意做小,用于验证语义而非压测) |
|||
- Q01 不新增索引;执行计划问题记录给 R01 |
|||
|
|||
## 1. 组合过滤 + latest 分页 |
|||
|
|||
Hibernate 实际生成并执行的 SQL(占位符形式): |
|||
|
|||
```sql |
|||
select j1_0.id,j1_0.city,j1_0.company_id,j1_0.created_at,j1_0.description,j1_0.employment_type,j1_0.published_at,j1_0.requirements,j1_0.salary_max,j1_0.salary_min,j1_0.status,j1_0.title,j1_0.updated_at from jobs j1_0 where j1_0.status=? and (lower(j1_0.title) like ? escape '' or lower(j1_0.description) like ? escape '' or lower(j1_0.requirements) like ? escape '') and lower(j1_0.city)=? and lower(j1_0.employment_type)=? and j1_0.salary_max>=? and j1_0.salary_min<=? and j1_0.company_id=? order by j1_0.created_at desc,j1_0.id desc limit ?,? |
|||
|
|||
select count(j1_0.id) from jobs j1_0 where j1_0.status=? and (lower(j1_0.title) like ? escape '' or lower(j1_0.description) like ? escape '' or lower(j1_0.requirements) like ? escape '') and lower(j1_0.city)=? and lower(j1_0.employment_type)=? and j1_0.salary_max>=? and j1_0.salary_min<=? and j1_0.company_id=? |
|||
``` |
|||
|
|||
等价 SQL(绑定参数 [PUBLISHED, %q01e75001岗位%, %q01e75001岗位%, %q01e75001岗位%, Q01E75001CITY20, FULL_TIME, 5000, 30000, 1, 20, 0]): |
|||
|
|||
```sql |
|||
SELECT j.id FROM jobs j |
|||
WHERE j.status = ? AND (lower(j.title) LIKE ? OR lower(j.description) LIKE ? OR lower(j.requirements) LIKE ?) |
|||
AND lower(j.city) = ? AND lower(j.employment_type) = ? AND j.salary_max >= ? AND j.salary_min <= ? AND j.company_id = ? |
|||
ORDER BY j.created_at DESC, j.id DESC |
|||
LIMIT ? OFFSET ? |
|||
``` |
|||
|
|||
计划摘要: |
|||
|
|||
``` |
|||
query_cost = 1.35 |
|||
using_filesort = true |
|||
using_temporary_table = false |
|||
- 表 query_block/ORDER BY: table_name="j", access_type="ALL", possible_keys=["idx_jobs_company_status","idx_jobs_status_created","idx_jobs_city_status","idx_jobs_status_employment_type","idx_jobs_status_salary_max"], rows_examined_per_scan=1, rows_produced_per_join=1, filtered="100.00" |
|||
``` |
|||
|
|||
<details><summary>EXPLAIN FORMAT=JSON 原文</summary> |
|||
|
|||
```json |
|||
{ |
|||
"query_block": { |
|||
"select_id": 1, |
|||
"cost_info": { |
|||
"query_cost": "1.35" |
|||
}, |
|||
"ordering_operation": { |
|||
"using_filesort": true, |
|||
"cost_info": { |
|||
"sort_cost": "1.00" |
|||
}, |
|||
"table": { |
|||
"table_name": "j", |
|||
"access_type": "ALL", |
|||
"possible_keys": [ |
|||
"idx_jobs_company_status", |
|||
"idx_jobs_status_created", |
|||
"idx_jobs_city_status", |
|||
"idx_jobs_status_employment_type", |
|||
"idx_jobs_status_salary_max" |
|||
], |
|||
"rows_examined_per_scan": 1, |
|||
"rows_produced_per_join": 1, |
|||
"filtered": "100.00", |
|||
"cost_info": { |
|||
"read_cost": "0.25", |
|||
"eval_cost": "0.10", |
|||
"prefix_cost": "0.35", |
|||
"data_read_per_join": "1K" |
|||
}, |
|||
"used_columns": [ |
|||
"id", |
|||
"company_id", |
|||
"title", |
|||
"description", |
|||
"requirements", |
|||
"city", |
|||
"employment_type", |
|||
"salary_min", |
|||
"salary_max", |
|||
"status", |
|||
"created_at" |
|||
], |
|||
"attached_condition": "((`mydreamjob_q01`.`j`.`company_id` = 1) and (`mydreamjob_q01`.`j`.`status` = 'PUBLISHED') and ((lower(`mydreamjob_q01`.`j`.`title`) like '%q01e75001岗位%') or (lower(`mydreamjob_q01`.`j`.`description`) like '%q01e75001岗位%') or (lower(`mydreamjob_q01`.`j`.`requirements`) like '%q01e75001岗位%')) and (lower(`mydreamjob_q01`.`j`.`city`) = 'q01e75001city20') and (lower(`mydreamjob_q01`.`j`.`employment_type`) = 'full_time') and (`mydreamjob_q01`.`j`.`salary_max` >= 5000) and (`mydreamjob_q01`.`j`.`salary_min` <= 30000))" |
|||
} |
|||
} |
|||
} |
|||
} |
|||
``` |
|||
|
|||
</details> |
|||
|
|||
## 2. latest 分页 |
|||
|
|||
等价 SQL(绑定参数 [PUBLISHED, Q01E75001CITY20, 20, 0]): |
|||
|
|||
```sql |
|||
SELECT j.id FROM jobs j |
|||
WHERE j.status = ? AND lower(j.city) = ? |
|||
ORDER BY j.created_at DESC, j.id DESC |
|||
LIMIT ? OFFSET ? |
|||
``` |
|||
|
|||
计划摘要: |
|||
|
|||
``` |
|||
query_cost = 1.35 |
|||
using_filesort = true |
|||
using_temporary_table = false |
|||
- 表 query_block/ORDER BY: table_name="j", access_type="ALL", possible_keys=["idx_jobs_status_created","idx_jobs_city_status","idx_jobs_status_employment_type","idx_jobs_status_salary_max"], rows_examined_per_scan=1, rows_produced_per_join=1, filtered="100.00" |
|||
``` |
|||
|
|||
<details><summary>EXPLAIN FORMAT=JSON 原文</summary> |
|||
|
|||
```json |
|||
{ |
|||
"query_block": { |
|||
"select_id": 1, |
|||
"cost_info": { |
|||
"query_cost": "1.35" |
|||
}, |
|||
"ordering_operation": { |
|||
"using_filesort": true, |
|||
"cost_info": { |
|||
"sort_cost": "1.00" |
|||
}, |
|||
"table": { |
|||
"table_name": "j", |
|||
"access_type": "ALL", |
|||
"possible_keys": [ |
|||
"idx_jobs_status_created", |
|||
"idx_jobs_city_status", |
|||
"idx_jobs_status_employment_type", |
|||
"idx_jobs_status_salary_max" |
|||
], |
|||
"rows_examined_per_scan": 1, |
|||
"rows_produced_per_join": 1, |
|||
"filtered": "100.00", |
|||
"cost_info": { |
|||
"read_cost": "0.25", |
|||
"eval_cost": "0.10", |
|||
"prefix_cost": "0.35", |
|||
"data_read_per_join": "1K" |
|||
}, |
|||
"used_columns": [ |
|||
"id", |
|||
"city", |
|||
"status", |
|||
"created_at" |
|||
], |
|||
"attached_condition": "((`mydreamjob_q01`.`j`.`status` = 'PUBLISHED') and (lower(`mydreamjob_q01`.`j`.`city`) = 'q01e75001city20'))" |
|||
} |
|||
} |
|||
} |
|||
} |
|||
``` |
|||
|
|||
</details> |
|||
|
|||
## 3. salary 排序(salaryMax DESC NULLS LAST) |
|||
|
|||
等价 SQL(绑定参数 [PUBLISHED, Q01E75001CITY20, 20, 0]): |
|||
|
|||
```sql |
|||
SELECT j.id FROM jobs j |
|||
WHERE j.status = ? AND lower(j.city) = ? |
|||
ORDER BY CASE WHEN j.salary_max IS NULL THEN 1 ELSE 0 END ASC, j.salary_max DESC, j.id DESC |
|||
LIMIT ? OFFSET ? |
|||
``` |
|||
|
|||
计划摘要: |
|||
|
|||
``` |
|||
query_cost = 1.35 |
|||
using_filesort = true |
|||
using_temporary_table = false |
|||
- 表 query_block/ORDER BY: table_name="j", access_type="ALL", possible_keys=["idx_jobs_status_created","idx_jobs_city_status","idx_jobs_status_employment_type","idx_jobs_status_salary_max"], rows_examined_per_scan=1, rows_produced_per_join=1, filtered="100.00" |
|||
``` |
|||
|
|||
<details><summary>EXPLAIN FORMAT=JSON 原文</summary> |
|||
|
|||
```json |
|||
{ |
|||
"query_block": { |
|||
"select_id": 1, |
|||
"cost_info": { |
|||
"query_cost": "1.35" |
|||
}, |
|||
"ordering_operation": { |
|||
"using_filesort": true, |
|||
"cost_info": { |
|||
"sort_cost": "1.00" |
|||
}, |
|||
"table": { |
|||
"table_name": "j", |
|||
"access_type": "ALL", |
|||
"possible_keys": [ |
|||
"idx_jobs_status_created", |
|||
"idx_jobs_city_status", |
|||
"idx_jobs_status_employment_type", |
|||
"idx_jobs_status_salary_max" |
|||
], |
|||
"rows_examined_per_scan": 1, |
|||
"rows_produced_per_join": 1, |
|||
"filtered": "100.00", |
|||
"cost_info": { |
|||
"read_cost": "0.25", |
|||
"eval_cost": "0.10", |
|||
"prefix_cost": "0.35", |
|||
"data_read_per_join": "1K" |
|||
}, |
|||
"used_columns": [ |
|||
"id", |
|||
"city", |
|||
"salary_max", |
|||
"status" |
|||
], |
|||
"attached_condition": "((`mydreamjob_q01`.`j`.`status` = 'PUBLISHED') and (lower(`mydreamjob_q01`.`j`.`city`) = 'q01e75001city20'))" |
|||
} |
|||
} |
|||
} |
|||
} |
|||
``` |
|||
|
|||
</details> |
|||
|
|||
## 4. count 查询 |
|||
|
|||
等价 SQL(绑定参数 [PUBLISHED, %q01e75001岗位%, %q01e75001岗位%, %q01e75001岗位%, Q01E75001CITY20, FULL_TIME, 5000, 30000, 1]): |
|||
|
|||
```sql |
|||
SELECT count(j.id) FROM jobs j |
|||
WHERE j.status = ? AND (lower(j.title) LIKE ? OR lower(j.description) LIKE ? OR lower(j.requirements) LIKE ?) |
|||
AND lower(j.city) = ? AND lower(j.employment_type) = ? AND j.salary_max >= ? AND j.salary_min <= ? AND j.company_id = ? |
|||
``` |
|||
|
|||
计划摘要: |
|||
|
|||
``` |
|||
query_cost = 0.35 |
|||
using_filesort = false |
|||
using_temporary_table = false |
|||
- 表 query_block: table_name="j", access_type="ALL", possible_keys=["idx_jobs_company_status","idx_jobs_status_created","idx_jobs_city_status","idx_jobs_status_employment_type","idx_jobs_status_salary_max"], rows_examined_per_scan=1, rows_produced_per_join=1, filtered="100.00" |
|||
``` |
|||
|
|||
<details><summary>EXPLAIN FORMAT=JSON 原文</summary> |
|||
|
|||
```json |
|||
{ |
|||
"query_block": { |
|||
"select_id": 1, |
|||
"cost_info": { |
|||
"query_cost": "0.35" |
|||
}, |
|||
"table": { |
|||
"table_name": "j", |
|||
"access_type": "ALL", |
|||
"possible_keys": [ |
|||
"idx_jobs_company_status", |
|||
"idx_jobs_status_created", |
|||
"idx_jobs_city_status", |
|||
"idx_jobs_status_employment_type", |
|||
"idx_jobs_status_salary_max" |
|||
], |
|||
"rows_examined_per_scan": 1, |
|||
"rows_produced_per_join": 1, |
|||
"filtered": "100.00", |
|||
"cost_info": { |
|||
"read_cost": "0.25", |
|||
"eval_cost": "0.10", |
|||
"prefix_cost": "0.35", |
|||
"data_read_per_join": "1K" |
|||
}, |
|||
"used_columns": [ |
|||
"id", |
|||
"company_id", |
|||
"title", |
|||
"description", |
|||
"requirements", |
|||
"city", |
|||
"employment_type", |
|||
"salary_min", |
|||
"salary_max", |
|||
"status" |
|||
], |
|||
"attached_condition": "((`mydreamjob_q01`.`j`.`company_id` = 1) and (`mydreamjob_q01`.`j`.`status` = 'PUBLISHED') and ((lower(`mydreamjob_q01`.`j`.`title`) like '%q01e75001岗位%') or (lower(`mydreamjob_q01`.`j`.`description`) like '%q01e75001岗位%') or (lower(`mydreamjob_q01`.`j`.`requirements`) like '%q01e75001岗位%')) and (lower(`mydreamjob_q01`.`j`.`city`) = 'q01e75001city20') and (lower(`mydreamjob_q01`.`j`.`employment_type`) = 'full_time') and (`mydreamjob_q01`.`j`.`salary_max` >= 5000) and (`mydreamjob_q01`.`j`.`salary_min` <= 30000))" |
|||
} |
|||
} |
|||
} |
|||
``` |
|||
|
|||
</details> |
|||
|
|||
## 5. 结果规模 1 / 20 / 50 下的 SQL 语句条数 |
|||
|
|||
Spring Data 对首页有优化:首页返回行数小于 `pageSize` 时不再发 count 查询, |
|||
因此结果数 < pageSize 时为 1 条(仅数据查询),否则为 2 条(count + 数据查询)。 |
|||
两种情况下语句条数都**不随结果规模增长**,即不存在 N+1。 |
|||
|
|||
| 匹配结果数 | 返回行数 | totalElements | Repository SQL数 | 完整Service SQL数 | |
|||
|---|---|---|---|---| |
|||
| 1 | 1 | 1 | 1 | 2 | |
|||
| 20 | 20 | 20 | 2 | 3 | |
|||
| 50 | 20 | 50 | 2 | 3 | |
|||
@ -0,0 +1,190 @@ |
|||
# Q01 岗位搜索真实查询覆盖设计 |
|||
|
|||
## 1. 状态 |
|||
|
|||
设计已确认,待实施、专项测试、真实 MySQL 复验与独立验收。 |
|||
|
|||
## 2. 背景与问题 |
|||
|
|||
`JobSearchSpecification` 承担公开岗位搜索的核心过滤规则,但当前 `JobSearchServiceTest` 将 |
|||
`JobRepository.findAll(Specification, Pageable)` Mock 掉后直接返回预设数据。这些测试可以证明 |
|||
参数校验、分页对象和 DTO 组装,却不能证明 Criteria 谓词、SQL 过滤、排序和分页真的正确。 |
|||
|
|||
Q01 的目标是补齐真实数据库证据,不以测试数量替代查询行为覆盖,也不改变现有公网接口和业务语义。 |
|||
|
|||
## 3. 范围 |
|||
|
|||
### 3.1 必须覆盖 |
|||
|
|||
- `JobSearchSpecification.searchJobs(...)` 的真实 Repository 查询; |
|||
- `JobService.searchJobs(...)` 的 latest、relevance、salary 排序和分页; |
|||
- PUBLISHED 强制过滤; |
|||
- keyword 对 title、description、requirements 的匹配; |
|||
- city、employmentType、companyId; |
|||
- salaryMin、salaryMax 的区间重叠语义; |
|||
- H2 快速测试与 MySQL 8 行为一致性; |
|||
- 查询参数化和特殊字符边界; |
|||
- 真实 SQL/EXPLAIN 证据及数据副作用检查。 |
|||
|
|||
### 3.2 明确不做 |
|||
|
|||
- 不改变 `/jobs/search` 请求或响应; |
|||
- 不新增、删除或重命名公网接口; |
|||
- 不修改岗位状态机、岗位归属或角色权限; |
|||
- 不把 relevance 改造成新的全文检索算法;当前 relevance 与 latest 等价,本轮锁定该语义; |
|||
- 不接入 Elasticsearch、搜索引擎或缓存; |
|||
- 不新增 Flyway、表或索引; |
|||
- 不改 Candidate Scoring、推荐、排名、邀约、Offer、申诉、消息、简历和认证; |
|||
- 不删除现有 Mock 测试。现有测试继续承担 Service 编排与参数校验职责; |
|||
- 不进入 R01 性能和结构重构。 |
|||
|
|||
## 4. 当前查询契约 |
|||
|
|||
| 参数 | 既有语义 | |
|||
|---|---| |
|||
| status | 无论调用方传什么条件,只返回 `PUBLISHED` | |
|||
| keyword | trim 后忽略大小写,匹配 title、description、requirements 任一字段 | |
|||
| city | trim 后忽略大小写精确匹配;显式归一化以保证 H2/MySQL 一致 | |
|||
| employmentType | trim 后忽略大小写精确匹配;不做前缀匹配 | |
|||
| salaryMin | 岗位 `salaryMax >= salaryMin` | |
|||
| salaryMax | 岗位 `salaryMin <= salaryMax` | |
|||
| companyId | 精确匹配 | |
|||
| sort=latest/null | `createdAt DESC, id DESC` | |
|||
| sort=relevance | 当前与 latest 相同,不改变 | |
|||
| sort=salary | `salaryMax` 非空优先、降序;相同值按 `id DESC`;NULL 最后 | |
|||
| page | 从 0 开始,不得为负数 | |
|||
| size | 1–50 | |
|||
|
|||
薪资过滤是区间重叠,不是“岗位上下限都完全落在用户区间内”。边界相等必须命中。 |
|||
|
|||
## 5. 测试分层 |
|||
|
|||
### 5.1 H2 Repository 快速层 |
|||
|
|||
新增 `JobSearchSpecificationIntegrationTest`: |
|||
|
|||
- 使用 Spring Data JPA、真实 `JobRepository` 和真实 H2; |
|||
- 激活 `integration-test`,由真实 Flyway 建表; |
|||
- 不 Mock Repository、EntityManager、Specification、CriteriaBuilder 或事务; |
|||
- 每个用例创建最小隔离夹具,按精确主键清理; |
|||
- 直接执行 `jobRepository.findAll(spec, pageable)`; |
|||
- 断言返回 ID 集合与顺序,不能只断言数量。 |
|||
|
|||
至少覆盖: |
|||
|
|||
1. title 关键词; |
|||
2. description 关键词; |
|||
3. requirements 关键词; |
|||
4. 关键词忽略大小写; |
|||
5. null、空字符串、纯空白关键词; |
|||
6. keyword 前后空格; |
|||
7. city 精确匹配与 trim; |
|||
8. employmentType 精确匹配与 trim; |
|||
9. companyId; |
|||
10. salaryMin; |
|||
11. salaryMax; |
|||
12. 两端薪资边界相等; |
|||
13. 薪资区间重叠; |
|||
14. 薪资区间不重叠; |
|||
15. 多条件 AND; |
|||
16. DRAFT、CLOSED 等非公开状态排除; |
|||
17. 无结果; |
|||
18. 排序与分页; |
|||
19. `%`、`_`、反斜杠、引号等特殊字符的实际既有语义; |
|||
20. null salary 字段的行为。 |
|||
|
|||
特殊字符测试首先锁定当前 Criteria `LIKE` 行为,不得在没有产品决策时把 `%` 和 `_` 自动改成字面量搜索。 |
|||
|
|||
### 5.2 Service 真实查询层 |
|||
|
|||
新增或扩展 `JobSearchServiceIntegrationTest`: |
|||
|
|||
- 使用真实 `JobService`、`JobRepository`、`CompanyRepository`、EntityManager; |
|||
- 不 Mock `findAll`; |
|||
- latest/null/relevance 的排序结果一致且稳定; |
|||
- salary 排序验证 NULL LAST、降序、相同薪资按 ID 倒序; |
|||
- 验证 page/size、totalElements、totalPages; |
|||
- 验证企业信息批量组装,不改变 DTO; |
|||
- 保留现有非法 sort、非法分页、非法薪资范围的测试。 |
|||
|
|||
### 5.3 MySQL 真实层 |
|||
|
|||
优先新增 `JobSearchSpecificationMySqlIntegrationTest`,使用项目已有 Testcontainers 依赖: |
|||
|
|||
- `@Testcontainers(disabledWithoutDocker = true)` 仅允许本地快速执行时环境性跳过; |
|||
- 独立验收和最终冻结时 Docker/MySQL 核心用例不得 SKIP; |
|||
- 使用 MySQL 8.x 容器、正式 Flyway 和 `ddl-auto=validate`; |
|||
- 使用动态属性注入 JDBC URL、用户名和密码; |
|||
- 不打印数据库凭据; |
|||
- 至少运行一组与 H2 完全相同的参数化用例,并对返回 ID 与顺序逐项对账; |
|||
- 重点对账大小写、LIKE 特殊字符、NULL 排序、边界比较、分页稳定性。 |
|||
|
|||
如果仓库约定不允许 Testcontainers 进入普通 `mvn test`,可以将其放入独立 profile;但最终报告必须给出真实执行命令和非 SKIP 结果,不能只提交一个从未运行的测试类。 |
|||
|
|||
## 6. 夹具设计 |
|||
|
|||
使用明确区分的岗位: |
|||
|
|||
- 三个包含关键词的岗位,关键词分别只存在于 title、description、requirements; |
|||
- 相同城市和不同城市; |
|||
- FULL_TIME、PART_TIME 等不同 employmentType; |
|||
- 至少两个企业; |
|||
- 薪资区间覆盖边界相等、部分重叠、不重叠、NULL; |
|||
- 相同 createdAt 的两个岗位,用 ID 验证稳定排序; |
|||
- 相同 salaryMax 的两个岗位,用 ID 验证稳定排序; |
|||
- PUBLISHED、DRAFT、CLOSED 等状态。 |
|||
|
|||
夹具必须以测试生成的唯一标识隔离,不能依赖 V1_1 演示数据或真实业务数据。 |
|||
|
|||
## 7. 数据库与副作用 |
|||
|
|||
- 查询测试不得修改岗位状态、企业、用户或其他业务表; |
|||
- 查询前后对相关稳定业务字段做行级快照,而不是只比较 COUNT; |
|||
- Testcontainers 由测试生命周期销毁; |
|||
- 使用负责人现有 MySQL 做独立黑盒时,只能精确清理本轮夹具; |
|||
- 不允许清库、重置自增、删除 volume; |
|||
- 不新增 Flyway 或索引。发现执行计划问题只记录到 R01,不在 Q01 修复。 |
|||
|
|||
## 8. SQL 与 EXPLAIN |
|||
|
|||
真实 MySQL 验收需记录: |
|||
|
|||
- 组合过滤 SQL; |
|||
- latest 分页; |
|||
- salary 排序; |
|||
- count 查询; |
|||
- 使用索引、access type、estimated rows、filesort/temporary 情况; |
|||
- 1、20、50 条结果规模下查询次数。 |
|||
|
|||
本阶段以“语义正确且无明显发布阻断”为通过条件,不为了 EXPLAIN 修改索引。 |
|||
|
|||
## 9. OpenAPI 与兼容性 |
|||
|
|||
- 不修改 path 和 operation; |
|||
- 目标仍为 176 paths / 214 operations; |
|||
- 不改变参数名、默认值、响应 DTO 或错误码; |
|||
- `docs/openapi-rc.json` 不应因仅新增测试而变化; |
|||
- 必须运行 `docs/verify_openapi.py` 防止意外契约漂移。 |
|||
|
|||
## 10. 通过条件 |
|||
|
|||
只有同时满足以下条件才能通过 Q01: |
|||
|
|||
1. `JobSearchSpecification` 不再是测试零引用; |
|||
2. H2 真实 Repository 测试覆盖全部过滤语义; |
|||
3. Service 自定义 salary 排序有真实数据库证据; |
|||
4. MySQL 核心用例真实运行且无 SKIP; |
|||
5. H2 与 MySQL 返回 ID、顺序和边界语义一致,或明确记录并解决差异; |
|||
6. 非公开岗位绝不进入公开搜索; |
|||
7. 分页稳定、total 正确; |
|||
8. 无业务数据副作用; |
|||
9. 原 Mock 测试保留且准确标注为编排测试; |
|||
10. 全量测试和 clean verify 通过; |
|||
11. OpenAPI 保持 176/214; |
|||
12. 无新增 Flyway、ErrorCode 或公网接口。 |
|||
|
|||
## 11. 最终状态 |
|||
|
|||
实施完成但尚未独立复验时,只能写: |
|||
|
|||
> Q01 已完成真实查询测试实现与自动化验证,待独立代码复验及真实 MySQL/Docker 验收。 |
|||
@ -0,0 +1,265 @@ |
|||
# Q01 岗位搜索真实查询覆盖实施指令 |
|||
|
|||
## 一、任务边界 |
|||
|
|||
项目根目录:`C:\Users\15642\Desktop\zhilai` |
|||
|
|||
目标分支:`test/q01-job-search-specification` |
|||
|
|||
本轮只补岗位搜索真实数据库测试和验收证据。原则上不修改生产代码;如果测试复现生产缺陷,先保存失败证据并停止,未经负责人确认不得顺手修复。 |
|||
|
|||
禁止修改岗位接口、DTO、权限、状态机和搜索语义;禁止新增 Flyway、表、索引、ErrorCode 或公网接口;禁止进入 R01;禁止删除现有 Mock 测试;禁止 `git add -A`、commit、push、reset、restore、checkout、clean;禁止删除或移动历史未跟踪文件。 |
|||
|
|||
## 二、开始前核查 |
|||
|
|||
执行: |
|||
|
|||
```powershell |
|||
cd C:\Users\15642\Desktop\zhilai |
|||
git branch --show-current |
|||
git status --short |
|||
git diff --cached --name-only |
|||
git log -5 --oneline |
|||
git diff --check |
|||
git status --short -- mydreamjob-server/src/main/resources/db/migration |
|||
``` |
|||
|
|||
确认分支正确、暂存区为空、HEAD 为 S03 检查点 `cb3bc57` 或报告实际偏差、历史未跟踪文件保留、V1–V43 均未修改。搜索并完整阅读适用 `AGENTS.md`;当前基线未发现时记录“未发现”。 |
|||
|
|||
## 三、必须阅读 |
|||
|
|||
```text |
|||
docs/q01-job-search-real-query-design.md |
|||
mydreamjob-server/src/main/java/com/mydreamjob/job/Job.java |
|||
mydreamjob-server/src/main/java/com/mydreamjob/job/JobRepository.java |
|||
mydreamjob-server/src/main/java/com/mydreamjob/job/JobSearchSpecification.java |
|||
mydreamjob-server/src/main/java/com/mydreamjob/job/JobService.java |
|||
mydreamjob-server/src/main/java/com/mydreamjob/job/JobController.java |
|||
mydreamjob-server/src/test/java/com/mydreamjob/job/JobSearchServiceTest.java |
|||
mydreamjob-server/src/test/resources/application-test.yml |
|||
mydreamjob-server/src/test/resources/application-integration-test.yml |
|||
mydreamjob-server/pom.xml |
|||
mydreamjob-server/src/main/resources/db/migration/V2__business_schema.sql |
|||
mydreamjob-server/src/main/resources/db/migration/V22__job_search_indexes.sql |
|||
``` |
|||
|
|||
执行: |
|||
|
|||
```powershell |
|||
rg -n "JobSearchSpecification|findAll\(.*Specification|findWithSalarySort|findPublishedJobs" mydreamjob-server/src |
|||
``` |
|||
|
|||
记录哪些现有测试 Mock 了查询层,不能把它们描述成真实过滤覆盖。 |
|||
|
|||
## 四、先写 H2 真实 Repository 测试 |
|||
|
|||
新增: |
|||
|
|||
```text |
|||
mydreamjob-server/src/test/java/com/mydreamjob/job/JobSearchSpecificationIntegrationTest.java |
|||
``` |
|||
|
|||
要求: |
|||
|
|||
- 激活 `integration-test`; |
|||
- 使用真实 Flyway、H2、JobRepository; |
|||
- 不 Mock Repository、Specification、EntityManager 或事务; |
|||
- 直接调用 `jobRepository.findAll(JobSearchSpecification.searchJobs(...), pageable)`; |
|||
- 每项断言返回 ID 和顺序; |
|||
- 测试隔离,不依赖种子岗位; |
|||
- 不用顺序无关的 containsOnly 代替排序断言。 |
|||
|
|||
至少实现以下测试: |
|||
|
|||
```text |
|||
keywordMatchesTitle |
|||
keywordMatchesDescription |
|||
keywordMatchesRequirements |
|||
keywordIsCaseInsensitive |
|||
nullBlankAndWhitespaceKeywordDoNotFilter |
|||
keywordIsTrimmed |
|||
cityUsesTrimmedExactMatch |
|||
cityCaseVariantHasSameResultAcrossDatabases |
|||
employmentTypeUsesTrimmedExactMatch |
|||
employmentTypeCaseVariantHasSameResultAcrossDatabases |
|||
companyIdFiltersExactly |
|||
salaryMinMatchesWhenJobMaximumEqualsBoundary |
|||
salaryMaxMatchesWhenJobMinimumEqualsBoundary |
|||
salaryRangesUseOverlapSemantics |
|||
nonOverlappingSalaryRangeIsExcluded |
|||
multipleConditionsUseAndSemantics |
|||
nonPublishedJobsAreAlwaysExcluded |
|||
specialLikeCharactersFollowExistingContract |
|||
nullSalaryFieldsFollowExistingContract |
|||
paginationReturnsStableOrderedIds |
|||
noResultReturnsEmptyPage |
|||
``` |
|||
|
|||
不要为了让测试符合预期而预先修改 `JobSearchSpecification`。如果任何既有语义与设计不一致,保留失败测试并报告。 |
|||
|
|||
## 五、补 Service 真实查询测试 |
|||
|
|||
新增: |
|||
|
|||
```text |
|||
mydreamjob-server/src/test/java/com/mydreamjob/job/JobSearchServiceIntegrationTest.java |
|||
``` |
|||
|
|||
使用真实 JobService、JobRepository、CompanyRepository 和 EntityManager,覆盖: |
|||
|
|||
```text |
|||
latestSortUsesCreatedAtThenIdDescending |
|||
nullSortUsesLatestOrder |
|||
relevanceCurrentlyUsesLatestOrder |
|||
salarySortUsesMaximumDescendingAndNullLast |
|||
salarySortUsesIdDescendingAsTieBreaker |
|||
salarySortReturnsCorrectTotalAndTotalPages |
|||
secondPageDoesNotRepeatOrLoseRows |
|||
companyInformationIsMappedFromRealRepository |
|||
searchDoesNotReturnDraftOrClosedJobs |
|||
``` |
|||
|
|||
原 `JobSearchServiceTest` 保留,用于参数校验和编排;如需修改注释,只能明确它不是 Specification SQL 覆盖,不得删除或放宽断言。 |
|||
|
|||
## 六、补 MySQL 真实测试 |
|||
|
|||
新增建议文件: |
|||
|
|||
```text |
|||
mydreamjob-server/src/test/java/com/mydreamjob/job/JobSearchSpecificationMySqlIntegrationTest.java |
|||
``` |
|||
|
|||
使用已有 `org.testcontainers:mysql` 和 `junit-jupiter`: |
|||
|
|||
- MySQL 8.x; |
|||
- 正式 Flyway; |
|||
- Hibernate `ddl-auto=validate`; |
|||
- `@DynamicPropertySource` 注入连接参数; |
|||
- 不打印密码; |
|||
- 不复用开发数据库; |
|||
- 不 Mock 查询路径; |
|||
- 容器用例至少对账 keyword 大小写、特殊字符、城市、薪资边界、非公开过滤、latest、salary NULL LAST、分页。 |
|||
|
|||
可复用参数化场景或夹具构造器,确保 H2/MySQL 输入一致。必须逐项断言结果 ID/业务标识和顺序,而不是只断言数量。 |
|||
|
|||
如果普通开发环境 Docker 不可用,可以让 Testcontainers 测试环境性跳过;但最终独立验收必须显式运行该类并得到 Tests run > 0、Skipped=0。不得用 H2 结果代替 MySQL。 |
|||
|
|||
## 七、查询副作用与执行计划 |
|||
|
|||
新增测试或独立验收证据证明查询前后以下数据不变: |
|||
|
|||
- jobs 稳定业务字段; |
|||
- companies; |
|||
- applications; |
|||
- candidate_dimension_scores; |
|||
- notifications; |
|||
- messages; |
|||
- audit_logs。 |
|||
|
|||
使用行级稳定字段快照,不能只比较 COUNT、SUM 或 CRC32。 |
|||
|
|||
真实 MySQL 验收对组合过滤、latest、salary 和 count SQL 执行 `EXPLAIN FORMAT=JSON`,记录使用索引、access type、estimated rows、temporary/filesort。Q01 不新增索引;发现性能问题记录给 R01。 |
|||
|
|||
## 八、文档 |
|||
|
|||
更新: |
|||
|
|||
```text |
|||
docs/backend-development-roadmap.md |
|||
docs/phase1-scope-and-acceptance.md |
|||
``` |
|||
|
|||
只记录 Q01 的测试层级和真实结果,不改变接口文档。原则上不修改 `docs/openapi-rc.json`;若仅新增测试却产生 OpenAPI 差异,视为异常并停止调查。 |
|||
|
|||
新增最终实施报告建议: |
|||
|
|||
```text |
|||
docs/q01-job-search-real-query-implementation-report.md |
|||
``` |
|||
|
|||
不得写“真实 MySQL 已通过”,除非 Testcontainers 或独立 MySQL 实际运行且核心项无 SKIP。 |
|||
|
|||
## 九、验证顺序 |
|||
|
|||
先运行 H2 测试: |
|||
|
|||
```powershell |
|||
cd C:\Users\15642\Desktop\zhilai\mydreamjob-server |
|||
mvn -Dtest="JobSearchSpecificationIntegrationTest,JobSearchServiceIntegrationTest" test |
|||
``` |
|||
|
|||
Docker 可用时运行 MySQL 测试: |
|||
|
|||
```powershell |
|||
mvn -Dtest="JobSearchSpecificationMySqlIntegrationTest" test |
|||
``` |
|||
|
|||
运行岗位关联回归: |
|||
|
|||
```powershell |
|||
mvn -Dtest="*JobSearch*Test,*Job*Test,*CompanyPublic*Test" test |
|||
``` |
|||
|
|||
随后: |
|||
|
|||
```powershell |
|||
mvn test |
|||
mvn clean verify |
|||
python ..\docs\verify_openapi.py |
|||
``` |
|||
|
|||
最后: |
|||
|
|||
```powershell |
|||
cd C:\Users\15642\Desktop\zhilai |
|||
git diff --check |
|||
git status --short |
|||
git diff --cached --name-only |
|||
git status --short -- mydreamjob-server/src/main/resources/db/migration |
|||
``` |
|||
|
|||
要求所有必跑测试 Failures=0、Errors=0、Skipped=0,clean verify 成功,OpenAPI 保持 176/214,暂存区为空,V1–V43 未修改。 |
|||
|
|||
## 十、发现缺陷时 |
|||
|
|||
如果真实测试发现生产缺陷: |
|||
|
|||
1. 保留最小失败测试; |
|||
2. 记录 H2 与 MySQL 是否一致; |
|||
3. 精确写明参数、夹具、预期 ID、实际 ID/顺序和生成 SQL; |
|||
4. 不自行修改生产代码; |
|||
5. 状态标记为 FAIL,等待负责人下发定向整改指令。 |
|||
|
|||
禁止通过修改夹具、删除断言、Mock Repository、禁用测试或放宽为只断言数量来制造绿色。 |
|||
|
|||
## 十一、最终报告 |
|||
|
|||
必须包含: |
|||
|
|||
1. 分支与 HEAD; |
|||
2. 现有 Mock 测试的真实职责; |
|||
3. H2 测试数及结果; |
|||
4. MySQL 测试数及结果; |
|||
5. 每个过滤条件的实际结果; |
|||
6. keyword 三字段、大小写和特殊字符结果; |
|||
7. 薪资边界与区间重叠结果; |
|||
8. PUBLISHED 强制过滤结果; |
|||
9. latest/relevance/salary 排序结果; |
|||
10. 分页与 total 结果; |
|||
11. H2/MySQL 差异; |
|||
12. EXPLAIN 摘要; |
|||
13. 数据副作用结果; |
|||
14. 关联和全量测试数; |
|||
15. Failures/Errors/Skipped; |
|||
16. clean verify; |
|||
17. OpenAPI paths/operations; |
|||
18. 精确修改文件; |
|||
19. 是否修改生产代码; |
|||
20. 是否新增 Flyway、ErrorCode 或接口; |
|||
21. Git 状态; |
|||
22. 明确未暂存、未提交、未推送; |
|||
23. 是否存在待整改缺陷。 |
|||
|
|||
实现和自动化通过后只能写: |
|||
|
|||
> Q01 已完成真实查询测试实现与自动化验证,待独立代码复验及真实 MySQL/Docker 验收。 |
|||
@ -0,0 +1,265 @@ |
|||
# Q01 岗位搜索真实查询覆盖实施报告 |
|||
|
|||
> **状态**:Q01 已完成真实查询测试实现、跨库一致性整改及独立复验,可以进入精确暂存检查点。 |
|||
|
|||
## 1. 分支与 HEAD |
|||
|
|||
| 项目 | 值 | |
|||
|---|---| |
|||
| 分支 | `test/q01-job-search-specification` | |
|||
| HEAD | `cb3bc57fab549137e3785f5d50bde86ce8ef5731`(S03 检查点 `cb3bc57`,无偏差) | |
|||
| 暂存区 | 空(`git diff --cached --name-only` 无输出) | |
|||
| 生产代码 | 定向修改 `JobSearchSpecification`:对 city/employmentType 显式 `lower`,消除 H2/MySQL 排序规则差异,并保持线上 MySQL 既有忽略大小写结果 | |
|||
| V1–V43 迁移 | 未修改(`git status --short -- .../db/migration` 无输出) | |
|||
|
|||
`AGENTS.md` 核查:仓库根目录、`mydreamjob-server/` 及其子目录**均未发现** `AGENTS.md`。 |
|||
|
|||
## 2. 现有 Mock 测试的真实职责 |
|||
|
|||
| 测试类 | 测试数 | 真实职责 | 不具备的能力 | |
|||
|---|---|---|---| |
|||
| `JobSearchServiceTest`(保留,未删除、未放宽断言) | 37 | 用 Mockito 把 `JobRepository.findAll(Specification, Pageable)` 与 `CompanyRepository` 整体 Mock 掉,验证参数校验(非法 sort / 非法分页 / 非法薪资范围)、分页对象、排序参数传递、企业信息组装与 DTO 编排 | **不能**证明 Criteria 谓词、SQL 过滤、排序与分页在真实数据库上的行为 | |
|||
| `JobServiceTest`(保留) | 13 | 岗位 CRUD、状态机与归属校验的编排 | 同上,与查询语义无关 | |
|||
|
|||
本轮为该 Mock 测试补充了类级 Javadoc,明确标注它是**编排测试**而非 Specification SQL 覆盖(仅注释,未改动任何断言)。Q01 之前 `JobSearchSpecification` 在生产代码外几乎零引用(仅被 Mock 测试当作 `Specification` 类型占位);本轮起被 4 个测试类**直接调用**其 `searchJobs(...)`:`JobSearchSpecificationIntegrationTest`、`JobSearchSpecificationMySqlIntegrationTest`、`JobSearchQueryExplainMySqlIntegrationTest`,以及副作用用例基类 `JobSearchQuerySideEffectTestBase`(H2 与 MySQL 两个子类共用)。 |
|||
|
|||
## 3. 测试分层与结果 |
|||
|
|||
| 层次 | 测试类 | 数据库 | Tests run | Failures | Errors | Skipped | |
|||
|---|---|---|---|---|---|---| |
|||
| H2 真实 Repository | `JobSearchSpecificationIntegrationTest` | H2(真实 Flyway,`integration-test`) | 21 | 0 | 0 | 0 | |
|||
| H2 真实 Service/EntityManager | `JobSearchServiceIntegrationTest` | H2(真实 Flyway) | 9 | 0 | 0 | 0 | |
|||
| H2 查询副作用 | `JobSearchQuerySideEffectIntegrationTest` | H2(真实 Flyway) | 4 | 0 | 0 | 0 | |
|||
| **MySQL 真实层** | `JobSearchSpecificationMySqlIntegrationTest` | **真实 MySQL 8.4(Testcontainers)** | 15 | 0 | 0 | 0 | |
|||
| **MySQL 查询副作用** | `JobSearchQuerySideEffectMySqlIntegrationTest` | **真实 MySQL 8.4(Testcontainers)** | 4 | 0 | 0 | 0 | |
|||
| **MySQL 执行计划** | `JobSearchQueryExplainMySqlIntegrationTest` | **真实 MySQL 8.4(Testcontainers)** | 1 | 0 | 0 | 0 | |
|||
| 合计(新增 Q01 测试) | | | **54** | **0** | **0** | **0** | |
|||
|
|||
MySQL 核心用例**真实执行**(Docker 可用,容器实启),`Skipped = 0`,未出现环境性跳过。 |
|||
|
|||
执行命令(原样): |
|||
|
|||
```powershell |
|||
cd C:\Users\15642\Desktop\zhilai\mydreamjob-server |
|||
mvn -Dtest="JobSearchSpecificationMySqlIntegrationTest" test |
|||
``` |
|||
|
|||
定向复验输出:`Tests run: 15, Failures: 0, Errors: 0, Skipped: 0` / `BUILD SUCCESS`。 |
|||
|
|||
> 首次按负责人指令执行该命令时为 `Tests run: 9, Failures: 0, Errors: 0, Skipped: 0`。随后为达成「与 H2 逐项对账」的完整性,补齐了 MySQL 侧原先缺失的 4 个 H2 已覆盖场景(空白关键词、关键词 trim、多条件 AND、NULL 薪资契约),并在独立复核后增加 city、employmentType 大小写变体 2 个跨库契约用例;该命令现为 **15 条**,仍为 `Failures=0 / Errors=0 / Skipped=0`。 |
|||
|
|||
数据库配置:`mysql:8.4` 独立容器(`mydreamjob_q01`),执行**正式 Flyway 迁移**并以 `ddl-auto=validate` 启动;JDBC URL / 用户名 / 密码由 `@DynamicPropertySource` 运行期注入,不落盘、不打印。应用级测试开关与测试密钥由测试专用 profile `mysql-it` 提供(`src/test/resources/application-mysql-it.yml`),方言沿用主配置的 `MySQLDialect`——H2 与 MySQL 用例因此跑在**同一套应用配置**上,仅数据库实现不同,便于逐项对账。 |
|||
|
|||
## 4. 每个过滤条件的实际结果 |
|||
|
|||
| 条件 | 契约 | 真实库实测结论(H2 与 MySQL 一致) | |
|||
|---|---|---| |
|||
| `status` | 无论传入什么,只返回 `PUBLISHED` | DRAFT、CLOSED 在任何组合、任何排序下都不出现;显式按 `companyId` 过滤时同样不出现 | |
|||
| `keyword` | trim 后忽略大小写,匹配 title / description / requirements 任一字段 | 三字段分别命中;`java` / `JaVa` / ` Java ` 结果相同 | |
|||
| `city` | trim 后忽略大小写精确匹配 | `Q01-Beijing`、`q01-beijing` 和带空格的小写查询命中同一岗位;与 `city + "别处"` 区分开 | |
|||
| `employmentType` | trim 后忽略大小写精确匹配 | `FULL_TIME`、`full_time` 和带空格的小写查询命中同一岗位;`PART_TIME`、`FULL` 不命中 | |
|||
| `companyId` | 精确匹配 | 只返回本企业岗位 | |
|||
| `salaryMin` | 岗位 `salaryMax >= salaryMin`(单边) | 边界相等命中;`salaryMax` 更高者也命中;`salaryMax` 低于边界者排除 | |
|||
| `salaryMax` | 岗位 `salaryMin <= salaryMax`(单边) | 边界相等命中;`salaryMin` 更低者也命中;`salaryMin` 高于边界者排除 | |
|||
| 两端同时给出 | 区间重叠 | 部分重叠命中、被用户区间包含命中;高于或低于用户区间均排除 | |
|||
| 多条件 | AND | 关键词/城市/用工类型/企业/薪资必须同时满足 | |
|||
| 无结果 | 空页 | `content` 为空、`totalElements = 0` | |
|||
|
|||
## 5. keyword 三字段、大小写与特殊字符 |
|||
|
|||
- **title / description / requirements 三字段**:三个只在一个字段含关键词的岗位都能被单独命中,未含关键词的岗位不命中。 |
|||
- **大小写**:`lower(title|description|requirements) LIKE lower(pattern)`,`java`、`JAVA`、`JaVa` 命中同一组岗位(MySQL 侧还叠加列排序规则,结论一致)。 |
|||
- **trim**:`" Java "` 与 `"Java"` 结果一致。 |
|||
- **空值**:`null`、`""`、`" "`、`" "`、`"\t"` 均**不参与过滤**(`isBlank()` 判定),返回该城市下全部岗位。 |
|||
- **LIKE 特殊字符**(锁定既有契约,**未做转义改动**): |
|||
- `%` 作为通配符,命中该城市下全部带标题的岗位; |
|||
- `_` 作为单字符通配符,同样命中全部; |
|||
- `A_B` 以通配符解释,命中 `A_B岗` 本身。 |
|||
- H2 与 MySQL 行为**一致**。这是既有语义,产品未决策前不改为字面量搜索。 |
|||
|
|||
## 6. 薪资边界与区间重叠 |
|||
|
|||
H2 与 MySQL 逐项一致: |
|||
|
|||
| 用例 | 用户条件 | 期望与实测 | |
|||
|---|---|---| |
|||
| `salaryMinMatchesWhenJobMaximumEqualsBoundary` | `salaryMin=20000` | 仅 `[10000,19999]` 被排除,`[10000,20000]` 命中(边界相等命中) | |
|||
| `salaryMaxMatchesWhenJobMinimumEqualsBoundary` | `salaryMax=10000` | 仅 `[10001,20000]` 被排除,`[10000,20000]` 命中 | |
|||
| 部分重叠 | `[15000,25000]` | `[10000,20000]`、`[12000,30000]` 命中 | |
|||
| 不重叠 | `[30000,35000]`、`[1000,4000]` | 空 | |
|||
| NULL 薪资 | 不加薪资条件 | `[null,null]`、`[10000,null]` 照常返回 | |
|||
|
|||
NULL 薪资的既有契约(SQL 三值逻辑):一旦给出 `salaryMin`,`job.salaryMax >= ?` 对 `salaryMax IS NULL` 求值为 UNKNOWN,因此 `salaryMax` 为 NULL 的岗位被排除。Q01 **锁定**该行为,不做修改。 |
|||
|
|||
## 7. PUBLISHED 强制过滤 |
|||
|
|||
无论是否传其它条件、无论 `sort` 取 `latest` / `relevance` / `salary` / `null`,DRAFT 与 CLOSED 岗位**绝不出现**;即使显式传入 DRAFT 岗位所属的 `companyId` 也不出现。H2 与 MySQL 一致。 |
|||
|
|||
## 8. latest / relevance / salary 排序 |
|||
|
|||
| 排序 | 语义 | 实测 | |
|||
|---|---|---| |
|||
| `latest` / `null` | `createdAt DESC, id DESC` | 按 `created_at` 倒序;`created_at` 相同时按 `id DESC`;`null` 与 `latest` 结果完全一致 | |
|||
| `relevance` | 当前与 `latest` 等价(本轮锁定,不改造成新算法) | 与 `latest` 结果完全一致 | |
|||
| `salary` | `salaryMax` 非空优先降序;相同值按 `id DESC`;NULL 最后 | `CASE WHEN salary_max IS NULL THEN 1 ELSE 0 END ASC, salary_max DESC, id DESC` 生效,NULL 排在最后;相同 `salaryMax` 按 `id DESC` | |
|||
|
|||
`salary` 排序走 `JobService.findWithSalarySort` 的 CriteriaQuery + CASE WHEN 路径(真实 `EntityManager`),在 H2 与真实 MySQL 上均通过。 |
|||
|
|||
## 9. 分页与 total |
|||
|
|||
- 5 条数据、`size=2` 时:`totalElements = 5`、`totalPages = 3`;第 0 页与第 1 页 ID 顺序与期望逐项一致;三页拼接等于完整期望顺序,**无重复、无遗漏**。 |
|||
- 无结果时 `totalElements = 0`。 |
|||
- H2 与 MySQL 一致。 |
|||
|
|||
## 10. H2 与 MySQL 差异 |
|||
|
|||
初次复验发现 MySQL `utf8mb4_unicode_ci` 与 H2 默认字符串比较规则可能造成 city、employmentType 大小写差异。现已在 Specification 中显式对字段和值使用 `lower`,并在两库增加同名大小写变体测试。两个库在 keyword、city、employmentType、特殊字符、薪资边界、PUBLISHED 过滤、排序、分页和 total 上结果现已逐项一致。 |
|||
|
|||
过程中在 **MySQL 用例内部**发现并修正了 3 处**用例自身的期望错误**(不是生产缺陷,见第 13 节): |
|||
|
|||
1. 只给 `city` 条件时,用例原本期望同城但 `PART_TIME` 的岗位不出现——契约只规定城市精确匹配,返回它是正确的; |
|||
2. `salaryMin=20000` 时用例原本期望只返回边界岗位,但契约是**单边**比较,`salaryMax=60000 >= 20000` 的岗位同样应命中; |
|||
3. `salaryMax=10000` 时同理,`salaryMin=5000 <= 10000` 的岗位同样应命中。 |
|||
|
|||
修正方式是把期望改为与设计文档第 4 节契约表一致,并**加强**而非放宽断言(补齐两条「区间完全在用户区间之外」的反向断言、补 `doesNotContain` 前缀城市断言)。H2 用例的同名场景夹具本就设计正确(相邻岗位取「刚好越界」值),因此未受影响。 |
|||
|
|||
## 11. EXPLAIN 摘要 |
|||
|
|||
真实 MySQL 8.4 上以 `EXPLAIN FORMAT=JSON` 采集,等价 SQL 与 Criteria 路径**绑定同一组参数**,并先断言二者返回**完全相同的 ID 与顺序**(保证 EXPLAIN 的不是一份不存在的语句)。完整原文见 [Q01 MySQL 执行计划证据](q01-job-search-mysql-explain-evidence.md)。 |
|||
|
|||
| 语句 | query_cost | access_type | 可用索引 | 实际选用 key | rows_examined_per_scan | using_filesort | using_temporary_table | |
|||
|---|---|---|---|---|---|---|---| |
|||
| 组合过滤 + latest 分页 | 1.35 | `ALL` | `idx_jobs_company_status`、`idx_jobs_status_created`、`idx_jobs_city_status`、`idx_jobs_status_employment_type`、`idx_jobs_status_salary_max` | 未选用 | 1 | true | false | |
|||
| latest 分页 | 1.35 | `ALL` | 同上(除 `idx_jobs_company_status`) | 未选用 | 1 | true | false | |
|||
| salary 排序(CASE WHEN) | 1.35 | `ALL` | 同上 | 未选用 | 1 | true | false | |
|||
| count 查询 | 0.35 | `ALL` | 同上 | 未选用 | 1 | false | false | |
|||
|
|||
结果规模与 SQL 语句条数(page size = 20): |
|||
|
|||
| 匹配结果数 | 返回行数 | totalElements | Repository SQL数 | 完整Service SQL数 | |
|||
|---|---|---|---|---| |
|||
| 1 | 1 | 1 | 1 | 2 | |
|||
| 20 | 20 | 20 | 2 | 3 | |
|||
| 50 | 20 | 50 | 2 | 3 | |
|||
|
|||
Spring Data 对首页有优化:首页返回行数小于 `pageSize` 时不再发 count 查询,因此 Repository 路径为 1/2/2 条。完整 Service 路径固定增加一次企业批量查询,为 2/3/3 条;两者均不随结果数量线性增长,不存在逐岗位查询企业的 N+1。 |
|||
|
|||
**结论与边界**:Q01 夹具刻意做小(每个城市 1 / 20 / 50 条),InnoDB 统计信息在此规模下估算行数为 1、优化器选择全表扫描并做 filesort,**这不构成生产规模的性能结论**。按 Q01 边界不新增、不调整索引;`%keyword%` 前导通配符本身无法走 B-Tree 索引、以及大规模数据下的 `ORDER BY` filesort 成本,记录给 **R01**。 |
|||
|
|||
## 12. 数据副作用结果 |
|||
|
|||
用例在 jobs、companies、users、applications、candidate_dimension_scores、notifications、conversations、messages、audit_logs **九张表**上做**行级稳定字段快照**(按主键排序、逐行取显式列出的稳定业务字段,不使用 `SELECT *`、不含 `password_hash` 等凭据列),执行覆盖「全部过滤条件 × 4 种排序 × 分页 × 无结果 × 聚合筛选项」的完整搜索矩阵后再次取快照,逐表逐行逐字段比较: |
|||
|
|||
- **H2**:前后完全一致; |
|||
- **真实 MySQL**:前后完全一致。 |
|||
|
|||
仅比较 COUNT / SUM / CRC32 无法证明「没有某一行被改写」,故另设**反证测试**(H2 与 MySQL 各跑一遍): |
|||
|
|||
| 反证用例 | 人为动作 | 要求 | |
|||
|---|---|---| |
|||
| `snapshotReportsRowFieldUpdate` | 直接改写 `jobs.status` | 必须被发现;把字段与 `updated_at` 一并还原后必须重新完全一致(证明差异定位精确而非「永远不等」) | |
|||
| `snapshotReportsInsertedRow` | 插入一行 notification | 必须被发现 | |
|||
| `snapshotReportsDeletedRow` | 删除一行 notification | 必须被发现 | |
|||
|
|||
反证全部通过,说明主证据的「无副作用」结论不是因为比较失效。测试同时断言九张受检表在测试库中**确有行**,避免空快照导致的假通过。 |
|||
|
|||
## 13. 缺陷与判定 |
|||
|
|||
定向复验发现测试环境与线上 MySQL 的大小写比较可能不一致。为锁定线上已有的忽略大小写结果并保证跨库一致,生产代码仅修改 `JobSearchSpecification`,对 city 和 employmentType 显式执行 Locale.ROOT 小写归一化;接口、DTO、权限、状态机和 MySQL 既有返回语义均未改变。`JobService`、`JobRepository`、`JobController` 与 `ErrorCode` 保持原样。 |
|||
|
|||
过程中处理的 2 类**非生产缺陷**问题,均已记录: |
|||
|
|||
1. **MySQL 用例无法启动上下文**:该类最初未激活任何 profile,导致 `app.jwt.*` 测试密钥缺失、`AppJwtProperties` 校验失败(`JWT_ACCESS_SECRET must be at least 32 characters`)。这是**用例配置缺失**,不是生产缺陷(生产要求 JWT 密钥必须显式配置,行为正确)。修复方式是新增测试专用 profile `mysql-it`,并保持连接参数由 `@DynamicPropertySource` 注入。 |
|||
2. **MySQL 用例期望与契约表自相矛盾**:见第 10 节的 3 处,已按契约修正并加强断言。 |
|||
|
|||
**无待整改生产缺陷。** 上述跨库一致性问题已定向解决并有 H2/MySQL 同名用例证明。 |
|||
|
|||
## 14. 关联与全量测试 |
|||
|
|||
| 项目 | 命令 | 结果 | |
|||
|---|---|---| |
|||
| 岗位关联回归 | `mvn -Dtest="*JobSearch*Test,*Job*Test,*CompanyPublic*Test" test` | **Tests run: 225, Failures: 0, Errors: 0, Skipped: 0** | |
|||
| 全量测试 | `mvn test` | **Tests run: 1890, Failures: 0, Errors: 0, Skipped: 0** | |
|||
| 干净构建 | `mvn clean verify` | **Tests run: 1890, Failures: 0, Errors: 0, Skipped: 0**,`BUILD SUCCESS`,产出 `mydreamjob-server-0.0.1-SNAPSHOT.jar` | |
|||
|
|||
关联回归中与 Q01 直接相关的类:`JobSearchSpecificationIntegrationTest` 21、`JobSearchServiceIntegrationTest` 9、`JobSearchSpecificationMySqlIntegrationTest` 15、`JobSearchQuerySideEffectIntegrationTest` 4、`JobSearchQuerySideEffectMySqlIntegrationTest` 4、`JobSearchQueryExplainMySqlIntegrationTest` 1、`JobSearchServiceTest` 37(保留)、`JobServiceTest` 13、`JobFavoriteServiceTest` 10、`CompanyPublicServiceTest` 22。 |
|||
|
|||
## 15. Failures / Errors / Skipped 与 clean verify |
|||
|
|||
| 项目 | Tests run | Failures | Errors | Skipped | 构建 | |
|||
|---|---|---|---|---|---| |
|||
| `mvn test` | **1890** | **0** | **0** | **0** | `BUILD SUCCESS` | |
|||
| `mvn clean verify` | **1890** | **0** | **0** | **0** | `BUILD SUCCESS`(含打包,产出 `mydreamjob-server-0.0.1-SNAPSHOT.jar`) | |
|||
|
|||
所有必跑测试 `Failures = 0`、`Errors = 0`、`Skipped = 0`。日志中不存在任何 `Skipped: <非零>` 的测试类。三个 MySQL Testcontainers 用例类在 `mvn clean verify` 中**真实启动容器并执行**(`JobSearchSpecificationMySqlIntegrationTest` 15 条 / `JobSearchQuerySideEffectMySqlIntegrationTest` 4 条 / `JobSearchQueryExplainMySqlIntegrationTest` 1 条),**未发生跳过**。 |
|||
|
|||
## 16. OpenAPI |
|||
|
|||
``` |
|||
python docs/verify_openapi.py |
|||
paths: 176 |
|||
operations: 214 |
|||
All checks passed! |
|||
``` |
|||
|
|||
`docs/openapi-rc.json` **未被修改**(`git status --short -- docs/openapi-rc.json` 无输出)。Q01 只新增测试,未新增、未删除、未重命名任何 path 或 operation,未改变参数名、默认值、响应 DTO 或错误码。 |
|||
|
|||
## 17. 精确修改文件 |
|||
|
|||
**新增(Q01 测试与证据)** |
|||
|
|||
```text |
|||
mydreamjob-server/src/test/java/com/mydreamjob/job/JobSearchSpecificationIntegrationTest.java |
|||
mydreamjob-server/src/test/java/com/mydreamjob/job/JobSearchServiceIntegrationTest.java |
|||
mydreamjob-server/src/test/java/com/mydreamjob/job/JobSearchSpecificationMySqlIntegrationTest.java |
|||
mydreamjob-server/src/test/java/com/mydreamjob/job/JobSearchQuerySideEffectTestBase.java |
|||
mydreamjob-server/src/test/java/com/mydreamjob/job/JobSearchQuerySideEffectIntegrationTest.java |
|||
mydreamjob-server/src/test/java/com/mydreamjob/job/JobSearchQuerySideEffectMySqlIntegrationTest.java |
|||
mydreamjob-server/src/test/java/com/mydreamjob/job/BusinessRowSnapshot.java |
|||
mydreamjob-server/src/test/java/com/mydreamjob/job/JobSearchQueryExplainMySqlIntegrationTest.java |
|||
mydreamjob-server/src/test/java/com/mydreamjob/job/Q01SqlRecorder.java |
|||
mydreamjob-server/src/test/resources/application-mysql-it.yml |
|||
docs/q01-job-search-real-query-design.md |
|||
docs/q01-job-search-real-query-implementation-instruction.md |
|||
docs/q01-job-search-real-query-implementation-report.md |
|||
docs/q01-job-search-mysql-explain-evidence.md |
|||
``` |
|||
|
|||
**修改(1 个定向生产修复、测试注释与文档)** |
|||
|
|||
```text |
|||
mydreamjob-server/src/main/java/com/mydreamjob/job/JobSearchSpecification.java # city/employmentType 跨库大小写语义显式统一 |
|||
mydreamjob-server/src/test/java/com/mydreamjob/job/JobSearchServiceTest.java # 仅补类级 Javadoc,标注为编排测试;断言未改动 |
|||
docs/backend-development-roadmap.md |
|||
docs/phase1-scope-and-acceptance.md |
|||
``` |
|||
|
|||
## 18. 是否修改生产代码 / 新增迁移或接口 |
|||
|
|||
- **生产代码:定向修改 1 个文件。** `JobSearchSpecification` 对 city 和 employmentType 显式使用 `lower` + `Locale.ROOT`,保持线上 MySQL 既有忽略大小写结果并消除 H2 差异;`JobService`、`JobRepository`、`JobController`、DTO、状态机与权限未改。 |
|||
- **Flyway:未新增。** 最新迁移仍为 V43;V1–V43 全部未修改。 |
|||
- **ErrorCode:未新增。** |
|||
- **公网接口:未新增、未删除、未重命名。** OpenAPI 保持 176 paths / 214 operations。 |
|||
- 未接入 Elasticsearch、缓存或搜索引擎;未进入 R01。 |
|||
|
|||
## 19. Git 状态 |
|||
|
|||
``` |
|||
git branch --show-current -> test/q01-job-search-specification |
|||
git diff --cached --name-only -> (空) |
|||
git status --short -- mydreamjob-server/src/main -> M JobSearchSpecification.java(Q01 定向修复) |
|||
git status --short -- mydreamjob-server/src/main/resources/db/migration -> (空) |
|||
git status --short -- docs/openapi-rc.json -> (空) |
|||
``` |
|||
|
|||
检查点执行前,本分支未暂存、未提交、未推送;未执行 `git add -A`、reset、restore、checkout、clean;未删除或移动任何历史未跟踪文件。Q01 改动由 1 个定向生产修复、真实数据库测试与正式证据文档组成。 |
|||
|
|||
## 20. 待整改事项 |
|||
|
|||
**Q01 范围内无待整改缺陷。** |
|||
|
|||
移交给 **R01**(不在 Q01 修复): |
|||
|
|||
- `keyword` 使用 `%keyword%` 前导通配符,B-Tree 索引无法用于该谓词; |
|||
- 小样本夹具下优化器选择全表扫描 + filesort,真实数据规模下的执行计划与排序成本需在 R01 以真实量级数据评估; |
|||
- `JobService.findWithSalarySort` 的 `CASE WHEN` 排序无法利用 `idx_jobs_status_salary_max` 的有序性,需要 R01 评估替代方案。 |
|||
@ -0,0 +1,110 @@ |
|||
# R01-A 请求身份与 requestId 收束实施指令 |
|||
|
|||
## 一、任务性质 |
|||
|
|||
本轮只完成 R01-A 第一批,不进入异常映射、评分规则、百分位、消息未读、PersonalDataCollector 或推荐权重重构。 |
|||
|
|||
允许修改公共请求上下文组件、最多 8 个 Controller 及其测试和文档;禁止新增接口、ErrorCode、Flyway、表、索引或依赖。 |
|||
|
|||
## 二、开始前核查 |
|||
|
|||
在 `C:\Users\15642\Desktop\zhilai` 执行并记录: |
|||
|
|||
```powershell |
|||
git branch --show-current |
|||
git status --short |
|||
git diff --cached --name-only |
|||
git log -5 --oneline |
|||
git diff --check |
|||
``` |
|||
|
|||
分支必须为 `refactor/r01-structure-performance`,HEAD 必须包含 Q01 检查点 `77dc624`,暂存区必须为空。完整搜索适用的 AGENTS.md;保护全部历史未跟踪文件。 |
|||
|
|||
## 三、先写基线清单 |
|||
|
|||
列出所有私有 `resolveUserId/userId/requireAdmin` 与 `requestId/getRequestId` 方法,记录:文件、公开端点、当前异常、角色检查位置、资源归属检查位置、requestId 来源。 |
|||
|
|||
第一批只迁移以下 8 个 Controller: |
|||
|
|||
1. `AssessmentController` |
|||
2. `AbilityController` |
|||
3. `ProfileController` |
|||
4. `ResumeVersionController` |
|||
5. `PreferenceController` |
|||
6. `CompetitionRankingController` |
|||
7. `PersonalDataExportController` |
|||
8. `PrivacySettingsController` |
|||
|
|||
若其中存在与未提交修改重叠,停止并报告,不得改换到其它文件凑数。 |
|||
|
|||
## 四、身份组件 |
|||
|
|||
复用现有 `CurrentUserAccessor`,不要创建第二个身份解析器。Controller 通过构造器注入并调用 `requireUserId()`。 |
|||
|
|||
必须保持: |
|||
|
|||
- null Authentication、AnonymousAuthenticationToken、`anonymousUser`、未认证、空白主体、Unicode 数字、带正负号、Long 溢出和 userId<=0 全部 401; |
|||
- 不回显主体内容、异常类或堆栈; |
|||
- 不接受请求参数中的 userId; |
|||
- 角色与资源归属仍由原 Service/Controller 的既有位置判断; |
|||
- 不将原 403 或 404 改成 401。 |
|||
|
|||
删除迁移 Controller 内的私有身份解析代码和不再使用的 SecurityContext 依赖。 |
|||
|
|||
## 五、requestId 组件 |
|||
|
|||
先完整阅读 `common/logging/RequestIdFilter`、`RequestLoggingFilter`、`AuditContext` 和 `GlobalExceptionHandler`。 |
|||
|
|||
新增 `common/logging/RequestIdAccessor`,提供 `String get(HttpServletRequest request)`。它必须复用过滤器产出的规范化 requestId;禁止重新生成第二个值,禁止直接信任超长/非法客户端头。 |
|||
|
|||
若 RequestIdFilter 当前没有 request attribute,允许增加一个常量属性并写入规范化后的值。响应头、MDC、Controller body、AuditContext 与异常响应必须保持同源。过滤器结束后仍清理 MDC。 |
|||
|
|||
只替换上述 8 个 Controller 的私有 requestId 方法。GlobalExceptionHandler 本轮只允许改为调用 accessor,不得重构 HTTP 状态映射。 |
|||
|
|||
## 六、必须先新增的测试 |
|||
|
|||
新增或加强: |
|||
|
|||
- `RequestIdAccessorTest` |
|||
- `RequestContextMigrationIntegrationTest` |
|||
- 8 个 Controller 的既有权限回归 |
|||
|
|||
至少覆盖: |
|||
|
|||
1. 合法客户端 requestId 原样使用; |
|||
2. 缺失、空白、超长、非法字符时使用过滤器规范化值; |
|||
3. 响应头、响应 body、MDC 日志与 AuditContext 同一值; |
|||
4. 并发请求不串 requestId; |
|||
5. 过滤器完成后 MDC 清理; |
|||
6. 八类非法 principal 均 401 而非 500; |
|||
7. 错误响应不包含 principal; |
|||
8. STUDENT/COMPANY/ADMIN 权限矩阵与改动前一致; |
|||
9. 拒绝请求无业务写入; |
|||
10. 反证:绕过 filter 直接传超长请求头时 accessor 不得返回原值。 |
|||
|
|||
禁止只 Mock 新组件后断言 Controller 返回 mock 值;至少一组测试必须使用真实 SecurityFilterChain、RequestIdFilter 和 MVC。 |
|||
|
|||
## 七、量化验收 |
|||
|
|||
整改前后分别统计: |
|||
|
|||
```powershell |
|||
rg -l "private (Long|long) resolveUserId|private .*userId\(\)" mydreamjob-server/src/main/java |
|||
rg -l "private String (getRequestId|requestId)\(" mydreamjob-server/src/main/java |
|||
``` |
|||
|
|||
第一批必须精确减少对应 8 个 Controller 的重复方法,不得声称全仓已完成。报告剩余数量和文件清单。 |
|||
|
|||
## 八、验证 |
|||
|
|||
依次运行定向测试、八个模块关联测试、`mvn test`、`mvn clean verify`、`python ..\docs\verify_openapi.py`、`git diff --check`。 |
|||
|
|||
预期 OpenAPI 保持 176/214;测试数量以实际为准,Failures=0、Errors=0、Skipped=0。 |
|||
|
|||
## 九、停止条件 |
|||
|
|||
完成 R01-A 第一批代码、测试和报告后停止。暂存区保持空;不得 commit/push;不得进入 R01-B/C/D/E。 |
|||
|
|||
最终状态只能写: |
|||
|
|||
`R01-A 第一批请求上下文收束完成,待独立代码复验。` |
|||
@ -0,0 +1,284 @@ |
|||
# R01-A 第一批请求上下文收束实施报告 |
|||
|
|||
> **状态**:R01-A 第一批请求上下文收束及阻断整改已完成,并通过独立代码复验与干净构建。 |
|||
|
|||
## 1. 范围与分支 |
|||
|
|||
| 项目 | 值 | |
|||
|---|---| |
|||
| 分支 | `refactor/r01-structure-performance` | |
|||
| HEAD | `77dc624`(Q01 检查点,见 `fix: align job search queries across databases`) | |
|||
| 范围 | 只完成 R01-A 第一批:8 个 Controller 的身份解析与 requestId 收束 | |
|||
| 未进入 | R01-B(异常映射)、R01-C(纯计算)、R01-D(查询收束)、R01-E(真实环境复验),以及 `UserRoleGuard`、`PersonalDataCollector`、评分规则、百分位、消息未读、推荐权重 | |
|||
| AGENTS.md | 仓库根目录、`mydreamjob-server/` 及其子目录**均未发现** `AGENTS.md` | |
|||
|
|||
## 2. 开始前核查 |
|||
|
|||
``` |
|||
git branch --show-current -> refactor/r01-structure-performance ✓ |
|||
git log -5 --oneline -> HEAD = 77dc624(Q01 检查点) ✓ |
|||
git diff --cached --name-only -> (空) ✓ 暂存区为空 |
|||
git diff --check -> 仅 LF/CRLF 提示,无空白错误 |
|||
``` |
|||
|
|||
工作区另有一处**与本次无关**的既存未提交修改:`docs/backend-development-roadmap.md`(Q01 行更新 + R01 行)。它不属于 8 个 Controller 的任何文件,不构成 §3 的「重叠」停止条件,本轮未触碰该文件。 |
|||
|
|||
## 3. 基线清单(整改前) |
|||
|
|||
### 3.1 第一批 8 个 Controller |
|||
|
|||
| # | Controller | 公开端点 | 整改前身份异常 | 角色检查位置 | 资源归属检查位置 | requestId 来源 | |
|||
|---|---|---|---|---|---|---| |
|||
| 1 | `assessment/AssessmentController` | `GET /assessments/types`、`GET /assessments/{type}/questions`、`POST /assessments/attempts`、`GET /assessments/attempts/{id}`、`PATCH .../answers`、`POST .../submit`、`GET .../result`、`GET /assessments/me` | `BusinessException(UNAUTHORIZED)`;非数字主体 `NumberFormatException` → 500 | `AssessmentAttemptService.requireStudent(userId)` | 答卷归属在 `AssessmentAttemptService`(非本人 → 2002/403) | `request.getHeader("X-Request-Id")` | |
|||
| 2 | `ability/AbilityController` | `GET /abilities/types`、`POST /abilities/attempts`、`GET /abilities/attempts/{id}`、`PATCH .../answers`、`POST .../submit`、`GET .../result`、`GET /abilities/me` | 同上 | `AbilityAttemptService.requireStudent(userId)` | 答卷归属在 `AbilityAttemptService` | 同上 | |
|||
| 3 | `profile/ProfileController` | `GET /profile/me`、`PUT /profile/me`、`POST /profile/me/resume`、`GET /profile/me/resume`、`DELETE /profile/me/resume` | 同上(全限定名写法) | `ProfileService.assertStudentRole(userId)`(Controller 显式调用) | 按当前 userId 取本人资料,无跨用户入参 | 同上 | |
|||
| 4 | `resume/ResumeVersionController` | `POST/GET /profile/me/resume-versions`、`GET/PUT/DELETE /profile/me/resume-versions/{id}`、`POST .../{id}/default`、`POST .../{id}/export`、`GET /resume-export-tasks/{taskId}`、`GET .../download` | 同上 | `ResumeVersionService` → `ProfileService.assertStudentRole` | `version.getUserId().equals(userId)`,否则 2002/403 | 同上 | |
|||
| 5 | `preference/PreferenceController` | `GET /profile/me/preferences`、`PUT /profile/me/preferences` | 同上(全限定名写法) | `PreferenceService.assertStudentRole` | 键为当前 userId | 同上 | |
|||
| 6 | `competition/CompetitionRankingController` | `GET /jobs/{jobId}/competition-ranking` | 同上 | `CompetitionRankingService.assertStudentRole` | 未投递该岗位 → 2002/403;岗位不存在 → 7001/404 | 同上 | |
|||
| 7 | `dataexport/PersonalDataExportController` | `POST/GET /profile/me/data-export-tasks`、`GET .../{taskId}`、`GET .../{taskId}/download` | 同上,且已 try/catch `NumberFormatException`(但仍映射为 2001 → HTTP **400**) | 无角色限制(STUDENT/COMPANY/ADMIN 均可操作本人任务) | `task.getUserId().equals(userId)`,否则 2002/403 | 同上 | |
|||
| 8 | `privacy/PrivacySettingsController` | `GET /profile/me/privacy`、`PUT /profile/me/privacy` | 同上 | `PrivacySettingsService.assertStudentRole` | 键为当前 userId | 同上 | |
|||
|
|||
**共性**:8 个类各自持有一份 `private Long resolveUserId(HttpServletRequest)` 与一份 `private String getRequestId/requestId(HttpServletRequest)`,共 16 个重复方法;身份只从 `SecurityContextHolder` 取,requestId 只从请求头取。 |
|||
|
|||
### 3.2 全仓量化基线 |
|||
|
|||
```powershell |
|||
rg -l "private (Long|long) resolveUserId|private .*userId()|private .*requireAdmin" mydreamjob-server/src/main/java # 42 个文件 / 44 处 |
|||
rg -l "private String (getRequestId|requestId)\(" mydreamjob-server/src/main/java # 46 个文件 / 46 处 |
|||
``` |
|||
|
|||
## 4. 身份组件(复用,不新建) |
|||
|
|||
沿用既有 `security/CurrentUserAccessor`,**未创建第二个身份解析器**。8 个 Controller 通过构造器注入(`@RequiredArgsConstructor` 的 `final` 字段)并调用 `requireUserId()`。 |
|||
|
|||
保持不变的语义: |
|||
|
|||
- `null` Authentication、未认证、`AnonymousAuthenticationToken`、主体为 `anonymousUser`、空白主体、Unicode 数字(`٤٢`)、带正负号(`+42` / `-7`)、Long 溢出(`99999999999999999999`)、`userId <= 0` —— 全部按未登录处理; |
|||
- 异常文本固定为 `authentication required`,**不回显主体、异常类或堆栈**; |
|||
- 不接受请求参数中的 `userId`(未新增任何 userId 入参); |
|||
- 角色与资源归属仍留在原 Service / Controller 位置判断,**未从 Controller 上移到身份组件**; |
|||
- 未把任何原 403 或 404 改成 401。 |
|||
|
|||
### 4.1 一处必须说明的既有差异(未扩大) |
|||
|
|||
整改前,8 个 Controller 的私有解析在非法主体上抛 `BusinessException(UNAUTHORIZED)`;由于 `GlobalExceptionHandler.resolveHttpStatus` 未把 `UNAUTHORIZED(2001)` 列入任何状态段,它落到「其余 → 400」,即**非法主体此前是 HTTP 400**(`PersonalDataExportController` 亦然)。 |
|||
|
|||
整改后统一为 `InsufficientAuthenticationException` → `AuthenticationException` 处理器 → **HTTP 401**。这与 R01 设计第 4 节「非法主体为 401」一致,也正是本批的目标;对**真实请求**而言该路径此前不可达(`SecurityConfig` 对相关路径要求 `authenticated()`,匿名请求在过滤器链就被拒绝并返回 401),因此没有改变任何真实客户端可观察的既有契约。唯一受影响的单元测试 `ResumeControllerTest.uploadResume_unauthenticated_shouldReturn401` 已改为断言新的 401 语义(断言未放宽)。 |
|||
|
|||
## 5. requestId 组件 |
|||
|
|||
### 5.1 新增 `common/logging/RequestIdAccessor` |
|||
|
|||
```java |
|||
public String get(HttpServletRequest request) // 唯一入口 |
|||
``` |
|||
|
|||
取值顺序: |
|||
|
|||
1. `RequestIdFilter` 写入的请求属性 `RequestIdFilter.REQUEST_ID_ATTRIBUTE`(规范化后的值); |
|||
2. 否则读请求头 `X-Request-Id` —— 过滤器已用请求包装器把它替换为同一个规范化值;若调用路径绕过了过滤器,则用**过滤器同一处** `RequestIdFilter.normalizeRequestId(...)` 收敛,**不会**把超长或非法客户端头原样返回。 |
|||
|
|||
没有第二套生成规则:缺失/空白时复用的就是过滤器那一处的逻辑。独立复验进一步收紧合法字符为 ASCII `A-Z/a-z/0-9/./_/:/-`;任何含空格、控制字符、中文、HTML 符号、斜杠或其它字符的值都会被整体替换为服务端生成的 16 位十六进制 ID,不进入响应头、MDC 或日志。 |
|||
|
|||
### 5.2 `RequestIdFilter` 的改动 |
|||
|
|||
- 新增公开常量 `REQUEST_ID_ATTRIBUTE = "requestId"`,并把**规范化后**的值写入请求属性; |
|||
- MDC、响应头、请求包装器、请求属性四者写的是同一个字符串; |
|||
- 常量名与原私有名一致,MDC key 未变,日志格式不受影响; |
|||
- 过滤器 `finally` 中仍然 `MDC.remove(...)`。 |
|||
|
|||
### 5.3 `GlobalExceptionHandler` |
|||
|
|||
只把私有 `getRequestId(request)` 的实现改为经 `RequestIdAccessor` 取值,**未重构任何 HTTP 状态映射**。 |
|||
|
|||
独立复验发现初版为兼容 `@WebMvcTest` 使用了可选字段注入和 `new RequestIdAccessor()` 兜底,这会掩盖正式应用的装配缺陷,因此判定为阻断并已整改。最终实现使用 Lombok `@RequiredArgsConstructor` 对 `RequestIdAccessor` 做强制构造器注入;测试切片在测试侧显式提供 Bean,生产代码不存在空依赖或手工实例兜底。 |
|||
|
|||
`AuditContext.from(request)` 未改动:它读的请求头已被过滤器包装器规范化,与响应头、MDC、响应体同源。 |
|||
|
|||
## 6. 8 个 Controller 的迁移方式 |
|||
|
|||
- 新增两个 `final` 字段 `currentUserAccessor` / `requestIdAccessor`; |
|||
- 调用点由 `resolveUserId(request)` → `currentUserAccessor.requireUserId()`,`requestId(request)` / `getRequestId(request)` → `requestIdAccessor.get(request)`; |
|||
- 删除各自的私有身份解析方法与不再使用的 `SecurityContextHolder` / `Authentication` / `BusinessException` / `ErrorCode` 导入; |
|||
- 逐点人工核对,未做机械全仓替换;每个 Controller 的公开端点、角色检查与归属检查位置均未移动。 |
|||
|
|||
## 7. 量化验收(整改前 → 整改后) |
|||
|
|||
| 指标 | 整改前 | 整改后 | 变化 | |
|||
|---|---|---|---| |
|||
| 身份解析私有方法:文件数 / 处数 | 42 / 44 | 35 / 37 | −7 / −7(净额,见下) | |
|||
| requestId 私有方法:文件数 / 处数 | 46 / 46 | 38 / 38 | **−8 / −8** | |
|||
| 8 个 Controller 内的上述私有方法 | 16 处 | **0 处** | −16 | |
|||
|
|||
requestId 指标精确减少 8 个文件,正好对应本批 8 个 Controller。 |
|||
|
|||
身份指标的净额是 −7 而非 −8:本批 8 个 Controller 各减少 1 处(共 −8),同时经负责人明确授权修复的 `DeletionGuardInterceptor` **新增**了 1 个私有辅助方法 `resolveUserIdOrNull`(见第 10 节),−8 + 1 = −7。该辅助方法是有意保留的(把「非法主体 → 放行」这一契约写在一处并加注释),不为迁就统计口径而内联。 |
|||
|
|||
### 7.1 剩余数量与文件清单(**未**声称全仓完成) |
|||
|
|||
仍有 **35 个文件 / 37 处**私有身份解析、**38 个文件 / 38 处**私有 requestId 方法,属后续批次。除本批 8 个外的主要剩余: |
|||
|
|||
```text |
|||
application/ApplicationController interview/InterviewController |
|||
bidirectionalmatch/ApplicationMatchController interviewscore/InterviewScoreController |
|||
bidirectionalmatch/MatchConversationController job/FavoriteController |
|||
bidirectionalmatch/MatchRuleController match/MatchController |
|||
candidatescore/CandidateScoreCardController messaging/MessagingController |
|||
candidatescore/CandidateScoringController notification/NotificationController |
|||
candidatescore/dashboard/CompanyScoringDashboardController offer/OfferController |
|||
candidatescore/governance/CandidateScoringGovernanceController recommendation/JobRecommendationController |
|||
careerprogress/CareerProgressController recommendation/JobRecommendationService |
|||
company/CompanyController review/AdminReviewController |
|||
deletion/AccountDeletionController review/AdminReviewReplyController |
|||
feedback/CandidateFeedbackController review/CompanyReviewManagementController |
|||
review/JobReviewController |
|||
review/StudentReviewWeightController |
|||
risk/AdminRiskController |
|||
safety/AdminReportController |
|||
safety/BlockController |
|||
safety/ReportController |
|||
scoringmonitoring/FairnessMonitoringController |
|||
talentdiscovery/TalentDiscoveryController |
|||
talentinvitation/TalentInvitationController |
|||
verification/CompanyVerificationController |
|||
common/exception/GlobalExceptionHandler(requestId,本轮已改为调用 accessor) |
|||
controller/ApiController、controller/AuthController、controller/SystemController、job/JobController(requestId) |
|||
``` |
|||
|
|||
## 8. 新增/加强的测试 |
|||
|
|||
| 测试类 | 类型 | Tests run | 覆盖 | |
|||
|---|---|---|---| |
|||
| `common/logging/RequestIdAccessorTest`(新增) | 纯单元(真实 `RequestIdFilter`) | 16 | 合法值原样使用;缺失/空白/刚好超一位/远超上限;空格、制表符、换行、回车、Unicode、HTML/路径/标点等短非法字符;**反证:绕过过滤器传 400 字符头不得返回原值**;请求属性优先于请求头;空白属性回退;null request | |
|||
| `common/logging/RequestIdFilterTest`(加强) | 纯单元 | 3 | 追加断言:规范化值同时写入请求属性,且与响应头一致 | |
|||
| `common/logging/RequestContextMigrationIntegrationTest`(新增) | **真实 SecurityFilterChain + RequestIdFilter + MVC** | 8 | 见下 | |
|||
| `deletion/DeletionGuardInterceptorTest`(加强) | 真实过滤链 + MVC | 7 | 新增非法/溢出主体回归 | |
|||
| `profile/ResumeControllerTest`(适配) | 单元 | 12 | 注入真实 `CurrentUserAccessor` / `RequestIdAccessor`;未登录断言改为新 401 语义 | |
|||
| `common/exception/GlobalExceptionHandler*Test`(适配) | 单元 | 1 / 23 / 10 | 测试侧显式传入真实 `RequestIdAccessor`;生产类不保留无参构造、可选注入或手工兜底 | |
|||
|
|||
`RequestContextMigrationIntegrationTest` 逐项覆盖任务书第六节: |
|||
|
|||
1. 合法客户端 requestId 原样使用 —— 在 8 个 Controller 的真实入口上逐一断言; |
|||
2. 缺失、空白、超长(120 字符)时使用过滤器规范化值; |
|||
3. **响应头、响应体 `requestId`、MDC、`AuditContext` 四者同值** —— MDC 与 `AuditContext` 由一个测试专用 `HandlerInterceptor` 在 MVC 派发过程中抓取,而不是只看过滤器自身; |
|||
4. 并发 8 个请求各有独立 requestId,互不串号; |
|||
5. 请求结束后 `MDC.get("requestId")` 为 `null`; |
|||
6. 8 类以上非法 principal 全部 **401 而非 5xx**(匿名令牌、`anonymousUser`、空白、非数字、Unicode 数字、`+42`、`-7`、`0`、`-1`、Long 溢出),另加「完全无凭据」401; |
|||
7. 错误响应不含 principal、`NumberFormatException` 或 `at com.mydreamjob` 堆栈; |
|||
8. 8 个 Controller 的公开端点与既有权限回归(见第 9 节 525 条关联回归); |
|||
9. 被拒绝的请求对 6 张业务表零写入; |
|||
10. **反证**:绕过过滤器直接传超长请求头时 accessor 不得返回原值(`RequestIdAccessorTest.overlongHeaderIsBoundedEvenWhenFilterIsBypassed`)。 |
|||
|
|||
**未只 Mock 新组件后断言 mock 值**:`RequestContextMigrationIntegrationTest` 与 `DeletionGuardInterceptorTest` 均使用真实 `SecurityFilterChain`、真实 `RequestIdFilter`、真实 MVC 与真实 JWT 会话。 |
|||
|
|||
## 9. 验证结果 |
|||
|
|||
| 步骤 | 命令 | 结果 | |
|||
|---|---|---| |
|||
| 定向测试 | `mvn -Dtest="CompanyControllerMvcTest,GlobalExceptionHandler*Test,RequestContextMigrationIntegrationTest,RequestIdAccessorTest,RequestIdFilterTest,DeletionGuardInterceptorTest,ResumeControllerTest" test` | **Tests run: 87, Failures: 0, Errors: 0, Skipped: 0**,`BUILD SUCCESS` | |
|||
| 八个模块关联回归 | `mvn -Dtest="*Assessment*Test,*Ability*Test,*Profile*Test,*Resume*Test,*Preference*Test,*Competition*Test,*DataExport*Test,*Privacy*Test,*Deletion*Test,*RequestId*Test,*RequestContext*Test,*GlobalExceptionHandler*Test,*CurrentUserAccessorTest" test` | **Tests run: 525, Failures: 0, Errors: 0, Skipped: 0**,`BUILD SUCCESS` | |
|||
| 全量测试 | `mvn test` | 见第 11 节 | |
|||
| 干净构建 | `mvn clean verify` | 见第 11 节 | |
|||
| OpenAPI | `python ..\docs\verify_openapi.py` | 见第 12 节 | |
|||
| 空白检查 | `git diff --check` | 仅 LF/CRLF 提示,无空白错误 | |
|||
|
|||
## 10. 越界生产缺陷(已获授权,已修复) |
|||
|
|||
测试在**先于修复**建立时捕获了一个真实生产缺陷,位置超出 §1 白名单,已单独取得负责人明确授权后修复。 |
|||
|
|||
**缺陷**:`deletion/DeletionGuardInterceptor` 注册在 `/**`([WebMvcConfig.java:20](mydreamjob-server/src/main/java/com/mydreamjob/config/WebMvcConfig.java#L20)),先于**所有** Controller 执行。它在 `preHandle` 里用 `Character::isDigit` 过滤主体后直接 `Long.valueOf(auth.getName())`: |
|||
|
|||
- 主体为 `"99999999999999999999"`(Long 溢出)→ `NumberFormatException` 逃逸 → **全站 HTTP 500**,且在任何 Controller 之前; |
|||
- 同一检查下 `Character.isDigit` 接受 Unicode 数字,只是因为 `Long.parseLong` 恰好也接受 Unicode 数字才没有一并炸掉。 |
|||
|
|||
**证据**(修复前):`RequestContextMigrationIntegrationTest.illegalPrincipalsReturn401InsteadOf500AndNeverEchoThePrincipal` 在「Long 溢出主体」上得到 `expected: 401 but was: 500`,堆栈为 `java.lang.NumberFormatException: For input string: "99999999999999999999"` at `DeletionGuardInterceptor.preHandle(DeletionGuardInterceptor.java:60)`。 |
|||
|
|||
**修复**:该拦截器的身份解析改为复用唯一的 `CurrentUserAccessor`;非法/溢出主体一律视为未认证并**放行**,交给 Controller 统一返回 401。独立复验将初版的 `ObjectProvider<CurrentUserAccessor>` 进一步改为强制构造器注入,装配缺失时应用失败启动,不允许安全依赖静默缺失。注销守卫本身的 9302 / 9303 语义、白名单路径与 OPTIONS 放行**均未改动**(`DeletionGuardInterceptorTest` 7 条全绿,含 4 条原有拦截语义用例)。 |
|||
|
|||
这条修复是 §1 白名单之外的第 9 个生产文件,**在此明确标出**,未混入 8 个 Controller 的改动叙述。 |
|||
|
|||
## 11. 全量测试与干净构建 |
|||
|
|||
| 项目 | Tests run | Failures | Errors | Skipped | 构建 | |
|||
|---|---|---|---|---|---| |
|||
| 独立整改定向复验 | **75** | **0** | **0** | **0** | `BUILD SUCCESS` | |
|||
| `mvn clean verify` | **1915** | **0** | **0** | **0** | `BUILD SUCCESS`(含打包,产出 `mydreamjob-server-0.0.1-SNAPSHOT.jar`) | |
|||
|
|||
三个日志中均不存在任何 `Skipped: <非零>` 的测试类。 |
|||
|
|||
期间发生过两次真实失败并已修正:实施阶段首次全量测试暴露 `CompanyControllerMvcTest` 未提供 `RequestIdAccessor`;最终处理是在该测试切片显式提供依赖,而不是弱化生产装配。独立整改第一次定向运行又因新增参数化测试同时残留 `@Test` 而产生 1 个 ParameterResolution Error;删除多余注解并补齐换行、回车边界后,同一集合 75/75 通过。两次失败均未通过删除断言或生产兜底绕过。 |
|||
|
|||
## 12. OpenAPI |
|||
|
|||
``` |
|||
python docs/verify_openapi.py |
|||
paths: 176 |
|||
operations: 214 |
|||
All checks passed! |
|||
``` |
|||
|
|||
`docs/openapi-rc.json` **未被修改**(`git status --short -- docs/openapi-rc.json` 无输出)。本批未新增、删除或重命名任何 path / operation,未改变参数名、默认值、响应 DTO 或错误码;8 个 Controller 的 Swagger 注解一律原样保留。 |
|||
|
|||
## 13. 修改文件清单 |
|||
|
|||
**新增** |
|||
|
|||
```text |
|||
docs/r01-a-request-context-implementation-report.md |
|||
mydreamjob-server/src/main/java/com/mydreamjob/common/logging/RequestIdAccessor.java |
|||
mydreamjob-server/src/test/java/com/mydreamjob/common/logging/RequestIdAccessorTest.java |
|||
mydreamjob-server/src/test/java/com/mydreamjob/common/logging/RequestContextMigrationIntegrationTest.java |
|||
``` |
|||
|
|||
**修改(生产,共 11 个文件:8 个 Controller + 过滤器 + 异常处理器 + 1 个经授权的越界修复)** |
|||
|
|||
```text |
|||
mydreamjob-server/src/main/java/com/mydreamjob/assessment/AssessmentController.java |
|||
mydreamjob-server/src/main/java/com/mydreamjob/ability/AbilityController.java |
|||
mydreamjob-server/src/main/java/com/mydreamjob/profile/ProfileController.java |
|||
mydreamjob-server/src/main/java/com/mydreamjob/resume/ResumeVersionController.java |
|||
mydreamjob-server/src/main/java/com/mydreamjob/preference/PreferenceController.java |
|||
mydreamjob-server/src/main/java/com/mydreamjob/competition/CompetitionRankingController.java |
|||
mydreamjob-server/src/main/java/com/mydreamjob/dataexport/PersonalDataExportController.java |
|||
mydreamjob-server/src/main/java/com/mydreamjob/privacy/PrivacySettingsController.java |
|||
mydreamjob-server/src/main/java/com/mydreamjob/common/logging/RequestIdFilter.java # 新增 request attribute 常量并写入 |
|||
mydreamjob-server/src/main/java/com/mydreamjob/common/exception/GlobalExceptionHandler.java # 改为调用 accessor |
|||
mydreamjob-server/src/main/java/com/mydreamjob/deletion/DeletionGuardInterceptor.java # 越界缺陷修复(已授权) |
|||
``` |
|||
|
|||
**修改(测试)** |
|||
|
|||
```text |
|||
mydreamjob-server/src/test/java/com/mydreamjob/common/logging/RequestIdFilterTest.java |
|||
mydreamjob-server/src/test/java/com/mydreamjob/deletion/DeletionGuardInterceptorTest.java |
|||
mydreamjob-server/src/test/java/com/mydreamjob/profile/ResumeControllerTest.java |
|||
mydreamjob-server/src/test/java/com/mydreamjob/company/CompanyControllerMvcTest.java |
|||
mydreamjob-server/src/test/java/com/mydreamjob/common/exception/GlobalExceptionHandlerAccountDeletionTest.java |
|||
mydreamjob-server/src/test/java/com/mydreamjob/common/exception/GlobalExceptionHandlerHttpStatusTest.java |
|||
mydreamjob-server/src/test/java/com/mydreamjob/common/exception/GlobalExceptionHandlerReportBlockTest.java |
|||
``` |
|||
|
|||
**未修改**:未新增接口、`ErrorCode`、Flyway、表、索引或依赖;V1–V43 未改动;`docs/openapi-rc.json` 未改动。 |
|||
|
|||
## 14. Git 状态 |
|||
|
|||
``` |
|||
git branch --show-current -> refactor/r01-structure-performance |
|||
git diff --cached --name-only -> (空) |
|||
git status --short -- mydreamjob-server/src/main -> 11 个 M + 1 个 ?? |
|||
git status --short -- mydreamjob-server/src/main/resources/db/migration -> (空) |
|||
git status --short -- docs/openapi-rc.json -> (空) |
|||
git diff --check -> 仅 LF/CRLF 提示,无空白错误 |
|||
``` |
|||
|
|||
工作区中另有一处**先于本轮就存在**的未提交修改 `docs/backend-development-roadmap.md`(Q01 行更新 + R01 行),本轮未触碰。 |
|||
|
|||
本批**未暂存、未提交、未推送**;未执行 `git add -A`、commit、push、reset、restore、checkout、clean;未删除或移动任何历史未跟踪文件。所有 R01-A 产物均为未跟踪新文件或仅本轮修改的已跟踪文件。 |
|||
|
|||
## 15. 未做的事 |
|||
|
|||
- 未进入 R01-B / R01-C / R01-D / R01-E; |
|||
- 未新增 `UserRoleGuard`(角色授权按设计要求留在后续批次,本批未把角色判断与身份解析混在一起); |
|||
- 未改动 `AuditContext`、`RequestLoggingFilter`(后者读的请求头已被过滤器包装器规范化,与其余输出同源); |
|||
- 未改动 `docs/backend-development-roadmap.md` 与 `docs/phase1-scope-and-acceptance.md`; |
|||
- 未新增接口、`ErrorCode`、Flyway、表、索引或依赖。 |
|||
@ -0,0 +1,197 @@ |
|||
# R01-A2 独立代码复验报告(第二批:8 个 Controller 请求身份与 requestId 收束) |
|||
|
|||
日期:2026-09-17 |
|||
分支:`refactor/r01-structure-performance` |
|||
HEAD:`52c8026b2e56dae7dbfae6a828a9efe23e180445`(复验全程未变) |
|||
复验对象:`docs/r01-a2-request-context-migration-report.md` |
|||
性质:**仅复验** —— 未修改生产代码、未暂存、未提交、未推送 |
|||
|
|||
--- |
|||
|
|||
## 0. 结论 |
|||
|
|||
> **R01-A2独立代码复验通过,可以进入精确暂存、本地提交与推送检查点。** |
|||
|
|||
**FAIL = 0,SKIP = 0。** 所有 FAIL 判据逐条核对后均未触发。 |
|||
|
|||
--- |
|||
|
|||
## 1. 基线 |
|||
|
|||
| 检查项 | 结果 | |
|||
|---|---| |
|||
| 分支 | `refactor/r01-structure-performance` ✔ | |
|||
| HEAD | `52c8026b2e56dae7dbfae6a828a9efe23e180445` ✔ | |
|||
| 暂存区 | 空 ✔ | |
|||
| 实际差异文件 | **8 个 Controller + `docs/backend-development-roadmap.md`**,加上 `GlobalExceptionHandler.java`(stat 缓存标记)✔ | |
|||
| `GlobalExceptionHandler.java` 内容 | 工作区 blob `1fdc341e…` == HEAD blob `1fdc341e…` —— **内容零差异** ✔ | |
|||
| `git diff --check` | 无空白错误 ✔ | |
|||
| 历史未跟踪文件 | 未操作,240 个完整保留 ✔ | |
|||
|
|||
--- |
|||
|
|||
## 2. 静态审查 |
|||
|
|||
### 2.1 禁用模式命中 |
|||
|
|||
| Controller | 命中 | |
|||
|---|---| |
|||
| ApplicationController | **0** | |
|||
| CompanyController | **0** | |
|||
| MessagingController | **0** | |
|||
| OfferController | **1** —— `:210 Long.valueOf(part)`,**业务参数解析**(路径/查询参数),非身份解析 | |
|||
| InterviewController | **0** | |
|||
| CandidateScoringController | **0** | |
|||
| CandidateScoreCardController | **0** | |
|||
| CompanyScoringDashboardController | **0** | |
|||
|
|||
模式:`SecurityContextHolder|Authentication|anonymousUser|Long\.valueOf|Long\.parseLong|getRequestId|resolveRequestId|X-Request-Id|new CurrentUserAccessor|new RequestIdAccessor` |
|||
|
|||
**私有身份/requestId 方法残留:0** ✔ |
|||
|
|||
### 2.2 注入方式 |
|||
|
|||
8 个 Controller 均为: |
|||
|
|||
```java |
|||
private final com.mydreamjob.security.CurrentUserAccessor currentUserAccessor; |
|||
private final com.mydreamjob.common.logging.RequestIdAccessor requestIdAccessor; |
|||
``` |
|||
|
|||
- **同一个** `CurrentUserAccessor` / `RequestIdAccessor`(全限定名一致)✔ |
|||
- **无** `@Autowired(required=false)`、`@Nullable`、`ObjectProvider`、`Optional<…>`、`new CurrentUserAccessor()`、`new RequestIdAccessor()` 回退 ✔ |
|||
(grep 命中的 `required = false` 全部是 `@RequestParam`,与依赖注入无关) |
|||
|
|||
### 2.3 调用顺序与契约 |
|||
|
|||
| 核查项 | 结论 | |
|||
|---|---| |
|||
| 删除行中是否含角色/归属检查 | **否**(`assertCompany*`/`assertStudent*`/`FORBIDDEN`/`findCompanyByOwnerUserId`/`getRoles` 均未被删除)✔ | |
|||
| 新增行中是否引入角色/归属判断 | **否** ✔ | |
|||
| Service 调用参数 | 未变(仍为 `userId`、`req`、`AuditContext.from(request)` 等)✔ | |
|||
| 响应结构 | 未变(`ApiResponse.ok(...)`)✔ | |
|||
| DTO | 未变 ✔ | |
|||
| 改动性质 | 仅:import 增删、字段新增、`resolveUserId(…)` → `currentUserAccessor.requireUserId()`、`getRequestId(…)` → `requestIdAccessor.get(request)`、私有方法删除 ✔ | |
|||
|
|||
--- |
|||
|
|||
## 3. 独立边界测试 |
|||
|
|||
新增**两个独立测试类**(不复用实施者的测试与断言):`A2IndependentVerificationTest`(11 例)、`A2AccessorWiringProbeTest`(3 例)。 |
|||
|
|||
### 3.1 身份 |
|||
|
|||
| 覆盖 | 结果 | |
|||
|---|---| |
|||
| 8 端点 × 8 非法主体(`anonymousUser`、空串、空白、`not-a-number`、`abc123`、**20 位溢出 `12345678901234567890`**、`0`、`-1`) | **全部 401**,**无 500** ✔ | |
|||
| 匿名 `AnonymousAuthenticationToken`、null 认证 | 401 ✔ | |
|||
| 合法正数主体 `"7"` | 不被身份层判为未登录(非 401)✔ | |
|||
| 响应体检查方式 | **解析 JSON 后只检查 `code`/`message`**,未对整响应做数字子串搜索 ✔ | |
|||
| 不回显异常类/堆栈/principal | `message` 精确属于 `{authentication required, not authenticated}`;无 `Exception`/`java.`/`principal`;无 `stackTrace`/`trace`/`principal` 字段 ✔ | |
|||
|
|||
### 3.2 角色与归属 |
|||
|
|||
| 覆盖 | 结果 | |
|||
|---|---| |
|||
| STUDENT 访问企业操作(`GET /company/candidates`) | **403** ✔(既有 `ApplicationControllerTest.list_student_shouldReturn403` 亦通过) | |
|||
| COMPANY 访问学生操作 | 企业账号在测试夹具下具备复合角色,端到端 403 由 Service 层产生;本次以**既有测试套件**(`*Application*Test` 等 630/630 全绿)作为该路径的回归证据 ✔ | |
|||
| 无 token | 8 端点全部 **401**(filter chain 层)✔ | |
|||
| 身份失败不被角色层覆盖 | 非法主体访问学生/企业端点均保持 **401**(非 403)✔ | |
|||
| 资源不存在 | 既有 404 映射未改动(`resolveHttpStatus` 与 `NOT_FOUND` 注册表未变)✔ | |
|||
| 合法用户可达 Service | `A2AccessorWiringProbeTest#legalPrincipalReachesService` 断言 `messagingService.listConversations(42L)` 被调用 ✔ | |
|||
|
|||
### 3.3 requestId(Filter / Accessor 直接测试) |
|||
|
|||
| 覆盖 | 结果 | |
|||
|---|---| |
|||
| 合法值原样保留 | ✔ | |
|||
| 空白 / 空串 / null 重新生成 | ✔ | |
|||
| 超长(100 字符)规范化为 32 | ✔ | |
|||
| CR / LF / CRLF 不进入结果 | ✔(`doesNotContain("\r")`、`doesNotContain("\n")`) | |
|||
| 非法字符(`<script>`、空格、分号、非 ASCII、制表符)整体拒绝 | ✔ | |
|||
| **request attribute 优先于原始 header** | ✔(属性 `attr-value` 覆盖 header `header-value`) | |
|||
| 响应头与响应体 requestId 相同 | ✔(5 种输入 × 8 端点,已认证路径) | |
|||
| 注入探针证明扫描器非空转 | ✔(见 §5) | |
|||
|
|||
> 说明:`RequestIdFilter.normalizeRequestId` 为包私有,独立测试通过 **public 的 `RequestIdAccessor`** 输入原始头值来观察同一规范化规则 —— 既避开包可见性,又更贴近真实调用路径。 |
|||
|
|||
--- |
|||
|
|||
## 4. 测试强度审查 |
|||
|
|||
| 审查项 | 结论 | |
|||
|---|---| |
|||
| `satisfiesAnyOf` 是否只允许两条明确文案 | **是** —— 仅 `contains("authentication required")` 与 `contains("not authenticated")`;且该断言与 `status==401`、`json.code==401`、无异常类名等断言**并列**,不存在"接受任意响应"的可能 ✔<br>(独立测试进一步收紧为 `isIn(…)` **精确相等**) | |
|||
| principal 泄漏检查是否覆盖非数字标记与溢出主体 | **是** —— 独立测试覆盖 `not-a-number`、`abc123`、20 位溢出主体,并对溢出主体**只检查 `message` 字段**,避免整响应数字搜索误判 ✔ | |
|||
| `/offers` 是否确由 `OfferController` 处理并触发预期身份链 | **是** —— `GET /offers` 为 `OfferController` 的 `@GetMapping`(`:53`);接线探针断言该端点返回 accessor mock 注入的 requestId,证明其身份链经过 `CurrentUserAccessor` ✔ | |
|||
| **测试有效性(变异检测)** | **有效** —— `A2AccessorWiringProbeTest` 以 `@MockBean` 替换两个 Accessor:① mock 抛 `InsufficientAuthenticationException` 时八端点必须 401(若 Controller 未接线则不可能);② mock 返回值必须出现在响应体;③ `verify(messagingService).listConversations(42L)`。**删除 Accessor 调用会导致该测试失败**,故不是空转测试 ✔ | |
|||
|
|||
--- |
|||
|
|||
## 5. 真实 HTTP 独立验证 |
|||
|
|||
只读实例(`SCORING_REFRESH_ENABLED=false`)PID 13140,验证后已停止。 |
|||
|
|||
| 项 | 结果 | |
|||
|---|---| |
|||
| 4 类非法请求(无 token / 畸形 Bearer / 非 JWT 串 / 空 Bearer)× 8 端点 | **全部 401** ✔ | |
|||
| 上述请求前后 **22 张表逐字段比较** | **全部零变化** ✔ | |
|||
| 异常/堆栈/敏感信息泄漏 | 无 ✔ | |
|||
| 已认证路径 requestId 同源 | 5 种输入(合法/空白/超长/非法字符/含空格)× 8 端点,**响应头 == 响应体** ✔ | |
|||
| 扫描器有效性探针 | 向 `audit_logs` 插入探针 → 命中 1 → 清理后 0;正则探针命中 —— **扫描器非空转** ✔ | |
|||
|
|||
**一处需要澄清的既有现象(非本次引入)**:未认证请求(无 token)的 401 由 **Spring Security filter chain** 直接产生,`RequestIdFilter`(默认 order)排在其后(Security 为 `-100`),因此该 401 响应**不含 `X-Request-Id` 头、body 也无 requestId**(`{"code":401,"message":"not authenticated","timestamp":""}`)。 |
|||
|
|||
- 本次改动**未触及任何 Filter / Security 配置**(`git diff --name-only` 对 `config/`、`common/logging/`、`security/` 为空)✔ |
|||
- 该行为在迁移前后**完全一致** ✔ |
|||
- 到达 Controller 的已认证请求,其响应头与响应体 requestId **同源** ✔ |
|||
|
|||
此现象属既有架构事实,不构成本次复验的 FAIL 项,但如实记录以备后续单独评估。 |
|||
|
|||
--- |
|||
|
|||
## 6. 回归结果 |
|||
|
|||
| 命令 | 结果 | 要求 | |
|||
|---|---|---| |
|||
| `mvn -Dtest="RequestContextMigrationIntegrationTest,A2IndependentVerificationTest,A2AccessorWiringProbeTest,CurrentUserAccessorTest,RequestIdAccessorTest,RequestIdFilterTest" test` | **61 / 61**,F=0 E=0 **S=0** | ✔ | |
|||
| `mvn -Dtest="*Application*Test,*Company*Test,*Messaging*Test,*Offer*Test,*Interview*Test,*CandidateScoring*Test,*Dashboard*Test,*RequestContext*Test,*RequestId*Test" test` | **630 / 630**,F=0 E=0 **S=0**,BUILD SUCCESS | ✔ | |
|||
| `mvn clean verify` | **2199 / 2199**,Failures=0、Errors=0、**Skipped=0**,BUILD SUCCESS | ≥ 2185 ✔ | |
|||
| `python docs\verify_openapi.py` | **176 paths / 214 operations**,All checks passed | 176/214 ✔ | |
|||
| `docs/openapi-rc.json` | **零修改** ✔ | ✔ | |
|||
|
|||
较实施者报告的 2185 增加 **14** 个测试,全部为本次独立复验新增的两个测试类。 |
|||
|
|||
--- |
|||
|
|||
## 7. FAIL 判据逐条核对 |
|||
|
|||
| 判据 | 是否触发 | |
|||
|---|---| |
|||
| 任一 Controller 仍直接解析主体或 requestId | **否**(7 个命中 0;`OfferController` 唯一命中为业务参数解析) | |
|||
| 非法主体出现 500 | **否**(8 端点 × 8 非法主体全部 401) | |
|||
| 角色/归属错误码漂移 | **否**(角色/归属检查未删未增;既有 630 项回归全绿) | |
|||
| requestId 可携带 CR/LF 或超长原值 | **否**(CR/LF 被整体拒绝;超长收敛至 32;响应头/体均不含原值) | |
|||
| 测试通过但未真正经过目标 Controller | **否**(接线变异检测证明必经 Accessor) | |
|||
| 核心测试出现 SKIP | **否**(Skipped=0) | |
|||
|
|||
--- |
|||
|
|||
## 8. 复验过程中的自暴露问题(非产品缺陷) |
|||
|
|||
新增独立测试首轮运行有 4 项失败,全部为**我的测试缺陷**,已修正: |
|||
|
|||
| # | 问题 | 处置 | |
|||
|---|---|---| |
|||
| 1 | `RequestIdFilter.normalizeRequestId` 为包私有,从 `r01a2` 包无法调用(编译错误) | 改经 public 的 `RequestIdAccessor` 验证同一规则 | |
|||
| 2 | 断言"响应头来自 Accessor mock" —— 但响应头由 `RequestIdFilter` 设置,非 Controller 职责 | 改为断言**响应体** `requestId` 来自 Accessor | |
|||
| 3 | 用 `PUT /companies/me` 验证学生 403 —— 该角色检查在 **Service 层**、被 `@MockBean` 跳过,返回 200 | 改为验证"身份失败不被角色层覆盖"(401 保持) | |
|||
| 4 | 含 CR/LF 的请求头在 MockMvc **客户端层**即被拒绝,请求发不出 | 改用可承载但会被规范化规则拒绝的字符集 | |
|||
|
|||
--- |
|||
|
|||
## 9. 结论 |
|||
|
|||
> **R01-A2独立代码复验通过,可以进入精确暂存、本地提交与推送检查点。** |
|||
|
|||
**未修改生产代码、未修改测试(除新增独立测试)、未暂存、未提交、未推送。** |
|||
@ -0,0 +1,218 @@ |
|||
# R01-A2 第二批:请求身份与 requestId 收束报告 |
|||
|
|||
日期:2026-09-17 |
|||
分支:`refactor/r01-structure-performance` |
|||
基线 HEAD:`52c8026b2e56dae7dbfae6a828a9efe23e180445` |
|||
范围:**仅 8 个 Controller**(ApplicationController、CompanyController、MessagingController、OfferController、InterviewController、CandidateScoringController、CandidateScoreCardController、CompanyScoringDashboardController) |
|||
|
|||
--- |
|||
|
|||
## 0. 结论 |
|||
|
|||
> **R01-A2第二批请求身份与requestId收束完成,待独立代码复验。** |
|||
|
|||
8 个 Controller 的身份与 requestId 解析已统一收束到既有 `CurrentUserAccessor` / `RequestIdAccessor`;非法主体由**逃逸为 HTTP 500** 改为**统一 401**;requestId 不再由 Controller 直读原始请求头。 |
|||
|
|||
--- |
|||
|
|||
## 1. 修改前后的私有解析方法 |
|||
|
|||
| Controller | 修改前(私有方法) | 修改后 | |
|||
|---|---|---| |
|||
| ApplicationController | `private Long resolveUserId(HttpServletRequest)` → `SecurityContextHolder.getContext().getAuthentication()`;`auth==null \|\| !isAuthenticated()` → `BusinessException(UNAUTHORIZED)`;`return Long.valueOf(auth.getName())`<br>`private String getRequestId(HttpServletRequest)` → `request.getHeader("X-Request-Id")` | `currentUserAccessor.requireUserId()`(10 处)<br>`requestIdAccessor.get(request)`(9 处) | |
|||
| CompanyController | 同上(私有 `resolveUserId` + `getRequestId`) | `requireUserId()` ×3;`requestIdAccessor.get(request)` ×6 | |
|||
| MessagingController | 同上 | `requireUserId()` ×7;`requestIdAccessor.get(request)` ×7 | |
|||
| OfferController | 同上 | `requireUserId()` ×13;`requestIdAccessor.get(request)` ×13 | |
|||
| InterviewController | 同上 | `requireUserId()` ×9;`requestIdAccessor.get(request)` ×9 | |
|||
| CandidateScoringController | `private Long resolveUserId()`(**无参**)+ `getRequestId` | `requireUserId()` ×4;`requestIdAccessor.get(request)` ×4 | |
|||
| CandidateScoreCardController | `private Long resolveUserId()`(**无参**)+ `getRequestId` | `requireUserId()` ×3;`requestIdAccessor.get(request)` ×3 | |
|||
| CompanyScoringDashboardController | `private Long resolveUserId()`(**无参**)+ `getRequestId` | `requireUserId()` ×7;`requestIdAccessor.get(request)` ×7 | |
|||
|
|||
**共删除私有解析方法 16 个**(8 个 `resolveUserId` + 8 个 `getRequestId`),并移除随之不再使用的 import:`org.springframework.security.core.Authentication`、`org.springframework.security.core.context.SecurityContextHolder`(后 3 个 Controller)。 |
|||
|
|||
新增字段(每个 Controller): |
|||
|
|||
```java |
|||
/** R01-A2:身份只来自安全上下文,不接受请求参数传入的 userId。 */ |
|||
private final com.mydreamjob.security.CurrentUserAccessor currentUserAccessor; |
|||
/** R01-A2:requestId 只来自过滤器规范化后的唯一产出点。 */ |
|||
private final com.mydreamjob.common.logging.RequestIdAccessor requestIdAccessor; |
|||
``` |
|||
|
|||
**未做**:未把 userId 加入请求参数;未调整角色检查调用位置;未调整归属判断;未复制第二套解析器。 |
|||
|
|||
--- |
|||
|
|||
## 2. 端点 401/403/404 语义 |
|||
|
|||
### 2.1 明确的语义变更(如实记录) |
|||
|
|||
| 场景 | 修改前 | 修改后 | 说明 | |
|||
|---|---|---|---| |
|||
| SecurityContext 为 null / 未认证 | `BusinessException(UNAUTHORIZED=2001)` | `InsufficientAuthenticationException` | 均映射 401,**无变化** | |
|||
| **主体名为空 / 非数字 / 溢出 / ≤0 / `anonymousUser`** | `Long.valueOf(name)` 抛 `NumberFormatException` → **HTTP 500** | 统一 **HTTP 401** | **语义变更:500 → 401** | |
|||
| 主体名合法但空字符串 `""` | `Long.valueOf("")` → **500** | **401** | 同上 | |
|||
|
|||
**依据**:`ErrorCode.UNAUTHORIZED(2001)` **不在** `GlobalExceptionHandler.BUSINESS_HTTP_STATUSES` 的任何 `register` 列表中,`resolveHttpStatus` 的 `getOrDefault(2001, BAD_REQUEST)` 使其落到 **400**;而空/非法名称在 `Long.valueOf` 处直接抛 `NumberFormatException` 逃逸为 **500**(修复前特征测试已实测复现)。 |
|||
|
|||
迁移后统一由 `CurrentUserAccessor` 抛 `InsufficientAuthenticationException`,由既有 `AuthenticationException` 处理器映射为 **401** —— 这正是任务书要求的「非法主体统一返回401,不是500」。 |
|||
|
|||
> 说明:修改前该路径实际为 **400 或 500**(取决于主体名是否合法可解析),并非 401。因此本次变更**不触及**原本就是 401/403/404 的场景。 |
|||
|
|||
### 2.2 保持不变的语义 |
|||
|
|||
- **403(角色/归属)**:`assertCompanyRole`、`assertCompanyOwner`、归属校验等调用位置与逻辑**一字未动** ✔ |
|||
- **404(资源不存在)**:`CompanyController` 的 `ApiResponse.error(404, "company not found", …)` 等保持不变 ✔ |
|||
- **业务错误码**:未修改 `ErrorCode`、未修改 `resolveHttpStatus`、未修改 `GlobalExceptionHandler` ✔ |
|||
- **filter chain 层 401**:无 token 的请求由 `SecurityFilterChain` 在 Controller 之前拒绝,行为不变(既有 `list_noToken_shouldReturn401` 等断言持续通过)✔ |
|||
|
|||
--- |
|||
|
|||
## 3. 修复前失败测试 |
|||
|
|||
新增 `RequestContextMigrationIntegrationTest`(`mydreamjob-server/src/test/java/com/mydreamjob/common/r01a2/`),使用**真实 `SecurityFilterChain` + `MockMvc`**(`@SpringBootTest` + `@AutoConfigureMockMvc`)。 |
|||
|
|||
**修复前实测(仅加入测试、未改生产代码):`Tests run: 17, Failures: 5`** |
|||
|
|||
| 失败测试 | 修复前实际 | |
|||
|---|---| |
|||
| `illegalPrincipals_alwaysReturn401_withoutEchoingPrincipal` | **`实际 500`**(空/非法主体时 `Long.valueOf` 抛 `NumberFormatException` 逃逸) | |
|||
| `failedRequests_produceNoBusinessWrites` | `Status expected:<401> but was:<500>` | |
|||
| `errorResponse_carriesSameRequestId` | 错误响应未携带同源 requestId | |
|||
| `requestId_maliciousCharacters_neverReachResponse` | 异常字符进入响应 | |
|||
| `legalPrincipal_isNotRejectedByIdentityLayer` | 合法主体被身份层拒绝 | |
|||
|
|||
**修复后:`Tests run: 17, Failures: 0`**(其中的断言经两轮校正:`principal="0"` 单字符与 JSON 数字碰撞、`AnonymousAuthenticationToken` 由 filter chain 提前拦截返回 `"not authenticated"`、MockMvc 客户端层拒绝含 CR/LF 的请求头 —— 三者均为测试自身缺陷,已修正并如实记录)。 |
|||
|
|||
--- |
|||
|
|||
## 4. requestId 同源证据 |
|||
|
|||
`RequestIdFilter` 是 requestId 的**唯一产出点**:规范化后同时写入请求属性 `RequestIdFilter.REQUEST_ID_ATTRIBUTE`、MDC、响应头,并用请求包装器覆盖 `X-Request-Id`。`RequestIdAccessor.get(request)` 优先读取该请求属性,兜底仍走同一 `normalizeRequestId`。 |
|||
|
|||
测试覆盖(每个用例遍历全部 8 个端点): |
|||
|
|||
| 用例 | 断言 | |
|||
|---|---| |
|||
| 合法值 `r01a2-legal-0001` | 响应头 == 响应体 `requestId` == 原值 ✔ | |
|||
| 超长值(120 字符) | 收敛为前 32 字符;响应头 == 响应体;响应体不含原超长串 ✔ | |
|||
| 空白值 `" "` | 重新生成非空值 ✔ | |
|||
| 异常字符(`<script>`、`;DROP`、制表符、非 ASCII、含空格) | 生成合法替代值;响应头不含 `<`/`>`/`;`/空格/制表符;响应体不含原值;响应体 `requestId` == 响应头 ✔ | |
|||
| 错误响应(401) | 响应头 == 响应体 `requestId` == 原值 ✔ | |
|||
|
|||
**Controller 不再直读原始请求头**:迁移后 8 个文件中 `request.getHeader("X-Request-Id")` 命中数为 **0**(逐文件核查见 §5)。 |
|||
|
|||
--- |
|||
|
|||
## 5. 逐文件核查 |
|||
|
|||
任务书指定模式在 8 个文件中的命中: |
|||
|
|||
| 文件 | 命中 | |
|||
|---|---| |
|||
| ApplicationController | **0** | |
|||
| CompanyController | **0** | |
|||
| MessagingController | **0** | |
|||
| OfferController | **1** —— `Long.valueOf(part)`(`:210`),系**业务参数解析**(路径/查询参数),非身份解析 | |
|||
| InterviewController | **0** | |
|||
| CandidateScoringController | **0** | |
|||
| CandidateScoreCardController | **0** | |
|||
| CompanyScoringDashboardController | **0** | |
|||
|
|||
除 `OfferController` 的必要业务数值解析外,身份与 requestId 业务解析命中为 **0**。 |
|||
|
|||
--- |
|||
|
|||
## 6. 全仓剩余数量(如实报告,未声称全仓完成) |
|||
|
|||
| 模式 | 迁移前(HEAD) | 迁移后(工作区) | 减少 | |
|||
|---|---|---|---| |
|||
| `SecurityContextHolder.getContext().getAuthentication` \| `Long.(valueOf\|parseLong)(.*getName` | **36 个文件** | **28 个文件** | 8 | |
|||
| `getRequestId` \| `resolveRequestId` \| `request.getHeader("X-Request-Id")` | **51 个文件** | **43 个文件** | 8 | |
|||
|
|||
减少量恰好等于本次处理的 8 个 Controller。 |
|||
|
|||
**剩余 28 / 43 个文件尚未迁移**,其中包含: |
|||
- **基础设施类(合理保留)**:`security/CurrentUserAccessor.java`(Accessor 自身)、`auth/JwtAuthenticationFilter.java`(认证过滤器)、`deletion/DeletionGuardInterceptor.java`(拦截器)、`common/exception/GlobalExceptionHandler.java`、`common/logging/RequestLoggingFilter.java` |
|||
- **待后续批次迁移的业务 Controller(约 23 个)**:`bidirectionalmatch/*`、`careerprogress/`、`feedback/`、`interviewscore/`、`job/FavoriteController`、`match/`、`notification/`、`recommendation/`、`review/*`、`risk/`、`safety/*`、`scoringmonitoring/`、`talentdiscovery/`、`talentinvitation/`、`verification/`、`candidatescore/governance/`、`account/` 等 |
|||
- **Service 层**:`audit/*`、`messaging/MessagingService`、`offer/OfferService`、`review/*Service`、`talentdiscovery/*Service`、`talentinvitation/*Service`、`bidirectionalmatch/BidirectionalMatchService` |
|||
|
|||
**本次未覆盖它们,也未修改它们。** |
|||
|
|||
--- |
|||
|
|||
## 7. 测试数量与 OpenAPI |
|||
|
|||
| 命令 | 结果 | |
|||
|---|---| |
|||
| 定向(11 个类:新增特征测试 + 8 个 Controller 测试 + 3 个既有 Accessor/Filter 测试) | **161 / 161**,F=0 E=0 **S=0** | |
|||
| 第二批(`*Application*Test,*Company*Test,*Messaging*Test,*Offer*Test,*Interview*Test,*CandidateScoring*Test,*Dashboard*Test,*RequestContext*Test,*RequestId*Test`) | **630 / 630**,F=0 E=0 **S=0**,BUILD SUCCESS | |
|||
| `mvn clean verify` | **2185 / 2185**,Failures=0、Errors=0、**Skipped=0**,BUILD SUCCESS | |
|||
| `python docs\verify_openapi.py` | **176 paths / 214 operations**,All checks passed | |
|||
|
|||
较上一轮(2176)新增 **9** 个测试,全部为本次新增的 `RequestContextMigrationIntegrationTest`。 |
|||
|
|||
- **未修改 `docs/openapi-rc.json`** ✔ |
|||
- **未出现新的数据库副作用**:本次改动只涉及 Controller 层解析与测试,未新增/修改任何 Flyway 迁移、实体或仓储调用 ✔ |
|||
|
|||
--- |
|||
|
|||
## 8. 精确修改文件 |
|||
|
|||
**生产代码(8 个,全部在任务书指定清单内)** |
|||
|
|||
``` |
|||
mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationController.java |
|||
mydreamjob-server/src/main/java/com/mydreamjob/company/CompanyController.java |
|||
mydreamjob-server/src/main/java/com/mydreamjob/messaging/MessagingController.java |
|||
mydreamjob-server/src/main/java/com/mydreamjob/offer/OfferController.java |
|||
mydreamjob-server/src/main/java/com/mydreamjob/interview/InterviewController.java |
|||
mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/CandidateScoringController.java |
|||
mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/CandidateScoreCardController.java |
|||
mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dashboard/CompanyScoringDashboardController.java |
|||
``` |
|||
|
|||
**测试(新增 1 个)** |
|||
|
|||
``` |
|||
mydreamjob-server/src/test/java/com/mydreamjob/common/r01a2/RequestContextMigrationIntegrationTest.java |
|||
``` |
|||
|
|||
**文档(1 个)** |
|||
|
|||
``` |
|||
docs/backend-development-roadmap.md # 仅更新顶部 R01 状态表,未重写历史章节 |
|||
``` |
|||
|
|||
**未修改**:`ErrorCode`、Flyway 迁移、`openapi-rc.json`、DTO、Service、角色检查、资源归属、`UserRoleGuard`、`GlobalExceptionHandler`。 |
|||
|
|||
> 关于 `GlobalExceptionHandler.java`:`git status` 显示 `M`,但工作区 / 索引 / HEAD 的 blob hash 均为 `1fdc341ebe3653393b3ec04f0c2d0bb9dfbb6d21`,**内容零差异**,系 Git 在 Windows 上的文件系统 stat 缓存失效所致。**未修改、未提交**。 |
|||
|
|||
--- |
|||
|
|||
## 9. Git 状态 |
|||
|
|||
``` |
|||
分支:refactor/r01-structure-performance |
|||
HEAD:52c8026b2e56dae7dbfae6a828a9efe23e180445(未变) |
|||
暂存区:空 |
|||
git add -A / commit / push:均未执行 |
|||
reset / restore / checkout / clean:均未执行 |
|||
Docker:未操作 |
|||
历史未跟踪文件(238 个,含 docs/acceptance、backend-infra 等):完整保留 |
|||
``` |
|||
|
|||
--- |
|||
|
|||
## 10. 明确声明 |
|||
|
|||
- **未暂存、未提交、未推送。** |
|||
- 未修改角色检查、资源归属、Service、DTO、ErrorCode、Flyway、接口或 OpenAPI。 |
|||
- 未进入 `UserRoleGuard`。 |
|||
- 未进入其他 R01 工作。 |
|||
- 测试切片使用真实 `SecurityFilterChain` + 真实 Accessor Bean(`@SpringBootTest`),**未**使用「允许缺失依赖 + new 默认实例」的绕过方式。 |
|||
- 全仓剩余身份/requestId 解析数量已如实报告,**未声称全仓完成**。 |
|||
|
|||
--- |
|||
|
|||
> **R01-A2第二批请求身份与requestId收束完成,待独立代码复验。** |
|||
@ -0,0 +1,221 @@ |
|||
# R01-A3 独立代码复验报告(第三批:8 个 Controller 请求身份与 requestId 收束) |
|||
|
|||
日期:2026-09-17 |
|||
分支:`refactor/r01-structure-performance` |
|||
HEAD:`52c8026b2e56dae7dbfae6a828a9efe23e180445`(复验全程未变) |
|||
复验对象:`docs/r01-a3-request-context-migration-report.md` |
|||
性质:**仅复验** —— 未修改生产代码、未修改既有测试、未暂存、未提交、未推送 |
|||
|
|||
--- |
|||
|
|||
## 0. 结论 |
|||
|
|||
> **R01-A3独立代码复验通过;A2与A3当前仍共存于未提交工作区,下一步必须按文件和hunk拆分为两个独立提交,不得合并提交。** |
|||
|
|||
**FAIL = 0(A3 范围内);SKIP = 0。** 另发现**基线已存在的非 A3 阻断缺陷** 1 项(§7),已用基线 worktree 证伪归因。 |
|||
|
|||
--- |
|||
|
|||
## 1. 边界确认 |
|||
|
|||
| 检查项 | 结果 | |
|||
|---|---| |
|||
| 分支 | `refactor/r01-structure-performance` ✔ | |
|||
| HEAD | `52c8026b2e56dae7dbfae6a828a9efe23e180445` ✔ | |
|||
| 暂存区 | 空 ✔ | |
|||
| `git diff --check` | 无空白错误 ✔ | |
|||
| 工作区构成 | **A2(8 个 Controller)+ A3(8 个 Controller)** + 路线图 + `GlobalExceptionHandler`(stat 缓存,内容零差异)✔ | |
|||
| 归因纪律 | 本报告的 A3 证据**仅取自 A3 的 8 个文件路径**;A2 的 8 个文件未计入 A3 证据 ✔ | |
|||
| 未进入 A4 | ✔ | |
|||
| 未修改既有污染测试 | ✔ | |
|||
|
|||
--- |
|||
|
|||
## 2. A3 文件静态审查 |
|||
|
|||
`git diff -- <8 个 A3 Controller 精确路径>` 的改动逐行分类: |
|||
|
|||
| 改动类型 | 数量 | |
|||
|---|---| |
|||
| `Long userId = resolveUserId(request);` → `currentUserAccessor.requireUserId()` | 21 | |
|||
| 新增 `private final CurrentUserAccessor currentUserAccessor;` | 8 | |
|||
| 新增 `private final RequestIdAccessor requestIdAccessor;` | 8 | |
|||
| 删除 `import ...SecurityContextHolder;` | 8 | |
|||
| 删除 `import ...Authentication;` | 7 | |
|||
| 删除 `return request.getHeader("X-Request-Id");` | 7 | |
|||
| 删除 `return Long.valueOf(auth.getName());` | 7 | |
|||
| `getRequestId(request)` / `requestId(request)` → `requestIdAccessor.get(request)` | 其余全部调用点 | |
|||
| `riskAuditContext` 内 header 直读 → `requestIdAccessor.get(request)` | 1 | |
|||
|
|||
### 2.1 私有方法删除清单 —— **实际为 16 个(实施报告写 17,属笔误)** |
|||
|
|||
逐条核对(`git diff | grep '^-.*private .*(resolveUserId\|getUserId\|userId(\|getRequestId\|resolveRequestId\|requestId('`): |
|||
|
|||
``` |
|||
7× resolveUserId(HttpServletRequest) AccountDeletion / CompanyVerification / AdminRisk / |
|||
AdminReport / Report / Block / Notification |
|||
3× getRequestId(HttpServletRequest) AccountDeletion / CompanyVerification / Notification |
|||
4× requestId(HttpServletRequest) AdminRisk / AdminReport / Report / Block |
|||
1× userId() JobRecommendation(紧凑单行) |
|||
1× requestId(HttpServletRequest r) JobRecommendation(紧凑单行) |
|||
──────────────────────────────────────── |
|||
= 16 |
|||
``` |
|||
|
|||
**这是本次复验发现的报告数字错误**:实施报告 §2.1 称「共删除私有解析方法 17 个」,实际为 **16 个**(8 个 Controller × 2)。行为本身正确(8 个文件门禁命中均为 0),仅报告计数有误。 |
|||
|
|||
`JobRecommendationController` 原压缩单行身份方法**已完整删除**: |
|||
```java |
|||
- private Long userId(){ Authentication a=SecurityContextHolder.getContext().getAuthentication(); ... } |
|||
- private String requestId(HttpServletRequest r){return r.getHeader("X-Request-Id");} |
|||
``` |
|||
其 9 处调用点全部改为 `currentUserAccessor.requireUserId()` / `requestIdAccessor.get(request)` ✔ |
|||
|
|||
### 2.2 注入方式 |
|||
|
|||
8 个 Controller 均为 `private final` 构造器注入;**无** `@Autowired(required=false)`、`@Nullable`、`ObjectProvider`、`new CurrentUserAccessor()`、`new RequestIdAccessor()` ✔ |
|||
|
|||
### 2.3 未漂移项 |
|||
|
|||
| 核查项 | 结论 | |
|||
|---|---| |
|||
| 角色检查位置 | **未移动** —— `verificationService.assertAdminRole(userId)`×4、`AdminRisk`/`AdminReport`/`Report` 内的 `userRepository.findById → getRoles → FORBIDDEN`、`JobRecommendation` 的 Service 层裁决,全部原样保留 ✔ | |
|||
| 资源归属检查 | 未移动 ✔ | |
|||
| Service 调用顺序 | 未变 ✔ | |
|||
| DTO / 错误码 / 响应结构 | 未变(`ErrorCode`、`SecurityConfig`、Flyway 均零修改)✔ | |
|||
| 是否混入 `UserRoleGuard` 或其他重构 | **否** ✔ | |
|||
|
|||
--- |
|||
|
|||
## 3. 独立测试(不复用实施者断言) |
|||
|
|||
新增两个独立测试类:`A3IndependentVerificationTest`(9 例)、`A3AccessorWiringProbeTest`(4 例)。 |
|||
|
|||
| 覆盖 | 结果 | |
|||
|---|---| |
|||
| 8 端点 × 8 非法主体(`anonymousUser`/空/空白/非数字/`abc123`/**20 位溢出**/`0`/`-1`) | 全部 **401**,无 500 ✔ | |
|||
| 匿名 token 与无 Token | 401 ✔(无 Token 由 Security 链提前返回,按要求**只断言 401**、不要求补 requestId) | |
|||
| 错误体安全 | `code=401`;`message ∈ {authentication required, not authenticated}`;无类名/堆栈/`principal`;非数字标记不回显(纯数字主体不参与该断言,避免与状态码/时间戳碰撞)✔ | |
|||
| 溢出主体 | 无 `NumberFormatException`;**解析 JSON 后仅检查业务字段**,未做整响应数字搜索 ✔ | |
|||
| 合法主体 | 不被身份层拒绝 ✔ | |
|||
| **角色语义未漂移** | 显式 stub 角色层抛 `FORBIDDEN` → **403**(非 401);`AdminRiskController` 桩造非 ADMIN 用户 → **403** ✔ | |
|||
| request attribute 优先于 header | ✔ | |
|||
| 响应 body 使用规范化 requestId(与响应头同源) | 5 种输入 × 8 端点 ✔ | |
|||
| CR/LF | 不进入规范化结果(直接经 Accessor 验证)✔ | |
|||
| **Accessor 接线变异检测** | mock 抛未登录 → 8 端点必须 401;mock requestId 必须出现在响应体;`verify(notificationService).list(42L, …)` —— **删除 Accessor 调用会使该测试失败** ✔ | |
|||
|
|||
--- |
|||
|
|||
## 4. 统计口径重新核对(任务书第四节) |
|||
|
|||
使用更完整的模式(含 `requestId(...)` 方法名与 `X-Request-Id` 直读): |
|||
|
|||
```bash |
|||
rg -l 'request\.getHeader\("X-Request-Id"\)|String\s+(getRequestId|resolveRequestId|requestId)\s*\(' \ |
|||
mydreamjob-server/src/main/java -g "*.java" |
|||
``` |
|||
|
|||
**结果:25 个文件**(此前报告的 36 为**宽口径误计** —— 旧模式 `getRequestId` 还匹配到 `AuditContext#getRequestId` 等非请求入口调用)。 |
|||
|
|||
| 分类 | 数量 | 明细 | |
|||
|---|---|---| |
|||
| **合理基础设施** | **4** | `audit/AuditContext.java`、`common/exception/GlobalExceptionHandler.java`、`common/logging/RequestLoggingFilter.java`、`deletion/DeletionGuardInterceptor.java` | |
|||
| **尚未迁移的业务 Controller** | **21** | `bidirectionalmatch/*`×3、`candidatescore/governance/`、`careerprogress/`、`controller/*`×3、`feedback/`、`interviewscore/`、`job/*`×2、`match/`、`review/*`×5、`scoringmonitoring/`、`talentdiscovery/`、`talentinvitation/` | |
|||
| **Service 或非请求入口** | **0** | — | |
|||
| **误报** | **0** | — | |
|||
|
|||
**真实业务剩余数 = 21 个业务 Controller**(非「36 个文件」)。 |
|||
|
|||
身份解析: |
|||
|
|||
```bash |
|||
rg -l 'SecurityContextHolder\.getContext\(\)\.getAuthentication|Long\.(valueOf|parseLong)\([^)]*getName\(\)' \ |
|||
mydreamjob-server/src/main/java -g "*.java" |
|||
``` |
|||
|
|||
**结果:20 个文件** = 基础设施 3(`security/CurrentUserAccessor`、`auth/JwtAuthenticationFilter`、`deletion/DeletionGuardInterceptor`)+ **业务 Controller 17** + 误报 0。 |
|||
|
|||
**真实业务剩余数 = 17 个业务 Controller。** |
|||
|
|||
--- |
|||
|
|||
## 5. 验证结果 |
|||
|
|||
| 命令 | 结果 | |
|||
|---|---| |
|||
| `mvn -Dtest="R01A3…,A3IndependentVerificationTest,A3AccessorWiringProbeTest,CurrentUserAccessorTest,RequestIdAccessorTest,RequestIdFilterTest" test` | **51 / 51**,F=0 E=0 **S=0** | |
|||
| 关联回归(`*Deletion*Test,*Verification*Test,*Risk*Test,*Report*Test,*Block*Test,*Notification*Test,*Recommendation*Test,*RequestContext*Test,*RequestId*Test`) | **260 / 260**,F=0 E=**1**,BUILD FAILURE —— **全部为基线已存在的非 A3 缺陷**(§7) | |
|||
| `mvn clean verify` | **2220 / 2220**,Failures=0、Errors=0、**Skipped=0**,BUILD SUCCESS | |
|||
| `python docs\verify_openapi.py` | **176 paths / 214 operations**,All checks passed | |
|||
|
|||
- 较实施轮次(2207)新增 **13** 个测试,全部为本次独立复验新增的两个测试类 |
|||
- **`docs/openapi-rc.json` 零修改**;**`ErrorCode` / Flyway / `SecurityConfig` 零修改** ✔ |
|||
|
|||
--- |
|||
|
|||
## 6. 独立真实 HTTP 验证 |
|||
|
|||
只读实例 PID 16604,验证后已停止。 |
|||
|
|||
| 场景 | 结果 | |
|||
|---|---| |
|||
| 无 token / 畸形 Bearer / 纯数字 Bearer × 8 端点 | 全部 **401** ✔ | |
|||
| STUDENT → ADMIN 风险概览 / 举报 / 认证审核 | **403** ✔ | |
|||
| ADMIN → 风险概览(合法) | **200** ✔ | |
|||
| STUDENT → 通知列表(合法) | **200** ✔ | |
|||
| **上述全部请求前后 22 张表逐字段比较** | **全部零变化** ✔ | |
|||
|
|||
--- |
|||
|
|||
## 7. 基线已存在的非 A3 阻断缺陷(如实记录,未修复) |
|||
|
|||
关联回归命令触发 1 个 Error: |
|||
|
|||
``` |
|||
AccountDeletionControllerTest.lazyTransition_firstGet_returnsCompleted_andConsistent:228->createUser:75 |
|||
DataIntegrityViolation: USERS(PHONE) 唯一键冲突 '930000000' |
|||
``` |
|||
|
|||
**隔离复现(任务书要求)**:在 `git worktree add --detach HEAD`(**不含 A2/A3 改动**)中用**完全相同**的命令运行: |
|||
|
|||
| | 测试数 | 失败签名 | |
|||
|---|---|---| |
|||
| 基线(HEAD) | **223** | `AccountDeletionControllerTest...createUser:75`,`USERS(PHONE)='930000000'` | |
|||
| 本次(含 A2+A3) | **260** | **完全相同** | |
|||
|
|||
测试数差 37 = A2(20) + A3(8) + 本次独立新增(9),数字自洽。 |
|||
|
|||
**判定**:**基线已存在的非 A3 阻断缺陷**,与 A2/A3 无关。 |
|||
|
|||
- 根因:`AccountDeletionControllerTest` 的 `PHONE` 为 `static AtomicLong(930_000_000L)`,配合共享命名 H2 库(`integrationdb`),在特定测试子集顺序下与其他类残留数据冲突 |
|||
- 单跑该类:**14/14 全绿** |
|||
- `mvn clean verify`(全量顺序):**2220/2220,0 Error** |
|||
|
|||
**未修复**(超出本批授权范围),未修改该既有测试。建议后续单独处理。 |
|||
|
|||
--- |
|||
|
|||
## 8. 复验过程中的自暴露问题(非产品缺陷) |
|||
|
|||
| # | 问题 | 处置 | |
|||
|---|---|---| |
|||
| 1 | 独立测试首轮 `nonAdminOnAdminEndpoints_is403` 失败:`/admin/companies/verifications` 因 Service 为 `@MockBean` 未 stub 而 500 | 改为**显式 stub 角色层抛 `FORBIDDEN`** 并断言 403,这才是验证"角色语义未漂移"的正确方式 | |
|||
| 2 | 接线探针 `verify(notificationService).list(42L)` 参数个数错误(实际为 3 参) | 改为 `list(eq(42L), anyInt(), anyInt())` | |
|||
|
|||
--- |
|||
|
|||
## 9. 结论与下一步约束 |
|||
|
|||
> **R01-A3独立代码复验通过;A2与A3当前仍共存于未提交工作区,下一步必须按文件和hunk拆分为两个独立提交,不得合并提交。** |
|||
|
|||
**必须遵守的下一步约束**: |
|||
|
|||
1. **按文件与 hunk 精确拆分**为两个独立提交: |
|||
- 提交 1(A2):`ApplicationController`、`CompanyController`、`MessagingController`、`OfferController`、`InterviewController`、`CandidateScoringController`、`CandidateScoreCardController`、`CompanyScoringDashboardController`(8 个)+ A2 测试 + A2 报告 |
|||
- 提交 2(A3):`AccountDeletionController`、`CompanyVerificationController`、`AdminRiskController`、`AdminReportController`、`ReportController`、`BlockController`、`NotificationController`、`JobRecommendationController`(8 个)+ A3 测试 + A3 报告 |
|||
- `docs/backend-development-roadmap.md` 同时被两批修改,**必须用 `git add -p` 拆分 hunk** |
|||
2. **不得合并提交**。 |
|||
3. `GlobalExceptionHandler.java` 仅 stat 缓存标记(内容零差异),**不得提交**。 |
|||
4. 既有污染测试缺陷**不属 A2/A3**,若修复应作为独立提交,不得混入。 |
|||
|
|||
**复验完成前,未暂存、未提交、未推送、未进入 A4。** |
|||
@ -0,0 +1,276 @@ |
|||
# R01-A3 第三批:请求身份与 requestId 收束报告 |
|||
|
|||
日期:2026-09-17 |
|||
分支:`refactor/r01-structure-performance` |
|||
HEAD:`52c8026b2e56dae7dbfae6a828a9efe23e180445` |
|||
范围:**仅 8 个 Controller**(AccountDeletionController、CompanyVerificationController、AdminRiskController、AdminReportController、ReportController、BlockController、NotificationController、JobRecommendationController) |
|||
|
|||
--- |
|||
|
|||
## 0. 结论 |
|||
|
|||
> **R01-A3第三批请求身份与requestId收束完成,待独立代码复验。** |
|||
|
|||
8 个 Controller 的身份与 requestId 已统一收束到既有 `CurrentUserAccessor` / `RequestIdAccessor`;非法主体由**逃逸为 HTTP 500** 改为**统一 401**;角色、归属与 403/404 契约未发生任何漂移。 |
|||
|
|||
--- |
|||
|
|||
## 1. 开始前检查与一处预期偏差 |
|||
|
|||
| 检查项 | 结果 | |
|||
|---|---| |
|||
| 分支 | `refactor/r01-structure-performance` ✔ | |
|||
| HEAD | `52c8026b2e56dae7dbfae6a828a9efe23e180445` ✔ | |
|||
| 与上游同步 | `0 / 0` ✔ | |
|||
| 暂存区 | 空 ✔ | |
|||
| `git diff --check` | 无空白错误 ✔ | |
|||
| 8 个目标文件在开始前 | **均未改动**(无 A3 以外的真实改动)✔ | |
|||
| `GlobalExceptionHandler.java` | 工作区/索引/HEAD blob 均为 `1fdc341e…`,**内容零差异**(stat 缓存标记),未处理、未暂存 ✔ | |
|||
|
|||
**预期偏差(如实记录)**:任务书要求「HEAD 必须包含 A2 提交」。**实际 HEAD 为 `52c8026`(R01-D4 提交),A2 尚未提交** —— 上一轮 A2 独立复验明确「未暂存、未提交、未推送」,其改动仍在工作区。因此本批是在 A2 未提交的工作区之上继续实施的。A2 与 A3 同属请求上下文收束、互不冲突;两者将一并进入后续暂存检查点。 |
|||
|
|||
--- |
|||
|
|||
## 2. 迁移前契约表 |
|||
|
|||
### 2.1 私有解析方法(迁移前 → 迁移后) |
|||
|
|||
| Controller | 迁移前私有方法 | 迁移后 | |
|||
|---|---|---| |
|||
| AccountDeletionController | `resolveUserId(HttpServletRequest)` + `getRequestId(HttpServletRequest)` | `currentUserAccessor.requireUserId()` ×3;`requestIdAccessor.get(request)` ×3 | |
|||
| CompanyVerificationController | `resolveUserId` + `getRequestId` | ×6;×6 | |
|||
| AdminRiskController | `resolveUserId` + `requestId` + `riskAuditContext` 内直读 header | `requireUserId()` ×1(在 `requireAdmin` 内);`requestIdAccessor.get(request)` ×8(含 `riskAuditContext`) | |
|||
| AdminReportController | `resolveUserId` + `requestId` | ×1;×3 | |
|||
| ReportController | `resolveUserId` + `requestId` | ×3;×3 | |
|||
| BlockController | `resolveUserId` + `requestId` | ×3;×3 | |
|||
| NotificationController | `resolveUserId` + `getRequestId` | ×4;×4 | |
|||
| JobRecommendationController | `userId()`(**无参紧凑式**)+ `requestId(HttpServletRequest)` | ×7;×8 | |
|||
|
|||
**共删除私有解析方法 16 个**(8 个 Controller × 2:7× `resolveUserId` + 3× `getRequestId` + 4× `requestId` + 1× `userId()` + 1× `requestId(r)`),并移除随之不再使用的 `Authentication` / `SecurityContextHolder` import(8 个文件均移除)。每个 Controller 新增: |
|||
|
|||
```java |
|||
/** R01-A3:身份只来自安全上下文,不接受请求参数传入的 userId。 */ |
|||
private final com.mydreamjob.security.CurrentUserAccessor currentUserAccessor; |
|||
/** R01-A3:requestId 只来自过滤器规范化后的唯一产出点。 */ |
|||
private final com.mydreamjob.common.logging.RequestIdAccessor requestIdAccessor; |
|||
``` |
|||
|
|||
### 2.2 每个端点的错误码契约(迁移前后一致) |
|||
|
|||
| Controller | 代表端点 | 401 | 403 | 404 | 业务码 | |
|||
|---|---|---|---|---|---| |
|||
| AccountDeletionController | `GET /profile/me/deletion` | 未登录 | 9002 保留(未使用) | — | 409:9301/9305/9306 | |
|||
| CompanyVerificationController | `GET /companies/me/verification` | 未登录 | `assertAdminRole` → 403 | 8501 | — | |
|||
| AdminRiskController | `GET /admin/scoring/risk/overview` | 未登录 | `requireAdmin` → 403 | 4001 | 9001 非法过滤 | |
|||
| AdminReportController | `GET /admin/reports` | 未登录 | `requireAdmin` → 403 | 4001 | — | |
|||
| ReportController | `GET /reports/me` | 未登录 | 举报自身 9608 | 4001 | — | |
|||
| BlockController | `GET /blocks/me` | 未登录 | — | 4001 目标不存在 | 400/9608 屏蔽自己 | |
|||
| NotificationController | `GET /notifications` | 未登录 | — | — | — | |
|||
| JobRecommendationController | `GET /profile/me/job-recommendations` | 未登录 | Service 层角色校验 | — | — | |
|||
|
|||
**角色检查位置未移动**:`CompanyVerificationController` 的 `verificationService.assertAdminRole(userId)`×4、`AdminRiskController`/`AdminReportController`/`ReportController` 内的 `userRepository.findById → getRoles → FORBIDDEN` 判断、`JobRecommendationController` 的 Service 层角色校验,全部**原样保留**。 |
|||
|
|||
**失败请求前的数据库写入**:8 个 Controller 的写操作仅出现在**身份解析之后**;身份失败在任何 Service 调用之前抛出,不存在"先写后拒"。 |
|||
|
|||
--- |
|||
|
|||
## 3. 修复前失败测试(TDD 证据) |
|||
|
|||
新增 `R01A3RequestContextMigrationIntegrationTest`(`mydreamjob-server/src/test/java/com/mydreamjob/common/r01a3/`),使用真实 `SecurityFilterChain` + `MockMvc`。 |
|||
|
|||
**修复前实测(仅加入测试、未改生产代码):`Tests run: 8, Failures: 2`** |
|||
|
|||
| 失败测试 | 修复前实际 | |
|||
|---|---| |
|||
| `illegalPrincipals_areAlways401` | `path=/profile/me/deletion principal='anonymousUser'` → **`expected: 401 but was: 500`** | |
|||
| `errorBody_isSafeAndDoesNotEchoPrincipal` | 同一路径 **`expected: 401 but was: 500`** | |
|||
|
|||
根因:`Long.valueOf("anonymousUser")` 抛 `NumberFormatException` 逃逸为 HTTP 500。 |
|||
|
|||
**修复后:`Tests run: 8, Failures: 0`** |
|||
|
|||
--- |
|||
|
|||
## 4. 逐文件静态门禁 |
|||
|
|||
任务书指定模式在 8 个文件中的命中: |
|||
|
|||
| Controller | 命中 | |
|||
|---|---| |
|||
| AccountDeletionController | **0** | |
|||
| CompanyVerificationController | **0** | |
|||
| AdminRiskController | **0** | |
|||
| AdminReportController | **0** | |
|||
| ReportController | **0** | |
|||
| BlockController | **0** | |
|||
| NotificationController | **0** | |
|||
| JobRecommendationController | **0** | |
|||
|
|||
模式:`SecurityContextHolder|Authentication|anonymousUser|Long\.valueOf|Long\.parseLong|getRequestId|resolveRequestId|X-Request-Id|new CurrentUserAccessor|new RequestIdAccessor` |
|||
|
|||
**全部为 0,无任何需要解释的业务参数命中。身份/requestId 私有解析残留为 0。** |
|||
|
|||
--- |
|||
|
|||
## 5. 全仓剩余数量(独立复验后的修正统计) |
|||
|
|||
> **修正说明**:本节初稿使用的 requestId 模式(`getRequestId|resolveRequestId|request.getHeader("X-Request-Id")`)为**宽口径**,会额外匹配 `AuditContext#getRequestId` 等**非请求入口**的调用,因此得出的「36 个文件」**不是真实剩余数**。以下为 R01-A3 独立复验用更完整模式重新核对后的权威统计。 |
|||
|
|||
**身份解析**(模式:`SecurityContextHolder\.getContext\(\)\.getAuthentication|Long\.(valueOf|parseLong)\([^)]*getName\(\)`) |
|||
|
|||
| 分类 | 数量 | |
|||
|---|---| |
|||
| 全仓命中文件 | **20** | |
|||
| —— 合理基础设施 | **3**(`security/CurrentUserAccessor`、`auth/JwtAuthenticationFilter`、`deletion/DeletionGuardInterceptor`) | |
|||
| —— **尚未迁移的业务 Controller** | **17** | |
|||
| —— 误报 | 0 | |
|||
|
|||
**requestId 解析**(模式:`request\.getHeader\("X-Request-Id"\)|String\s+(getRequestId|resolveRequestId|requestId)\s*\(`) |
|||
|
|||
| 分类 | 数量 | |
|||
|---|---| |
|||
| 全仓命中文件 | **25** | |
|||
| —— 合理基础设施 | **4**(`audit/AuditContext`、`common/exception/GlobalExceptionHandler`、`common/logging/RequestLoggingFilter`、`deletion/DeletionGuardInterceptor`) | |
|||
| —— **尚未迁移的业务 Controller** | **21** | |
|||
| —— Service 或非请求入口 | **0** | |
|||
| —— 误报 | **0** | |
|||
|
|||
**A2 + A3 合计已迁移 16 个业务 Controller**,本批(A3)未覆盖的剩余业务 Controller 为**身份解析 17 个、requestId 解析 21 个**,均未修改。 |
|||
|
|||
--- |
|||
|
|||
## 6. 角色与副作用验证(真实 HTTP) |
|||
|
|||
只读实例(`SCORING_REFRESH_ENABLED=false`)PID 22228,验证后已停止。 |
|||
|
|||
| 场景 | 结果 | |
|||
|---|---| |
|||
| STUDENT → ADMIN 风险概览 | **403** ✔ | |
|||
| STUDENT → ADMIN 举报管理 | **403** ✔ | |
|||
| STUDENT → ADMIN 认证审核 | **403** ✔ | |
|||
| COMPANY → ADMIN 风险概览 | **403** ✔ | |
|||
| ADMIN → 风险概览(合法路径) | **200** ✔ | |
|||
| STUDENT → 企业认证查询 | **403** ✔ | |
|||
| STUDENT → 通知列表(合法路径) | **200** ✔ | |
|||
| 无 token → 通知列表 | **401** ✔ | |
|||
|
|||
**副作用**:上述全部请求前后 **22 张表逐字段比较,全部零变化** ✔ —— 覆盖 `account_deletions`、`company_verifications`、`scoring_risk_actions`、`reports`、`block_relations`、`notifications`、推荐反馈等业务表;失败请求**未新增审计、通知或 RabbitMQ 消息**(`messages` 表亦零变化)。 |
|||
|
|||
--- |
|||
|
|||
## 7. requestId 同源证据 |
|||
|
|||
| 输入 | 结果 | |
|||
|---|---| |
|||
| 合法值 `a3-legal-0001` | 响应头 == 响应体,原样保留 ✔ | |
|||
| 空白 `" "` | 重新生成非空值,响应头 == 响应体 ✔ | |
|||
| 超长(80 字符) | 收敛至 ≤32,响应头 == 响应体 ✔ | |
|||
| 非法字符 `<script>x</script>`、`a b` | 生成合法替代值;响应头不含 `<`/`>`/空格;响应体 == 响应头 ✔ | |
|||
| 已存在规范化 request attribute | **attribute 优先于 header** ✔ | |
|||
| CR / LF / CRLF(直接经 Accessor 验证) | 不进入规范化结果 ✔ | |
|||
|
|||
覆盖 8 个端点的全部上述输入组合。 |
|||
|
|||
--- |
|||
|
|||
## 8. 测试数量与 OpenAPI |
|||
|
|||
| 命令 | 结果 | |
|||
|---|---| |
|||
| A3 定向(`R01A3…,CurrentUserAccessorTest,RequestIdAccessorTest,RequestIdFilterTest`) | **38 / 38**,F=0 E=0 **S=0** | |
|||
| 关联回归(`*Deletion*Test,*Verification*Test,*Risk*Test,*Report*Test,*Block*Test,*Notification*Test,*Recommendation*Test,*RequestContext*Test,*RequestId*Test`) | **251 / 251**,F=0 E=0 **S=0**(1 项既有污染详见 §9) | |
|||
| `mvn test` | **2207 / 2207**,Failures=0、Errors=0、**Skipped=0**,BUILD SUCCESS | |
|||
| `mvn clean verify` | **2207 / 2207**,Failures=0、Errors=0、**Skipped=0**,BUILD SUCCESS | |
|||
| `python docs\verify_openapi.py` | **176 paths / 214 operations**,All checks passed | |
|||
|
|||
- 较 A2 轮次(2199)新增 **8** 个测试,全部为本批新增的 `R01A3RequestContextMigrationIntegrationTest` |
|||
- **`docs/openapi-rc.json` 零修改** ✔ |
|||
- **未新增公网接口、迁移、表、索引或 ErrorCode** ✔ |
|||
|
|||
--- |
|||
|
|||
## 9. 一处既有测试污染(非本批引入,已用基线 worktree 证伪) |
|||
|
|||
关联回归首次运行出现 1 个 Error: |
|||
|
|||
``` |
|||
AccountDeletionControllerTest.lazyTransition_firstGet_returnsCompleted_andConsistent:228->createUser:75 |
|||
DataIntegrityViolation: USERS(PHONE) 唯一键冲突 '930000000' |
|||
``` |
|||
|
|||
**归因验证**:用 `git worktree add --detach HEAD`(HEAD 不含本批改动)在**基线代码**上运行**完全相同**的测试批次: |
|||
|
|||
``` |
|||
基线(HEAD):Tests run: 223, Failures: 0, Errors: 1 ← 完全相同的失败 |
|||
``` |
|||
|
|||
**结论**:该失败是**既有的测试间交叉污染**(`AccountDeletionControllerTest` 的 `PHONE` 为 static 计数器、基数 `930000000`,配合共享 H2 库 `integrationdb`,在特定测试子集顺序下与其他类残留数据冲突),**与本批改动无关**。 |
|||
|
|||
- 单独运行该类:**14/14 全绿** ✔ |
|||
- `mvn test` 与 `mvn clean verify` 全量运行:**2207/2207,0 Error** ✔(全量顺序下不触发) |
|||
|
|||
该缺陷未在本批修复(属既有测试脆弱性,超出本批授权范围),建议后续单独处理;本批未修改任何既有测试。 |
|||
|
|||
--- |
|||
|
|||
## 10. 精确修改文件 |
|||
|
|||
**生产代码(8 个,全部在任务书指定清单内)** |
|||
|
|||
``` |
|||
mydreamjob-server/src/main/java/com/mydreamjob/deletion/AccountDeletionController.java |
|||
mydreamjob-server/src/main/java/com/mydreamjob/verification/CompanyVerificationController.java |
|||
mydreamjob-server/src/main/java/com/mydreamjob/risk/AdminRiskController.java |
|||
mydreamjob-server/src/main/java/com/mydreamjob/safety/AdminReportController.java |
|||
mydreamjob-server/src/main/java/com/mydreamjob/safety/ReportController.java |
|||
mydreamjob-server/src/main/java/com/mydreamjob/safety/BlockController.java |
|||
mydreamjob-server/src/main/java/com/mydreamjob/notification/NotificationController.java |
|||
mydreamjob-server/src/main/java/com/mydreamjob/recommendation/JobRecommendationController.java |
|||
``` |
|||
|
|||
(`8 files changed, 100 insertions(+), 154 deletions(-)`) |
|||
|
|||
**测试(新增 1 个)** |
|||
|
|||
``` |
|||
mydreamjob-server/src/test/java/com/mydreamjob/common/r01a3/R01A3RequestContextMigrationIntegrationTest.java |
|||
``` |
|||
|
|||
**文档(1 个)** |
|||
|
|||
``` |
|||
docs/backend-development-roadmap.md # 仅更新顶部 R01 状态表,未重写历史章节 |
|||
``` |
|||
|
|||
**未修改**:Service、DTO、`ErrorCode`、Flyway、`SecurityConfig`、Filter 顺序、`UserRoleGuard`、既有测试、"匿名 401 无 requestId"观察项。 |
|||
|
|||
--- |
|||
|
|||
## 11. Git 状态 |
|||
|
|||
``` |
|||
分支:refactor/r01-structure-performance |
|||
HEAD:52c8026b2e56dae7dbfae6a828a9efe23e180445(未变) |
|||
暂存区:空 |
|||
git add -A / commit / push:均未执行 |
|||
reset / restore / checkout / clean:均未执行 |
|||
git worktree:临时基线 worktree 已用 --force 移除并 prune(无残留) |
|||
历史未跟踪文件(247 个,含 docs/acceptance、backend-infra 等):完整保留 |
|||
Docker:未操作 |
|||
``` |
|||
|
|||
验收临时用户(8025/8026)已按精确主键清理,`users` 残留 0;`audit_logs` 4 行 append-only 保留并登记。 |
|||
|
|||
--- |
|||
|
|||
## 12. 明确声明 |
|||
|
|||
- **未暂存、未提交、未推送。** |
|||
- 未修改角色检查位置、资源归属检查、Service、DTO、ErrorCode、Flyway、SecurityConfig、Filter 顺序、既有测试。 |
|||
- 未进入 `UserRoleGuard`;未处理"匿名 401 无 requestId"观察项。 |
|||
- 未使用 `@Autowired(required=false)`、`@Nullable`、`ObjectProvider` 或 `new Accessor()` 回退;测试切片使用真实 `SecurityFilterChain` 与真实 Accessor Bean。 |
|||
- 全仓剩余身份/requestId 解析数量已如实报告(含 7 而非 8 的差异说明),未声称全仓完成。 |
|||
|
|||
--- |
|||
|
|||
> **R01-A3第三批请求身份与requestId收束完成,待独立代码复验。** |
|||
@ -0,0 +1,72 @@ |
|||
# R01-A4 第四批请求上下文收束独立复验 |
|||
|
|||
复验日期:2026-09-17 |
|||
|
|||
分支:`refactor/r01-structure-performance` |
|||
|
|||
基线 HEAD:`35baa8416d31e71dd2c2556877f9d2e1c93b14a1` |
|||
|
|||
## 结论 |
|||
|
|||
**R01-A4 独立代码复验通过,可以进入精确暂存、本地提交与推送检查点。** |
|||
|
|||
FAIL=0,SKIP=0。复验未修改生产代码,未暂存、未提交、未推送。 |
|||
|
|||
## 独立审查 |
|||
|
|||
逐文件审查以下 8 个 Controller: |
|||
|
|||
- `CandidateFeedbackController` |
|||
- `ApplicationMatchController` |
|||
- `MatchConversationController` |
|||
- `MatchRuleController` |
|||
- `TalentDiscoveryController` |
|||
- `CareerProgressController` |
|||
- `MatchController` |
|||
- `TalentInvitationController` |
|||
|
|||
结论: |
|||
|
|||
1. 身份统一由构造器注入的 `CurrentUserAccessor.requireUserId()` 获取;requestId 统一由构造器注入的 `RequestIdAccessor.get(request)` 获取。 |
|||
2. 8 个文件对 `SecurityContextHolder`、`Authentication`、`anonymousUser`、身份 `Long.valueOf/parseLong`、私有 requestId 方法、可选注入和手工创建 Accessor 的门禁扫描均为 0。 |
|||
3. 角色、资源归属、Service 入参与响应结构未被改写;V1-V45、ErrorCode、OpenAPI 文档均无差异。 |
|||
|
|||
## 独立测试 |
|||
|
|||
新增两个独立测试类,仅位于测试目录: |
|||
|
|||
- `A4IndependentVerificationTest`:3 个测试,覆盖 8 个真实 MVC 映射的非法主体、匿名主体及 requestId 归一化同源。 |
|||
- `A4AccessorWiringIndependentTest`:2 个测试,通过 mock Accessor 的异常和值传播,逐一验证 8 个 Controller 的真实接线和 Service 参数。 |
|||
|
|||
发现实施方 `A4AccessorWiringProbeTest` 的 GET 路径列表只有 7 个 Controller,遗漏了唯一使用 POST 的 `MatchConversationController`。独立测试已使用真实方法 `POST /applications/{id}/conversation` 补齐;该路径通过,没有发现生产缺陷。 |
|||
|
|||
定向命令结果:48/48,Failures=0,Errors=0,Skipped=0。 |
|||
|
|||
关联回归结果:163/163,Failures=0,Errors=0,Skipped=0。 |
|||
|
|||
## 全量验证 |
|||
|
|||
- `mvn clean verify`:BUILD SUCCESS,2243/2243,Failures=0,Errors=0,Skipped=0。 |
|||
- `python docs/verify_openapi.py`:All checks passed,176 paths / 214 operations。 |
|||
- `git diff --check`:无空白错误,仅既有 LF/CRLF 提示。 |
|||
- 暂存区:空。 |
|||
- 迁移差异:空。 |
|||
- `docs/openapi-rc.json` 差异:空。 |
|||
|
|||
全量数由实施前的 2238 增至 2243,增量恰好是本轮新增的 5 个独立测试。 |
|||
|
|||
## 全仓剩余统计纠正 |
|||
|
|||
按完整模式逐文件分类: |
|||
|
|||
- 身份解析:12 个文件,其中基础设施 3、业务 Controller 9;与实施报告一致。 |
|||
- requestId 入口:19 个文件,其中基础设施/非业务入口 6、业务 Controller 13。 |
|||
|
|||
实施报告中的“完整模式 18 个文件”少计 1 个基础设施/非业务入口;业务 Controller 剩余数 13 正确。该统计偏差不影响 A4 的 8 个目标文件全部完成迁移。 |
|||
|
|||
## Git 与边界 |
|||
|
|||
- HEAD 未改变;未暂存、未提交、未推送。 |
|||
- 本次独立复验只新增上述 2 个测试文件和本报告,未修改生产代码。 |
|||
- `GlobalExceptionHandler.java` 仍是 Windows stat 缓存造成的工作区标记,内容 blob 与 HEAD 相同,本轮未触碰。 |
|||
- 历史未跟踪文件全部保留,未删除、移动或清理。 |
|||
@ -0,0 +1,278 @@ |
|||
# R01-A4 第四批:请求身份与 requestId 收束报告 |
|||
|
|||
日期:2026-09-17 |
|||
分支:`refactor/r01-structure-performance` |
|||
HEAD:`35baa8416d31e71dd2c2556877f9d2e1c93b14a1` |
|||
范围:**仅 8 个 Controller**(CandidateFeedbackController、ApplicationMatchController、MatchConversationController、MatchRuleController、TalentDiscoveryController、CareerProgressController、MatchController、TalentInvitationController) |
|||
|
|||
--- |
|||
|
|||
## 0. 结论 |
|||
|
|||
> **R01-A4第四批请求身份与requestId收束完成,待独立代码复验。** |
|||
|
|||
8 个 Controller 的身份与 requestId 已统一收束到既有 `CurrentUserAccessor` / `RequestIdAccessor`;非法主体由**逃逸为 HTTP 500** 改为**统一 401**;角色、归属与 403/404 契约未发生漂移。 |
|||
|
|||
--- |
|||
|
|||
## 1. 基线 |
|||
|
|||
| 检查项 | 结果 | |
|||
|---|---| |
|||
| 分支 | `refactor/r01-structure-performance` ✔ | |
|||
| HEAD | `35baa8416d31e71dd2c2556877f9d2e1c93b14a1` ✔ | |
|||
| 与上游同步 | `0 / 0` ✔ | |
|||
| 暂存区 | 空 ✔ | |
|||
| `git diff --check` | 无空白错误 ✔ | |
|||
| 8 个目标文件开始前状态 | **均未改动**(无本批以外修改)✔ | |
|||
| `GlobalExceptionHandler.java` | 仍为 stat 缓存标记(内容零差异),未处理、未暂存 ✔ | |
|||
| 未跟踪文件 | 247 个,全部保护 ✔ | |
|||
|
|||
--- |
|||
|
|||
## 2. 迁移前契约表 |
|||
|
|||
| Controller | 真实 Mapping(代表端点) | 允许角色 / 归属 | 私有解析方法 | 写操作涉及表 | |
|||
|---|---|---|---|---| |
|||
| CandidateFeedbackController | `POST/GET/PUT/DELETE /applications/{applicationId}/feedback` | 企业(`assertCompanyOwner`)+ 学生可读 | `resolveUserId` + `getRequestId` | `candidate_feedback` | |
|||
| ApplicationMatchController | `PUT /applications/{id}/candidate-interest-score`、`PUT .../hr-suitability-score`、`GET /applications/{id}/match` | 学生 / 企业(Service 层) | 同上 | `application_match_records`、`application_match_events` | |
|||
| MatchConversationController | `POST /applications/{id}/conversation` | 匹配双方 | 同上 | `conversations`、`conversation_members` | |
|||
| MatchRuleController | `GET/PUT /companies/me/match-rule`、`GET/PUT /profile/me/match-rule` | 企业 / 学生各自规则 | 同上 | `company_match_rules`、`student_match_rules` | |
|||
| TalentDiscoveryController | `GET/PUT /profile/me/talent-discovery`、`GET /companies/me/jobs/{jobId}/talent-recommendations` | 学生(偏好)/ COMPANY(推荐) | `userId()` + `requestId()`(**紧凑式**) | `talent_discovery_preferences` | |
|||
| CareerProgressController | `GET /profile/me/career-progress` | STUDENT | `resolveUserId` + `getRequestId` | 只读 | |
|||
| MatchController | `GET /jobs/{jobId}/match` | STUDENT | 同上 | 只读 | |
|||
| TalentInvitationController | `POST /companies/me/jobs/{jobId}/talent-invitations`、`GET /companies/me/talent-invitations`、`GET /profile/me/talent-invitations`、`POST /profile/me/talent-invitations/{id}/decision` 等 6 个 | 企业创建 / 学生决定 / 双方读取 | `userId()` + `requestId()`(**紧凑式**) | `talent_invitations` | |
|||
|
|||
**契约要点**:8 个 Controller 的角色与资源归属判断**全部位于 Service 层**(Controller 仅解析身份后委派),因此本批迁移不涉及任何角色判断的移动。 |
|||
|
|||
**迁移前状态码**:非法/匿名主体因 `Long.valueOf(auth.getName())` 抛 `NumberFormatException` → **HTTP 500**(见 §3 实测)。 |
|||
|
|||
--- |
|||
|
|||
## 3. 修复前失败测试(TDD 证据) |
|||
|
|||
新增 `R01A4RequestContextMigrationIntegrationTest`(`mydreamjob-server/src/test/java/com/mydreamjob/common/r01a4/`),真实 `SecurityFilterChain` + `MockMvc`。 |
|||
|
|||
**修复前实测(仅加入测试、未改生产代码):`Tests run: 9, Failures: 3`** |
|||
|
|||
| 失败测试 | 修复前实际 | |
|||
|---|---| |
|||
| `illegalPrincipals_areAlways401` | `path=/applications/1/feedback principal='anonymousUser'` → **`expected: 401 but was: 500`** | |
|||
| `conversationEndpoint_illegalPrincipals_are401` | `principal='anonymousUser'` → **`expected: 401 but was: 500`** | |
|||
| `errorBody_isSafe` | 同路径 → **500** | |
|||
|
|||
**修复后:`Tests run: 9, Failures: 0`** |
|||
|
|||
--- |
|||
|
|||
## 4. 生产修改 |
|||
|
|||
8 个 Controller 统一构造器注入(均为 `private final`): |
|||
|
|||
```java |
|||
/** R01-A4:身份只来自安全上下文,不接受请求参数传入的 userId。 */ |
|||
private final com.mydreamjob.security.CurrentUserAccessor currentUserAccessor; |
|||
/** R01-A4:requestId 只来自过滤器规范化后的唯一产出点。 */ |
|||
private final com.mydreamjob.common.logging.RequestIdAccessor requestIdAccessor; |
|||
``` |
|||
|
|||
| Controller | `requireUserId()` | `requestIdAccessor.get()` | |
|||
|---|---|---| |
|||
| CandidateFeedbackController | 4 | 4 | |
|||
| ApplicationMatchController | 3 | 3 | |
|||
| MatchConversationController | 1 | 1 | |
|||
| MatchRuleController | 4 | 4 | |
|||
| TalentDiscoveryController | 3 | 3 | |
|||
| CareerProgressController | 1 | 1 | |
|||
| MatchController | 1 | 1 | |
|||
| TalentInvitationController | 6 | 6 | |
|||
|
|||
**共删除私有解析方法 16 个**(8 Controller × 2:6× `resolveUserId` + 6× `getRequestId` + 2× `userId()` + 2× `requestId()`),并移除随之不再使用的 `Authentication` / `SecurityContextHolder` import。 |
|||
|
|||
**未做**:未使用可选注入 / `@Nullable` / `ObjectProvider` / `new Accessor()`;未新增 userId 请求参数;未移动或删除角色检查与资源归属检查;未修改 Service 调用顺序、错误码或接口结构;未顺手重构紧凑代码。 |
|||
|
|||
--- |
|||
|
|||
## 5. 静态门禁 |
|||
|
|||
任务书指定模式在 8 个文件中的命中: |
|||
|
|||
| Controller | 命中 | |
|||
|---|---| |
|||
| CandidateFeedbackController | **0** | |
|||
| ApplicationMatchController | **0** | |
|||
| MatchConversationController | **0** | |
|||
| MatchRuleController | **0** | |
|||
| TalentDiscoveryController | **0** | |
|||
| CareerProgressController | **0** | |
|||
| MatchController | **0** | |
|||
| TalentInvitationController | **0** | |
|||
|
|||
**全部为 0,无业务参数解析误报。身份/requestId 私有解析残留为 0。** |
|||
|
|||
### 全仓剩余统计 |
|||
|
|||
**身份解析**(`SecurityContextHolder.getContext().getAuthentication` \| `Long.(valueOf|parseLong)([^)]*getName()`) |
|||
|
|||
| | A3 后 | A4 后 | 预期 | |
|||
|---|---|---|---| |
|||
| 全仓文件 | 20 | **12** | 12 ✔ | |
|||
| —— 基础设施 | 3 | **3**(`security/CurrentUserAccessor`、`auth/JwtAuthenticationFilter`、`deletion/DeletionGuardInterceptor`) | — | |
|||
| —— **业务 Controller** | 17 | **9** | 9 ✔ | |
|||
| —— 误报 | 0 | **0** | — | |
|||
|
|||
**requestId 解析**(`request.getHeader("X-Request-Id")` \| `request.getHeader(RequestIdFilter.REQUEST_ID_HEADER)` \| `String (getRequestId|resolveRequestId|requestId)(`) |
|||
|
|||
| | A3 后 | A4 后 | 预期 | |
|||
|---|---|---|---| |
|||
| 全仓文件(**本轮完整模式**) | 25 | **18** | 17 | |
|||
| 全仓文件(**A3 口径**,不含 `REQUEST_ID_HEADER` 项) | 25 | **17** | 17 ✔ | |
|||
| —— 基础设施(完整模式) | 4 | **5** | — | |
|||
| —— **业务 Controller** | 21 | **13** | 13 ✔ | |
|||
| —— Service / 非请求入口 | 0 | **0** | — | |
|||
| —— 误报 | 0 | **0** | — | |
|||
|
|||
**数量差异的逐文件解释(不修改代码迁就数字)**:本轮模式比 A3 口径多含 `request.getHeader(RequestIdFilter.REQUEST_ID_HEADER)` 一项,该模式额外命中的**唯一文件**是 `common/logging/RequestIdAccessor.java`(第 35 行的兜底读取 `request.getHeader(RequestIdFilter.REQUEST_ID_HEADER)`)—— 这是 **Accessor 自身的合法实现**,属基础设施,非业务入口。因此:完整模式 18 = 基础设施 5 + 业务 13;A3 口径 17 = 基础设施 4 + 业务 13。**业务 Controller 数量与预期完全一致(13)**。 |
|||
|
|||
--- |
|||
|
|||
## 6. 角色与副作用验证 |
|||
|
|||
### 6.1 既有测试回归(角色/归属语义证据) |
|||
|
|||
| 命令 | 结果 | |
|||
|---|---| |
|||
| A4 关联回归(`*CandidateFeedback*Test,*ApplicationMatch*Test,*MatchConversation*Test,*MatchRule*Test,*TalentDiscovery*Test,*CareerProgress*Test,*MatchController*Test,*TalentInvitation*Test,*RequestContext*Test,*RequestId*Test`) | **163 / 163**,F=0 E=0 S=0,BUILD SUCCESS | |
|||
|
|||
既有测试覆盖:反馈仅企业可写、匹配双方可见性、会话归属、匹配规则角色、人才发现 COMPANY 权限、职业进度 STUDENT 权限、邀约企业创建/学生决定/双方读取、其他企业与其他学生的 403/404。 |
|||
|
|||
### 6.2 真实 HTTP 验证 |
|||
|
|||
只读实例 PID 7648,验证后已停止。 |
|||
|
|||
| 场景 | 结果 | |
|||
|---|---| |
|||
| 无 token / 畸形 Bearer / 纯数字 Bearer × 7 端点 | 全部 **401** ✔ | |
|||
| STUDENT → `GET /companies/me/match-rule` | **403** ✔ | |
|||
| COMPANY → `GET /companies/me/match-rule` | **403**(该账号无企业资料,既有权属语义)✔ | |
|||
| STUDENT → `GET /profile/me/career-progress` | **200** ✔ | |
|||
| COMPANY → `GET /profile/me/career-progress` | **200**(该账号注册时自带 STUDENT 角色,属既有语义)✔ | |
|||
| STUDENT → `GET /profile/me/talent-discovery` | **200** ✔ | |
|||
| ADMIN → `GET /profile/me/talent-discovery` | **403** ✔ | |
|||
| STUDENT → `GET /profile/me/talent-invitations` | **200** ✔ | |
|||
| COMPANY → `GET /companies/me/talent-invitations` | **403**(无企业资料)✔ | |
|||
|
|||
### 6.3 副作用快照 |
|||
|
|||
上述**全部请求前后 22 张表逐行稳定排序的逐字段快照比对 → 全部零变化** ✔ |
|||
|
|||
- 覆盖表含 `candidate_feedback`、`application_match_records`、`application_match_events`、`conversations`、`conversation_members`、`company_match_rules`、`student_match_rules`、`talent_discovery_preferences`、`talent_invitations`、`notifications`、`messages`、`audit_logs` 等 |
|||
- **未使用** COUNT/SUM 代替内容快照;采用显式列 + 稳定 `ORDER BY` 的逐字段比较 |
|||
|
|||
--- |
|||
|
|||
## 7. 独立验证辅助测试(实施证据) |
|||
|
|||
新增 `A4AccessorWiringProbeTest`(4 例),证明接线真实存在: |
|||
|
|||
| 用例 | 断言 | |
|||
|---|---| |
|||
| `accessorThrowsUnauthenticated_endpointsReturn401` | mock Accessor 抛认证异常 → 7 端点全部 **401**(若未接线则不可能) | |
|||
| `serviceReceivesAccessorUserId` | mock 返回 `4242L` → `careerProgressService.getCareerProgress(4242L, …)`、`talentInvitationService.listCompany(4242L, …)` 收到该值 | |
|||
| `requestIdComesFromAccessorBean` | mock 的 requestId 必须出现在响应体 | |
|||
| `noToken_is401` | 无 Token → Security 链 401 | |
|||
|
|||
**删除 Accessor 调用会使本测试失败** ✔(属实施证据,不代替后续独立复验) |
|||
|
|||
--- |
|||
|
|||
## 8. requestId 同源证据 |
|||
|
|||
| 输入 | 结果 | |
|||
|---|---| |
|||
| 合法值 `a4-legal-0001` | 响应头 == 响应体 `requestId`,原样保留 ✔ | |
|||
| 空白 `" "` | 重新生成非空值,头体同源 ✔ | |
|||
| 超长(80 字符) | 收敛至 ≤32,头体同源 ✔ | |
|||
| 非法字符 `<script>x</script>`、`a b` | 生成合法替代值;响应头不含 `<`/`>`/空格 ✔ | |
|||
| request attribute 与 header 不同 | **attribute 优先** ✔ | |
|||
| CR / LF / CRLF(直接经 Accessor) | 不进入规范化结果 ✔ | |
|||
|
|||
覆盖 8 个端点(含 POST 端点)。 |
|||
|
|||
--- |
|||
|
|||
## 9. 测试数量与 OpenAPI |
|||
|
|||
| 命令 | 结果 | |
|||
|---|---| |
|||
| A4 定向(`R01A4…,A4AccessorWiringProbeTest,CurrentUserAccessorTest,RequestIdAccessorTest,RequestIdFilterTest`) | **43 / 43**,F=0 E=0 **S=0** | |
|||
| A4 关联回归 | **163 / 163**,F=0 E=0 **S=0** | |
|||
| `mvn test` | **2238 / 2238**,Failures=0、Errors=0、**Skipped=0** | |
|||
| `mvn clean verify` | **2238 / 2238**,Failures=0、Errors=0、**Skipped=0**,BUILD SUCCESS(≥ 2225 ✔) | |
|||
| `python docs\verify_openapi.py` | **176 paths / 214 operations**,All checks passed | |
|||
|
|||
- 较上轮(2225)新增 **13** 个测试(9 + 4) |
|||
- **`docs/openapi-rc.json` 零修改** ✔ |
|||
- **V1–V45 零修改** ✔ |
|||
- **未新增接口、表、索引、迁移或 ErrorCode** ✔ |
|||
- **未发现新的基线测试污染**(上一轮的账号注销夹具隔离修复已生效)✔ |
|||
|
|||
--- |
|||
|
|||
## 10. 精确修改文件 |
|||
|
|||
**生产代码(8 个,全部在任务书指定清单内)** |
|||
|
|||
``` |
|||
mydreamjob-server/src/main/java/com/mydreamjob/feedback/CandidateFeedbackController.java |
|||
mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/ApplicationMatchController.java |
|||
mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/MatchConversationController.java |
|||
mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/MatchRuleController.java |
|||
mydreamjob-server/src/main/java/com/mydreamjob/talentdiscovery/TalentDiscoveryController.java |
|||
mydreamjob-server/src/main/java/com/mydreamjob/careerprogress/CareerProgressController.java |
|||
mydreamjob-server/src/main/java/com/mydreamjob/match/MatchController.java |
|||
mydreamjob-server/src/main/java/com/mydreamjob/talentinvitation/TalentInvitationController.java |
|||
``` |
|||
|
|||
**测试(新增 2 个)** |
|||
|
|||
``` |
|||
mydreamjob-server/src/test/java/com/mydreamjob/common/r01a4/R01A4RequestContextMigrationIntegrationTest.java |
|||
mydreamjob-server/src/test/java/com/mydreamjob/common/r01a4/A4AccessorWiringProbeTest.java |
|||
``` |
|||
|
|||
**文档(1 个)** |
|||
|
|||
``` |
|||
docs/backend-development-roadmap.md # 仅更新顶部 R01 状态行,历史章节未动 |
|||
``` |
|||
|
|||
**未修改**:Service、DTO、`ErrorCode`、Flyway、`SecurityConfig`、Filter 顺序、OpenAPI、既有测试、`UserRoleGuard`、"匿名 401 缺 requestId"观察项。 |
|||
|
|||
--- |
|||
|
|||
## 11. Git 状态 |
|||
|
|||
``` |
|||
分支:refactor/r01-structure-performance |
|||
HEAD:35baa8416d31e71dd2c2556877f9d2e1c93b14a1(未变) |
|||
暂存区:空 |
|||
git add -A / commit / push:均未执行 |
|||
Docker:未操作 |
|||
历史未跟踪文件:247 个完整保留 |
|||
``` |
|||
|
|||
验收临时用户(a4vstu / a4vcom)已按精确主键清理,`users` 残留 0;`audit_logs` 4 行 append-only 保留并登记。 |
|||
|
|||
--- |
|||
|
|||
## 12. 明确声明 |
|||
|
|||
- **未暂存、未提交、未推送。** |
|||
- 未进入 `UserRoleGuard`;未处理"匿名 401 缺 requestId"观察项。 |
|||
- 未使用 `@Autowired(required=false)`、`@Nullable`、`ObjectProvider` 或 `new Accessor()` 回退;测试切片使用真实 `SecurityFilterChain` 与真实 Accessor Bean。 |
|||
- 全仓剩余身份/requestId 数量已**逐文件分类解释**,未修改代码迁就数字。 |
|||
|
|||
--- |
|||
|
|||
> **R01-A4第四批请求身份与requestId收束完成,待独立代码复验。** |
|||
@ -0,0 +1,279 @@ |
|||
# R01-A5 独立代码复验报告(第五批:8 个 Controller 请求身份与 requestId 收束) |
|||
|
|||
日期:2026-09-17 |
|||
分支:`refactor/r01-structure-performance` |
|||
HEAD:`0226977063243fce101e3f6c8dcf66492d306ef7`(A4 后最新提交,复验全程未变) |
|||
复验对象:`docs/r01-a5-request-context-migration-report.md` |
|||
性质:**仅复验** —— 未修改生产代码、未修改既有测试断言、未暂存、未提交、未推送 |
|||
|
|||
--- |
|||
|
|||
## 0. 结论 |
|||
|
|||
> **R01-A5独立代码复验通过,可以进入精确暂存、本地提交与推送检查点。** |
|||
|
|||
**FAIL = 0;SKIP = 0。未发现生产缺陷。** |
|||
|
|||
--- |
|||
|
|||
## 1. 开始前状态 |
|||
|
|||
| 检查项 | 结果 | |
|||
|---|---| |
|||
| 分支 | `refactor/r01-structure-performance` ✔ | |
|||
| HEAD | `0226977063243fce101e3f6c8dcf66492d306ef7`(A4 后最新)✔ | |
|||
| 与上游同步 | `0 / 0` ✔ | |
|||
| 暂存区 | 空 ✔ | |
|||
| `git diff --check` | 无空白错误 ✔ | |
|||
| 工作区改动 | **仅 8 个 A5 Controller + 路线图**,无 A5 之外的生产代码改动 ✔ | |
|||
| 历史未跟踪文件 | 全部保留 ✔ | |
|||
| reset/restore/checkout/clean、`git add -A`、Docker | 均未执行 ✔ | |
|||
|
|||
### `GlobalExceptionHandler.java` 三方 blob 比对 |
|||
|
|||
| 来源 | blob | |
|||
|---|---| |
|||
| worktree(`git hash-object`) | `1fdc341ebe3653393b3ec04f0c2d0bb9dfbb6d21` | |
|||
| index(`git ls-files -s`) | `1fdc341ebe3653393b3ec04f0c2d0bb9dfbb6d21` | |
|||
| HEAD(`git rev-parse HEAD:<path>`) | `1fdc341ebe3653393b3ec04f0c2d0bb9dfbb6d21` | |
|||
|
|||
**三者完全一致 → 纯 Windows stat 缓存现象,未触碰该文件** ✔ |
|||
|
|||
--- |
|||
|
|||
## 2. 8 个 Controller 静态审查 |
|||
|
|||
| Controller | 代表端点 | Accessor 调用(auth / reqId) | |
|||
|---|---|---| |
|||
| CandidateScoringGovernanceController | `GET /profile/me/candidate-scoring/appeals` | 9 / 9 | |
|||
| InterviewScoreController | `GET /interviews/{id}/score` | 4 / 4 | |
|||
| FavoriteController | `GET /jobs/favorites` | 3 / 3 | |
|||
| AdminReviewController | `GET /admin/reviews/pending` | 3 / 3 | |
|||
| AdminReviewReplyController | `GET /admin/review-replies/pending` | 2 / 2 | |
|||
| CompanyReviewManagementController | `GET /companies/me/job-reviews` | 4 / 4 | |
|||
| JobReviewController | `DELETE /jobs/{jobId}/reviews/{reviewId}` | 3 / 7 | |
|||
| StudentReviewWeightController | `GET /profile/me/review-weights` | 3 / 4 | |
|||
|
|||
(`JobReviewController` / `StudentReviewWeightController` 的 reqId 调用数高于 auth 属正常 —— 部分只读端点只需 requestId。) |
|||
|
|||
**改动性质**:`8 files changed, 101 insertions(+), 198 deletions(-)`,删除的 `ErrorCode.UNAUTHORIZED` 共 10 处,**全部来自被移除的私有解析方法**;**角色检查(`assertAdminRole`/`assertCompanyOwner`/`assertStudentRole`)、资源归属、`FORBIDDEN`/`NOT_FOUND` 及 Service 调用顺序未删未增、未移动** ✔ |
|||
|
|||
**`JobReviewController.parseIds` 完整保留** ✔ —— 它解析 `ids` 查询参数(业务参数,非认证主体),并以 `catch (NumberFormatException) → BAD_REQUEST` 处理,属正确的业务参数语义,未被误删或误判。 |
|||
|
|||
--- |
|||
|
|||
## 3. 禁止模式扫描 |
|||
|
|||
任务书指定模式在 8 个文件中的命中:**全部为 0** ✔ |
|||
|
|||
覆盖:`SecurityContextHolder`、`AnonymousAuthenticationToken`、`anonymousUser`、`auth.getName()`、`authentication.getName()`、`Long.(valueOf|parseLong)(...(auth|authentication)`、`private (resolveUserId|getRequestId|requestId)(`、`getHeader(...X-Request-Id|REQUEST_ID_HEADER)`、`@Autowired(required=false)`、`@Nullable`、`ObjectProvider`、`new CurrentUserAccessor`、`new RequestIdAccessor` |
|||
|
|||
--- |
|||
|
|||
## 4. 构造器注入与无回退证明 |
|||
|
|||
8 个 Controller 均声明: |
|||
|
|||
```java |
|||
private final CurrentUserAccessor currentUserAccessor; |
|||
private final RequestIdAccessor requestIdAccessor; |
|||
``` |
|||
|
|||
- 全部为 **`private final` 构造器注入**(Lombok `@RequiredArgsConstructor`)✔ |
|||
- **无** `@Autowired(required = false)` / `@Nullable` / `ObjectProvider` / `new CurrentUserAccessor()` / `new RequestIdAccessor()` 回退 ✔ |
|||
- 身份统一为 `currentUserAccessor.requireUserId()`;requestId 统一为 `requestIdAccessor.get(request)` ✔ |
|||
|
|||
--- |
|||
|
|||
## 5. 独立接线变异测试(Accessor 真实生效的证明) |
|||
|
|||
新增 `A5AccessorWiringIndependentTest`(4 例),**不复用实施者断言**,使用 `@MockBean` 替换 `CurrentUserAccessor`、`RequestIdAccessor` 与 8 个 Service: |
|||
|
|||
| 用例 | 证明 | |
|||
|---|---| |
|||
| `allEightEndpoints_useAccessorUserId` | mock 返回 `4242L` → 逐一验证 **8 个 Service 均收到 `4242L`**(`listMyAppeals` / `getScore` / `listFavorites` / `listPendingReviews` / `listPending` / `listMine` / `deleteReview` / `getMyWeights`),并 `verify(currentUserAccessor, times(8)).requireUserId()` | |
|||
| `illegalIdentity_rejectedBeforeService` | mock 抛 `InsufficientAuthenticationException` → 8 端点全部 401、无 500,且各 Service **零调用** | |
|||
| `responseBodyRequestId_comesFromAccessor` | mock 返回 `a5-independent-request` → 8 个端点响应体 `requestId` 均为该值,`verify(requestIdAccessor, times(8)).get(...)` | |
|||
| `noToken_is401` | 无 Token → Security 链 401 | |
|||
|
|||
**变异有效性**:若删除任一 Controller 中的 `currentUserAccessor.requireUserId()` 或 `requestIdAccessor.get(request)` 调用,对应 `verify` 与断言必然失败 —— 即**删除 Accessor 调用会使本测试失败** ✔ |
|||
|
|||
> 说明:响应头 `X-Request-Id` 由 `RequestIdFilter` 设置,**不来自 Controller**;本测试只断言**响应体**的 `requestId` 来源于 Accessor,未错误声称响应头来源。 |
|||
|
|||
--- |
|||
|
|||
## 6. 八端点非法主体结果(独立身份边界复验) |
|||
|
|||
`A5IndependentVerificationTest` 使用**真实 `CurrentUserAccessor` 与真实 `SecurityFilterChain`**(不 mock Accessor): |
|||
|
|||
| 主体 | 结果 | |
|||
|---|---| |
|||
| `anonymousUser` | **401** ✔ | |
|||
| `not-a-number` | **401** ✔ | |
|||
| `99999999999999999999`(20 位溢出) | **401** ✔ | |
|||
| `0` | **401** ✔ | |
|||
| `-1` | **401** ✔ | |
|||
| `AnonymousAuthenticationToken` / 无 Token | **401** ✔ | |
|||
|
|||
逐端点断言(7 个 GET 端点 + `DELETE /jobs/1/reviews/1`): |
|||
- **HTTP 401,无 500** ✔ |
|||
- **业务 Service 零调用** ✔(`verify(..., never())`) |
|||
- **不回显非法主体** —— 仅解析 JSON 后检查 `code`/`message` 稳定字段,**未对整段 JSON 做数字搜索** ✔ |
|||
- 响应不包含 `Exception` / `java.` / `principal` / `anonymousUser` / `com.mydreamjob` / `NumberFormat` ✔ |
|||
|
|||
**未使用**任何捕获 `NumberFormatException` 的方式"修复"测试。 |
|||
|
|||
--- |
|||
|
|||
## 7. 合法主体传入 Service 的 userId 证据 |
|||
|
|||
§5 的 `verify` 系列即为证据:mock Accessor 返回 `4242L` 时,**8 个 Service 各自收到 `4242L`**(逐一 `eq(4242L)` 断言)✔ |
|||
|
|||
同时 `A5IndependentVerificationTest#legalPrincipal_reachesService` 使用**真实 Accessor**(数字主体 `"7"`)验证 8 端点**不被身份层判为未登录**(非 401)✔ |
|||
|
|||
--- |
|||
|
|||
## 8. requestId Accessor 接线证据 |
|||
|
|||
见 §5 第 3 项:mock `RequestIdAccessor#get` 返回固定值 → 8 个端点(7 个 GET + 1 个 DELETE,均返回 `ApiResponse`)响应体 `requestId` 均为 `a5-independent-request`,并 `verify(requestIdAccessor, times(8)).get(any(HttpServletRequest.class))` ✔ |
|||
|
|||
--- |
|||
|
|||
## 9. 401 / 403 / 404 分层保持 |
|||
|
|||
| 检查项 | 结果 | |
|||
|---|---| |
|||
| 静态审查:角色/归属检查是否被删改或移动 | **否**(`git diff` 确认未删未增)✔ | |
|||
| `FORBIDDEN` / `NOT_FOUND` 错误码 | **未修改** ✔ | |
|||
| `ErrorCode.java` | **零改动** ✔ | |
|||
| 合法主体不被判为未登录 | ✔(§7) | |
|||
|
|||
**401 与 403/404 的分层未被统一**:身份失败为 401,角色/归属失败仍由既有 Service 逻辑产生 403/404,语义未漂移。 |
|||
|
|||
--- |
|||
|
|||
## 10. 失败请求的业务副作用 |
|||
|
|||
新增 `A5SideEffectSnapshotTest`(2 例),使用 H2 **行级快照**(显式列 + 稳定 `ORDER BY`,**逐字段**比较,**非 COUNT/SUM**): |
|||
|
|||
快照覆盖 9 张核心表:`JOB_FAVORITES`、`JOB_REVIEWS`、`COMPANY_REVIEW_REPLIES`、`INTERVIEW_SCORES`、`CANDIDATE_SCORING_APPEALS`、`STUDENT_REVIEW_WEIGHT_PREFERENCES`、`NOTIFICATIONS`、`MESSAGES`、`AUDIT_LOGS`。 |
|||
|
|||
| 用例 | 结果 | |
|||
|---|---| |
|||
| 非法身份请求(`anonymousUser` + `not-a-number` × 7 端点 + DELETE)前后 | **9 张表逐字段零变化** ✔ | |
|||
| 无 Token 请求前后 | **9 张表逐字段零变化** ✔ | |
|||
|
|||
**结论**:非法身份请求不写收藏、评价、回复、面试评分、治理申诉、权重;不发通知;不发 RabbitMQ 消息;不写审计成功记录(`AUDIT_LOGS` 零变化)✔ |
|||
|
|||
> 本轮**未启动 Docker**,以上为 H2 独立复验结论;**不声称真实 MySQL 结论**。 |
|||
|
|||
--- |
|||
|
|||
## 11. 测试数 |
|||
|
|||
| 命令 | 结果 | |
|||
|---|---| |
|||
| `mvn -Dtest="A5AccessorWiringIndependentTest,A5IndependentVerificationTest" test` | **8 / 8**,F=0 E=0 **S=0** | |
|||
| `mvn -Dtest="A5SideEffectSnapshotTest" test` | **2 / 2**,F=0 E=0 **S=0** | |
|||
| A5 定向(`R01A5…,A5*Test,` 8 个 Controller 测试 + 3 个 Accessor/Filter 测试) | **98 / 98**,F=0 E=0 **S=0**,BUILD SUCCESS | |
|||
| `mvn clean verify` | **2255 / 2255**,Failures=0、Errors=0、**Skipped=0**,BUILD SUCCESS | |
|||
|
|||
- 实施基线 2245 → 复验后 **2255**(新增 **10** 个独立测试:4 + 4 + 2),**不低于基线** ✔ |
|||
- 未见新的基线测试污染 ✔ |
|||
|
|||
--- |
|||
|
|||
## 12. OpenAPI |
|||
|
|||
`python docs\verify_openapi.py` → **176 paths / 214 operations**,All checks passed ✔ |
|||
|
|||
**`docs/openapi-rc.json` 零修改** ✔ |
|||
|
|||
--- |
|||
|
|||
## 13. Flyway / ErrorCode / DTO / 接口 |
|||
|
|||
| 项 | 结果 | |
|||
|---|---| |
|||
| V1–V45 迁移 | **零修改**(`git diff --name-only -- db/migration` 为空)✔ | |
|||
| `ErrorCode.java` | **零改动** ✔ | |
|||
| DTO | 零改动 ✔ | |
|||
| 公网接口 / OpenAPI | 零新增零修改 ✔ | |
|||
| 新增表 / 索引 | 无 ✔ | |
|||
|
|||
--- |
|||
|
|||
## 14. 全仓剩余统计(逐文件分类) |
|||
|
|||
**身份解析**(`SecurityContextHolder.getContext().getAuthentication` \| `Long.(valueOf|parseLong)([^)]*getName()`)—— **4 文件** |
|||
|
|||
| 分类 | 文件 | |
|||
|---|---| |
|||
| 基础设施(3) | `security/CurrentUserAccessor`(Accessor 自身)、`auth/JwtAuthenticationFilter`(认证过滤器)、`deletion/DeletionGuardInterceptor`(拦截器) | |
|||
| **业务 Controller(1)** | `scoringmonitoring/FairnessMonitoringController` | |
|||
| 误报 | 0 | |
|||
|
|||
**requestId 解析**(含 `X-Request-Id` 直读、`RequestIdFilter.REQUEST_ID_HEADER`、`String (getRequestId|resolveRequestId|requestId)(`)—— **10 文件** |
|||
|
|||
| 分类 | 文件 | |
|||
|---|---| |
|||
| 基础设施 / 非业务入口(5) | `audit/AuditContext`、`common/exception/GlobalExceptionHandler`、`common/logging/RequestIdAccessor`、`common/logging/RequestLoggingFilter`、`deletion/DeletionGuardInterceptor` | |
|||
| **业务 Controller(5)** | `controller/ApiController`、`controller/AuthController`、`controller/SystemController`、`job/JobController`、`scoringmonitoring/FairnessMonitoringController` | |
|||
| 误报 | 0 | |
|||
|
|||
**与路线图记数的一处差异(如实记录)**:路线图 R01 行记「全仓 requestId 11 文件 = 基础设施/非业务入口 6 + 业务 5」。本次独立复验用任务书指定模式实测为 **10 文件 = 基础设施 5 + 业务 5**。**业务 Controller 数一致(5)**;差异在基础设施计数(5 vs 6),推测为路线图对"基础设施/非业务入口"的分类口径略有不同(如是否计入某调用点)。**不应据此修改代码或路线图**;建议后续批次统一口径。 |
|||
|
|||
--- |
|||
|
|||
## 15. 精确新增文件 |
|||
|
|||
``` |
|||
新增测试(3) |
|||
mydreamjob-server/src/test/java/com/mydreamjob/common/r01a5/A5AccessorWiringIndependentTest.java |
|||
mydreamjob-server/src/test/java/com/mydreamjob/common/r01a5/A5IndependentVerificationTest.java |
|||
mydreamjob-server/src/test/java/com/mydreamjob/common/r01a5/A5SideEffectSnapshotTest.java |
|||
|
|||
复验报告(1) |
|||
docs/r01-a5-independent-verification-20260917.md |
|||
``` |
|||
|
|||
**未修改**:8 个 A5 Controller 生产代码、既有测试断言、`GlobalExceptionHandler`、`SecurityConfig`、Filter、Accessor、`ErrorCode`、DTO、OpenAPI、Flyway。 |
|||
|
|||
--- |
|||
|
|||
## 16. Git 状态 |
|||
|
|||
``` |
|||
分支:refactor/r01-structure-performance |
|||
HEAD:0226977063243fce101e3f6c8dcf66492d306ef7(未变) |
|||
暂存区:空 |
|||
git add -A / commit / push:均未执行 |
|||
reset / restore / checkout / clean:均未执行 |
|||
Docker:未操作 |
|||
工作区:8 个 A5 Controller + 路线图(实施者改动,仍未暂存)+ 本报告与 3 个独立测试(未跟踪) |
|||
历史未跟踪文件:全部保留 |
|||
``` |
|||
|
|||
--- |
|||
|
|||
## 17. 是否发现生产缺陷 |
|||
|
|||
**未发现生产缺陷。** 8 个 Controller 的迁移完整、正确: |
|||
- 禁止模式 0 命中;构造器注入无回退;`parseIds` 业务解析正确保留 |
|||
- 角色/归属/403/404 语义未漂移 |
|||
- 独立接线变异测试证明 Accessor 真实生效(删除调用即失败) |
|||
- 独立身份边界测试证明非法主体在业务调用前被拒(401、无 500、零 Service 调用)且不回显主体 |
|||
- 失败请求 9 张核心表逐字段零变化 |
|||
|
|||
--- |
|||
|
|||
## 18. 明确声明 |
|||
|
|||
- **未暂存、未提交、未推送。** |
|||
- 未修改 8 个 Controller 或任何生产代码;未修改、删除或放宽既有测试断言;未修改 `GlobalExceptionHandler`、`SecurityConfig`、Filter、Accessor、`ErrorCode`、DTO、OpenAPI、Flyway。 |
|||
- 未进入 R01-A6;未删除历史未跟踪文件。 |
|||
- 本轮未启动 Docker,**未伪造真实 MySQL 结论**。 |
|||
|
|||
--- |
|||
|
|||
> **R01-A5独立代码复验通过,可以进入精确暂存、本地提交与推送检查点。** |
|||
@ -0,0 +1,79 @@ |
|||
# R01-A5 第五批请求上下文收束实施报告 |
|||
|
|||
实施日期:2026-09-17 |
|||
|
|||
分支:`refactor/r01-structure-performance` |
|||
|
|||
基线 HEAD:`0226977063243fce101e3f6c8dcf66492d306ef7` |
|||
|
|||
## 结论 |
|||
|
|||
R01-A5 第五批请求身份与 requestId 收束已完成自动化验证,待独立代码复验。 |
|||
|
|||
## 修改范围 |
|||
|
|||
本批仅修改以下 8 个 Controller: |
|||
|
|||
1. `CandidateScoringGovernanceController` |
|||
2. `InterviewScoreController` |
|||
3. `FavoriteController` |
|||
4. `AdminReviewController` |
|||
5. `AdminReviewReplyController` |
|||
6. `CompanyReviewManagementController` |
|||
7. `JobReviewController` |
|||
8. `StudentReviewWeightController` |
|||
|
|||
每个 Controller 均执行相同的受控迁移: |
|||
|
|||
- 构造器注入既有 `CurrentUserAccessor` 与 `RequestIdAccessor`; |
|||
- 身份统一调用 `currentUserAccessor.requireUserId()`; |
|||
- requestId 统一调用 `requestIdAccessor.get(request)`; |
|||
- 删除本地身份解析方法和 requestId 读取方法; |
|||
- 保留原有角色、资源归属、Service 参数、DTO 和响应结构。 |
|||
|
|||
`JobReviewController.parseIds` 是岗位 ID 业务参数解析,予以保留。 |
|||
|
|||
## 静态门禁 |
|||
|
|||
8 个目标文件对以下模式均为零命中: |
|||
|
|||
- `SecurityContextHolder` / `Authentication` / `anonymousUser`; |
|||
- 身份 `Long.valueOf(auth.getName())`; |
|||
- `resolveUserId` / `getRequestId` / 私有 `requestId(...)`; |
|||
- 原始 `X-Request-Id` header 读取; |
|||
- 可选注入、`ObjectProvider` 或手工 `new Accessor()` 回退。 |
|||
|
|||
## 新增验证 |
|||
|
|||
新增 `R01A5RequestContextMigrationIntegrationTest`,使用真实 Spring Security 与 MVC 映射: |
|||
|
|||
- 8 个 Controller 各选一个真实端点; |
|||
- 非法主体覆盖 `anonymousUser`、非数字、20 位 Long 溢出、0、负数; |
|||
- 所有组合必须返回 HTTP 401,且响应不含异常类、堆栈或非法主体; |
|||
- 合法主体下验证规范化 requestId 的响应头与响应体同源。 |
|||
|
|||
定向与关联测试:90/90,Failures=0,Errors=0,Skipped=0。 |
|||
|
|||
## 全量验证 |
|||
|
|||
- `mvn clean verify`:BUILD SUCCESS,2245/2245,Failures=0,Errors=0,Skipped=0; |
|||
- `python docs/verify_openapi.py`:176 paths / 214 operations,All checks passed; |
|||
- V1-V45 无差异; |
|||
- `docs/openapi-rc.json` 无差异; |
|||
- `git diff --check` 无空白错误,仅既有 LF/CRLF 提示。 |
|||
|
|||
## 全仓剩余 |
|||
|
|||
完成 A5 后: |
|||
|
|||
- 身份解析共 4 个文件:基础设施 3 个,业务 Controller 仅 `FairnessMonitoringController` 1 个; |
|||
- requestId 入口共 11 个文件:基础设施/非业务入口 6 个,业务 Controller 5 个; |
|||
- 最终 A6 业务白名单为 `FairnessMonitoringController`、`ApiController`、`AuthController`、`SystemController`、`JobController`。 |
|||
|
|||
## 边界 |
|||
|
|||
- 未新增或修改 Flyway、ErrorCode、接口、DTO、表、索引或依赖; |
|||
- 未修改评分、申诉、面试评分、岗位收藏或评价业务规则; |
|||
- 未暂存、未提交、未推送; |
|||
- 未删除或移动历史未跟踪文件; |
|||
- 未进入 A6。 |
|||
@ -0,0 +1,128 @@ |
|||
# Fairness 认证夹具隔离修复(A6 后独立纯测试提交) |
|||
|
|||
日期:2026-09-17 |
|||
分支:`refactor/r01-structure-performance` |
|||
A6 提交:`c6db74a1a8300e1aab8a452fb67909f86861fa43` |
|||
本轮性质:**纯测试修复** —— 生产代码 diff 为 0,Flyway 为 0,OpenAPI 为 0 |
|||
|
|||
--- |
|||
|
|||
## 0. 结论 |
|||
|
|||
`FairnessMonitoringControllerTest` 已不再依赖种子数据、测试执行顺序或共享表清空, |
|||
**单独运行稳定通过**;并新增反证测试锁定该隔离性。 |
|||
|
|||
--- |
|||
|
|||
## 1. 缺陷(A6 独立复验定位) |
|||
|
|||
| 环节 | 事实 | |
|||
|---|---| |
|||
| 冲突 | 该测试用 `token(1L, "ADMIN")` / `token(2L, "STUDENT")`,而 `V1_1__seed_data.sql` 的种子账号占用 `users.id = 1/2/3` | |
|||
| 被禁用 | `V43__disable_legacy_seed_accounts.sql` 把这三行置为 `is_active = 0` | |
|||
| 夹具缺陷 | `TestAuthTokens.writeSession()` 仅在 `SELECT COUNT(*) FROM users WHERE id = ?` 为 0 时 INSERT,**不会激活已存在的禁用行** | |
|||
| 后果 | `AuthSessionService.isSessionActive()` 的用户检查短路 → 全部 Token 请求 401 | |
|||
| 被掩盖 | 全量顺序下 `com.mydreamjob.controller.AuthIntegrationTest` 的 `@BeforeEach` 执行 `userRepository.deleteAll()`,偶然清掉种子行,夹具随后才补建 active 用户 | |
|||
|
|||
单跑实测(修复前):`Tests run: 7, Failures: 6`,全部 `Status expected:<200|403|400> but was:<401>`。 |
|||
|
|||
详见 `docs/r01-a6-independent-verification-20260917.md` §9。 |
|||
|
|||
--- |
|||
|
|||
## 2. 修复方案 |
|||
|
|||
**选择:让 `FairnessMonitoringControllerTest` 使用独占用户,而不是修改公共 `TestAuthTokens`。** |
|||
理由:公共 helper 若"自动激活已存在的 inactive 用户",会静默复活本应被禁用的账号 |
|||
(包括生产语义上被 V43 禁用的种子账号),风险远大于收益。 |
|||
|
|||
### 2.1 改动内容 |
|||
|
|||
| 项 | 修复前 | 修复后 | |
|||
|---|---|---| |
|||
| 夹具身份 | `userId = 1` / `2`(与种子账号冲突) | **独占** `ADMIN_USER_ID = 9_310_001L`、`STUDENT_USER_ID = 9_310_002L` | |
|||
| 手机号 | 由 `TestAuthTokens` 隐式生成 `"t"+id` | 独占 `a6fair-admin` / `a6fair-student` | |
|||
| 用户来源 | 依赖 `TestAuthTokens` 的"不存在才插入" | `@BeforeEach` **显式插入 `is_active = 1`** 的独占用户 | |
|||
| 清理 | 无 | `@AfterEach` 按**精确 ID**、按外键顺序删除:`auth_tokens` → `user_roles` → `users` | |
|||
|
|||
`@BeforeEach` 先执行与 `@AfterEach` 相同的精确清理再插入,因此对"上轮异常中断残留"具备幂等性, |
|||
且**从不把任何已存在的 inactive 用户直接激活**(是删除后重建自有行)。 |
|||
|
|||
### 2.2 未使用的手段(明确排除) |
|||
|
|||
未改 `V43`;未改生产认证逻辑;未使用 `deleteAll()` 清空共享表;未使用 `@DirtiesContext` 掩盖污染; |
|||
未捕获并忽略约束异常;未依赖测试执行顺序;未放宽任何既有 Fairness 断言;未修改公共 `TestAuthTokens`。 |
|||
|
|||
--- |
|||
|
|||
## 3. 反证测试 `FairnessMonitoringFixtureIsolationTest`(7 例) |
|||
|
|||
| # | 用例 | 覆盖点 | |
|||
|---|---|---| |
|||
| 1 | `fixtureIdsAreOutsideSeedAccountRange` | 夹具 ID 不在种子号段 1/2/3;夹具用户确为 active;未占用种子手机号 | |
|||
| 2 | `seedRowsInactiveOrAbsent_stillAuthenticates` | 种子行 **inactive(V43 状态)** 与 **完全缺失** 两种前置下端点均返回 200 | |
|||
| 3 | `repeatedProvisioningAndRequests_doNotConflict` | 连续 3 轮"装配 + 请求"均成功,且不产生重复用户行 | |
|||
| 4 | `fixtureCycleLeavesSeedRowsUntouched` | 完整周期前后种子行逐字段不变(顺序无关的根本保证) | |
|||
| 5 | `cleanupRemovesOnlyOwnFixtureRows` | 清理只删本类行;探针用户及其会话不受波及 | |
|||
| 6 | `unrelatedUsersAndSessionsUnchanged` | 非夹具用户与会话逐字段不变 | |
|||
| 7 | `publicTokenFixtureDoesNotSilentlyReviveInactiveUser` | `TestAuthTokens` 对已存在 inactive 用户**不激活**,该 Token 请求为 401 且 Service 零调用 | |
|||
|
|||
用例 2 会临时快照并按主键**原样恢复**种子行(`try` 语义由 `@AfterEach` 兜底),不新增、不批量重建。 |
|||
|
|||
--- |
|||
|
|||
## 4. 验证矩阵 |
|||
|
|||
命令均在 `mydreamjob-server/` 下执行;日志位于 `docs/acceptance/r01-d4-2-20260916/ff-*.log`。 |
|||
|
|||
| # | 命令 | 结果 | 日志 | |
|||
|---|---|---|---| |
|||
| 1 | `mvn -Dtest="FairnessMonitoringControllerTest" test` | **7 / 7**,F=0 E=0 S=0 ✔ | `ff-1-standalone.log` | |
|||
| 2 | `mvn -Dtest="FairnessMonitoringFixtureIsolationTest,FairnessMonitoringControllerTest" test` | **14 / 14**,F=0 E=0 S=0 ✔ | `ff-2-isolation.log` | |
|||
| 3 | Fairness **先**跑 ×3(`-Dsurefire.runOrder=reversealphabetical`) | **13 / 13** ×3 ✔ | `ff-3-fairnessfirst-{1,2,3}.log` | |
|||
| 4 | Auth **先**跑 ×3(`-Dsurefire.runOrder=alphabetical`) | **13 / 13** ×3 ✔ | `ff-4-authfirst-{1,2,3}.log` | |
|||
| 5 | `mvn clean verify` | 见 §4.1 | `ff-5-clean-verify.log` | |
|||
| 6 | `python ..\docs\verify_openapi.py` | **176 paths / 214 operations**,All checks passed ✔ | — | |
|||
|
|||
> **顺序说明**:surefire 默认 `runOrder=filesystem` 会忽略 `-Dtest` 的书写顺序, |
|||
> 因此额外用 `-Dsurefire.runOrder=reversealphabetical`(Fairness 先)与 `=alphabetical`(Auth 先) |
|||
> 强制两个方向,并在日志中逐次核对实际执行顺序(`reversealphabetical` 下 |
|||
> `scoringmonitoring.FairnessMonitoringControllerTest` 确在 `controller.AuthIntegrationTest` 之前)。 |
|||
> `runOrder=testorder` 在本仓库使用的 surefire 3.5.6 中不可用(已实测报错)。 |
|||
|
|||
### 4.1 全量结果 |
|||
|
|||
| 项 | 数值 | |
|||
|---|---| |
|||
| 全量测试总数 | 2278 → **2285**(+7:新增反证测试类) | |
|||
| Failures / Errors / Skipped | **0 / 0 / 0** ✔ | |
|||
| 生产代码 diff | **0** ✔ | |
|||
| V1–V45 | **未改动** ✔ | |
|||
| `docs/openapi-rc.json` | **未修改** ✔ | |
|||
|
|||
--- |
|||
|
|||
## 5. 修改文件 |
|||
|
|||
``` |
|||
mydreamjob-server/src/test/java/com/mydreamjob/scoringmonitoring/FairnessMonitoringControllerTest.java (修改:夹具独占化) |
|||
mydreamjob-server/src/test/java/com/mydreamjob/scoringmonitoring/FairnessMonitoringFixtureIsolationTest.java(新增:反证测试) |
|||
docs/r01-a6-fairness-fixture-isolation-fix-20260917.md (新增:本报告) |
|||
docs/backend-development-roadmap.md (夹具问题关闭记录) |
|||
``` |
|||
|
|||
**无生产代码、无 Flyway、无 ErrorCode、无 DTO、无接口、无 OpenAPI、无 Filter/Accessor/SecurityConfig 改动。** |
|||
|
|||
--- |
|||
|
|||
## 6. 明确声明 |
|||
|
|||
- 未修改任何生产代码,未修改 `V43`,未修改公共 `TestAuthTokens` 夹具。 |
|||
- 未删除、未放宽任何既有断言;`FairnessMonitoringControllerTest` 的业务断言逐字保留。 |
|||
- 未使用 `deleteAll()`、`@DirtiesContext`、顺序依赖或异常吞没等掩盖手段。 |
|||
- 未与 R01-B 混合。 |
|||
|
|||
--- |
|||
|
|||
> **FairnessMonitoringControllerTest 单跑夹具缺陷已通过独立纯测试提交修复; |
|||
> 该测试不再依赖种子数据状态与测试执行顺序。** |
|||
@ -0,0 +1,359 @@ |
|||
# R01-A6 最终业务 Controller 请求身份与 requestId 收束报告 |
|||
|
|||
日期:2026-09-17 |
|||
分支:`refactor/r01-structure-performance` |
|||
A6 基线 HEAD:`534afe686b966f175bcaf4af0726cec061092050`(A5 提交) |
|||
本轮性质:**实施与自动化验证** —— 未提交、未推送,待独立代码复验 |
|||
|
|||
--- |
|||
|
|||
## 0. 结论 |
|||
|
|||
> **R01-A6最终业务Controller请求上下文收束完成,待独立代码复验;A系列到此结束,不再拆分A7/A8。** |
|||
|
|||
**业务 Controller 身份解析残留 = 0;业务 Controller requestId 重复解析残留 = 0。** 剩余命中全部为基础设施职责。 |
|||
|
|||
--- |
|||
|
|||
## 1. 开始时状态 |
|||
|
|||
### 1.1 A5 固化(第一阶段) |
|||
|
|||
A5 此前**未提交**,本轮按已确认清单精确固化: |
|||
|
|||
| 项 | 结果 | |
|||
|---|---| |
|||
| 暂存文件数 | **15**(8 生产 Controller + 4 测试 + 2 报告 + 1 路线图) | |
|||
| `git add` 方式 | **逐文件 `git add --`**;未使用 `git add -A` / `git add .` | |
|||
| 路线图 | `git diff --numstat` 为 `1 1`(**仅 R01 行一处 hunk**),无需 `git add -p` | |
|||
| 排除项 | `GlobalExceptionHandler.java`、`db/migration`、`docs/acceptance`、`backend-infra`、A2–A4 文件 **全部排除** ✔ | |
|||
| `git diff --cached --check` | 无输出 ✔ | |
|||
| 敏感扫描 | 1050 新增行,12 类(手机号/JWT/Bearer/验证码/口令/密钥/私钥/AWS/64位摘要/本机路径/UUID/连接串)**全部命中 0** ✔ | |
|||
| A5 定向 | **42 / 42**,F=0 E=0 S=0 | |
|||
| `mvn clean verify` | **2255 / 2255**,F=0 E=0 **S=0**,BUILD SUCCESS | |
|||
| OpenAPI | **176 / 214**,All checks passed | |
|||
| 提交 | `refactor: centralize fifth batch request context handling` | |
|||
| **A5 提交 hash** | **`534afe686b966f175bcaf4af0726cec061092050`** | |
|||
| 推送 | `0226977..534afe6`;**未 force push** | |
|||
| 推送后同步 | **0 / 0** ✔ | |
|||
|
|||
### 1.2 A6 开始前核查(第二阶段) |
|||
|
|||
| 检查项 | 结果 | |
|||
|---|---| |
|||
| 分支 | `refactor/r01-structure-performance` ✔ | |
|||
| 新 HEAD | `534afe686b966f175bcaf4af0726cec061092050` ✔ | |
|||
| 暂存区 | 空 ✔ | |
|||
| `git diff --check` | 无空白错误 ✔ | |
|||
| **A5 未提交残余** | **无**(工作区仅 `GlobalExceptionHandler.java` 的 stat 缓存标记,blob 三方一致)✔ | |
|||
| 历史未跟踪文件 | 全部保留 ✔ | |
|||
| Docker / R01-B·C·E | 未触碰 ✔ | |
|||
|
|||
**A6 基线测试数:2255。** |
|||
|
|||
--- |
|||
|
|||
## 2. 五个 Controller 的修改内容 |
|||
|
|||
### 2.1 迁移前契约 |
|||
|
|||
| Controller | 真实代表端点 | 身份解析 | requestId 解析 | |
|||
|---|---|---|---| |
|||
| `controller/ApiController` | `GET /` | 无 | `private String getRequestId(HttpServletRequest)` → `request.getHeader("X-Request-Id")` | |
|||
| `controller/AuthController` | `POST /auth/login`、`GET /auth/me` 等 | **已有 `CurrentUserAccessor`** ✓ | `private String getRequestId(...)` | |
|||
| `controller/SystemController` | `GET /health`、`GET /info` | 无 | `private String getRequestId(...)` | |
|||
| `job/JobController` | `GET /jobs`、`POST /jobs` 等 | **已有 `CurrentUserAccessor`** ✓ | `private String getRequestId(...)` | |
|||
| `scoringmonitoring/FairnessMonitoringController` | `GET /admin/scoring/fairness/*` | **需迁移**:`private Long userId()` 直读 `SecurityContextHolder` + `Long.valueOf(auth.getName())` | `private String requestId(HttpServletRequest)` 单行直读 | |
|||
|
|||
### 2.2 修改后 |
|||
|
|||
| Controller | 注入 | `requireUserId()` | `requestIdAccessor.get(request)` | |
|||
|---|---|---|---| |
|||
| ApiController | 新增 `RequestIdAccessor`(并补 `@RequiredArgsConstructor`) | 0 | 1 | |
|||
| AuthController | 新增 `RequestIdAccessor`(`CurrentUserAccessor` 既有) | 2(既有) | 14 | |
|||
| SystemController | 新增 `RequestIdAccessor`(并补 `@RequiredArgsConstructor`) | 0 | 2 | |
|||
| JobController | 新增 `RequestIdAccessor`(`CurrentUserAccessor` 既有) | 1(既有) | 8 | |
|||
| FairnessMonitoringController | 新增 `CurrentUserAccessor` + `RequestIdAccessor` | 5 | 5 | |
|||
|
|||
**`JobController` 的身份迁移未被重复改造** ✔ —— 它此前已使用 `CurrentUserAccessor`,本批只迁移 requestId。 |
|||
|
|||
**`AuthController` 无重复身份解析** ✔ —— 它此前已使用 `CurrentUserAccessor`,本批只迁移 requestId。 |
|||
|
|||
### 2.3 删除的私有方法(共 6 个) |
|||
|
|||
| Controller | 删除 | |
|||
|---|---| |
|||
| ApiController | `getRequestId` ×1 | |
|||
| AuthController | `getRequestId` ×1 | |
|||
| SystemController | `getRequestId` ×1 | |
|||
| JobController | `getRequestId` ×1 | |
|||
| FairnessMonitoringController | `userId` ×1、`requestId` ×1 | |
|||
| **合计** | **6** | |
|||
|
|||
> **计数澄清(独立复验补充)**:曾出现"`5 × getRequestId + 1 × userId + 1 × requestId`"的表述,其算术结果为 7,**与真实 diff 不符,该表达式不成立**。经逐文件 `git diff` 核对:`getRequestId` 实际只有 **4 个**(ApiController / AuthController / SystemController / JobController);`FairnessMonitoringController` 的 requestId 方法签名是 **`requestId(HttpServletRequest)`(无 `get` 前缀)**,因此**不能被计入 `getRequestId`**。正确分解为 **4 × getRequestId + 1 × userId + 1 × requestId = 6**,与本表逐文件清单完全一致。**未为迎合数字修改任何生产代码。** |
|||
|
|||
**保留**:`FairnessMonitoringController.parseDate`(业务参数解析)、`checkParams`(业务参数校验)✔ 未被误删。 |
|||
|
|||
**新增注解**:`ApiController` 与 `SystemController` 原本无依赖、缺 `@RequiredArgsConstructor`,为支持构造器注入补齐该注解与 import(**唯一的新增注解**,未引入 `@Autowired`)。 |
|||
|
|||
**未修改**:`RequestIdFilter`、`RequestIdAccessor`、`RequestLoggingFilter`、`AuditContext`、`GlobalExceptionHandler`、`SecurityConfig`、Filter 顺序 ✔ |
|||
|
|||
--- |
|||
|
|||
## 3. 修复前失败证据 |
|||
|
|||
新增 `mydreamjob-server/src/test/java/com/mydreamjob/common/r01a6/` 下两个测试类。 |
|||
|
|||
**修复前实测:`Tests run: 10, Failures: 5`** |
|||
|
|||
| 失败测试 | 修复前实际 | |
|||
|---|---| |
|||
| `A6AccessorWiringProbeTest.requestIdComesFromAccessorAcrossFiveControllers` | `path=/` 响应体 `requestId` **不是** Accessor 返回值(Controller 直读 header) | |
|||
| `A6AccessorWiringProbeTest.fairnessMonitoringUsesCurrentUserAccessor` | `FairnessMonitoringService.drift(...)` **未被调用**(身份未走 Accessor) | |
|||
| `A6AccessorWiringProbeTest.accessorThrowsUnauthenticated_fairnessReturns401` | 不成立 | |
|||
| `R01A6RequestContextMigrationIntegrationTest.fairnessMonitoring_illegalPrincipals_are401` | `principal='anonymousUser'` → **`expected: 401 but was: 400`** | |
|||
| `R01A6RequestContextMigrationIntegrationTest.fairnessMonitoring_illegalPrincipals_produceNoServiceCall` | 不成立 | |
|||
|
|||
**关于 400 而非 500(如实记录)**:`FairnessMonitoringController.userId()` 带有 |
|||
`try { Long.valueOf(auth.getName()); } catch (NumberFormatException) { throw BusinessException(UNAUTHORIZED); }`, |
|||
因此其**既有行为本身是安全的(未逃逸为 500)**,但抛出的 `BusinessException(ErrorCode.UNAUTHORIZED=2001)` |
|||
不在 `GlobalExceptionHandler.BUSINESS_HTTP_STATUSES` 映射中,`resolveHttpStatus` 的 |
|||
`getOrDefault(2001, BAD_REQUEST)` 使其落到 **HTTP 400**。这正是本批收束的真实语义缺陷: |
|||
**非法主体应返回 401 而非 400**。接线探针则因"未调用 Accessor"而失败 —— **未编造 500**。 |
|||
|
|||
**修复后:`Tests run: 10, Failures: 0`** |
|||
|
|||
--- |
|||
|
|||
## 4. 角色与资源归属语义 |
|||
|
|||
| 核查项 | 结论 | |
|||
|---|---| |
|||
| 静态审查:删除行中是否含角色/归属检查 | **否** ✔ | |
|||
| 静态审查:新增行中是否引入角色/归属逻辑 | **否** ✔ | |
|||
| `ErrorCode.java` | **零改动** ✔ | |
|||
| ADMIN / COMPANY / STUDENT 角色检查 | 未删除、未移动 ✔ | |
|||
| 企业归属 / 资源归属 / 403 / 404 分支 | 未删除、未移动 ✔ | |
|||
| 监控数据访问权限(ADMIN 只读聚合) | 未改变 ✔ | |
|||
|
|||
**分层保持**:身份失败 → **401**;角色不足 → **403**(由 Service 层或既有逻辑产生);资源不存在 → **404**。 |
|||
|
|||
--- |
|||
|
|||
## 5. 独立接线探针 |
|||
|
|||
`A6AccessorWiringProbeTest`(3 例)使用 `@MockBean` 替换 `CurrentUserAccessor`、`RequestIdAccessor` 与对应 Service: |
|||
|
|||
| 用例 | 证明 | |
|||
|---|---| |
|||
| `requestIdComesFromAccessorAcrossFiveControllers` | mock `RequestIdAccessor` 返回 `a6-probe-request-id` → **5 个 Controller 的响应体 requestId 均为该值**(修复前为直读 header 的 null) | |
|||
| `fairnessMonitoringUsesCurrentUserAccessor` | mock `CurrentUserAccessor` 返回 `5151L` → `fairnessMonitoringService.drift(5151L)` 被调用 | |
|||
| `accessorThrowsUnauthenticated_fairnessReturns401` | mock 抛 `InsufficientAuthenticationException` → FairnessMonitoring 返回 **401**,Service **零调用** | |
|||
|
|||
**变异有效性**:删除任一 Controller 中的 Accessor 调用会使对应 `verify` 失败 ✔ |
|||
|
|||
--- |
|||
|
|||
## 6. 非法 principal 的结果 |
|||
|
|||
`R01A6RequestContextMigrationIntegrationTest` 对 `GET /admin/scoring/fairness/drift` 覆盖: |
|||
|
|||
| 主体 | 结果 | |
|||
|---|---| |
|||
| `anonymousUser` | **401** ✔ | |
|||
| `not-a-number` | **401** ✔ | |
|||
| `99999999999999999999`(20 位溢出) | **401** ✔ | |
|||
| `0` | **401** ✔ | |
|||
| `-1` | **401** ✔ | |
|||
| 匿名 token / 无 Token | **401** ✔ | |
|||
| 合法数字主体 `"7"` | 不被身份层拒绝(非 401)✔ | |
|||
|
|||
断言:**401 且无 500**;响应体仅解析 `code`/`message` 稳定字段,**不对整段 JSON 做数字搜索**;不含 `Exception`/`java.`/`principal`/`anonymousUser`/`com.mydreamjob`/`NumberFormat`;无 `stackTrace`/`trace`;**业务 Service 零调用** ✔ |
|||
|
|||
--- |
|||
|
|||
## 7. requestId 规范化 |
|||
|
|||
覆盖 5 个 Controller 的真实端点(`/`、`/health`、`/auth/me`、`/jobs`、`/admin/scoring/fairness/drift`): |
|||
|
|||
| 输入 | 结果 | |
|||
|---|---| |
|||
| 合法值 `a6-legal-0001` | 响应头 == 响应体,原样保留 ✔ | |
|||
| 空白 `" "` | 重新生成,头体同源 ✔ | |
|||
| 超长(80 字符) | 收敛至 ≤32,头体同源 ✔ | |
|||
| 非法字符 `<script>x</script>`、`a b` | 生成合法替代值,响应头不含 `<`/`>`/空格 ✔ | |
|||
| request attribute 与 header 不同 | **attribute 优先** ✔ | |
|||
| CR / LF / CRLF(直接经 Accessor) | 不进入规范化结果 ✔ | |
|||
|
|||
--- |
|||
|
|||
## 8. 全仓剩余命中逐文件分类 |
|||
|
|||
**身份解析**(`SecurityContextHolder.getContext().getAuthentication` \| `Long.(valueOf|parseLong)([^)]*getName()`)—— **3 文件** |
|||
|
|||
| 文件 | 分类 | |
|||
|---|---| |
|||
| `security/CurrentUserAccessor` | 基础设施(Accessor 自身) | |
|||
| `auth/JwtAuthenticationFilter` | 基础设施(认证过滤器) | |
|||
| `deletion/DeletionGuardInterceptor` | 基础设施(拦截器) | |
|||
|
|||
**requestId 解析**(含 `X-Request-Id` 直读、`RequestIdFilter.REQUEST_ID_HEADER`、`String (getRequestId|resolveRequestId|requestId)(`)—— **5 文件** |
|||
|
|||
| 文件 | 分类 | |
|||
|---|---| |
|||
| `audit/AuditContext` | 基础设施 | |
|||
| `common/exception/GlobalExceptionHandler` | 基础设施 | |
|||
| `common/logging/RequestIdAccessor` | 基础设施(Accessor 自身) | |
|||
| `common/logging/RequestLoggingFilter` | 基础设施 | |
|||
| `deletion/DeletionGuardInterceptor` | 基础设施 | |
|||
|
|||
**全部落在任务书允许的基础设施清单内**(`CurrentUserAccessor`、`JwtAuthenticationFilter`、`DeletionGuardInterceptor`、`RequestIdAccessor`、`RequestIdFilter`、`RequestLoggingFilter`、`AuditContext`、`GlobalExceptionHandler`)✔ |
|||
|
|||
**未为"全仓零命中"修改任何基础设施类** ✔ |
|||
|
|||
**未在 A6 清单内的业务 Controller 残留:0** ✔ —— 无需越界修改。 |
|||
|
|||
--- |
|||
|
|||
## 9. 业务 Controller 残留 |
|||
|
|||
| 项 | 结果 | |
|||
|---|---| |
|||
| 业务 Controller 身份解析 | **0** ✔ | |
|||
| 业务 Controller requestId 重复解析 | **0** ✔ | |
|||
|
|||
**A 系列请求上下文收束达成目标终态。A7/A8 不再设置。** |
|||
|
|||
--- |
|||
|
|||
## 10. 测试数 |
|||
|
|||
| 命令 | 结果 | |
|||
|---|---| |
|||
| A6 定向(`R01A6…,A6AccessorWiringProbeTest,CurrentUserAccessorTest,RequestIdAccessorTest,RequestIdFilterTest`) | **40 / 40**,F=0 E=0 **S=0** | |
|||
| A6 关联(`*ApiController*Test,*AuthController*Test,*SystemController*Test,*JobController*Test,*FairnessMonitoring*Test,*Security*Test,*RequestId*Test`) | **76 / 76**(1 项既有夹具脆弱性,见 §10.1,**全量顺序下不触发**) | |
|||
| `mvn test` | **2265 / 2265**,Failures=0、Errors=0、**Skipped=0** | |
|||
| `mvn clean verify` | **2265 / 2265**,Failures=0、Errors=0、**Skipped=0**,BUILD SUCCESS | |
|||
|
|||
A5 基线 2255 → A6 **2265**(新增 **10** 个测试:7 + 3),**不低于基线** ✔ |
|||
|
|||
### 10.1 基线已存在的测试夹具脆弱性(未在本批修复) |
|||
|
|||
A6 关联回归单独运行时,**既有** `FairnessMonitoringControllerTest` 出现 6 个失败(`200/403/400 → 401`),根因为: |
|||
|
|||
``` |
|||
Caused by: AuthSessionService - session validation rejected: user not usable |
|||
JwtAuthenticationFilter - JWT authentication rejected: session not active |
|||
``` |
|||
|
|||
**归因验证(任务书要求)**:在 `git worktree add --detach HEAD`(**不含 A6 改动**)中运行**完全相同**的命令: |
|||
|
|||
| | 测试数 | 失败 | 根因 | |
|||
|---|---|---|---| |
|||
| 基线(HEAD) | **7** | **6** | `user not usable` | |
|||
| 本次(含 A6) | **7** | **6** | **完全相同** | |
|||
|
|||
**实施阶段初步判断(后被独立复验否定,保留于此以存档过程)**:当时认为 |
|||
`TestAuthTokens` 夹具插入 `users` 行时**不写 `user_roles`**,导致该用户被判 "not usable"。 |
|||
**该判断不成立**:`AuthSessionService.isUsable(User)` 只检查用户存在且 `is_active = true`, |
|||
`UserRepository.findById` 无角色过滤,该路径**从不查询 `user_roles`**。 |
|||
|
|||
**独立复验结论(准确根因)**:见 `docs/r01-a6-independent-verification-20260917.md` §9。 |
|||
|
|||
| 环节 | 事实 | |
|||
|---|---| |
|||
| 身份冲突 | `FairnessMonitoringControllerTest` 使用 `token(1L, "ADMIN")` / `token(2L, "STUDENT")`,与 `V1_1__seed_data.sql` 种子账号占用的 `users.id = 1 / 2 / 3` **冲突** | |
|||
| 种子被禁用 | `V43__disable_legacy_seed_accounts.sql` 按原始指纹把这三行 `is_active` 置为 **0** | |
|||
| 夹具缺陷 | `TestAuthTokens.writeSession()` 仅当 `SELECT COUNT(*) FROM users WHERE id = ?` 为 0 时才 INSERT;**对已存在但 `is_active = 0` 的行不做激活**,永不修复其启用状态 | |
|||
| 直接后果 | `AuthSessionService.isSessionActive()` 第一步 `isUserUsable()` 返回 false 并短路,请求在进入 Controller 前即 401 | |
|||
| 全量为何通过 | 全量顺序中 `com.mydreamjob.controller.AuthIntegrationTest` 的 `@BeforeEach` 执行 `userRepository.deleteAll()`,**偶然清除**了 V43 已禁用的种子行,夹具随后才补建出 active 用户 —— 问题被掩盖,而非被修复 | |
|||
|
|||
**与 A6 生产改动无关**:401 由 `JwtAuthenticationFilter` / `AuthSessionService` 在请求进入 |
|||
Controller 之前产生(`Handler: Type = null`),A6 只改了 5 个 Controller 内部取身份 / 取 |
|||
requestId 的写法,该路径上一行 A6 代码都未执行。独立复验的决定性对照实验:同一份含 A6 的 |
|||
代码,单跑 Fairness 为 7 run / 6 failures,而让 `AuthIntegrationTest` 先跑则为 **7 run / 0 failures**。 |
|||
|
|||
`mvn clean verify`(全量顺序)下 **2278/2278 全通过**,该失败不触发。 |
|||
|
|||
按任务书要求:**未把失败写成通过,未在本批顺手修复**;该夹具问题将在 A6 提交后 |
|||
**以独立纯测试提交修复**(不与本批生产改动混合)。 |
|||
|
|||
--- |
|||
|
|||
## 11. OpenAPI |
|||
|
|||
`python docs\verify_openapi.py` → **176 paths / 214 operations**,All checks passed ✔ |
|||
|
|||
**`docs/openapi-rc.json` 零修改** ✔ |
|||
|
|||
--- |
|||
|
|||
## 12. 精确修改文件 |
|||
|
|||
**生产代码(5 个,全部在 A6 指定清单内)** |
|||
|
|||
``` |
|||
mydreamjob-server/src/main/java/com/mydreamjob/controller/ApiController.java |
|||
mydreamjob-server/src/main/java/com/mydreamjob/controller/AuthController.java |
|||
mydreamjob-server/src/main/java/com/mydreamjob/controller/SystemController.java |
|||
mydreamjob-server/src/main/java/com/mydreamjob/job/JobController.java |
|||
mydreamjob-server/src/main/java/com/mydreamjob/scoringmonitoring/FairnessMonitoringController.java |
|||
``` |
|||
|
|||
**测试(新增 2 个)** |
|||
|
|||
``` |
|||
mydreamjob-server/src/test/java/com/mydreamjob/common/r01a6/R01A6RequestContextMigrationIntegrationTest.java |
|||
mydreamjob-server/src/test/java/com/mydreamjob/common/r01a6/A6AccessorWiringProbeTest.java |
|||
``` |
|||
|
|||
**文档(2 个)** |
|||
|
|||
``` |
|||
docs/r01-a6-final-request-context-migration-report.md # 本报告 |
|||
docs/backend-development-roadmap.md # 仅更新顶部 R01 状态行 |
|||
``` |
|||
|
|||
**未修改**:Service、DTO、`ErrorCode`、Flyway、`SecurityConfig`、Filter、Accessor、OpenAPI、既有测试、`UserRoleGuard`。 |
|||
|
|||
--- |
|||
|
|||
## 13. Flyway / ErrorCode / DTO / 接口 |
|||
|
|||
| 项 | 结果 | |
|||
|---|---| |
|||
| V1–V45 迁移 | **零修改** ✔ | |
|||
| `ErrorCode.java` | **零改动** ✔ | |
|||
| DTO | **零改动** ✔ | |
|||
| 公网接口 / OpenAPI | **零新增零修改** ✔ | |
|||
| 新增表 / 索引 / 迁移 | **无** ✔ | |
|||
|
|||
--- |
|||
|
|||
## 14. Git 状态 |
|||
|
|||
``` |
|||
git diff --check → 无输出 |
|||
git status --short → 8 个生产/文档改动 + GlobalExceptionHandler(stat 缓存标记) |
|||
git diff --cached --name-only → 空 |
|||
git diff --name-only -- db/migration → 空 |
|||
git diff -- docs/openapi-rc.json → 空 |
|||
``` |
|||
|
|||
- `GlobalExceptionHandler.java`:工作区 / 索引 / HEAD 三方 blob 均为 `1fdc341e…`,**内容零差异**,未触碰 ✔ |
|||
- 临时基线 worktree 已 `--force` 移除并 prune,`git worktree list` 仅主仓库 ✔ |
|||
|
|||
--- |
|||
|
|||
## 15. 明确声明 |
|||
|
|||
- **未暂存、未提交、未推送。** |
|||
- 未操作 Docker。 |
|||
- **未进入 R01-B / R01-C / R01-E。** |
|||
- 未修改基础设施类(`RequestIdFilter`、`RequestIdAccessor`、`RequestLoggingFilter`、`AuditContext`、`GlobalExceptionHandler`、`SecurityConfig`)。 |
|||
- 未删除历史未跟踪文件。 |
|||
- 基线测试污染已用 HEAD 隔离 worktree 复现并如实记录,**未写成通过、未顺手修复**。 |
|||
|
|||
--- |
|||
|
|||
> **R01-A6最终业务Controller请求上下文收束完成,待独立代码复验;A系列到此结束,不再拆分A7/A8。** |
|||
@ -0,0 +1,492 @@ |
|||
# R01-A6 独立代码复验报告(最终批:5 个 Controller 请求身份与 requestId 收束) |
|||
|
|||
日期:2026-09-17 |
|||
分支:`refactor/r01-structure-performance` |
|||
HEAD:`534afe686b966f175bcaf4af0726cec061092050`(A5 提交;复验全程未变) |
|||
暂存区:**空** |
|||
复验对象:`docs/r01-a6-final-request-context-migration-report.md` |
|||
性质:**仅复验** —— 未修改生产代码、未修改既有测试断言、未暂存、未提交、未推送 |
|||
|
|||
--- |
|||
|
|||
## 0. 结论 |
|||
|
|||
> **R01-A6独立代码复验通过,可以进入精确暂存与独立提交检查点;A系列到此结束,不再设置A7/A8。FairnessMonitoringControllerTest的单跑夹具缺陷须在A6提交后以独立纯测试提交修复。** |
|||
|
|||
**业务 Controller 身份解析残留 = 0;业务 Controller requestId 重复解析残留 = 0。** |
|||
剩余命中全部落在基础设施职责内(身份 3 文件、requestId 5 文件)。 |
|||
|
|||
**未发现 A6 引入的生产缺陷。** `FairnessMonitoringControllerTest` 的单跑失败经证据链确认为 |
|||
**测试夹具缺陷**(`TestAuthTokens` 对"已存在但被禁用"的用户行不做激活), |
|||
**与 A6 生产改动无关**;其准确根因见 §9。 |
|||
|
|||
--- |
|||
|
|||
## 1. 开始前状态 |
|||
|
|||
| 项 | 结果 | |
|||
|---|---| |
|||
| `git branch --show-current` | `refactor/r01-structure-performance` | |
|||
| `git rev-parse HEAD` | `534afe686b966f175bcaf4af0726cec061092050` | |
|||
| `git diff --cached --name-only` | **空**(暂存区为空) | |
|||
| `git diff --check` | 仅 `docs/backend-development-roadmap.md` 的 LF/CRLF 警告,**exit 0**,无空白错误 | |
|||
| Java/Maven 残留进程 | 无 | |
|||
|
|||
工作区改动共 6 个文件:5 个生产 Controller + `docs/backend-development-roadmap.md`。 |
|||
|
|||
--- |
|||
|
|||
## 2. 五个 Controller 静态审查 |
|||
|
|||
`git diff --stat`: |
|||
|
|||
``` |
|||
docs/backend-development-roadmap.md | 2 +- |
|||
.../com/mydreamjob/controller/ApiController.java | 12 +++++--- |
|||
.../com/mydreamjob/controller/AuthController.java | 33 +++++++++++----------- |
|||
.../mydreamjob/controller/SystemController.java | 14 +++++---- |
|||
.../java/com/mydreamjob/job/JobController.java | 21 +++++++------- |
|||
.../FairnessMonitoringController.java | 25 +++++++--------- |
|||
6 files changed, 54 insertions(+), 53 deletions(-) |
|||
``` |
|||
|
|||
| 项 | 结果 | |
|||
|---|---| |
|||
| 修改范围是否仅限 A6 清单内 5 个 Controller | **是** ✔ | |
|||
| 是否新增接口 / ErrorCode / Flyway / 表 / 索引 / 依赖 | **否** ✔ | |
|||
| 是否触碰 `SecurityConfig` / Filter / Accessor / `GlobalExceptionHandler` | **否** ✔ | |
|||
| 是否修改既有测试文件 | **否** ✔(`git status` 中无任何 `src/test` 既有文件被修改) | |
|||
|
|||
### 2.1 禁止模式扫描(静态门禁) |
|||
|
|||
对 5 个 Controller 逐一扫描 `X-Request-Id` 直读、`SecurityContextHolder`、 |
|||
`getAuthentication()`、`Long.valueOf(auth.getName())`: |
|||
|
|||
| 文件 | 命中 | |
|||
|---|---| |
|||
| `controller/ApiController.java` | **0** ✔ | |
|||
| `controller/AuthController.java` | **0** ✔ | |
|||
| `controller/SystemController.java` | **0** ✔ | |
|||
| `job/JobController.java` | **0** ✔ | |
|||
| `scoringmonitoring/FairnessMonitoringController.java` | **0** ✔ | |
|||
|
|||
全仓 `*Controller.java` 扫描同一组模式:**0 命中** ✔ |
|||
|
|||
### 2.2 构造器注入与无回退 |
|||
|
|||
`ApiController`、`SystemController` 新增 `@RequiredArgsConstructor` 并声明 |
|||
`private final CurrentUserAccessor` / `private final RequestIdAccessor`; |
|||
`AuthController`、`JobController`、`FairnessMonitoringController` 复用既有 |
|||
`@RequiredArgsConstructor`。**全部为 final 构造器注入,无字段默认值、无 `null` 回退、 |
|||
无 `new` 兜底实现** ✔ |
|||
|
|||
### 2.3 `GlobalExceptionHandler.java` 三方 blob 比对 |
|||
|
|||
``` |
|||
git hash-object <file> → 1fdc341ebe3653393b3ec04f0c2d0bb9dfbb6d21 |
|||
git rev-parse HEAD:<file> → 1fdc341ebe3653393b3ec04f0c2d0bb9dfbb6d21 |
|||
git ls-files -s <file> → 100644 1fdc341ebe3653393b3ec04f0c2d0bb9dfbb6d21 0 <file> |
|||
git diff --quiet <file> → exit 0 |
|||
``` |
|||
|
|||
**工作区 blob 与 HEAD blob 完全一致**,该文件在 `git status` 中的 `M` 标记是 |
|||
**Windows stat 缓存假阳性**,未修改、未触碰 ✔ |
|||
|
|||
--- |
|||
|
|||
## 3. 删除的私有方法(真实数量:6) |
|||
|
|||
逐文件 `git diff` 核对(`-` 行为真实删除): |
|||
|
|||
| # | 文件 | 删除的方法签名 | |
|||
|---|---|---| |
|||
| 1 | `controller/ApiController` | `private String getRequestId(HttpServletRequest)` | |
|||
| 2 | `controller/AuthController` | `private String getRequestId(HttpServletRequest)` | |
|||
| 3 | `controller/SystemController` | `private String getRequestId(HttpServletRequest)` | |
|||
| 4 | `job/JobController` | `private String getRequestId(HttpServletRequest)` | |
|||
| 5 | `scoringmonitoring/FairnessMonitoringController` | `private Long userId()` | |
|||
| 6 | `scoringmonitoring/FairnessMonitoringController` | `private String requestId(HttpServletRequest)` | |
|||
|
|||
**计数澄清**:`getRequestId` 名称的方法实际为 **4 个**; |
|||
`FairnessMonitoringController` 的 requestId 方法签名为 `requestId(HttpServletRequest)` |
|||
(**无 `get` 前缀**),不能被计入 `getRequestId`。 |
|||
正确分解为 **4 × `getRequestId` + 1 × `userId` + 1 × `requestId` = 6**, |
|||
与上表逐文件清单一致 ✔ |
|||
|
|||
**未为迎合任何数字修改生产代码** ✔ |
|||
|
|||
--- |
|||
|
|||
## 4. 独立接线变异证明 |
|||
|
|||
新增独立测试 `A6AccessorWiringIndependentTest` 将 `CurrentUserAccessor`、 |
|||
`RequestIdAccessor` 全部替换为 mock,覆盖 5 个 Controller 的代表端点 |
|||
(`/`、`/health`、`/auth/me`、`GET /jobs`、`/admin/scoring/fairness/drift`): |
|||
|
|||
| 断言 | 结果 | |
|||
|---|---| |
|||
| 5 个端点响应体 `requestId` 均等于 mock 返回值 `a6-indep-request-id` | **通过** ✔ | |
|||
| Accessor 返回 4242 时 `service.drift(4242L)` 被调用 | **通过** ✔ | |
|||
| Accessor 返回 77L 时 `overview/dimensions/rankingStability` 收到 77L | **通过** ✔ | |
|||
| Accessor 抛 `InsufficientAuthenticationException` → 401 且 `service` 零调用 | **通过** ✔ | |
|||
| `POST /jobs` 企业侧身份经 `CurrentUserAccessor.requireUserId()` | **通过** ✔ | |
|||
|
|||
**变异检测含义**:若任一 Controller 把 `requestIdAccessor.get(request)` 换回直读 |
|||
`request.getHeader("X-Request-Id")`,响应体将不再出现 mock 值 → 该断言必然失败; |
|||
若 `FairnessMonitoringController` 换回直读 `SecurityContextHolder`, |
|||
`4242/77` 将不会传到 Service → 断言失败。**接线真实生效,非空转** ✔ |
|||
|
|||
--- |
|||
|
|||
## 5. 独立测试质量审查 |
|||
|
|||
复验对象: |
|||
`A6AccessorWiringIndependentTest`(6 个用例)、`A6IndependentVerificationTest`(7 个用例)。 |
|||
|
|||
| 核对项 | 结果 | |
|||
|---|---| |
|||
| 公开端点未被错误要求 401 | ✔ 无 Token 断言仅覆盖 `/auth/me` 与 `/admin/scoring/fairness/drift`,`/`、`/health`、`GET /jobs` 明确排除在断言之外 | |
|||
| JobController 身份接线使用真正需要企业身份的端点 | ✔ 改用 `POST /jobs`(`resolveCompanyId()` 路径),非公开的 `GET /jobs` | |
|||
| Service 参数验证不是空转 | ✔ 断言具体数值(4242 / 77),而非仅 `any()` | |
|||
| requestId 断言未错误声称响应头来自 Accessor | ✔ 只断言「响应头非空且已规范化」+「响应体 requestId 与响应头同源」;响应头由 `RequestIdFilter` 产出,未作错误归属 | |
|||
| 副作用快照列真实存在 | ✔ 9 张表列名逐一对齐迁移文件(`SCORING_RISK_EVENTS`、`SCORING_RISK_ACTIONS`、`JOBS`、`USERS`、`USER_ROLES`、`AUDIT_LOGS`、`NOTIFICATIONS`、`MESSAGES`、`CANDIDATE_DIMENSION_SCORES`) | |
|||
| 未修改或放宽既有测试 | ✔ 既有测试文件零改动 | |
|||
| 未通过过度 mock 绕过目标 Controller | ✔ 目标 Controller 全部真实(`@SpringBootTest` + `MockMvc`),只 mock 下游 Service 与两个 Accessor | |
|||
|
|||
### 5.1 独立测试首次运行的两处自身错误及修正 |
|||
|
|||
| # | 原断言 | 问题性质 | 修正 | |
|||
|---|---|---|---| |
|||
| 1 | 把公开端点路径 `/`、`/health`、`GET /jobs` 也断言为无 Token 401 | **测试自身错误**(公开端点本不应 401),非生产缺陷 | 断言范围收窄为 `/auth/me`、`/admin/scoring/fairness/drift` 两个需认证端点 | |
|||
| 2 | 用公开的 `GET /jobs` 验证 `JobController` 的身份 Accessor 接线 | **测试自身错误**(该端点不解析身份,断言无意义) | 改用 `POST /jobs` 企业侧端点 | |
|||
|
|||
**两处均为新测试自身缺陷,已修正;未修改生产代码,未修改任何既有测试。** |
|||
修正后两个类合计 **13/13 通过**。 |
|||
|
|||
--- |
|||
|
|||
## 6. 身份边界与 401 / 403 / 404 分层 |
|||
|
|||
### 6.1 非法主体一律 401(真实 Accessor + 真实 SecurityFilterChain) |
|||
|
|||
对主体 `anonymousUser`、`not-a-number`、`99999999999999999999`、`0`、`-1`, |
|||
以及 `AnonymousAuthenticationToken` 和完全无 Token: |
|||
|
|||
| 断言 | 结果 | |
|||
|---|---| |
|||
| 状态码一律 **401**(不得 400/500) | ✔ | |
|||
| 响应体 `code = 401` | ✔ | |
|||
| `message` ∈ {`authentication required`, `not authenticated`} | ✔ | |
|||
| `message` 不回显异常类名、`java.`、`principal`、`anonymousUser`、`com.mydreamjob`、`NumberFormat` | ✔ | |
|||
| 响应体无 `stackTrace` / `trace` | ✔ | |
|||
| 业务 Service **零调用** | ✔ | |
|||
| 合法数字主体(`7`)不被身份层拒绝 | ✔ | |
|||
|
|||
### 6.2 401 / 403 / 404 分层未漂移 |
|||
|
|||
静态审查确认:A6 diff **未删除、未新增任何角色校验或资源归属校验**; |
|||
`JobController.resolveCompanyId()` 与 `jobService.assertOwnerOrThrow(companyId, jobId)` 原样保留, |
|||
`AuthController` 的「已注销手机号 → HTTP 409 / code=9303」分支原样保留。 |
|||
|
|||
既有契约由既有测试继续守护且**全部通过**(例:`AuthenticationBoundaryIntegrationTest` |
|||
对 `POST /jobs` 断言 `403` + `ErrorCode.FORBIDDEN`)。**403 / 404 契约未漂移** ✔ |
|||
|
|||
--- |
|||
|
|||
## 7. requestId 语义 |
|||
|
|||
对 5 个 Controller 的代表端点,注入 `X-Request-Id` ∈ |
|||
{`a6v-legal-1`, 全空白, 90 字符超长, `<b>x</b>`, `a b`}: |
|||
|
|||
| 断言 | 结果 | |
|||
|---|---| |
|||
| 响应头 `X-Request-Id` 非空 | ✔ | |
|||
| 不含 `<`、`>`、空格、CR、LF | ✔ | |
|||
| 长度 ≤ 32 | ✔ | |
|||
| 响应体 `"requestId":"<响应头同值>"` | ✔ | |
|||
| 请求属性优先于原始 Header | ✔ | |
|||
| CR/LF 不进入规范化结果(6 组恶意输入) | ✔ | |
|||
|
|||
--- |
|||
|
|||
## 8. 失败请求的业务副作用 |
|||
|
|||
`illegalIdentity_producesNoBusinessWrite`:对 9 张核心表按显式列 + 稳定主键排序做全量快照, |
|||
执行 5 组非法主体请求 + 1 组无 Token 请求后再次快照。 |
|||
|
|||
| 结果 | |
|||
|---| |
|||
| 9 张表**逐字段零变化** ✔ | |
|||
|
|||
--- |
|||
|
|||
## 9. `FairnessMonitoringControllerTest` 单跑:真实结果与准确根因 |
|||
|
|||
### 9.1 单跑命令与真实结果(**不写成通过**) |
|||
|
|||
``` |
|||
mvn -Dtest="FairnessMonitoringControllerTest" test |
|||
``` |
|||
|
|||
日志:`docs/acceptance/r01-d4-2-20260916/a6v-fairness-repeat.log` |
|||
|
|||
``` |
|||
Tests run: 7, Failures: 6, Errors: 0, Skipped: 0 |
|||
BUILD FAILURE |
|||
``` |
|||
|
|||
6 个失败全部是 `Status expected:<200|403|400> but was:<401>`; |
|||
唯一通过的是 `anonymous_isUnauthorized`(不发 Token 的那个)。 |
|||
|
|||
### 9.2 逐项查明(任务书第 2 步的 6 个问题) |
|||
|
|||
| # | 问题 | 结论 | 证据 | |
|||
|---|---|---|---| |
|||
| 1 | HTTP 实际状态 | **401**,响应体 `{"code":401,"message":"not authenticated","timestamp":""}` | MockHttpServletResponse 输出;`Handler: Type = null`(请求**未进入任何 Controller**) | |
|||
| 2 | Filter 为什么拒绝 | 不是验签失败,而是**服务端会话校验的用户检查失败** | 日志:`AuthSessionService - session validation rejected: user not usable` → `JwtAuthenticationFilter - JWT authentication rejected: session not active` | |
|||
| 3 | 测试用户是否存在且 `is_active = true` | **行存在,但 `is_active = 0`**(不是"不存在",也不是"active") | 见 §9.3 推理链 | |
|||
| 4 | `auth_tokens` 会话记录是否存在/未过期/未消费/未撤销 | **存在且有效**;拒绝发生在读取会话记录**之前** | `TestAuthTokens.writeSession` 自身执行 `SELECT COUNT(*) FROM auth_tokens WHERE user_id=? AND session_id=? AND revoked=0 AND consumed_at IS NULL AND expired_at > ?`,不可见即抛 `IllegalStateException`;日志中**无该异常**,说明会话行有效。而 `isSessionActive()` 先判用户、后判会话,拒绝文本是 "user not usable" 而非 "no active session" | |
|||
| 5 | JWT 的 `sid/jti/sub/type/aud/iss` 是否与库一致 | **一致**;JWT 本身完全合法 | `parseAndValidateAccessToken` **未抛异常**(否则拒绝路径不同)。日志中 Token 载荷:`sub=1`、`type=access`、`sid=0073abf7-…`、`role=ADMIN`、`iss=mydreamjob`、`aud=["mydreamjob-api"]`、`iat=1577836800`(2020-01-01)、`exp=2523916800`(2050-01-01),与夹具 `FIXTURE_BASE`/30 年 TTL 完全对应;`sid` 即写入 `auth_tokens.session_id` 的同值 | |
|||
| 6 | 是否真的与 `user_roles` 有关 | **无关** | `AuthSessionService.isUsable(User)` 只读 `user.getActive()`;`UserRepository.findById` 无角色过滤;该路径**从不查询 `user_roles`** | |
|||
|
|||
### 9.3 准确根因(证据链,取代此前未经证实的 `user_roles` 说法) |
|||
|
|||
**根因:`TestAuthTokens` 夹具只对"不存在的" `users.id` 执行 INSERT,对"已存在但 `is_active=0`"的行不做激活,而 `V1_1` 种子账号恰好占用 id=1/2/3 并被 `V43` 置为禁用。** |
|||
|
|||
证据链(逐步闭合): |
|||
|
|||
1. `V1_1__seed_data.sql` 按自增插入三名演示账号 → 占据 `users.id = 1 / 2 / 3` |
|||
(`13800000001` ADMIN、`13800000002` STUDENT、`13800000003` COMPANY)。 |
|||
2. `V43__disable_legacy_seed_accounts.sql` 按原始指纹把这三行 `is_active` 置为 **0**。 |
|||
3. `FairnessMonitoringControllerTest` 使用 `token(1L, "ADMIN")` / `token(2L, "STUDENT")` |
|||
→ 命中 id=1/2 这两个**已被禁用的种子行**。 |
|||
4. `TestAuthTokens.writeSession()` 的行为是: |
|||
```java |
|||
SELECT COUNT(*) FROM users WHERE id = ? // id=1 已存在 → count = 1 |
|||
if (count == null || count == 0) { INSERT ... is_active=1 } // → 被跳过,永不激活 |
|||
``` |
|||
即夹具**只补建缺失行,不修复既有行的启用状态**。 |
|||
5. `users.created_at` / `updated_at` 在 `V1__base_schema.sql` 中均有 |
|||
`DEFAULT CURRENT_TIMESTAMP`,因此"若行不存在则夹具 INSERT 必然成功且 `is_active=1`"成立。 |
|||
既然实际观察到 401(而非 500/ERROR),**只可能是行已存在且 `is_active=0`** —— 推理闭合。 |
|||
6. `AuthSessionService.isSessionActive()` 第一步 `isUserUsable(userId)` 因此返回 false, |
|||
直接短路返回,**从未走到会话记录检查**,与日志文本完全吻合。 |
|||
|
|||
### 9.4 与 A6 生产改动的归因判定:**无关** |
|||
|
|||
| 判据 | 内容 | |
|||
|---|---| |
|||
| 代码路径 | 401 由 `JwtAuthenticationFilter` / `AuthSessionService` 在**请求进入 Controller 之前**产生(`Handler: Type = null`)。A6 只改了 5 个 Controller 内部取 requestId / 取身份的写法,**该路径上一行 A6 代码都未执行** | |
|||
| A6 diff 范围 | 仅 5 个 Controller + 文档;`SecurityConfig`、Filter、`AuthSessionService`、`UserRepository`、`TestAuthTokens`、Flyway 迁移**零改动** | |
|||
| 失败面 | 7 个用例中,唯一不携带 Token 的用例通过;其余全部 401 —— 与「Token 认证链路在用户检查处失败」完全一致,与「Controller 被改写」不一致 | |
|||
|
|||
### 9.5 为何全量顺序下不触发(机制已定位,非"偶然") |
|||
|
|||
`com.mydreamjob.controller.AuthIntegrationTest` 的 `@BeforeEach`(源码第 97 行)执行 |
|||
`userRepository.deleteAll();`,在**共享的 `integrationdb`**(无 `@TestPropertySource` 覆盖)上 |
|||
**清空全部用户,包括 V43 已禁用的 V1_1 种子行**。 |
|||
|
|||
全量日志 `docs/acceptance/r01-d4-2-20260916/a3-commit-cv.log` 的执行顺序印证: |
|||
|
|||
``` |
|||
第 151767 行 [INFO] Running com.mydreamjob.controller.AuthIntegrationTest |
|||
第 247038 行 [INFO] Running com.mydreamjob.scoringmonitoring.FairnessMonitoringControllerTest |
|||
``` |
|||
|
|||
种子行被清空后,夹具第 4 步的 `COUNT(*)` 变为 0 → **INSERT 补建 `is_active=1` 的用户** → |
|||
用户检查通过 → 该类在全量顺序下 7/7 通过。 |
|||
|
|||
因此:**同一份代码,只因子夹具前置数据不同而结果不同 —— 属顺序依赖的夹具脆弱性, |
|||
不是被测代码的缺陷。** |
|||
|
|||
**决定性对照实验**(不新增任何文件、不修改任何代码,只改变前置数据): |
|||
|
|||
``` |
|||
mvn -Dtest="com.mydreamjob.controller.AuthIntegrationTest,FairnessMonitoringControllerTest" test |
|||
``` |
|||
|
|||
日志:`docs/acceptance/r01-d4-2-20260916/a6v-fairness-mechanism.log` |
|||
|
|||
``` |
|||
[INFO] Running com.mydreamjob.controller.AuthIntegrationTest |
|||
[INFO] Tests run: 6, Failures: 0, Errors: 0, Skipped: 0 -- in ...AuthIntegrationTest |
|||
[INFO] Running com.mydreamjob.scoringmonitoring.FairnessMonitoringControllerTest |
|||
[INFO] Tests run: 7, Failures: 0, Errors: 0, Skipped: 0 -- in ...FairnessMonitoringControllerTest |
|||
[INFO] BUILD SUCCESS |
|||
``` |
|||
|
|||
| 运行方式 | 同一份含 A6 的代码 | 结果 | |
|||
|---|---|---| |
|||
| 单独运行 Fairness | A6 在场 | **7 run / 6 failures** | |
|||
| AuthIntegrationTest 先运行 | A6 在场 | **7 run / 0 failures** | |
|||
| 全量 `mvn clean verify` | A6 在场 | **7 run / 0 failures** | |
|||
|
|||
**A6 代码在三行中完全相同,唯一变量是前置数据** —— 归因判定为夹具脆弱性的直接实验证据。 |
|||
|
|||
### 9.6 处置 |
|||
|
|||
- **不修改生产代码** ✔ |
|||
- **不修改既有 `FairnessMonitoringControllerTest`** ✔ |
|||
- 该缺陷列为 **A6 提交后的独立纯测试修复**:让 `TestAuthTokens` 在行已存在时 |
|||
显式确保 `is_active = 1`(或改用不与 V1_1 种子账号冲突的 userId)。 |
|||
- **未把该单跑命令写成通过** ✔ |
|||
|
|||
--- |
|||
|
|||
## 10. 全仓剩余统计(逐文件分类) |
|||
|
|||
**身份解析**(`SecurityContextHolder.getContext().getAuthentication` / |
|||
`Long.(valueOf|parseLong)(...getName())`)—— **3 文件** |
|||
|
|||
| 文件 | 分类 | |
|||
|---|---| |
|||
| `security/CurrentUserAccessor` | 基础设施(Accessor 自身) | |
|||
| `auth/JwtAuthenticationFilter` | 基础设施(认证过滤器) | |
|||
| `deletion/DeletionGuardInterceptor` | 基础设施(拦截器) | |
|||
|
|||
**requestId 解析**(`X-Request-Id` 直读)—— **5 文件** |
|||
|
|||
| 文件 | 分类 | |
|||
|---|---| |
|||
| `audit/AuditContext` | 基础设施 | |
|||
| `common/logging/RequestIdAccessor` | 基础设施(Accessor 自身) | |
|||
| `common/logging/RequestIdFilter` | 基础设施(过滤器) | |
|||
| `common/logging/RequestLoggingFilter` | 基础设施 | |
|||
| `deletion/DeletionGuardInterceptor` | 基础设施 | |
|||
|
|||
**业务 Controller 残留:身份解析 0、requestId 重复解析 0** ✔ |
|||
|
|||
--- |
|||
|
|||
## 11. 测试数 |
|||
|
|||
### 11.1 A6 定向 |
|||
|
|||
``` |
|||
mvn -Dtest="R01A6RequestContextMigrationIntegrationTest,A6*Test,CurrentUserAccessorTest,RequestIdAccessorTest,RequestIdFilterTest" test |
|||
``` |
|||
|
|||
日志:`docs/acceptance/r01-d4-2-20260916/a6v-v1-targeted.log` |
|||
|
|||
| 测试类 | Tests | |
|||
|---|---| |
|||
| `common.r01a6.R01A6RequestContextMigrationIntegrationTest` | 7 | |
|||
| `common.r01a6.A6AccessorWiringProbeTest` | 3 | |
|||
| `common.r01a6.A6AccessorWiringIndependentTest` | 6 | |
|||
| `common.r01a6.A6IndependentVerificationTest` | 7 | |
|||
| `security.CurrentUserAccessorTest` | 11 | |
|||
| `common.logging.RequestIdAccessorTest` | 16 | |
|||
| `common.logging.RequestIdFilterTest` | 3 | |
|||
| **合计** | **53 / 53**,Failures=0、Errors=0、**Skipped=0**,BUILD SUCCESS ✔ | |
|||
|
|||
### 11.2 A6 关联 |
|||
|
|||
``` |
|||
mvn -Dtest="*ApiController*Test,*AuthController*Test,*SystemController*Test,*JobController*Test,*Security*Test,*RequestId*Test" test |
|||
``` |
|||
|
|||
日志:`docs/acceptance/r01-d4-2-20260916/a6v-v2-related.log` |
|||
|
|||
| 测试类 | Tests | |
|||
|---|---| |
|||
| `auth.SmsVerificationSecurityTest` | 10 | |
|||
| `common.logging.RequestIdAccessorTest` | 16 | |
|||
| `common.logging.RequestIdFilterTest` | 3 | |
|||
| **合计** | **29 / 29**,Failures=0、Errors=0、**Skipped=0**,BUILD SUCCESS ✔ | |
|||
|
|||
> **说明**:该命令的 `*ApiController*Test`、`*AuthController*Test`、`*SystemController*Test`、 |
|||
> `*JobController*Test` 四个模式在本仓库**不存在同名测试类**(`find` 全量核对: |
|||
> 无 `ApiControllerTest` / `SystemControllerTest` / `JobControllerTest` / `AuthControllerTest`), |
|||
> 故只命中上表 3 个类。这 5 个 Controller 的既有覆盖由 |
|||
> `mvn clean verify` 全量承担(含 `AuthenticationBoundaryIntegrationTest` 对 `POST /jobs` |
|||
> 的 403 断言、`controller.AuthIntegrationTest` 的登录/刷新/登出全链路等)。**未因此放宽任何断言。** |
|||
|
|||
### 11.3 全量 |
|||
|
|||
``` |
|||
mvn clean verify |
|||
``` |
|||
|
|||
日志:`docs/acceptance/r01-d4-2-20260916/a6v-v3-verify.log` |
|||
|
|||
``` |
|||
[INFO] Tests run: 2278, Failures: 0, Errors: 0, Skipped: 0 |
|||
[INFO] BUILD SUCCESS |
|||
``` |
|||
|
|||
| 项 | 数值 | |
|||
|---|---| |
|||
| 全量测试总数 | **2278** | |
|||
| Failures | **0** ✔ | |
|||
| Errors | **0** ✔ | |
|||
| Skipped | **0** ✔ | |
|||
| 原实施基线 | 2265 | |
|||
| 独立新增 | **13**(`A6AccessorWiringIndependentTest` 6 + `A6IndependentVerificationTest` 7) | |
|||
| 2265 + 13 | **2278** ✔ 与 Surefire 汇总完全一致 | |
|||
|
|||
**无需要按 Surefire XML 另行解释的差异** —— 汇总行与预期逐位吻合。 |
|||
|
|||
### 11.4 `FairnessMonitoringControllerTest` 单跑(**不计入通过**) |
|||
|
|||
| 命令 | 结果 | |
|||
|---|---| |
|||
| `mvn -Dtest="FairnessMonitoringControllerTest" test` | **7 run / 6 failures / 0 errors / 0 skipped,BUILD FAILURE** | |
|||
|
|||
按任务书要求:**该单跑命令不写成通过**,其失败与准确根因见 §9。 |
|||
|
|||
--- |
|||
|
|||
## 12. OpenAPI |
|||
|
|||
``` |
|||
python ..\docs\verify_openapi.py |
|||
→ paths: 176 / operations: 214 |
|||
→ All checks passed! |
|||
``` |
|||
|
|||
`git diff -- docs/openapi-rc.json` → **空**,`openapi-rc.json` 零修改 ✔ |
|||
|
|||
--- |
|||
|
|||
## 13. Flyway / ErrorCode / DTO / 接口 |
|||
|
|||
| 项 | 结果 | |
|||
|---|---| |
|||
| `git diff --name-only -- mydreamjob-server/src/main/resources/db/migration` | **空** —— V1–V45 未改动 ✔ | |
|||
| `ErrorCode` | 未修改 ✔ | |
|||
| DTO / 响应体结构 | 未修改(仅取值来源改变) ✔ | |
|||
| 接口(路径 / 方法 / 参数) | 未新增、未删除、未改签名 ✔ | |
|||
| 依赖(`pom.xml`) | 未修改 ✔ | |
|||
|
|||
--- |
|||
|
|||
## 14. Git 状态 |
|||
|
|||
| 项 | 结果 | |
|||
|---|---| |
|||
| 分支 | `refactor/r01-structure-performance` | |
|||
| HEAD | `534afe686b966f175bcaf4af0726cec061092050`(未变) | |
|||
| `git diff --cached --name-only` | **空** | |
|||
| `git diff --check` | 仅 LF/CRLF 警告,exit 0 | |
|||
| 已暂存 / 已提交 / 已推送 | **均无** ✔(`git log -1` 仍为 A5 提交 `534afe6`) | |
|||
| 与远端关系 | `## refactor/r01-structure-performance...origin/refactor/r01-structure-performance`(无 ahead/behind) | |
|||
| 历史未跟踪文件 | 未删除 ✔ | |
|||
| 本轮验收日志 | 位于 `docs/acceptance/r01-d4-2-20260916/`,该路径被 `.gitignore:43 **/*.log` 忽略(`docs/acceptance` 受版本管理的文件数为 0),**不会进入任何提交** ✔ | |
|||
| 残留 Java/Maven 进程 | 无 ✔ | |
|||
|
|||
--- |
|||
|
|||
## 15. 明确声明 |
|||
|
|||
- **未暂存、未提交、未推送。** |
|||
- 未修改任何生产代码;未修改、删除或放宽任何既有测试断言; |
|||
未修改 `GlobalExceptionHandler`、`SecurityConfig`、Filter、Accessor、`ErrorCode`、DTO、 |
|||
OpenAPI、Flyway 迁移与 V1–V45。 |
|||
- 本轮仅新增 `docs/r01-a6-independent-verification-20260917.md` 与验收日志; |
|||
独立测试自身的两处缺陷已就地修正(§5.1),未触碰生产代码与既有测试。 |
|||
- 未进入 R01-B;**A 系列到此结束,不再设置 A7/A8**。 |
|||
- 本轮未启动 Docker,**未伪造真实 MySQL 结论**。 |
|||
|
|||
--- |
|||
|
|||
> **R01-A6独立代码复验通过,可以进入精确暂存与独立提交检查点;A系列到此结束,不再设置A7/A8。FairnessMonitoringControllerTest的单跑夹具缺陷须在A6提交后以独立纯测试提交修复。** |
|||
@ -0,0 +1,497 @@ |
|||
# R01-B 角色授权与异常映射收束报告 |
|||
|
|||
日期:2026-09-17 |
|||
分支:`refactor/r01-structure-performance` |
|||
HEAD:`84fe9f5febcf56910d5c978dd44e0e16d15d7209`(A6 后夹具修复提交) |
|||
本轮性质:**实施与自动化验证** —— 未暂存、未提交、未推送,待独立代码复验 |
|||
范围:**B1 角色授权重复收束 + B2 异常到 HTTP 状态映射收束**(不拆分子阶段,不进入 R01-C/E) |
|||
|
|||
--- |
|||
|
|||
## 0. 结论 |
|||
|
|||
> **R01-B角色授权与异常映射收束完成,待独立代码复验。** |
|||
|
|||
- 角色授权重复逻辑收束到唯一实现点 `security/UserRoleGuard`,**54 个调用点**改走公共 guard; |
|||
角色 guard 方法定义 **32 → 21**,`getRoles()` 角色比较 **46 处 / 31 文件 → 23 处 / 16 文件**; |
|||
- HTTP 状态映射收束到 `common/exception/ErrorHttpStatusRegistry`,`GlobalExceptionHandler` |
|||
不再持有映射表;**112 个 ErrorCode 的映射逐项保持不变**,未映射的 54 个仍回退 400; |
|||
- 收束过程中发现一处既有潜在缺陷(`ErrorCode` 166 个常量只有 165 个唯一数字码), |
|||
**已记录、未擅自修改**(见 §9.4)。 |
|||
|
|||
--- |
|||
|
|||
## 1. 前置 Git 状态 |
|||
|
|||
| 项 | 结果 | |
|||
|---|---| |
|||
| `git branch --show-current` | `refactor/r01-structure-performance` | |
|||
| `git rev-parse HEAD` | `84fe9f5febcf56910d5c978dd44e0e16d15d7209` | |
|||
| `git rev-list --left-right --count HEAD...@{upstream}` | `0 0` | |
|||
| `git diff --cached --name-only` | **空** | |
|||
| `git diff --check` | 仅 LF/CRLF 警告,**exit 0** | |
|||
| 与目标文件重叠的未提交修改 | **无**。`GlobalExceptionHandler.java` 在 `git status` 中显示 `M`,但 `git hash-object` = `git rev-parse HEAD:<file>` = `1fdc341e…`,`git diff --quiet` exit 0 —— 属 Windows stat 缓存假阳性,非真实修改 | |
|||
|
|||
--- |
|||
|
|||
## 2. 角色授权库存(重构前) |
|||
|
|||
搜索范围:`mydreamjob-server/src/main/java`。 |
|||
重构前共 **32 个**角色 guard 方法定义 + **7 个** `private boolean hasRole(User, Role)` 助手。 |
|||
|
|||
**共同语义(近乎逐字相同的复制)**: |
|||
|
|||
``` |
|||
User user = userRepository.findById(userId) |
|||
.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); |
|||
boolean ok = user.getRoles().stream().anyMatch(r -> r == User.Role.X); // user_roles |
|||
if (!ok) throw new BusinessException(ErrorCode.FORBIDDEN); |
|||
``` |
|||
|
|||
| 项 | 统一事实 | |
|||
|---|---| |
|||
| 输入来源 | 已解析出的 `userId`(由 `CurrentUserAccessor` 或调用方传入),**没有任何一处从请求参数取角色** | |
|||
| 角色来源 | **`user_roles` 关联表**(`user.getRoles()`)—— 31 个文件、46 处 `getRoles()`;`users.role` 单值列**不**参与授权判定(它只用于 JWT 声明签发) | |
|||
| 失败错误码 | 用户不存在 → `USER_NOT_FOUND`(4001);角色不符 → `FORBIDDEN`(2002) | |
|||
| HTTP 状态 | 4001 → 404;2002 → 403(经 §3 的映射表) | |
|||
| 是否含资源归属 | **不含**。归属(如 `assertOwnerOrThrow`、`findByOwnerUserId`)由各业务 Service 独立执行 | |
|||
| 是否允许 ADMIN | **不允许**(无隐式超级权限);`requireAny` 由调用方显式列出 ADMIN 才放行 | |
|||
| 可否安全委托 | 纯 void 角色门禁 → **可**;返回 `Company` 实体的 `requireCompany` → **不可整体委托**(见 §7) | |
|||
|
|||
--- |
|||
|
|||
## 3. HTTP 映射库存(重构前) |
|||
|
|||
`GlobalExceptionHandler.buildBusinessHttpStatuses()`(第 71–136 行)+ `register()`(第 138–147 行) |
|||
+ `resolveHttpStatus()`(第 67–69 行),共 **112 个 `ErrorCode` 引用**: |
|||
|
|||
| HttpStatus | 数量 | 备注 | |
|||
|---|---|---| |
|||
| CONFLICT | 77 | | |
|||
| NOT_FOUND | 27 | | |
|||
| FORBIDDEN | 5 | `FORBIDDEN`、`INTERVIEW_ACCESS_DENIED`、`APPLICATION_OWNER_MISMATCH`、`MATCH_REQUIRED_FOR_CONVERSATION`、`OFFER_COMPARE_NOT_OWNER` | |
|||
| TOO_MANY_REQUESTS | 3 | | |
|||
| **合计** | **112** | | |
|||
|
|||
| 项 | 结果 | |
|||
|---|---| |
|||
| 重复键 | **0**(112 个 `ErrorCode` 常量互不相同) | |
|||
| 重复项映射一致性 | 不适用 | |
|||
| 未显式映射的 ErrorCode | **54 / 166** → 一律回退 **HTTP 400** | |
|||
| 关键既有语义 | `UNAUTHORIZED(2001)` **未显式映射 → 400**(不是 401);`SERVICE_UNAVAILABLE`、`RATE_LIMITED`、`INTERNAL_ERROR` 同为 **400** | |
|||
| 已有测试锁定状态码 | **有**:`GlobalExceptionHandlerStatusContractTest` 以 `@EnumSource(ErrorCode.class)` 对**全部 166 个**错误码锁定状态 + 业务码 + message + requestId | |
|||
|
|||
**快照已保存**:`docs/acceptance/r01-b/b2-mapping-baseline.json`(112 条 `名称 → 状态`), |
|||
并逐条转写进 `GlobalExceptionHandlerMappingParityTest` 作为独立基线。 |
|||
|
|||
--- |
|||
|
|||
## 4. 修复前失败测试(红灯) |
|||
|
|||
| 观察 | 说明 | |
|||
|---|---| |
|||
| 角色授权 | 重构前**没有**公共 guard:32 个方法各自实现,无任何调用点经过统一入口。新增的 `RoleAuthorizationIntegrationTest` 以 `@SpyBean UserRoleGuard` 断言"端点确实调用公共 guard"——在重构前该 bean 不存在,测试无法编译/通过 | |
|||
| HTTP 映射 | 重构前映射表在 `GlobalExceptionHandler` 内部,**无法在初始化期检出重复键**(`register()` 仅在"同码不同状态"时抛错,同码同状态被静默容忍);新增的 `ErrorHttpStatusRegistryTest` 直接断言"同码重复注册必须抛 `IllegalStateException`"——重构前无从表达 | |
|||
| 未改变任何既有业务期望 | `GlobalExceptionHandlerStatusContractTest` 的 4 个状态桶与全部断言**逐字保留**,仅把其反射调用私有方法的用例改为调用 `registry.resolve(int)` | |
|||
|
|||
--- |
|||
|
|||
## 5. 公共角色 guard 设计 |
|||
|
|||
新增 `security/UserRoleGuard.java`(`@Component`,仅依赖 `UserRepository`)。 |
|||
|
|||
```java |
|||
void requireStudent(Long userId); void requireStudent(User user); |
|||
void requireCompany(Long userId); void requireCompany(User user); |
|||
void requireAdmin(Long userId); void requireAdmin(User user); |
|||
void requireAny(Long userId, User.Role... allowedRoles); |
|||
void requireAny(User user, User.Role... allowedRoles); |
|||
User requireUser(Long userId); // 存在性 + active 校验 |
|||
void requireRole(User user, User.Role... allowedRoles); |
|||
``` |
|||
|
|||
| 要求 | 落实 | |
|||
|---|---| |
|||
| 角色来自权威数据库 | 从 `user_roles`(`user.getRoles()`)读取,与全部被替换的实现一致 | |
|||
| 校验存在且 active | `requireUser` 不存在 → `USER_NOT_FOUND`;**存在但 inactive → `FORBIDDEN`(失败关闭)** | |
|||
| `users.role` 与 `user_roles` 核对 | 授权**只**依据 `user_roles`;`users.role` 与之不一致时按 `user_roles` 判定 → 缺少所需角色即拒绝(失败关闭)。**混用两者会改变既有语义,故未采纳** | |
|||
| 复用现有 ErrorCode | 只用 `USER_NOT_FOUND` / `FORBIDDEN`,**未新增任何 ErrorCode** | |
|||
| 不解析 SecurityContext | 只接收 `userId`;结构断言验证其声明字段**只有** `UserRepository` | |
|||
| 不处理 requestId | 无 `HttpServletRequest`、无 `RequestIdAccessor` | |
|||
| 不处理资源归属 | 类中无任何 ownership 逻辑 | |
|||
| 不记录敏感信息 | 无日志、无审计写入,无副作用 | |
|||
| 无隐式 ADMIN | `requireStudent`/`requireCompany` 对 ADMIN 同样拒绝,测试已锁定 | |
|||
| 查询次数受控 | `requireXxx(Long)` 恰好 **1** 次 `findById`;`requireXxx(User)` **0** 次;**无跨请求缓存**(用户被禁用/注销后立即失效) | |
|||
|
|||
检索确认:仓库中**原本不存在** `UserRoleGuard` 或等价的公共角色组件,故新增; |
|||
未新建重复角色枚举(复用既有 `User.Role`)。 |
|||
|
|||
--- |
|||
|
|||
## 6. 每个迁移调用点(54 处) |
|||
|
|||
`userRoleGuard.requireStudent` **37** 处、`requireAdmin` **13** 处、`requireCompany` **4** 处, |
|||
覆盖 **21 个生产文件**。 |
|||
|
|||
| # | 文件 | 原方法 | 迁移后 | |
|||
|---|---|---|---| |
|||
| 1 | `ability/AbilityAttemptService` | `requireStudent`(私有) | 删除,6 处改调 guard | |
|||
| 2 | `assessment/AssessmentAttemptService` | `requireStudent`(私有) | 删除,5 处改调 guard | |
|||
| 3 | `bidirectionalmatch/MatchRuleService` | `requireStudent`(私有) | 删除,2 处改调 guard | |
|||
| 4 | `careerprogress/CareerProgressService` | `assertStudentRole`(私有) | 删除,1 处改调 guard | |
|||
| 5 | `competition/CompetitionRankingService` | `assertStudentRole`(私有) | 删除,1 处改调 guard | |
|||
| 6 | `job/JobFavoriteService` | `assertStudentRole`(私有) | 删除,3 处改调 guard | |
|||
| 7 | `match/MatchService` | `assertStudentRole`(私有) | 删除,1 处改调 guard | |
|||
| 8 | `review/JobReviewService` | `assertStudentRole`+`assertAdminRole`(私有) | 删除,3+3 处改调 guard | |
|||
| 9 | `talentdiscovery/TalentDiscoveryService` | `requireStudent`(私有) | 删除,2 处改调 guard | |
|||
| 10 | `talentinvitation/TalentInvitationService` | `requireStudent`(私有) | 删除,3 处改调 guard | |
|||
| 11 | `recommendation/JobRecommendationService` | `requireStudent`+`requireAdmin`(私有) | 删除,3+2 处改调 guard | |
|||
| 12 | `candidatescore/governance/CandidateScoringGovernanceService` | `assertStudentRole`+`assertAdminRole`(私有) | 删除,3+4 处改调 guard | |
|||
| 13 | `candidatescore/dashboard/CompanyScoringDashboardService` | `assertAdminRole`(私有) | 删除,1 处改调 guard | |
|||
| 14 | `application/ApplicationController` | `assertCompanyRole`(私有) | 删除,3 处改调 guard | |
|||
| 15 | `safety/AdminReportController` | `requireAdmin(HttpServletRequest)` | 保留方法(返回 userId),角色部分委托 guard,3 处调用不变 | |
|||
| 16 | `risk/AdminRiskController` | `requireAdmin(HttpServletRequest)` | 同上,7 处调用不变;随之移除已不需要的 `UserRepository` 字段与 import | |
|||
| 17 | `company/CompanyService` | `assertStudentRole`+`assertCompanyRole`(公开) | 保留签名,方法体改为一行委托 | |
|||
| 18 | `profile/ProfileService` | `assertStudentRole`(公开) | 保留签名,一行委托 | |
|||
| 19 | `preference/PreferenceService` | `assertStudentRole`(公开) | 保留签名,一行委托 | |
|||
| 20 | `privacy/PrivacySettingsService` | `assertStudentRole`(公开) | 保留签名,一行委托 | |
|||
| 21 | `verification/CompanyVerificationService` | `assertAdminRole`(公开) | 保留签名,一行委托 | |
|||
|
|||
**公开方法为何保留签名**:它们已被跨包调用(如 `profileService.assertStudentRole` 被 |
|||
`ResumeVersionService`、`ResumeExportService`、`ProfileController` 等调用; |
|||
`companyService.assertStudentRole` 被 `CandidateScoreCardController`、`CandidateScoringController`、 |
|||
`StudentReviewWeightService` 调用)。保留签名、把**实现**收敛为一行委托,可在不扩大改动面的前提下 |
|||
消除重复逻辑。**每个调用点的原错误码(`USER_NOT_FOUND`/`FORBIDDEN`)、原角色集合、 |
|||
原调用顺序均未改变。** |
|||
|
|||
--- |
|||
|
|||
## 7. 保留调用点及理由(非等价,未迁移) |
|||
|
|||
| 类别 | 数量 | 保留理由 | |
|||
|---|---|---| |
|||
| `private Company requireCompany(Long)` | **7** —— `MatchRuleService`、`InterviewService`、`OfferService`、`CompanyReviewManagementService`、`CompanyReviewReplyService`、`CompanyVerificationService`、`CompanyScoringDashboardService` | **不是纯角色门禁**:除角色判定外还承担实体查找与归属语义(`companyRepository.findByOwnerUserId(userId).orElseThrow(COMPANY_NOT_FOUND)`),返回值被业务逻辑使用。整体委托公共 guard 会改变查询次数与错误码(`COMPANY_NOT_FOUND` vs `USER_NOT_FOUND`),**按任务书要求不迁移不等价逻辑** | |
|||
| `private boolean hasRole(User, Role)` | **7** —— `InterviewService`、`InterviewScoreService`、`MessagingService`、`OfferService`、`TalentDiscoveryService`、`TalentInvitationService`、`CompanyVerificationService` | 用于**资源归属/可见性分支**(如 `hasRole(user, STUDENT) && interview.getStudentUserId().equals(userId)`、`MessagingService` 中 ADMIN 可见性分支),不是"必须具有某角色"的门禁。**角色授权与资源归属是两件事**,迁移会改变语义 | |
|||
| `AdminReportController`/`AdminRiskController.requireAdmin(HttpServletRequest)` | 2 | 第 15/16 行已委托 guard;保留该方法是因为它还负责返回 `userId`,属 Controller 内的便捷封装 | |
|||
| `CompanyService` 等 5 个公开方法 | 5 | 见 §6 末尾说明 | |
|||
|
|||
**未为"grep 为零"迁移任何不等价逻辑。** |
|||
|
|||
--- |
|||
|
|||
## 8. 角色 / 资源归属边界 |
|||
|
|||
| 边界 | 事实 | |
|||
|---|---| |
|||
| 身份("我是谁") | 仍由 `CurrentUserAccessor` 负责;`UserRoleGuard` **不**解析 SecurityContext(结构测试断言其唯一依赖是 `UserRepository`) | |
|||
| 角色授权("我能不能") | 收束到 `UserRoleGuard` | |
|||
| 资源归属("这条数据是不是我的") | **仍由各业务 Service 执行**,未删除、未移动。`RoleAuthorizationIntegrationTest.ownershipRemainsWithBusinessService` 用"角色正确但无企业实体的 COMPANY 用户"访问 |
|||
`/company/candidates/{id}` → 得到 **404**(业务层归属/存在性判定),证明 guard 既没有替代也没有跳过归属校验 | |
|||
| 403 / 404 分层 | 保持既有:角色不符 403(2002);资源不存在 404(如 `RISK_EVENT_NOT_FOUND`) | |
|||
|
|||
--- |
|||
|
|||
## 9. B2 实现与逐项一致性 |
|||
|
|||
### 9.1 实现 |
|||
|
|||
新增 `common/exception/ErrorHttpStatusRegistry.java`: |
|||
|
|||
```java |
|||
public HttpStatus resolve(ErrorCode errorCode); |
|||
public HttpStatus resolve(int errorCode); // BusinessException 只暴露数字 code |
|||
public HttpStatus defaultStatus(); // 400 |
|||
public int explicitMappingCount(); // 112 |
|||
public static Map<ErrorCode, HttpStatus> explicitMappingsView(); |
|||
public static Map<Integer, HttpStatus> numericMappingsView(); // 111 |
|||
``` |
|||
|
|||
| 要求 | 落实 | |
|||
|---|---| |
|||
| 不可变映射 | 构建后 `Map.copyOf(...)`,运行期 `put`/`remove` 抛 `UnsupportedOperationException`(测试锁定) | |
|||
| 键类型 | **`ErrorCode` 常量**(见 §9.4);数字入口由该映射派生 | |
|||
| 初始化禁止重复键 | `Builder.put` 用 `putIfAbsent`,**同码重复注册即抛 `IllegalStateException`**(无论目标状态是否相同) | |
|||
| 显式映射集中一处 | 只在 `explicitMappings()` 内,全部 `ErrorCode.X` 符号,**无手写数字** | |
|||
| 未映射默认 400 | `DEFAULT_STATUS = BAD_REQUEST`,与重构前 `getOrDefault(code, BAD_REQUEST)` 一致 | |
|||
| 不引入反射扫描 | 无反射 | |
|||
| 不使用可运行时修改的 Map | 静态 final + `Map.copyOf` | |
|||
| Handler 只调用 registry | `GlobalExceptionHandler` 删除 `BUSINESS_HTTP_STATUSES`、`buildBusinessHttpStatuses()`、`register()`、`resolveHttpStatus()`;`handleBusinessException` 改为 `errorHttpStatusRegistry.resolve(errorCode)` | |
|||
| 同码映射到不同状态 | **未发现**(唯一同数字码的两个常量映射到同一状态,见 §9.4) | |
|||
|
|||
`ErrorHttpStatusRegistry` 通过 `@Import` 挂在 `GlobalExceptionHandler` 上,使 `@WebMvcTest` 切片 |
|||
(`CompanyControllerMvcTest`)无需改动即可解析到该组件。 |
|||
|
|||
### 9.2 响应契约未变 |
|||
|
|||
- 响应 JSON 结构、字段、业务码、message 全部不变(`@EnumSource` 全量断言) |
|||
- requestId 仍来自 `RequestIdAccessor`,测试断言 `"r01b-contract"` / `"r01b-parity"` |
|||
- 日志级别未变(`handleBusinessException` 仍为 `log.warn`) |
|||
- 未知 code 仍回退 **400** |
|||
- `ErrorCode` 数字、枚举成员**零修改**(`git diff` 为空) |
|||
- 未新增接口、DTO、依赖 |
|||
|
|||
### 9.3 逐项一致性证明 |
|||
|
|||
`GlobalExceptionHandlerMappingParityTest`: |
|||
|
|||
1. **显式映射集合** = 重构前快照的 112 个 `ErrorCode`(对**全部 166 个**错误码逐一核对: |
|||
快照中未显式映射的,重构后也不得显式映射); |
|||
2. **处理器实际返回状态** ≡ **registry 解析结果** ≡ **快照基线**(`@EnumSource` 166 参数化); |
|||
3. 未映射码(含 `UNAUTHORIZED(2001)`、`SERVICE_UNAVAILABLE`)保持 **400**; |
|||
4. `FORBIDDEN` 保持业务码 **2002** + HTTP **403**; |
|||
5. NOT_FOUND 类 404、CONFLICT 类 409、限流类 429; |
|||
6. 响应体不含异常类名、堆栈、内部路径。 |
|||
|
|||
### 9.4 收束过程中发现的既有潜在缺陷(已记录,未修改) |
|||
|
|||
**`ErrorCode` 166 个常量只有 165 个唯一数字码**:`REVIEW_ALREADY_DECIDED(9103)` 与 |
|||
`DUPLICATE_FEEDBACK(9103)` 共用同一数字码。 |
|||
|
|||
- 重构前该重复被 `register()` **静默容忍**(`putIfAbsent` 只在"同码不同状态"时抛错); |
|||
- 本轮的严格重复检测因此**在启动期暴露了它**; |
|||
- 二者当前都映射到 **CONFLICT**,故数字入口无歧义、行为与重构前完全一致; |
|||
- 按任务书"不修改 `ErrorCode` 数字、不新增 `ErrorCode`"的要求,**本轮不修改**, |
|||
仅把映射键改为 `ErrorCode` 常量(这也正是"键必须是 ErrorCode 或稳定错误码类型"的实践理由), |
|||
并以 `ErrorHttpStatusRegistryTest.distinctConstantsWithSharedNumericCodeAreAllowed` 锁定该事实。 |
|||
|
|||
**处置建议(留待后续独立提交)**:为 `DUPLICATE_FEEDBACK` 分配唯一数字码。若两个常量被映射到 |
|||
**不同状态**,注册表的 `indexByCode` 会在初始化期直接抛错,不会静默任选其一。 |
|||
|
|||
--- |
|||
|
|||
### 9.5 量化结果(前后对比) |
|||
|
|||
| 指标 | 重构前 | 重构后 | 说明 | |
|||
|---|---|---|---| |
|||
| 角色 guard 方法定义总数 | **32** | **21** | 其中 6 个是 `UserRoleGuard` 自身;6 个是保留签名的公开方法(方法体已是一行委托);8 个是实体返回型 `requireCompany`(非等价,保留);2 个是 Controller 的 `requireAdmin(request)`(已委托) | |
|||
| 重复 student guard 实现 | **16** | **1**(公共 guard) | 其余为公开委托方法/实体返回型 | |
|||
| 重复 company guard 实现 | **2**(纯角色门禁)+ 7(实体返回型) | **1**(公共 guard);7 个实体返回型保留 | |
|||
| 重复 admin guard 实现 | **7** | **1**(公共 guard) | | |
|||
| 已迁移调用点 | 0 | **54** | 37 student + 13 admin + 4 company | |
|||
| 明确保留的非等价调用点 | — | **14** | 7 个实体返回型 `requireCompany` + 7 个归属/可见性用 `hasRole` | |
|||
| `getRoles()` 角色比较 | **46 处 / 31 文件** | **23 处 / 16 文件** | 减少 50% | |
|||
| `GlobalExceptionHandler` 行数 | **232** | **146** | 净减 **86 行**;映射表、`register()`、`resolveHttpStatus()` 全部移出 | |
|||
| `resolveHttpStatus` 条件链 | 映射构建 66 行 + 注册 10 行 + 解析 3 行 | **0**(逻辑迁入 `ErrorHttpStatusRegistry`,261 行,含 javadoc 与显式 112 条映射) | | |
|||
| `ErrorCode.X.getCode()` 用于状态比较的次数 | 0(原为 `Map` 查找) | **0** | 状态解析无数字字面量比较 | |
|||
| 重复 HTTP 映射数 | 0(且**不可检测**) | **0**(且**初始化即拒绝**) | | |
|||
| 未映射 ErrorCode 数 | **54 / 166** → 400 | **54 / 166** → 400 | 逐项不变 | |
|||
| 角色授权 DB 查询次数 | 每处 1 次 `findById` | `requireXxx(Long)` **1 次**;`requireXxx(User)` **0 次**;无跨请求缓存 | | |
|||
|
|||
--- |
|||
|
|||
## 10. 查询次数 |
|||
|
|||
| 场景 | 数据库查询次数 | |
|||
|---|---| |
|||
| `requireStudent/Company/Admin/Any(Long userId)` | **恰好 1** 次 `users.findById`(`RoleAuthorizationGuardTest.queryCountIsBounded` 以 Mockito `times(1)` 锁定) | |
|||
| `requireStudent/Company/Admin/Any(User user)` | **0** 次(供已加载实体的调用点使用) | |
|||
| 一个请求内相同用户重复角色检查 | 调用方已加载实体时应使用实体重载;现有调用点每个请求路径至多 1 次 guard 调用 | |
|||
| 跨请求缓存 | **无**(不缓存 active 状态,注销/禁用后立即失效) | |
|||
| Redis 依赖 | **无** | |
|||
| 事务边界 | **未改变**(guard 只读、无 `@Transactional`) | |
|||
| 单次授权的 N+1 | **无**:guard 为单条主键查询,`user_roles` 由 JPA 关联加载 | |
|||
|
|||
--- |
|||
|
|||
## 11. 测试结果 |
|||
|
|||
日志目录:`docs/acceptance/r01-b/` |
|||
|
|||
| # | 命令 | 结果 | 日志 | |
|||
|---|---|---|---| |
|||
| 1 | `mvn -Dtest="RoleAuthorizationGuardTest,RoleAuthorizationIntegrationTest,ErrorHttpStatusRegistryTest,GlobalExceptionHandlerMappingParityTest" test` | **371 / 371**,F=0 E=0 S=0 ✔ | `b1b2-tests.log` | |
|||
| 2 | `mvn -Dtest="*Security*Test,*Authorization*Test,*Role*Test,*Admin*Controller*Test,*Company*Controller*Test,*Student*Controller*Test,*GlobalExceptionHandler*Test,*BusinessException*Test" test` | **448 中 6 项失败** —— 全部为**既有夹具缺陷**(§11.3),非本轮引入 | `b1-related.log` | |
|||
| 3 | `mvn test` | **2656 / 2656**,Failures=**0**,Errors=**0**,Skipped=**0**,BUILD SUCCESS ✔ | `b-full-mvntest.log` | |
|||
| 4 | `mvn clean verify` | 见 §11.4 | `b-clean-verify.log` | |
|||
| 5 | `python ..\docs\verify_openapi.py` | **176 paths / 214 operations**,All checks passed ✔ | — | |
|||
|
|||
### 11.1 新增测试逐类结果 |
|||
|
|||
| 测试类 | Tests | 覆盖 | |
|||
|---|---|---| |
|||
| `RoleAuthorizationGuardTest` | 16 | STUDENT/COMPANY/ADMIN 通过各自 guard;跨角色拒绝;ADMIN 无隐式超级权限;inactive 与 `active=null` 失败关闭;角色缺失;多角色;`users.role` 与 `user_roles` 不一致失败关闭;错误码/HTTP 契约;不解析 requestId/SecurityContext;查询次数 | |
|||
| `RoleAuthorizationIntegrationTest` | 10 | 三个真实端点(ADMIN `/admin/scoring/risk/overview`、STUDENT `/profile/me/career-progress`、COMPANY `/company/candidates`)经真实 SecurityFilterChain 验证 401/403/404 分层;接线变异(`@SpyBean` 断言 guard 确被调用);资源归属仍由业务层判定 | |
|||
| `ErrorHttpStatusRegistryTest` | 174 | 112 条显式映射、111 个唯一数字码、四状态白名单、逐 ErrorCode 自洽、未映射回退 400、重复键初始化拒绝、不可变、键必须为 ErrorCode、9103 共用数字码 | |
|||
| `GlobalExceptionHandlerMappingParityTest` | 171 | 显式映射集合 = 重构前快照;处理器返回 ≡ registry ≡ 快照(166 参数化);FORBIDDEN 2002/403;`UNAUTHORIZED(2001)` 保持 400;服务不可用保持 400;响应体无内部细节 | |
|||
|
|||
### 11.2 测试总数 |
|||
|
|||
| 项 | 数值 | |
|||
|---|---| |
|||
| R01-B 前基线 | **2285** | |
|||
| 本轮新增 | **371** | |
|||
| 预期合计 | 2656 | |
|||
| 实测 (`mvn test`) | **2656** ✔ 与预期逐位一致 | |
|||
|
|||
### 11.3 关联回归中暴露的**既有**夹具缺陷(与本轮无关,如实记录) |
|||
|
|||
`mvn -Dtest="*Admin*Controller*Test,..."` 单独运行时,**既有**的 |
|||
`AdminReviewControllerTest`(4 项)与 `CompanyReviewManagementControllerTest`(2 项) |
|||
出现 `expected 200/403/409 but was 401`。根因与 A6 独立复验 §9 定位的**同一缺陷**: |
|||
|
|||
``` |
|||
AuthSessionService - session validation rejected: user not usable |
|||
JwtAuthenticationFilter - JWT authentication rejected: session not active |
|||
``` |
|||
|
|||
- `AdminReviewControllerTest` 使用 `ADMIN_ID = 1L`;`CompanyReviewManagementControllerTest` 使用 `token(1L, "COMPANY")` / `token(2L, "STUDENT")`; |
|||
- `V1_1` 种子账号占用 `users.id = 1/2/3` 且被 `V43` 置为 `inactive`,而 `TestAuthTokens` 对"已存在但 inactive"的行不插入也不激活; |
|||
- 全量顺序下 `com.mydreamjob.controller.AuthIntegrationTest` 的 `deleteAll()` 偶然清掉种子行,问题被掩盖 —— 故 §11.2 的全量结果是干净的; |
|||
- 日志中该警告恰好 6 次,分别落在上述两个类,**无一处落在本轮改动的代码路径**; |
|||
- 本轮**未修改**这两个测试类,也**未**把该失败写成通过。 |
|||
|
|||
> 该缺陷与 `FairnessMonitoringControllerTest` 属同一类,已在 A6 后以独立提交 `84fe9f5` 修复 |
|||
> **其中一个类**;其余同类类别的隔离修复应另开独立纯测试提交,不并入 R01-B。 |
|||
|
|||
### 11.4 `mvn clean verify` |
|||
|
|||
``` |
|||
[INFO] Tests run: 2656, Failures: 0, Errors: 0, Skipped: 0 |
|||
[INFO] BUILD SUCCESS |
|||
``` |
|||
|
|||
| 项 | 数值 | |
|||
|---|---| |
|||
| 全量测试总数 | **2656**(不低于收束前 2285 ✔) | |
|||
| Failures / Errors / Skipped | **0 / 0 / 0** ✔ | |
|||
| 日志 | `docs/acceptance/r01-b/b-clean-verify.log` | |
|||
|
|||
--- |
|||
|
|||
## 12. OpenAPI |
|||
|
|||
``` |
|||
python ..\docs\verify_openapi.py |
|||
→ paths: 176 / operations: 214 |
|||
→ All checks passed! |
|||
``` |
|||
|
|||
`git diff -- docs/openapi-rc.json` → **空**,`openapi-rc.json` 零修改 ✔ |
|||
|
|||
--- |
|||
|
|||
## 13. 精确修改文件 |
|||
|
|||
**新增生产代码(2)** |
|||
|
|||
``` |
|||
mydreamjob-server/src/main/java/com/mydreamjob/security/UserRoleGuard.java |
|||
mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorHttpStatusRegistry.java |
|||
``` |
|||
|
|||
**修改生产代码(22)** |
|||
|
|||
``` |
|||
ability/AbilityAttemptService.java |
|||
application/ApplicationController.java |
|||
assessment/AssessmentAttemptService.java |
|||
bidirectionalmatch/MatchRuleService.java |
|||
candidatescore/dashboard/CompanyScoringDashboardService.java |
|||
candidatescore/governance/CandidateScoringGovernanceService.java |
|||
careerprogress/CareerProgressService.java |
|||
common/exception/GlobalExceptionHandler.java ← B2 |
|||
company/CompanyService.java |
|||
competition/CompetitionRankingService.java |
|||
job/JobFavoriteService.java |
|||
match/MatchService.java |
|||
preference/PreferenceService.java |
|||
privacy/PrivacySettingsService.java |
|||
profile/ProfileService.java |
|||
recommendation/JobRecommendationService.java |
|||
review/JobReviewService.java |
|||
risk/AdminRiskController.java |
|||
safety/AdminReportController.java |
|||
talentdiscovery/TalentDiscoveryService.java |
|||
talentinvitation/TalentInvitationService.java |
|||
verification/CompanyVerificationService.java |
|||
``` |
|||
|
|||
**新增测试(5)** |
|||
|
|||
``` |
|||
common/r01b/RoleAuthorizationGuardTest.java |
|||
common/r01b/RoleAuthorizationIntegrationTest.java |
|||
common/r01b/ErrorHttpStatusRegistryTest.java |
|||
common/r01b/GlobalExceptionHandlerMappingParityTest.java |
|||
common/r01b/UserRoleGuardTestSupport.java ← 测试支持(非测试类) |
|||
``` |
|||
|
|||
**修改测试(21)** —— 仅接线,未放宽任何断言: |
|||
|
|||
``` |
|||
careerprogress/CareerProgressServiceTest.java ← 构造器参数补 guard(真实实例) |
|||
common/exception/GlobalExceptionHandler{AccountDeletion,HttpStatus,ReportBlock,StatusContract}Test.java ← 构造器补 registry |
|||
candidatescore/governance/CandidateScoringGovernanceServiceTest.java |
|||
profile/ResumeServiceTest.java |
|||
review/JobReviewDimensionServiceTest.java |
|||
assessment/AssessmentAttemptServiceTest.java |
|||
match/MatchServiceTest.java |
|||
privacy/PrivacySettingsServiceTest.java |
|||
review/JobReviewServiceTest.java |
|||
job/JobFavoriteServiceTest.java |
|||
company/CompanyServiceTest.java |
|||
preference/PreferenceServiceTest.java |
|||
competition/CompetitionRankingServiceTest.java |
|||
ability/AbilityAttemptServiceTest.java |
|||
bidirectionalmatch/MatchRuleServiceTest.java |
|||
verification/CompanyVerificationServiceTest.java |
|||
talentdiscovery/TalentDiscoveryServiceTest.java |
|||
recommendation/JobRecommendationServiceTest.java |
|||
``` |
|||
|
|||
> 这 16 个 `@InjectMocks` 单元测试类新增了 `@Mock UserRoleGuard` 并在 `@BeforeEach` |
|||
> 通过 `UserRoleGuardTestSupport.delegateToRealGuard(...)` 把 mock **委托给真实 guard**, |
|||
> 复用测试自身已 stub 的 `UserRepository`。这样"角色不符 → 403"的既有断言**语义不变**, |
|||
> 没有被削弱成"只验证调用了一次"。 |
|||
|
|||
**文档(2)** |
|||
|
|||
``` |
|||
docs/r01-b-authorization-exception-mapping-report.md (新增) |
|||
docs/backend-development-roadmap.md (更新:A 系列完成 / 84fe9f5 / R01-B 状态 / 下一阶段 R01-C) |
|||
``` |
|||
|
|||
--- |
|||
|
|||
## 14. Flyway / ErrorCode / 接口 / DTO 是否变化 |
|||
|
|||
| 项 | 结果 | |
|||
|---|---| |
|||
| `git diff --name-only -- mydreamjob-server/src/main/resources/db/migration` | **空** —— V1–V45 未改动 | |
|||
| `ErrorCode.java` | **未修改**(数字、成员、message 全部不变) | |
|||
| 接口(路径/方法/参数/响应结构) | **未修改**(未新增、未删除、未改签名) | |
|||
| DTO | **未修改** | |
|||
| `docs/openapi-rc.json` | **未修改** | |
|||
| 依赖(`pom.xml`) | **未修改** | |
|||
| 新依赖 | **未引入**(guard 只用 Spring Data JPA 与既有 `ErrorCode`) | |
|||
|
|||
--- |
|||
|
|||
## 15. Git 状态 |
|||
|
|||
| 项 | 结果 | |
|||
|---|---| |
|||
| 分支 / HEAD | `refactor/r01-structure-performance` / `84fe9f5…`(未变) | |
|||
| `git diff --cached --name-only` | **空** | |
|||
| `git diff --check` | 仅 LF/CRLF 警告,exit 0 | |
|||
| 已暂存 / 已提交 / 已推送 | **均无** | |
|||
| 历史未跟踪文件 | 未删除 | |
|||
|
|||
--- |
|||
|
|||
## 16. 未暂存、未提交、未推送 |
|||
|
|||
**确认:未暂存、未提交、未推送。** 本轮未使用 `git add`、未创建提交、未推送; |
|||
未执行 `reset`/`restore`/`checkout`/`clean`;未操作 Docker;未修改 V1–V45。 |
|||
|
|||
--- |
|||
|
|||
## 17. 未进入 R01-C/E |
|||
|
|||
**确认:未进入 R01-C、未进入 R01-E。** 本轮未处理 `overviewStatus`、推荐权重、 |
|||
高校名单与评分阈值、N+1、会话未读数、requestId、身份主体解析、Token、评分快照、 |
|||
新接口与数据库迁移中的任何一项。 |
|||
|
|||
--- |
|||
|
|||
> **R01-B角色授权与异常映射收束完成,待独立代码复验。** |
|||
@ -0,0 +1,19 @@ |
|||
# R01-B 业务异常 HTTP 状态映射收束实施指令 |
|||
|
|||
## 1. 目标 |
|||
|
|||
在不改变任何对外行为的前提下,将 `GlobalExceptionHandler.resolveHttpStatus` 的长 `if` 链收束为集中、不可变、可完整验证的整数业务码映射。 |
|||
|
|||
## 2. 硬边界 |
|||
|
|||
- 不修改 `ErrorCode` 的常量、数值或文案; |
|||
- 不新增接口、Flyway、表、索引、依赖; |
|||
- 不改变 HTTP 状态、响应业务码、message、requestId 或兜底语义; |
|||
- 必须继续按整数业务码解析,因为历史常量可能共享数值; |
|||
- 同一整数码若被登记为不同 HTTP 状态,应用初始化必须失败,不能静默覆盖; |
|||
- 未显式登记的整数码继续返回 HTTP 400; |
|||
- 不进入 R01-C/D/E,不提交、不推送。 |
|||
|
|||
## 3. 测试门槛 |
|||
|
|||
先建立全量特征测试,对 `ErrorCode.values()` 每个常量逐一断言 HTTP 状态、业务码、message、requestId,并单独验证未知整数码回退 400。随后运行异常关联测试、`mvn clean verify` 与静态 OpenAPI 校验,Failures/Errors/Skipped 必须全部为 0。 |
|||
@ -0,0 +1,46 @@ |
|||
# R01-B 业务异常 HTTP 状态映射收束实施报告 |
|||
|
|||
> 状态:R01-B 已完成实现和自动化验证,待独立代码复验。 |
|||
|
|||
## 1. 基线与范围 |
|||
|
|||
- 分支:`refactor/r01-structure-performance` |
|||
- 起点:`0b5b849 refactor: centralize request identity and request ID handling` |
|||
- 仅修改异常 HTTP 状态解析和对应特征测试;未进入其他 R01 子阶段。 |
|||
- 开始前暂存区和已跟踪工作区为空;历史未跟踪产物完整保留。 |
|||
|
|||
## 2. 实现 |
|||
|
|||
`GlobalExceptionHandler` 使用不可变 `Map<Integer, HttpStatus>` 集中登记 403、404、409、429;未登记业务码继续回退 400。登记辅助方法使用 `putIfAbsent`,同一整数业务码若出现不同状态会抛固定初始化异常,避免静默覆盖。 |
|||
|
|||
映射键保持为整数而非 `ErrorCode`:仓库存在历史共享数值常量,生产处理入口收到的也是整数业务码。此次未修改或“修正”任何历史常量。 |
|||
|
|||
## 3. 特征测试 |
|||
|
|||
新增 `GlobalExceptionHandlerStatusContractTest`: |
|||
|
|||
- 对全部现有 `ErrorCode` 常量逐项调用正式 `handleBusinessException`; |
|||
- 独立快照四类显式状态集合,其余断言为 400; |
|||
- 同时断言业务码、message、requestId 原样保持; |
|||
- 通过反射只读调用私有解析器,证明未知整数码仍回退 400,未为测试扩大生产 API。 |
|||
|
|||
第一次定向编译曾因测试错误地调用不存在的 `BusinessException(int, String)` 构造器失败;整改为反射验证私有兜底后通过。未修改生产 API,也未删除或放宽断言。 |
|||
|
|||
## 4. 量化与验证 |
|||
|
|||
| 项目 | 结果 | |
|||
|---|---| |
|||
| `GlobalExceptionHandler` 行数 | 328 → 232 | |
|||
| `errorCode == ErrorCode...` 比较 | 121 → 0 | |
|||
| 异常定向/关联集合 | 207/207,0/0/0 | |
|||
| `mvn clean verify` | 2082/2082,Failures=0、Errors=0、Skipped=0,BUILD SUCCESS | |
|||
| OpenAPI | 176 paths / 214 operations,All checks passed | |
|||
|
|||
## 5. 边界 |
|||
|
|||
- `ErrorCode` 未修改; |
|||
- V1–V43 未修改,无新增迁移; |
|||
- 无新增公网接口、依赖、表或索引; |
|||
- 未操作 Docker; |
|||
- 未暂存、未提交、未推送; |
|||
- 未删除历史未跟踪文件。 |
|||
@ -0,0 +1,617 @@ |
|||
# R01-B 独立代码复验报告(角色授权与异常映射收束) |
|||
|
|||
日期:2026-09-18 |
|||
分支:`refactor/r01-structure-performance` |
|||
HEAD:`84fe9f5febcf56910d5c978dd44e0e16d15d7209`(A6 后夹具修复提交,复验全程未变) |
|||
复验对象:`docs/r01-b-authorization-exception-mapping-report.md` |
|||
性质:**仅复验** —— 未修改生产代码、未放宽既有断言、未暂存、未提交、未推送 |
|||
|
|||
--- |
|||
|
|||
## 0. 结论 |
|||
|
|||
> **R01-B独立代码复验通过,可以进入精确暂存与独立提交检查点;两个Controller测试夹具缺陷须在R01-B提交后以独立纯测试提交修复。** |
|||
|
|||
- 角色授权:54 个调用点全部真实存在且仅经 HTTP 入口可达;保留的 14 个点**确为不等价**; |
|||
**未发现角色/归属/错误码/事务语义变化**。 |
|||
- HTTP 映射:从 `HEAD` 独立提取的 **166 项基线逐项一致(0 处不一致)**;112 显式、54 默认 400; |
|||
`UNAUTHORIZED` 仍 400、`FORBIDDEN` 仍 403、`SERVICE_UNAVAILABLE` 仍 400。 |
|||
- 9103 重复码:**运行时确实无法区分**(`BusinessException` 只保存 `int`)。未夸大为"已解决身份歧义"; |
|||
已证明两者状态相同故 HTTP 行为一致,且"同数字不同状态"会初始化失败而非静默任选。 |
|||
- 测试委托模式:**真实**。变异实验(把委托改为无条件下放行)令 63 个负向用例中 **50 个失败**。 |
|||
- 仅有两个**既有**测试夹具缺陷(`AdminReviewControllerTest`、`CompanyReviewManagementControllerTest`), |
|||
已用证据证明其失败发生在安全链认证阶段、**未进入 R01-B 代码**。 |
|||
- **一处需如实说明的环境偏差**:本轮 `mvn clean verify` 为 `2681 / F=0 / E=0 / Skipped=26`。 |
|||
26 个跳过全部是 `@Testcontainers(disabledWithoutDocker = true)` 的 MySQL 用例, |
|||
因本机 **Docker 守护进程未运行**而按设计跳过;上一轮同代码全量为 `Skipped=0` |
|||
(这些用例执行并通过)。**不声称 Skipped=0**,原因与自洽性见 §14.1。 |
|||
**不构成 R01-B 阻断项。** |
|||
|
|||
--- |
|||
|
|||
## 1. 分支、HEAD 与 Git 状态 |
|||
|
|||
| 项 | 结果 | |
|||
|---|---| |
|||
| `git branch --show-current` | `refactor/r01-structure-performance` | |
|||
| `git rev-parse HEAD` | `84fe9f5febcf56910d5c978dd44e0e16d15d7209` | |
|||
| `git rev-list --left-right --count HEAD...@{upstream}` | `0 0` ✔ | |
|||
| `git diff --cached --name-only` | **空** ✔ | |
|||
| `git diff --check` | **exit 0**,无空白错误 ✔ | |
|||
| R01-B 改动位置 | 全部仍在工作区(22 生产 + 21 测试 + 1 文档) | |
|||
| R01-B 之外的新生产改动 | **无**(逐文件比对允许清单,无越界) | |
|||
|
|||
--- |
|||
|
|||
## 2. 角色库存独立计数 |
|||
|
|||
独立复验者自行从 `HEAD` 与工作区分别提取(正则按方法签名匹配,不采信实施报告数字): |
|||
|
|||
| 项 | HEAD | 工作区 | |
|||
|---|---|---| |
|||
| 角色 guard 方法定义总数 | **32** | **21** | |
|||
| `private boolean hasRole(User, Role)` 助手 | 7 | 7 | |
|||
| `getRoles()` 出现次数 / 文件数 | 46 / 31 | 23 / 17 | |
|||
| 调用公共 guard 的点位 | 0 | **54** | |
|||
|
|||
工作区 21 个定义的构成(逐条列出于下): |
|||
|
|||
| 类别 | 数量 | 明细 | |
|||
|---|---|---| |
|||
| `UserRoleGuard` 自身 | **6** | `requireStudent/Company/Admin` × (`Long`, `User`) | |
|||
| 公开兼容委托(方法体一行) | **6** | `CompanyService.assertCompanyRole`、`CompanyService.assertStudentRole`、`PreferenceService.assertStudentRole`、`PrivacySettingsService.assertStudentRole`、`ProfileService.assertStudentRole`、`CompanyVerificationService.assertAdminRole` | |
|||
| 实体返回型 `requireCompany`(非等价,保留) | **7** | `MatchRuleService`、`CompanyScoringDashboardService`、`InterviewService`、`OfferService`、`CompanyReviewManagementService`、`CompanyReviewReplyService`、`CompanyVerificationService` | |
|||
| Controller 便捷包装(返回 userId,已委托 guard) | **2** | `AdminRiskController.requireAdmin(HttpServletRequest)`、`AdminReportController.requireAdmin(HttpServletRequest)` | |
|||
|
|||
6 + 6 + 7 + 2 = **21** ✔ 与工作区实测一致。 |
|||
|
|||
> 与实施报告的一处**非语义差异**:实施报告 §9.5 写 `getRoles()` 为「23 处 / 16 文件」, |
|||
> 独立实测为「23 处 / **17** 文件」(出现次数一致,文件计数差 1)。 |
|||
> 该差异不影响任何结论,仅记录以备核对。 |
|||
|
|||
--- |
|||
|
|||
## 3. 54 个迁移调用点对账 |
|||
|
|||
逐调用点清单(文件 / 方法 / 旧 guard / 新 guard / 角色 / 错误码 / 归属 / 查询次数)已落盘: |
|||
`docs/acceptance/r01-b/iv-role-callsites.tsv`(54 行 + 表头)。 |
|||
|
|||
按文件汇总: |
|||
|
|||
| 文件 | 调用点 | |
|||
|---|---| |
|||
| `ability/AbilityAttemptService` | 6 | |
|||
| `assessment/AssessmentAttemptService` | 5 | |
|||
| `candidatescore/governance/CandidateScoringGovernanceService` | 7 | |
|||
| `review/JobReviewService` | 6 | |
|||
| `recommendation/JobRecommendationService` | 5 | |
|||
| `application/ApplicationController` | 3 | |
|||
| `job/JobFavoriteService` | 3 | |
|||
| `talentinvitation/TalentInvitationService` | 3 | |
|||
| `bidirectionalmatch/MatchRuleService` | 2 | |
|||
| `company/CompanyService` | 2 | |
|||
| `talentdiscovery/TalentDiscoveryService` | 2 | |
|||
| `candidatescore/dashboard/CompanyScoringDashboardService` | 1 | |
|||
| `careerprogress/CareerProgressService` | 1 | |
|||
| `competition/CompetitionRankingService` | 1 | |
|||
| `match/MatchService` | 1 | |
|||
| `preference/PreferenceService` | 1 | |
|||
| `privacy/PrivacySettingsService` | 1 | |
|||
| `profile/ProfileService` | 1 | |
|||
| `risk/AdminRiskController` | 1 | |
|||
| `safety/AdminReportController` | 1 | |
|||
| `verification/CompanyVerificationService` | 1 | |
|||
| **合计** | **54** | |
|||
|
|||
| 独立核对项 | 结果 | |
|||
|---|---| |
|||
| 迁移调用点总数 | **54** ✔ | |
|||
| student / admin / company 分布 | **37 / 13 / 4** ✔(与实施报告一致) | |
|||
| 重复统计 | **无**(同一行不会被两个正则重复计入) | |
|||
| 32 → 21 方法数 | ✔ 逐方法差异已列出:HEAD 独有 17 个定义被删除,工作区新增 `UserRoleGuard` 的 6 个 | |
|||
| 是否删除角色检查 | **否** —— 被删方法的 HEAD 调用点数合计 **46**,对应迁移后 54 个调用点(含新增的 `MatchRuleService` 2 处与委托内调用),无一处丢失 | |
|||
| 是否扩大 ADMIN 权限 | **否** —— 未引入任何 `requireAny(..., ADMIN)`;ADMIN 仍被 STUDENT/COMPANY 门禁拒绝(HTTP 实测 403) | |
|||
| 是否把 404/业务不存在改成 403 | **否** —— `USER_NOT_FOUND` 仍映射 404;`COMPANY_NOT_FOUND` 等业务码未变;实测验端点返回 404 而非 403 | |
|||
|
|||
--- |
|||
|
|||
## 4. 14 个保留点及其不等价性核验 |
|||
|
|||
| 类别 | 数量 | 独立核验结论 | |
|||
|---|---|---| |
|||
| `private Company requireCompany(Long)` | 7 | **确为不等价**:除角色判定外还执行 `companyRepository.findByOwnerUserId(...)` 并抛 `COMPANY_NOT_FOUND`,返回值被业务使用。用 void guard 替换会丢失实体与错误码语义,且改变查询次数 | |
|||
| `private boolean hasRole(User, Role)` | 7 | **确为不等价**:返回**布尔值**用于**分支选择**(如 `listMyInterviews` 按 COMPANY/STUDENT 走不同查询;`listOffers` 还要先取 company 实体),并含归属判定(`hasRole(user, STUDENT) && interview.getStudentUserId().equals(userId)`)。void guard 无法表达"值" | |
|||
|
|||
已逐处读取源码确认(例:`InterviewService.listMyInterviews`、`OfferService.listOffers` 的 |
|||
`if (hasRole(...)) {...} if (hasRole(...)) {...} else 403` 分支结构)。 |
|||
**14 与 21 的关系**:21 个方法 = 6(guard 自身)+ 6(一行委托)+ 7(实体返回型)+ 2(Controller 包装); |
|||
其中真正"不等价保留"的是 **7 个实体返回型 `requireCompany` + 7 个 `hasRole` 助手 = 14**。 |
|||
|
|||
**一处残留观察(非阻断)**:`ApplicationController.getCompanyCandidateDetail` 先调 |
|||
`requireCompany(userId)`,随后调用的 `companyService.findCompanyByOwnerUserId(userId)` 内部又调 |
|||
`assertCompanyRole(userId)` —— 同一请求 2 次角色检查。经与 `HEAD` 逐行比对,**该重复在收束前就存在** |
|||
(HEAD 的 `assertCompanyRole` 同样被 Controller 与 `findCompanyByOwnerUserId` 各调一次), |
|||
**非本轮引入**,本轮未改变查询次数。可作为后续独立优化点。 |
|||
|
|||
--- |
|||
|
|||
## 5. inactive 语义分析 |
|||
|
|||
| 项 | 结论 | |
|||
|---|---| |
|||
| 用户不存在 | `USER_NOT_FOUND`(4001) → 404(沿用既有错误码,未新增) | |
|||
| 用户存在但 inactive | **`FORBIDDEN`(2002) → 403**(新增的失败关闭;既有实现无此校验) | |
|||
| `active` 为 `null` | 同样拒绝(`isActive` 要求 `Boolean.TRUE`) | |
|||
| 角色不足 | `FORBIDDEN`(2002) → 403(与既有完全一致) | |
|||
| 与既有实现的差异面 | **仅"inactive 用户"这一种输入**:收束前会通过,收束后拒绝 | |
|||
|
|||
**HTTP 入口的 inactive 用户在 guard 之前已被拒绝**(证据链,非推测): |
|||
|
|||
1. `JwtAuthenticationFilter.authenticate` 在建立认证前调用 |
|||
`authSessionService.isSessionActive(...)`; |
|||
2. `AuthSessionService.isSessionActive` 第一步 `isUserUsable(userId)` → |
|||
`user.isPresent() && isUsable(user)`,而 `isUsable` 只检查 `user.getActive()`; |
|||
3. 为 false 时 `SecurityContextHolder.clearContext()` 并直接返回,**请求根本不会进入 Controller**; |
|||
4. 因此对 HTTP 入口而言,guard 的 active 检查是**第二道**,观察不到行为差异。 |
|||
|
|||
--- |
|||
|
|||
## 6. 非 HTTP 入口分析 |
|||
|
|||
对全部 **54 个迁移调用点**逐个追溯调用链,结论:**全部仅经 HTTP 入口可达**。 |
|||
|
|||
| 入口类型 | 独立核验 | |
|||
|---|---| |
|||
| HTTP Controller 链路 | 54/54 调用点的宿主方法均由 `@RestController` 直达(21 个文件逐一确认) | |
|||
| 定时任务 | 全仓 `@Scheduled` 类共 8 个:`MatchNotificationScheduler`、`CandidateScoringRefreshScheduler`、`PersonalDataExportWorker`、`AccountDeletionService`、`NotificationConsumer`、`OfferExpiryScheduler`、`RiskEventDetector`、`RiskEventScanScheduler` —— **无一个注入或调用任何被迁移的服务或 `UserRoleGuard`** | |
|||
| Worker | 同上(`PersonalDataExportWorker`、`CandidateScoringRefreshWorker`、`AccountDeletionCompletionWorker` 的依赖已逐一列出核对) | |
|||
| 消息消费者 | `NotificationConsumer` 仅依赖 notification 三件套,未触碰 | |
|||
| 内部 Service 调用 | 非 Controller 的调用者仅 4 处:`ResumeVersionService`/`ResumeExportService`/`StudentReviewWeightService`(均只被 Controller 调用)、`MatchService.diagnose`(唯一调用者是 `MatchController`)。**均为 HTTP 链路** | |
|||
| 注销/导出/补偿流程 | `AccountDeletionService`、`PersonalDataExportWorker`、补偿扫描器均未触碰迁移点 | |
|||
| 管理后台任务 | `AdminRiskController`/`AdminReportController` 的 `requireAdmin` 只被各自 HTTP 端点调用 | |
|||
|
|||
**因此:不存在"合法的历史数据处理、注销收尾或后台任务需要对 inactive 用户执行受保护动作"的场景。** |
|||
独立变量验证:`MatchService.diagnose` 的唯一调用方是 `MatchController`(`grep "\.diagnose("` 全仓仅 1 处)。 |
|||
|
|||
--- |
|||
|
|||
## 7. 查询次数 |
|||
|
|||
| 场景 | 独立实测 | |
|||
|---|---| |
|||
| `requireXxx(Long userId)` | **恰好 1** 次 `users.findById`(Mockito `times(1)` + `verifyNoMoreInteractions` 锁定) | |
|||
| `requireXxx(User user)` | **0** 次(`verifyNoMoreInteractions` 覆盖) | |
|||
| guard 写库 | **0**(`never()).save/deleteById/delete`) | |
|||
| 单次授权 N+1 | **无**(单条主键查询;`user_roles` 由 JPA 关联加载) | |
|||
| 跨请求缓存 | **无**(guard 无状态字段,唯一依赖 `UserRepository`;用户禁用后立即失效) | |
|||
| Redis / SecurityContext / requestId | **均未触碰**(结构断言 + 行为断言双重证明) | |
|||
| 每请求角色检查次数 | 多数路径 1 次;`/company/candidates/{id}` 为 2 次 —— **与 HEAD 相同,非回归**(见 §4) | |
|||
|
|||
--- |
|||
|
|||
## 8. 测试委托模式是否真实(含变异验证) |
|||
|
|||
### 8.1 静态核验 |
|||
|
|||
| 项 | 结果 | |
|||
|---|---| |
|||
| 使用 `delegateToRealGuard` 的测试类 | **16 个**(另 1 个为支持类本身) | |
|||
| 每个类是否同时声明 `@Mock private UserRoleGuard userRoleGuard;` | **是**(16/16,各 1 处) | |
|||
| 委托是否使用**真实** `UserRoleGuard` 实现 | **是**:`new UserRoleGuard(userRepository)` | |
|||
| 是否使用每个测试**自己 stub 的** `UserRepository` | **是**:`delegateToRealGuard(userRoleGuard, userRepository)` 传入该测试类的 `@Mock UserRepository` | |
|||
| 是否使用宽泛 `doNothing()` | **否**:`UserRoleGuardTestSupport` 内 **0 处** `doNothing()`;模块内 7 处均为 `lenient().doAnswer(...)` 显式转发 | |
|||
| 是否把所有 guard 调用默认放行 | **否**:四个方法均逐字转发到真实 guard;`lenient()` 只用于消除"未被该用例触发"的多余桩告警,不改变行为 | |
|||
| 本轮是否新增 `doNothing` | **否**:全仓 10 处 `doNothing()` 全部位于**未被 R01-B 改动**的文件(`git status` 为空),且目标是其他 mock(`favoriteRepository`、`messagingService`、被 mock 的 `companyService` 等) | |
|||
|
|||
### 8.2 变异验证(决定性) |
|||
|
|||
把 `UserRoleGuardTestSupport.delegateToRealGuard` 临时改为**空实现**(即 mock 的所有 void 方法无条件放行), |
|||
运行 5 个含负向断言的测试类: |
|||
|
|||
``` |
|||
mvn -Dtest="CompanyServiceTest,JobFavoriteServiceTest,PrivacySettingsServiceTest,MatchServiceTest,JobReviewServiceTest" test |
|||
→ Tests run: 63, Failures: 13, Errors: 37 ← 50/63 失败 |
|||
``` |
|||
|
|||
失败样例: |
|||
|
|||
``` |
|||
CompanyServiceTest.findCompany_asStudent_shouldReturn403:71 |
|||
Expected BusinessException to be thrown, but nothing was thrown. |
|||
CompanyServiceTest.updateCompany_asStudent_shouldReturn403:83 |
|||
expected: <2002> but was: <5001> |
|||
JobFavoriteServiceTest.favorite_asCompany_shouldReturn403:177 |
|||
Unexpected exception type thrown ... but was: java.lang.NullPointerException |
|||
MatchServiceTest.diagnose_asAdmin_shouldReturn403:91 |
|||
Unexpected exception type thrown ... but was: java.lang.NullPointerException |
|||
``` |
|||
|
|||
**判据成立**:若负向测试在"无条件放行"下仍通过,说明测试未覆盖授权;实测 **50 个用例立即失败**, |
|||
证明这些测试**真的执行了角色判断**,委托模式未被削弱。 |
|||
变异后已把文件**按原 SHA256 还原**(`02f25b74cf5d2192882a77930ec1c748a1049509323bf02668dc5b04822c82a1`,逐字节一致), |
|||
临时备份已删除。日志:`docs/acceptance/r01-b/iv-mutation.log`。 |
|||
|
|||
--- |
|||
|
|||
## 9. HTTP 映射 166 项对账(独立提取) |
|||
|
|||
**基线来源**:`git show HEAD:…GlobalExceptionHandler.java` 的 `buildBusinessHttpStatuses()`, |
|||
独立解析后写入 `docs/acceptance/r01-b/reconcile.tsv`(166 行)与 |
|||
`mydreamjob-server/src/test/resources/r01b/error-code-http-status-baseline.tsv`(测试资源)。 |
|||
**未复用工作区实现的任何映射表**,不存在同源假证明。 |
|||
|
|||
| 项 | HEAD | 工作区 | 一致 | |
|||
|---|---|---|---| |
|||
| 枚举常量总数 | 166 | 166 | ✔ | |
|||
| 显式映射常量数 | 112 | 112 | ✔ | |
|||
| 显式映射覆盖的唯一数字码 | 111 | 111 | ✔ | |
|||
| 默认 400 项 | 54 | 54 | ✔ | |
|||
| **逐项状态不一致数** | — | — | **0** ✔ | |
|||
| HEAD 中重复注册 | 无 | 无 | ✔ | |
|||
|
|||
关键项逐项核对: |
|||
|
|||
| ErrorCode | 数字码 | HEAD | 工作区 | |
|||
|---|---|---|---| |
|||
| `UNAUTHORIZED` | 2001 | 400(未登记默认) | **400** ✔ | |
|||
| `FORBIDDEN` | 2002 | 403 | **403** ✔ | |
|||
| `SERVICE_UNAVAILABLE` | 9001 | 400(未登记默认) | **400** ✔ | |
|||
| `USER_NOT_FOUND` | 4001 | 404 | **404** ✔ | |
|||
| `APPLICATION_DUPLICATE` | 8001 | 409 | **409** ✔ | |
|||
| `ABILITY_RETAKE_LIMIT` | 9811 | 429 | **429** ✔ | |
|||
| `REVIEW_ALREADY_DECIDED` | 9103 | 409 | **409** ✔ | |
|||
| `DUPLICATE_FEEDBACK` | 9103 | 409 | **409** ✔ | |
|||
|
|||
(上表数字码与状态均取自独立提取的 `docs/acceptance/r01-b/reconcile.tsv`。) |
|||
|
|||
响应 JSON、requestId、日志行为未变:`GlobalExceptionHandler.handleBusinessException` 仍为 |
|||
`log.warn`、仍用 `ApiResponse.error(errorCode, e.getMessage(), requestIdAccessor.get(request))`, |
|||
仅把状态解析改为 `errorHttpStatusRegistry.resolve(errorCode)`。 |
|||
|
|||
--- |
|||
|
|||
## 10. 112 显式 / 54 默认统计(独立复核) |
|||
|
|||
| 项 | 实施报告 | 独立复核 | |
|||
|---|---|---| |
|||
| 显式映射 | 112 | **112** ✔ | |
|||
| 未映射(默认 400) | 54 | **54** ✔ | |
|||
| 唯一数字码 | 111(未声明) | **111** ✔(9103 共用) | |
|||
|
|||
--- |
|||
|
|||
## 11. 9103 重复码的真实运行时语义 |
|||
|
|||
### 11.1 逐层查清 |
|||
|
|||
| 层 | 事实 | |
|||
|---|---| |
|||
| `BusinessException` 保存什么 | **`private final int errorCode;`** —— 只保存数字,**枚举身份在构造时即丢失**;无 `ErrorCode` 字段,无 `fromCode` 反查 | |
|||
| `GlobalExceptionHandler` 拿到什么 | `int errorCode = e.getErrorCode();` → **int** | |
|||
| `ErrorHttpStatusRegistry.resolve(...)` 以什么为键 | `resolve(ErrorCode)` 用**枚举键**表 `EXPLICIT`;**运行时路径 `resolve(int)` 用派生数字表 `BY_CODE`** | |
|||
| 是否使用 `ErrorCode.values()` / `fromCode()` / 首次匹配 | **全部没有**(registry 与 handler 中 `values()`/`valueOf`/`ordinal`/`stream()` 命中为 0;`ErrorCode` 亦无 `fromCode` 辅助方法) | |
|||
| 相同数字码不同枚举是否可能被错误归类 | **运行时不可能区分** —— 两者都是 9103,`resolve(9103)` 只有一个结果 | |
|||
| 映射是否依赖枚举声明顺序 | **不依赖**。`EXPLICIT` 按**注册顺序**构建(与枚举声明顺序无关),`BY_CODE` 由它派生并用 `putIfAbsent` 去重;顺序只影响"谁先写入",而冲突时**抛错**而非取先者 | |
|||
|
|||
### 11.2 明确声明(不夸大) |
|||
|
|||
> **运行时无法区分 `REVIEW_ALREADY_DECIDED` 与 `DUPLICATE_FEEDBACK`**:`BusinessException` 只携带 |
|||
> `int 9103`,Handler 也只拿到该数字。因此**不能声称"以枚举为键已彻底解决身份歧义"**; |
|||
> 准确表述是:**两者当前都映射为 409 CONFLICT,故 HTTP 行为一致**; |
|||
> 枚举键表的价值在于**配置期可审计与可去重**,而不是运行期消歧。 |
|||
|
|||
### 11.3 独立测试对六项要求的覆盖 |
|||
|
|||
| # | 要求 | 覆盖用例 | |
|||
|---|---|---| |
|||
| 1 | 两个枚举常量都显式注册 | `bothSharedNumericCodeConstantsAreExplicitlyMappedTo409` | |
|||
| 2 | 两者分别解析为 409 | 同上 | |
|||
| 3 | `resolve(9103)` 行为明确 | `runtimeCannotDistinguishTheTwoSharedConstants`(= CONFLICT,并断言两个异常的业务码相同) | |
|||
| 4 | 改成不同状态时初始化/解析必须失败 | `conflictingStatusForSharedNumericCodeFailsFast`(构造 `9103 → CONFLICT` + `9103 → NOT_FOUND`,调用数字索引构建必须抛 `IllegalStateException`,消息含 `9103`;同时反证状态相同时允许) | |
|||
| 5 | 不依赖 `ErrorCode.values()` 顺序 | `mappingDoesNotDependOnEnumDeclarationOrder`(正序/逆序注册结果相同 + 逐项比对基线) | |
|||
| 6 | 未映射 code 仍为 400 | `unknownCodeFallsBackTo400AndResponseIsClean` | |
|||
|
|||
--- |
|||
|
|||
## 12. 独立新增测试 |
|||
|
|||
| 测试类 | Tests | 结果 | |
|||
|---|---|---| |
|||
| `common/r01b/R01BIndependentRoleVerificationTest` | 11 | **通过** | |
|||
| `common/r01b/R01BIndependentHttpMappingParityTest` | 9 | **通过** | |
|||
| `common/r01b/R01BWiringMutationTest` | 5 | **通过** | |
|||
| **合计** | **25** | **25 / 25**,F=0 E=0 S=0 ✔ | |
|||
|
|||
日志:`docs/acceptance/r01-b/iv-1-independent.log` |
|||
|
|||
覆盖要点: |
|||
|
|||
- **角色**:active 三角色各自通过;三类交叉全部拒绝;ADMIN 不自动通过 STUDENT/COMPANY; |
|||
用户不存在(`USER_NOT_FOUND`);inactive 与 `active=null`(`FORBIDDEN`);`roles` 为空; |
|||
多角色;`users.role` 与 `user_roles` 不一致时按 `user_roles` 失败关闭; |
|||
`requireXxx(Long)` 恰好 1 次查询、`requireXxx(User)` 零查询;无写库; |
|||
不触碰 SecurityContext(在上下文里放 ADMIN 仍按传入 userId 判定)/requestId/Redis; |
|||
三个真实 HTTP 端点验证 **401 / 403 / 404** 分层。 |
|||
- **接线变异**:guard 拒绝 → 403 且**下游业务方法零调用**;guard 放行 → **STUDENT 也能通过 ADMIN 端点** |
|||
(若端点仍保留自有角色比较,STUDENT 不可能通过 → 该断言即变异检测器); |
|||
guard 收到正确 userId;归属失败仍由业务层返回 404。 |
|||
- **映射**:166 项按**名称**逐项比对 HEAD 基线(不作数字去重)、112/54 计数、 |
|||
9103 六项、未知码 400、响应体无堆栈与内部类名。 |
|||
|
|||
--- |
|||
|
|||
## 13. 两个 Controller 测试单跑结果(如实记录,未写成通过) |
|||
|
|||
| 命令 | 结果 | |
|||
|---|---| |
|||
| `mvn -Dtest="AdminReviewControllerTest" test` | **11 run / 4 failures / 0 errors / 0 skipped**,BUILD FAILURE | |
|||
| `mvn -Dtest="CompanyReviewManagementControllerTest" test` | **3 run / 2 failures / 0 errors / 0 skipped**,BUILD FAILURE | |
|||
|
|||
失败断言:`expected 200/400/409 but was 401`(6 项合计)。 |
|||
|
|||
**根因证据链(非按日志文本猜测)**: |
|||
|
|||
| 证据 | AdminReview | CompanyReviewMgmt | |
|||
|---|---|---| |
|||
| `Handler: Type = null` 次数 | 4 | 2 | |
|||
| `session validation rejected: user not usable` | 4 | 2 | |
|||
| `JWT authentication rejected: session not active` | 4 | 2 | |
|||
| 401 响应体 | `{"code":401,"message":"not authenticated","timestamp":""}` | 同 | |
|||
| 测试使用的用户 ID | `ADMIN_ID = 1L` | `token(1L,"COMPANY")` / `token(2L,"STUDENT")` | |
|||
| 是否在 R01-B 改动清单内 | **否** | **否** | |
|||
|
|||
**判定**: |
|||
|
|||
1. `Handler: Type = null` 证明请求**从未进入任何 Controller**,`JobReviewService` / |
|||
`UserRoleGuard` / `GlobalExceptionHandler` **均未被调用** —— 因此**与 R01-B 新逻辑无关**; |
|||
2. 401 由 `SecurityConfig` 的认证入口产出(body 形状 `{"code":401,"message":"not authenticated"}`), |
|||
不是 `GlobalExceptionHandler` 的 `ApiResponse` 形状(后者会带业务码); |
|||
3. 拒绝发生在 `AuthSessionService.isUserUsable` —— 用户 id=1/2 正是 `V1_1` 种子账号、 |
|||
被 `V43` 置为 inactive; |
|||
4. 全量顺序下 `com.mydreamjob.controller.AuthIntegrationTest` 的 `deleteAll()` 偶然清除种子行, |
|||
问题被掩盖,故 §14 全量为绿。 |
|||
|
|||
**处置**:未修改这两个测试;未写成通过;不让它们阻断对 R01-B 生产代码的判断。 |
|||
**R01-B 提交后须以独立纯测试提交修复**(与 A6 后 `84fe9f5` 修复 `FairnessMonitoringControllerTest` 同法)。 |
|||
|
|||
--- |
|||
|
|||
## 14. 全量测试 |
|||
|
|||
| 命令 | 结果 | 日志 | |
|||
|---|---|---| |
|||
| `mvn -Dtest="RoleAuthorizationGuardTest,RoleAuthorizationIntegrationTest,ErrorHttpStatusRegistryTest,GlobalExceptionHandlerMappingParityTest" test` | **371 / 371**,F=0 E=0 S=0 ✔ | `iv-2-impl.log` | |
|||
| `mvn -Dtest="*Security*Test,*Authorization*Test,*Role*Test,*GlobalExceptionHandler*Test,*BusinessException*Test" test` | **425 / 425**,F=0 E=0 S=0 ✔(该命令不含两个已知夹具类,已单独记录于 §13) | `iv-3-related.log` | |
|||
| `mvn clean verify` | 见 §14.1 | `iv-4-clean-verify.log` | |
|||
|
|||
### 14.1 `mvn clean verify` |
|||
|
|||
``` |
|||
[INFO] Tests run: 2681, Failures: 0, Errors: 0, Skipped: 26 |
|||
[INFO] BUILD SUCCESS |
|||
``` |
|||
|
|||
| 项 | 数值 | |
|||
|---|---| |
|||
| 测试总数 | **2681**(= 前次 2656 + 本轮新增独立测试 25)✔ 不低于 2656 | |
|||
| Failures | **0** ✔ | |
|||
| Errors | **0** ✔ | |
|||
| **Skipped** | **26 —— 未达"0"要求,原因如下(环境性,非代码回归)** | |
|||
|
|||
#### Skipped=26 的准确原因(如实记录,不掩饰、不写成 0) |
|||
|
|||
26 个跳过**全部**来自 4 个 MySQL / Testcontainers 测试类: |
|||
|
|||
| 测试类 | Skipped | |
|||
|---|---| |
|||
| `job.JobSearchSpecificationMySqlIntegrationTest` | 15 | |
|||
| `candidatescore.refresh.CandidateScoringRefreshMySqlIntegrationTest` | 6 | |
|||
| `job.JobSearchQuerySideEffectMySqlIntegrationTest` | 4 | |
|||
| `job.JobSearchQueryExplainMySqlIntegrationTest` | 1 | |
|||
| 合计 | **26** | |
|||
|
|||
**根因**:这些类标注 `@Testcontainers(disabledWithoutDocker = true)`(见 |
|||
`JobSearchSpecificationMySqlIntegrationTest:44` 等),Testcontainers 在 **Docker 守护进程不可用**时 |
|||
按设计**环境性跳过**。本机实测: |
|||
|
|||
``` |
|||
docker info → failed to connect to the docker API at npipe:////./pipe/dockerDesktopLinuxEngine; |
|||
check if the path is correct and if the daemon is running |
|||
``` |
|||
|
|||
**为何与 R01-B 无关**: |
|||
|
|||
1. 本轮全程**未操作 Docker**(任务书明令禁止),Docker 守护进程不在运行属环境状态; |
|||
2. **紧邻的上一次全量 `mvn clean verify`(R01-B 实施阶段)为 `2656 / F=0 / E=0 / Skipped=0`** —— |
|||
同一份生产代码下这 4 个类**执行并通过**,证明它们未被 R01-B 破坏; |
|||
3. 本轮相对该次运行,**生产代码零改动**(仅新增 3 个测试类 + 1 个测试资源 + 1 份文档); |
|||
4. 计数自洽:`2656 − 26(改为跳过)+ 25(本轮新增)= 2655 执行 + 26 跳过 = 2681`, |
|||
即**除这 26 个 Docker 条件用例外的全部测试仍执行且全部通过**。 |
|||
|
|||
**结论**:该偏差为**环境条件**,不构成 R01-B 的阻断项;但按任务书"跳过必须是 0"的字面要求, |
|||
本环境**无法满足**,故**不声称 Skipped=0**。若需在 Docker 可用环境下复核, |
|||
重新执行 `mvn clean verify` 即可(无需任何代码改动)。 |
|||
|
|||
--- |
|||
|
|||
## 15. OpenAPI |
|||
|
|||
``` |
|||
python ..\docs\verify_openapi.py |
|||
→ paths: 176 / operations: 214 |
|||
→ All checks passed! |
|||
``` |
|||
|
|||
`git diff -- docs/openapi-rc.json` → **空** ✔ |
|||
|
|||
--- |
|||
|
|||
## 16. 精确文件范围 |
|||
|
|||
**R01-B 生产改动(22 修改 + 2 新增)** |
|||
|
|||
``` |
|||
修改: ability/AbilityAttemptService、application/ApplicationController、assessment/AssessmentAttemptService、 |
|||
bidirectionalmatch/MatchRuleService、candidatescore/dashboard/CompanyScoringDashboardService、 |
|||
candidatescore/governance/CandidateScoringGovernanceService、careerprogress/CareerProgressService、 |
|||
common/exception/GlobalExceptionHandler、company/CompanyService、competition/CompetitionRankingService、 |
|||
job/JobFavoriteService、match/MatchService、preference/PreferenceService、 |
|||
privacy/PrivacySettingsService、profile/ProfileService、recommendation/JobRecommendationService、 |
|||
review/JobReviewService、risk/AdminRiskController、safety/AdminReportController、 |
|||
talentdiscovery/TalentDiscoveryService、talentinvitation/TalentInvitationService、 |
|||
verification/CompanyVerificationService |
|||
新增: security/UserRoleGuard、common/exception/ErrorHttpStatusRegistry |
|||
``` |
|||
|
|||
**测试改动(21 修改 + 4 新增 + 1 支持类 + 1 基线资源)**、**文档(1 修改 + 1 新增)**。 |
|||
本轮独立复验**仅新增**: |
|||
|
|||
``` |
|||
mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/R01BIndependentRoleVerificationTest.java |
|||
mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/R01BIndependentHttpMappingParityTest.java |
|||
mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/R01BWiringMutationTest.java |
|||
mydreamjob-server/src/test/resources/r01b/error-code-http-status-baseline.tsv |
|||
docs/r01-b-independent-verification-20260918.md |
|||
docs/acceptance/r01-b/*(验收日志与对账产物,被 .gitignore 覆盖) |
|||
``` |
|||
|
|||
**未修改任何生产代码** ✔(`git diff` 生产文件数与复验开始前一致,均为 R01-B 实施者留下的改动)。 |
|||
|
|||
--- |
|||
|
|||
## 17. Git 边界 |
|||
|
|||
| 检查 | 结果 | |
|||
|---|---| |
|||
| `git diff --check` | **exit 0**,无空白错误 ✔ | |
|||
| `git diff --cached --name-only` | **空** ✔ | |
|||
| `git diff --name-only -- …/db/migration` | **空** —— V1–V45 未改 ✔ | |
|||
| `git diff -- docs/openapi-rc.json` | **空** ✔ | |
|||
| `git diff -- mydreamjob-server/pom.xml` | **空** ✔ | |
|||
| `ErrorCode.java` | **未修改** ✔ | |
|||
| 接口 / DTO | **未修改** ✔ | |
|||
| 历史未跟踪文件 | 完整保留 ✔ | |
|||
| Java / Maven 残留进程 | 无 ✔ | |
|||
| HEAD / upstream | `84fe9f5…` / **0 0** ✔ | |
|||
|
|||
--- |
|||
|
|||
## 18. 是否存在阻断 |
|||
|
|||
**无阻断。** |
|||
|
|||
| 复核项 | 结论 | |
|||
|---|---| |
|||
| 角色语义变化 | **无**(仅新增 inactive 失败关闭,且 HTTP 入口不可观测、非 HTTP 入口不存在) | |
|||
| 归属语义变化 | **无**(归属仍由原 Service 执行,实测 404 未被改写为 403) | |
|||
| 错误码变化 | **无**(`USER_NOT_FOUND`/`FORBIDDEN` 沿用,未新增 ErrorCode) | |
|||
| HTTP 映射变化 | **无**(166 项逐项一致) | |
|||
| 事务语义变化 | **无**(guard 只读、无 `@Transactional`,未改变任何事务边界) | |
|||
| ADMIN 权限扩大 | **无** | |
|||
| 测试削弱 | **无**(变异实验 50/63 失败) | |
|||
| 阻断项 | **0** | |
|||
|
|||
--- |
|||
|
|||
## 19. 未暂存、未提交、未推送 |
|||
|
|||
**确认:未暂存、未提交、未推送。** 本轮未执行 `git add`、未创建提交、未推送; |
|||
未执行 `reset`/`restore`/`checkout`/`clean`;首轮复验未操作 Docker,随后在负责人授权的 |
|||
补充复验中仅启动本机既有 Docker Desktop 并由 Testcontainers 创建临时 MySQL 容器, |
|||
未重建项目容器、未删除 volume;未删除历史未跟踪文件。 |
|||
变异实验对测试支持文件的临时修改已**按原 SHA256 逐字节还原**并清理备份。 |
|||
|
|||
--- |
|||
|
|||
## 20. 未进入 R01-C/E |
|||
|
|||
**确认:未进入 R01-C、未进入 R01-E。** 本轮未处理 `overviewStatus`、推荐权重、 |
|||
高校名单与评分阈值、N+1、会话未读数、requestId、身份主体解析、Token、评分快照、 |
|||
新接口与数据库迁移中的任何一项。 |
|||
|
|||
--- |
|||
|
|||
> **R01-B独立代码复验通过,可以进入精确暂存与独立提交检查点;两个Controller测试夹具缺陷须在R01-B提交后以独立纯测试提交修复。** |
|||
|
|||
--- |
|||
|
|||
## 21. Docker / Testcontainers 补充复验(2026-09-18) |
|||
|
|||
### 21.1 首轮 SKIP 记录保留 |
|||
|
|||
首次独立复验时 Docker 守护进程不可用,以下 4 个测试类合计 26 个 MySQL/Testcontainers |
|||
用例按 `disabledWithoutDocker = true` 跳过;该事实及原因分析保留在本报告第 14 节,未删除、 |
|||
未改写成首次即通过: |
|||
|
|||
| 测试类 | Tests | 首轮 Skipped | |
|||
|---|---:|---:| |
|||
| `CandidateScoringRefreshMySqlIntegrationTest` | 6 | 6 | |
|||
| `JobSearchQueryExplainMySqlIntegrationTest` | 1 | 1 | |
|||
| `JobSearchQuerySideEffectMySqlIntegrationTest` | 4 | 4 | |
|||
| `JobSearchSpecificationMySqlIntegrationTest` | 15 | 15 | |
|||
| **合计** | **26** | **26** | |
|||
|
|||
### 21.2 环境恢复 |
|||
|
|||
经负责人授权启动本机既有 Docker Desktop: |
|||
|
|||
```text |
|||
Docker Server: 29.6.2 |
|||
Testcontainers: 1.21.3 |
|||
MySQL image: mysql:8.4 |
|||
``` |
|||
|
|||
未重建项目容器、未删除或修改既有 volume。Testcontainers 实际连接 Windows npipe Docker |
|||
Engine,并成功启动 Ryuk 与临时 MySQL 8.4 容器。 |
|||
|
|||
### 21.3 26 个 MySQL 用例定向补验 |
|||
|
|||
执行: |
|||
|
|||
```text |
|||
mvn -Dtest="CandidateScoringRefreshMySqlIntegrationTest,JobSearchQueryExplainMySqlIntegrationTest,JobSearchQuerySideEffectMySqlIntegrationTest,JobSearchSpecificationMySqlIntegrationTest" test |
|||
``` |
|||
|
|||
结果: |
|||
|
|||
```text |
|||
Tests run: 26, Failures: 0, Errors: 0, Skipped: 0 |
|||
BUILD SUCCESS |
|||
``` |
|||
|
|||
4 个类均真实启动 MySQL 8.4 Testcontainer,未使用 H2 代替,未通过修改 |
|||
`disabledWithoutDocker` 或测试选择规则规避执行。 |
|||
|
|||
### 21.4 完整干净构建 |
|||
|
|||
随后执行: |
|||
|
|||
```text |
|||
mvn clean verify |
|||
``` |
|||
|
|||
结果: |
|||
|
|||
```text |
|||
Tests run: 2681, Failures: 0, Errors: 0, Skipped: 0 |
|||
BUILD SUCCESS |
|||
``` |
|||
|
|||
并重新执行 OpenAPI 校验: |
|||
|
|||
```text |
|||
paths: 176 |
|||
operations: 214 |
|||
Valid JSON: True |
|||
All checks passed! |
|||
``` |
|||
|
|||
本次补验未修改任何生产代码、测试代码、Flyway、`ErrorCode`、DTO、接口、依赖或 |
|||
`openapi-rc.json`。 |
|||
|
|||
### 21.5 最终冻结结论 |
|||
|
|||
首轮因环境导致的 26 个 SKIP 已由真实 Docker/Testcontainers 执行闭环,最终硬性门禁为 |
|||
`Failures=0 / Errors=0 / Skipped=0`。 |
|||
|
|||
> **R01-B独立代码复验及Docker/Testcontainers补充验证全部通过,可以进入精确暂存与独立提交检查点;两个Controller测试夹具缺陷须在R01-B提交后以独立纯测试提交修复。** |
|||
@ -0,0 +1,183 @@ |
|||
# R01-C 评分/Fairness 组合测试污染隔离修复(独立纯测试提交) |
|||
|
|||
日期:2026-09-18 |
|||
分支:`refactor/r01-structure-performance` |
|||
R01-C 提交:`7d4704ee28e4d8e2d1fbcd174a990fc6bfe68169` |
|||
本轮性质:**纯测试修复** —— **生产代码 diff = 0** |
|||
|
|||
--- |
|||
|
|||
## 0. 结论 |
|||
|
|||
`*CandidateScoring*Test,*Fairness*Test` 组合在**任意顺序**下均全绿; |
|||
污染源已按**逐类行级快照实测**精确归因(**未按类名猜测定性**), |
|||
**5 个候选类经证明零残留、未做任何修改**。 |
|||
|
|||
--- |
|||
|
|||
## 1. 最初的错误归因及纠正 |
|||
|
|||
| 阶段 | 归因 | 判定 | |
|||
|---|---|---| |
|||
| R01-C 复验阶段(初判) | `CompanyScoringDashboardPersistenceIntegrationTest`(用 `LocalDate.now()` 生成当前期间且无清理) | **错误** —— 该类**根本不在**失败命令内;单独与 Fairness 组合 **3 次全绿** | |
|||
| 二分定位 | `*CandidateScoring*Test` 是污染所在半边;进一步收敛到 `CandidateScoringPersistenceIntegrationTest + FairnessMonitoringHttpIntegrationTest`(7 用例 / 2 失败) | **正确但不完整** —— 修复后污染量仅由 +15 降到 +12,说明仍有其它污染源 | |
|||
| 本轮(逐类实测) | 见 §2,**5 个类**合计残留 15 个 student | **完整** —— 与受害测试观测的 +15(修复前)/ +12(仅修 1 类后)**精确吻合** | |
|||
|
|||
--- |
|||
|
|||
## 2. 每个候选类的实测残留表 |
|||
|
|||
**方法**:临时诊断探针 `ZR01CLeakProbeTest`(置于 `candidatescore.zz` 以确保 alphabetical 下**最后**运行), |
|||
对 `candidate_dimension_scores` 的**当前期间**抓取**行级快照** |
|||
(`student_user_id / dimension / period / status / version`)与相关表计数。 |
|||
每次 `mvn` 均为**全新 JVM + 全新 H2 库**,故"运行后快照"即为该类在**干净上下文中**的净残留。 |
|||
探针诊断完成后**已删除**(非交付物)。 |
|||
|
|||
| 候选类 | 单独运行 | 当前期间残留行数 | 残留 student 数 | 已有 @AfterEach | 是否需修 | |
|||
|---|---|---|---|---|---| |
|||
| `CandidateScoringPersistenceIntegrationTest` | 3/3 通过 | **24**(3×8) | **3** | 无 | **是**(已修) | |
|||
| `CandidateScoringRefreshClaimFenceTest` | 7/7 通过 | **16** | **2** | 无 | **是** | |
|||
| `CandidateScoringRefreshClaimTokenTest` | 14/14 通过 | **8** | **1** | 有(不全) | **是** | |
|||
| `CandidateScoringRefreshFailureTransactionTest` | 4/4 通过 | **8** | **1** | 无 | **是** | |
|||
| `CandidateScoringRefreshInfrastructureTest` | 15/15 通过 | **57** | **8** | 无 | **是** | |
|||
| `CandidateScoringClockBoundaryIntegrationTest` | 3/3 通过 | **0** | 0 | 无 | **否**(无需修改) | |
|||
| `CandidateScoringRefreshBatchIsolationTest` | 5/5 通过 | **0** | 0 | 无 | **否**(无需修改) | |
|||
| `R01D4GetWritesSnapshotEvidenceTest` | 6/6 通过 | **0** | 0 | 无 | **否**(无需修改) | |
|||
| `CandidateScoringGovernancePersistenceIntegrationTest` | 5/5 通过 | **0** | 0 | 无 | **否**(无需修改) | |
|||
| `CompanyScoringDashboardPersistenceIntegrationTest` | 11/11 通过 | **0** | 0 | 无 | **否**(无需修改) | |
|||
|
|||
> **5 个候选类经实测零残留**(其中 4 个无 `@AfterEach` 但依然不留残留, |
|||
> 说明其写入被事务回滚或未落入 `current_period`)。**未因"名字可疑"而修改它们。** |
|||
|
|||
--- |
|||
|
|||
## 3. +15、+12 的精确来源 |
|||
|
|||
| 项 | 组成 | 合计 | |
|||
|---|---|---| |
|||
| **修复前 +15** | Persistence 3 + ClaimFence 2 + ClaimToken 1 + FailureTransaction 1 + Infrastructure 8 | **15** ✔ 与受害测试观测 `100 → 115` 完全一致 | |
|||
| **仅修 Persistence 后 +12** | ClaimFence 2 + ClaimToken 1 + FailureTransaction 1 + Infrastructure 8 | **12** ✔ 与观测 `100 → 112` 完全一致 | |
|||
|
|||
关键佐证:`CandidateScoringRefreshInfrastructureTest:271` 以循环 |
|||
`"131" + String.format("%08d", i + 7000)`(i=0..7)创建 **8 个用户**, |
|||
与探针实测的 **8 个残留 student** 精确对应。 |
|||
|
|||
--- |
|||
|
|||
## 4. 修复内容(仅改测试,生产代码 0 改动) |
|||
|
|||
| 文件 | 修复前 | 修复后 | |
|||
|---|---|---| |
|||
| `CandidateScoringPersistenceIntegrationTest` | 3 个用例各建 1 个学生并物化当前期间快照,**零清理** | 登记自有 userId;`@BeforeEach` **断言独占手机号不存在**;`@AfterEach` 按外键顺序**精确 id** 删除快照 → 角色 → 用户 | |
|||
| `CandidateScoringRefreshClaimFenceTest` | 残留 16 行 | 新增 `@AfterEach`:按**本类独占手机号前缀** `193`/`192` 解析出精确 id 后按外键顺序删除 | |
|||
| `CandidateScoringRefreshClaimTokenTest` | 残留 8 行(原有 `@AfterEach` 未覆盖快照) | 同上,前缀 `190`/`189` | |
|||
| `CandidateScoringRefreshFailureTransactionTest` | 残留 8 行 | 同上,前缀 `130` | |
|||
| `CandidateScoringRefreshInfrastructureTest` | 残留 57 行 | 同上,前缀 `132`/`13107` | |
|||
|
|||
**统一遵守的约束**:独占手机号(不与 V1_1 种子账号 `138…`、其它测试 `t<id>`、`1390000xxx` 冲突); |
|||
按外键顺序(`candidate_dimension_scores` / `candidate_scoring_refresh_tasks` → `user_roles` → `users`); |
|||
**不使用 `deleteAll`/`TRUNCATE`/`@DirtiesContext`/`sleep`**;不捕获忽略清理异常; |
|||
**不修改生产统计或查询**;**不通过改期望 `populationSize` 掩盖污染**;不依赖测试执行顺序。 |
|||
|
|||
> **未修改** `FairnessMonitoringHttpIntegrationTest` 的任何断言 —— 其 `populationSize` 期望值 |
|||
> 仍为既有的 100 / 200,污染消除后**自然精确命中**。 |
|||
|
|||
--- |
|||
|
|||
## 5. 修复前失败与修复后结果 |
|||
|
|||
| 组合 | 修复前 | 修复后 | |
|||
|---|---|---| |
|||
| `CandidateScoringPersistenceIntegrationTest,FairnessMonitoringHttpIntegrationTest` | **7 run / 2 failures** | **7 / 0** ✔ | |
|||
| `*CandidateScoring*Test,*Fairness*Test`(原失败命令) | **296 / 2 failures**(另一次 260 / 2) | **260 / 0** ✔ | |
|||
|
|||
失败断言(修复前):`$.data.populationSize expected:<100> but was:<115>`、 |
|||
`expected:<200> but was:<215>`。 |
|||
修复后 `populationSize` **精确等于既有期望** 100 / 200 ✔ |
|||
|
|||
--- |
|||
|
|||
## 6. 双向顺序各 3 次结果 |
|||
|
|||
实际执行顺序**从日志核对**(非依据命令参数): |
|||
|
|||
| 顺序 | 实测类顺序 | 结果 | |
|||
|---|---|---| |
|||
| `-Dsurefire.runOrder=alphabetical` ×3 | `candidatescore.CandidateScoringCalculatorTest` … → … `scoringmonitoring.FairnessStatisticsCalculatorTest`;**Fairness HTTP 在所有 Candidate 类之后** | **260 / F=0 / E=0 / S=0** ×3 ✔ | |
|||
| `-Dsurefire.runOrder=reversealphabetical` ×3 | `scoringmonitoring.FairnessStatisticsCalculatorTest` … → … `candidatescore.CandidateScoringCalculatorTest`;**Fairness HTTP 在 Candidate 类之前** | **260 / F=0 / E=0 / S=0** ×3 ✔ | |
|||
|
|||
**两个方向 6 次运行全部 260/260,Failures=0、Errors=0、Skipped=0。** |
|||
|
|||
--- |
|||
|
|||
## 7. 行级清理与反证 |
|||
|
|||
新增独立隔离门禁 `R01CScoringFixtureIsolationGateTest`(与污染类共用 `integrationdb`): |
|||
|
|||
| # | 断言 | 覆盖要求 | |
|||
|---|---|---| |
|||
| 1 | 5 个已修污染类的**独占手机号段**在库中零残留用户 | 自有 users 残留为 0 | |
|||
| 2 | 若发现残留,进一步报告其**当前期间快照数**(失败信息含类名/前缀/用户数/快照数) | 自有快照残留为 0 | |
|||
| 3 | 当前期间不得存在指向已删除用户的**孤立快照行** | 清理不留孤儿 | |
|||
| 4 | 其它 period 的行数可查询且非负(清理不波及) | 其它 period 不受影响 | |
|||
| 5 | 断言以**独占前缀**为界 → 清理只影响本类登记的数据 | 不删除其他测试数据 | |
|||
| 6 | 门禁与污染类同库同 JVM 运行,**只要任一污染类遗留自有行即失败** | Fairness 统计不再依赖先前顺序 | |
|||
|
|||
`CandidateScoringPersistenceIntegrationTest` 的 `@BeforeEach` 前置断言 |
|||
("独占夹具手机号不得预先存在")即"**预置冲突时前置断言失败**"的反证: |
|||
若上轮清理失效,测试在**开始前**即失败,而不是让残留静默参与断言。 |
|||
|
|||
--- |
|||
|
|||
## 8. `clean verify` 与 OpenAPI |
|||
|
|||
``` |
|||
docker info → ServerVersion=29.6.2(Testcontainers 真实执行,未跳过) |
|||
mvn clean verify → Tests run: 2746, Failures: 0, Errors: 0, Skipped: 0 BUILD SUCCESS |
|||
python ..\docs\verify_openapi.py → paths: 176 / operations: 214 / All checks passed! |
|||
``` |
|||
|
|||
| 项 | 数值 | |
|||
|---|---| |
|||
| 测试总数 | **2746** = R01-C 基线 **2743** + 本轮新增隔离门禁 **3** ✔(不低于 2743 + 新增数) | |
|||
| Failures / Errors / **Skipped** | **0 / 0 / 0** ✔ | |
|||
| OpenAPI | **176 / 214** ✔ | |
|||
| 生产代码 diff | **0** ✔ | |
|||
|
|||
--- |
|||
|
|||
## 9. 精确文件清单 |
|||
|
|||
**被修复的测试文件(5)** |
|||
|
|||
``` |
|||
mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoringPersistenceIntegrationTest.java |
|||
mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshClaimFenceTest.java |
|||
mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshClaimTokenTest.java |
|||
mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshFailureTransactionTest.java |
|||
mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshInfrastructureTest.java |
|||
``` |
|||
|
|||
**新增隔离测试(1)** |
|||
|
|||
``` |
|||
mydreamjob-server/src/test/java/com/mydreamjob/common/r01c/R01CScoringFixtureIsolationGateTest.java |
|||
``` |
|||
|
|||
**报告(1)**:本文件 |
|||
|
|||
**路线图(1)**:`docs/backend-development-roadmap.md` —— 仅追加一条夹具问题关闭记录 |
|||
|
|||
> 临时诊断探针 `ZR01CLeakProbeTest` **已在诊断后删除**,不进入提交。 |
|||
|
|||
--- |
|||
|
|||
## 10. 生产代码修改数 = 0 |
|||
|
|||
**确认:本轮生产代码 diff = 0。** 未修改 V1–V45、`ErrorCode`、DTO、公网接口、 |
|||
`pom.xml`、`docs/openapi-rc.json`;未修改任何生产查询或统计逻辑; |
|||
未修改 `FairnessMonitoringHttpIntegrationTest` 的断言。 |
|||
|
|||
--- |
|||
|
|||
> **评分与 Fairness 组合测试污染已完成精确归因,并通过独立纯测试提交修复。** |
|||
@ -0,0 +1,347 @@ |
|||
# R01-C 独立代码复验报告(评分与推荐规则结构收束) |
|||
|
|||
日期:2026-09-18 |
|||
分支:`refactor/r01-structure-performance` |
|||
HEAD:`9954600650c3e7cd40a5f4fa55bc38595973f214`(复验全程未变) |
|||
复验对象:`docs/r01-c-scoring-recommendation-rules-refactor-report.md` |
|||
性质:**仅复验** —— 未修改生产代码、未放宽既有断言、未暂存、未提交、未推送、未进入 R01-E |
|||
|
|||
--- |
|||
|
|||
## 0. 结论 |
|||
|
|||
> **R01-C独立代码复验通过,可以进入精确暂存与独立提交检查点。** |
|||
|
|||
- `overviewStatus` **确为唯一产出点**;三处调用方全部委托; |
|||
- 偏好公式实现点 **2 → 1**(HEAD 的 `TalentDiscoveryService` 确实内联了第二套等价公式); |
|||
- 985/211 名单定义点 **唯一**;规则集**不可变、无框架依赖、无 setter、集合只读**; |
|||
- 生产变更 **新增 1 + 修改 2 = 3 个生产文件**; |
|||
- 三类变异均使对应测试失败,恢复后**无残留**; |
|||
- Docker 已按给定路径启动,**Testcontainers 26 项真实执行、Skipped=0**; |
|||
- 一处**既有测试隔离脆弱性**被独立发现并定性(见 §10.3),**与 R01-C 无关**。 |
|||
|
|||
--- |
|||
|
|||
## 1. 独立库存结果 |
|||
|
|||
不采信实施报告数字,独立搜索证据: |
|||
|
|||
| # | 核查项 | 独立结论 | 证据 | |
|||
|---|---|---|---| |
|||
| 1 | `overviewStatus` 是否只有 `CandidateOverviewStatusPolicy` 一个规则产出点 | **是** | 全 `src` 中 `"PARTIAL"` 仅在该类**主代码**出现一次(`CandidateOverviewStatusPolicy.java:43`);其余命中全部在测试的断言/测试数据中,非产出点 | |
|||
| 2 | 三个调用方是否全部委托该策略 | **是** | `CandidateScoringEngineService:123`、`:161`(`fromComputedResults`);`CompetitionRankingCalculator:103`(`fromSnapshots`)—— 均为直接委托调用 | |
|||
| 3 | `JobRecommendationService` 是否已经只是编排 | **是** | `preferenceScore` 方法体 13 行:构造 `Criteria`/`Target` → `JobPreferenceScoreCalculator.calculate(...)`,无任何内联权重 | |
|||
| 4 | `TalentDiscoveryService` 修复前是否确实维护了第二套等价偏好公式 | **是(已由 HEAD 证实)** | `git show HEAD:…TalentDiscoveryService.java` 的 `preferenceScore` 为内联 `total += 30/20/20/15/15` + `BigDecimal.valueOf(got*100L).divide(...,2,HALF_UP)` —— 与计算器**逐字等价** | |
|||
| 5 | 本轮是否只新增一个规则集并修改两个文件 | **是** | 新增 `CandidateScoringRuleSet.java`;修改 `CandidateScoringCalculator.java`、`TalentDiscoveryService.java` | |
|||
| 6 | **生产代码变更总数** | **新增 1 + 修改 2 = 3 个生产文件** | 与 `git diff --name-status` + 未跟踪文件清单一致 | |
|||
|
|||
--- |
|||
|
|||
## 2. overviewStatus 唯一产出点证据 |
|||
|
|||
``` |
|||
main/java/com/mydreamjob/candidatescore/CandidateOverviewStatusPolicy.java:43: return "PARTIAL"; |
|||
``` |
|||
|
|||
`"PARTIAL"` 在**主代码**中的唯一出现点即为该策略类(第 43 行)。 |
|||
状态聚合器**必须**能够产出 `PARTIAL`,因此该字符串的出现位置即为产出点的充要证据。 |
|||
调用方 3 处(`CandidateScoringEngineService` ×2、`CompetitionRankingCalculator` ×1)全部为委托。 |
|||
|
|||
**结构门禁已加入独立复验测试**:扫描 `src/main/java/com/mydreamjob` 全部 `.java`, |
|||
断言含 `"PARTIAL"` 的文件**恰为** `CandidateOverviewStatusPolicy.java`。 |
|||
|
|||
--- |
|||
|
|||
## 3. 偏好公式修复前后实现点数量 |
|||
|
|||
| 时点 | 30/20/20/15/15 规则实现处 | 证据 | |
|||
|---|---|---| |
|||
| 修复前(HEAD) | **2** —— `recommendation/JobPreferenceScoreCalculator.java` + `talentdiscovery/TalentDiscoveryService.java`(内联) | `git grep -l "total += 30\|CITY_WEIGHT" HEAD` → 2 个文件 | |
|||
| 修复后(工作区) | **1** —— 仅 `recommendation/JobPreferenceScoreCalculator.java` | 同 grep → 1 个文件 | |
|||
|
|||
`preferenceScore` 方法定义仍为 2 处(两个 Service 各一),但**两处都只是编排**,均调用 |
|||
`JobPreferenceScoreCalculator.calculate(...)`;规则**实现**唯一。 |
|||
|
|||
--- |
|||
|
|||
## 4. 985/211 名单定义点数量 |
|||
|
|||
| 时点 | 定义点 | 证据 | |
|||
|---|---|---| |
|||
| HEAD | **2**(`C985`、`C211`)**均在** `CandidateScoringCalculator.java` | `git grep "Set<String> C985\|Set<String> C211" HEAD` | |
|||
| 工作区 | **2**(`C985`、`C211`)**均在** `candidatescore/rules/CandidateScoringRuleSet.java` | `grep` 主代码 → 唯一文件 | |
|||
|
|||
判定为"唯一权威定义点":名单**只在规范集内定义一次**,计算器内**已无**名单定义 |
|||
(独立测试断言 `CandidateScoringCalculator.java` 不含 `Set<String> C985` / `C211`)。 |
|||
|
|||
| 项 | HEAD | 工作区 | 一致 | |
|||
|---|---|---|---| |
|||
| C985 唯一项 | 38 | **38** ✔ | | |
|||
| C211 字面量条数 | 62 | **62** ✔ | | |
|||
| C211 唯一项 | 61 | **61** ✔ | | |
|||
| `北京科技大学` 重复项 | 存在 | **原样保留** ✔ | | |
|||
|
|||
--- |
|||
|
|||
## 5. 规则集不可变性和依赖边界 |
|||
|
|||
逐项核验 `CandidateScoringRuleSet.java`: |
|||
|
|||
| 要求 | 结果 | |
|||
|---|---| |
|||
| `final` | ✔ `public final class CandidateScoringRuleSet`(私有构造) | |
|||
| 无 setter | ✔ 主代码 `grep "public void set"` 命中 0 | |
|||
| 无可变集合泄漏 | ✔ 集合经 `Collections.unmodifiableSet(new HashSet<>(...))` 包装后返回 | |
|||
| 返回集合不可修改 | ✔ 独立测试断言 `add`/`remove`/`clear` 均抛 `UnsupportedOperationException` | |
|||
| 无 Spring 注解 | ✔ `@Component`/`@Service`/`@Autowired`/`@Value` 命中 0 | |
|||
| 无数据库/Repository | ✔ `Repository`/`JdbcTemplate`/`EntityManager` 命中 0 | |
|||
| 无配置中心/环境变量 | ✔ `System.getenv`/`Properties` 命中 0 | |
|||
| 不接运行时可变配置 | ✔ 全部规则为静态常量,只能随代码发布变更 | |
|||
| BigDecimal 字符串构造 | ✔ 17 个常量全部 `new BigDecimal("…")`;`new BigDecimal(<数字>)` 命中 **0** | |
|||
| 同值不同义不合并 | ✔ `EDUCATION_LEVEL_MASTER(85)` 与 `SCHOOL_TIER_985(85)` 为不同常量;`EDUCATION_LEVEL_UNKNOWN(50)` 与 `SCHOOL_TIER_MISSING(50)` 同理 | |
|||
| 规则版本单一定义点 | ✔ `CandidateScoringRuleSet.VERSION` 为权威;`CandidateScoringCalculator.RULES_VERSION` 现**指向该常量**(赋值而非字面量) | |
|||
|
|||
--- |
|||
|
|||
## 6. BigDecimal 精确结果与边界表(独立测试推导) |
|||
|
|||
独立复验测试 `R01CIndependentVerificationTest`(18 个用例)全部通过, |
|||
期望值**手工推算**,断言 `isEqualByComparingTo` + `scale` + `toPlainString()`(不使用近似比较): |
|||
|
|||
| 输入 | 期望 | 说明 | |
|||
|---|---|---| |
|||
| 五维全命中(可用权重 100) | **100.00** | 上界 | |
|||
| 五维全缺失 / 零分母 | **null** | 非 0.00 | |
|||
| 城市(30) 命中、其余缺失 | **100.00** | 单维独立 | |
|||
| 用工类型(20) 命中、其余缺失 | **100.00** | | |
|||
| 薪资(20) 命中(边界相等 20000 == 20000) | **100.00** | 相等视为命中 | |
|||
| 行业(15) 命中、其余缺失 | **100.00** | | |
|||
| 岗位族(15) 命中、其余缺失 | **100.00** | | |
|||
| 城市命中(30) + 行业未命中(15) → 30/45 | **66.67** | HALF_UP 舍入 | |
|||
| 城市未命中(30) + 行业命中(15) → 15/45 | **33.33** | | |
|||
| 四方存在命中两项 → 15/65 | **23.08** | | |
|||
| 薪资低一单位(19999 < 20000) | **0.00** | 下界 | |
|||
| 薪资高一单位(20001) | **100.00** | | |
|||
| 岗位 salaryMax 为 null | **0.00** | | |
|||
|
|||
**跨服务逐值一致**:以反射调用两个 Service 的私有 `preferenceScore`,同一组输入下 |
|||
`JobRecommendationService` 与 `TalentDiscoveryService` 的返回值 |
|||
**`isEqualByComparingTo` 相等且 `scale` 相同**(三组输入:全命中 / 部分命中 / 全不命中)。 |
|||
|
|||
**overviewStatus 边界**(独立推导):全可用→`OK`;部分可用→`PARTIAL`;全不可用(全 `PENDING`)→`PENDING`; |
|||
含 `INSUFFICIENT_DATA`→`INSUFFICIENT_DATA`;空列表(计算结果入口)→`PENDING`、(快照入口)→`INSUFFICIENT_DATA`; |
|||
`null`(计算结果入口)→`NPE`;未知状态字符串→`INSUFFICIENT_DATA`;**输入顺序反转结果不变**。 |
|||
|
|||
--- |
|||
|
|||
## 7. 三类变异测试的失败证据 |
|||
|
|||
| # | 变异 | 文件 | 结果 | |
|||
|---|---|---|---| |
|||
| 1 | `if (computableCount > 0)` → `> 1`(overviewStatus 边界) | `CandidateOverviewStatusPolicy` | **18 run / 1 failure** ✔ | |
|||
| 2 | `INDUSTRY_WEIGHT = 15` → `10`(偏好权重) | `JobPreferenceScoreCalculator` | **18 run / 1 failure** ✔ | |
|||
| 3 | 985 名单删除 `"北京大学", `(被测试覆盖的学校) | `CandidateScoringRuleSet` | **18 run / 1 failure** ✔ | |
|||
|
|||
**恢复核验**: |
|||
|
|||
- 变异前的模式唯一性检查:三个替换串在目标文件中的出现次数均为 **1**; |
|||
- 恢复后 `git diff --quiet` 对两个**已跟踪**目标文件均为**逐字节一致**; |
|||
其中 `JobPreferenceScoreCalculator.java` 的 SHA-256 恢复为 `5771647b…`, |
|||
与**首次诊断时**测得的哈希完全相同; |
|||
- 未跟踪的 `CandidateScoringRuleSet.java` 经内容检查确认变异条目已还原 |
|||
(`"北京大学", "清华大学", "中国人民大学"` 出现次数为 1); |
|||
- `git diff --name-status` 仅显示 3 个预期文件,**无变异残留**;`git diff --check` exit 0; |
|||
- **未使用 `git restore` / `git checkout`**。 |
|||
|
|||
> 透明说明:脚本首次报告的三次 `restored_ok=NO` 经逐层排查确认是**我方脚本的变量取值缺陷**, |
|||
> 而非文件损坏 —— 用同一 Python 读写惯用法对未变异文件做往返实验,SHA-256 保持不变; |
|||
> 再对目标文件执行"变异→还原"全程,A/B 哈希一致(`5771647b…`)。 |
|||
> 上述三项内容级核验(git 逐字节一致 + 哈希比对 + 变异条目还原)均已独立完成。 |
|||
|
|||
--- |
|||
|
|||
## 8. Docker 启动路径与 `docker info` 结果 |
|||
|
|||
| 项 | 结果 | |
|||
|---|---| |
|||
| 复验开始时 `docker info` | **失败**(`failed to connect … npipe:////./pipe/dockerDesktopLinuxEngine`) | |
|||
| 指定的已知路径 | `D:\DevApps\DockerDesktop\Docker Desktop.exe` —— **存在**(15,142,832 字节,2025-07-23) | |
|||
| 启动命令 | `Start-Process -FilePath 'D:\DevApps\DockerDesktop\Docker Desktop.exe' -WindowStyle Hidden` | |
|||
| 轮询 | 每 15 秒一次,上限 180 秒 | |
|||
| **结果** | **约 15 秒后成功**:`docker info` → `ServerVersion=29.6.2` ✔ | |
|||
|
|||
未重建、删除或清理任何容器、volume 或 image。 |
|||
|
|||
--- |
|||
|
|||
## 9. Testcontainers 实际执行数 |
|||
|
|||
``` |
|||
mvn -Dtest="JobSearchSpecificationMySqlIntegrationTest,CandidateScoringRefreshMySqlIntegrationTest,JobSearchQuerySideEffectMySqlIntegrationTest,JobSearchQueryExplainMySqlIntegrationTest" test |
|||
→ Tests run: 26, Failures: 0, Errors: 0, Skipped: 0 BUILD SUCCESS |
|||
``` |
|||
|
|||
| 测试类 | Tests | Skipped | |
|||
|---|---|---| |
|||
| `JobSearchSpecificationMySqlIntegrationTest` | 15 | **0** | |
|||
| `CandidateScoringRefreshMySqlIntegrationTest` | 6 | **0** | |
|||
| `JobSearchQuerySideEffectMySqlIntegrationTest` | 4 | **0** | |
|||
| `JobSearchQueryExplainMySqlIntegrationTest` | 1 | **0** | |
|||
| **合计** | **26** | **0** ✔ | |
|||
|
|||
这 26 项正是此前因 Docker 不可用而跳过的全部用例,现已**真实执行并通过**。 |
|||
|
|||
--- |
|||
|
|||
## 10. 定向、全量、clean verify 的 Tests/Failures/Errors/Skipped |
|||
|
|||
| 命令 | Tests | Failures | Errors | Skipped | 结论 | |
|||
|---|---|---|---|---|---| |
|||
| 独立复验测试 `R01CIndependentVerificationTest` | **18** | 0 | 0 | 0 | ✔ | |
|||
| 定向 `*CandidateScoring*Test,*OverviewStatus*Test,*JobPreference*Test,*JobRecommendation*Test,*TalentDiscovery*Test,*Fairness*Test` | 296 | **2** | 0 | 0 | 见 §10.3(既有隔离脆弱性) | |
|||
| `mvn test` | 见 §10.1 | | | | | |
|||
| `mvn clean verify` | 见 §10.1 | | | | | |
|||
|
|||
### 10.1 全量与 clean verify |
|||
|
|||
``` |
|||
mvn test → Tests run: 2743, Failures: 0, Errors: 0, Skipped: 0 BUILD SUCCESS |
|||
mvn clean verify → Tests run: 2743, Failures: 0, Errors: 0, Skipped: 0 BUILD SUCCESS |
|||
``` |
|||
|
|||
| 项 | 数值 | |
|||
|---|---| |
|||
| 测试总数 | **2743** | |
|||
| 下限校验 | 实施基线 **2725** + 本轮独立新增 **18** = **2743** ✔ **恰好等于下限** | |
|||
| Failures / Errors / **Skipped** | **0 / 0 / 0** ✔ | |
|||
| BUILD | **SUCCESS** ✔ | |
|||
|
|||
**Skipped=0 的达成路径**:实施阶段因 Docker 不可用而跳过的 26 个 Testcontainers 用例, |
|||
在本轮 Docker 启动后**全部真实执行**(见 §9),因此不再计入 Skipped; |
|||
`2743 = 2725(实施阶段总数,含 26 项跳过)+ 18(本轮独立新增)= 2699 执行 + 26 执行 + 18`。 |
|||
|
|||
**该结果同时反证 §10.3 的定性**:`FairnessMonitoringHttpIntegrationTest` 在**全量顺序下通过** |
|||
(`mvn test` 与 `mvn clean verify` 均为 0 失败),说明其失败只在我方**自选的定向类组合**下触发, |
|||
是组合相关的既有隔离脆弱性,而非 R01-C 的行为差异。 |
|||
|
|||
### 10.2 Testcontainers 已执行 |
|||
|
|||
见 §9(26 项,Skipped=0)。 |
|||
|
|||
### 10.3 定向命令中的 2 个失败:既有测试隔离脆弱性(与 R01-C 无关) |
|||
|
|||
**现象**:定向命令中 `FairnessMonitoringHttpIntegrationTest` 有 2 项失败: |
|||
|
|||
``` |
|||
$.data.populationSize expected:<100> but was:<115> |
|||
$.data.populationSize expected:<200> but was:<215> |
|||
``` |
|||
|
|||
**定性证据链**: |
|||
|
|||
1. **单独运行该类:4/4 全部通过**(`mvn -Dtest="FairnessMonitoringHttpIntegrationTest" test` → `Tests run: 4, Failures: 0`); |
|||
2. 失败仅在**与其他类同批**时出现,且两次都恰好多 **15** 行 → 典型的共享 H2 库数据泄漏; |
|||
3. **泄漏源已定位**:`candidatescore/dashboard/CompanyScoringDashboardPersistenceIntegrationTest` |
|||
以 `LocalDate.now()` 生成 **当前期间**(`period = String.format("%04d-%02d", …)`), |
|||
并向 `candidate_dimension_scores` 插入该期间的快照; |
|||
`FairnessMonitoringHttpIntegrationTest` 同样使用 `YearMonth.now()`,且其 `populationSize` |
|||
**未按自身前缀限定范围**,因此把同批类的行一并计入; |
|||
4. **该泄漏源不在 R01-C 改动范围内**(`git diff --name-only` 中无该文件); |
|||
5. R01-C 的生产改动仅为"教育规则常量改读规则集"与"偏好分改委托共用计算器", |
|||
**不涉及任何持久化、清理或计数逻辑**。 |
|||
|
|||
**判定**:属**既有测试隔离脆弱性**(与 A6/R01-B 定位的同类问题同源),**非 R01-C 行为差异**, |
|||
不构成 R01-C 阻断项。按任务要求**如实记录、不写成通过**,也不修改该类测试。 |
|||
|
|||
**明确结论**: |
|||
|
|||
1. **R01-C 单独测试通过** —— `FairnessMonitoringHttpIntegrationTest` 单独运行 4/4 全绿; |
|||
全量 `mvn test` / `mvn clean verify`(2743/2743)中该类亦通过; |
|||
2. **泄漏源(`CompanyScoringDashboardPersistenceIntegrationTest`)与受害测试 |
|||
(`FairnessMonitoringHttpIntegrationTest`)均不在 R01-C 生产改动内**(3 个生产文件中无它们); |
|||
3. **该问题将由下一独立纯测试提交修复**(修污染源:独占 period/studentId + 精确清理), |
|||
**不并入 R01-C 提交**; |
|||
4. **该次定向组合命令(296/2 失败)不得写成全绿** —— 上表如实保留 `Failures=2`。 |
|||
|
|||
--- |
|||
|
|||
## 11. OpenAPI 数量 |
|||
|
|||
``` |
|||
python ..\docs\verify_openapi.py |
|||
→ paths: 176 / operations: 214 |
|||
→ All checks passed! |
|||
``` |
|||
|
|||
`git diff -- docs/openapi-rc.json` → **空**,`openapi-rc.json` **未被修改** ✔ |
|||
|
|||
--- |
|||
|
|||
## 12. 精确文件清单 |
|||
|
|||
**本轮 R01-C 生产改动(新增 1 + 修改 2 = 3 个生产文件)** |
|||
|
|||
``` |
|||
新增: mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/rules/CandidateScoringRuleSet.java |
|||
修改: mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/CandidateScoringCalculator.java |
|||
修改: mydreamjob-server/src/main/java/com/mydreamjob/talentdiscovery/TalentDiscoveryService.java |
|||
``` |
|||
|
|||
**实施者新增测试(4)**:`mydreamjob-server/src/test/java/com/mydreamjob/common/r01c/` 下 |
|||
`OverviewStatusPolicyCharacterizationTest`、`PreferenceScoreCharacterizationTest`、 |
|||
`CandidateScoringRuleSetCharacterizationTest`、`RecommendationOrderingCharacterizationTest` |
|||
|
|||
**本轮独立复验新增(2)** |
|||
|
|||
``` |
|||
mydreamjob-server/src/test/java/com/mydreamjob/common/r01c/R01CIndependentVerificationTest.java |
|||
docs/r01-c-independent-verification-20260918.md |
|||
``` |
|||
|
|||
**文档(1 修改)**:`docs/backend-development-roadmap.md` |
|||
|
|||
**本轮未修改任何生产代码** —— 复验前后 `git diff --name-only -- …/src/main` 恒为上述 2 个文件。 |
|||
|
|||
--- |
|||
|
|||
## 13. 是否修改迁移、ErrorCode、接口、依赖 |
|||
|
|||
| 项 | 结果 | |
|||
|---|---| |
|||
| V1–V45 / Flyway | **未修改**(`git diff --name-status -- …/db/migration` 为空) | |
|||
| `ErrorCode` | **未修改** | |
|||
| DTO | **未修改** | |
|||
| 公网接口 / OpenAPI 契约 | **未修改**(176/214,`openapi-rc.json` 未变) | |
|||
| `pom.xml` / 依赖 | **未修改** | |
|||
| Security / Token / 角色 / requestId | **未修改** | |
|||
| D4 快照物化语义 | **未修改** | |
|||
| 排名、申诉、公平性抑制规则 | **未修改** | |
|||
|
|||
**未为了测试通过修改生产公式或放宽既有断言**(独立测试的期望值均独立推导; |
|||
实施者的 40 个特征测试断言未被改动)。 |
|||
|
|||
--- |
|||
|
|||
## 14. Git 状态 |
|||
|
|||
| 项 | 结果 | |
|||
|---|---| |
|||
| 分支 | `refactor/r01-structure-performance` ✔ | |
|||
| HEAD | `9954600650c3e7cd40a5f4fa55bc38595973f214`(未变)✔ | |
|||
| `git diff --cached --name-only` | **空** ✔ | |
|||
| `git diff --check` | **exit 0** ✔ | |
|||
| `git diff --name-status` | 仅 `docs/backend-development-roadmap.md`、`CandidateScoringCalculator.java`、`TalentDiscoveryService.java`(**无变异残留**)✔ | |
|||
| HEAD 与 upstream | `0 0` ✔ | |
|||
| 历史未跟踪文件 | 完整保留 ✔ | |
|||
| 残留 Java/Maven 进程 | 无 ✔ | |
|||
|
|||
--- |
|||
|
|||
## 15. 未暂存、未提交、未推送、未进入 R01-E |
|||
|
|||
**确认:** 未执行 `git add`;未创建提交;未推送;未执行 `reset`/`restore`/`checkout`/`clean`; |
|||
未进入 R01-E;未继续开发。 |
|||
|
|||
--- |
|||
|
|||
> **R01-C独立代码复验通过,可以进入精确暂存与独立提交检查点。** |
|||
@ -0,0 +1,17 @@ |
|||
# R01-C 纯计算与重复规则收束实施指令 |
|||
|
|||
## 目标 |
|||
|
|||
在不改变评分、排名和推荐结果的前提下,收束候选人综合状态规则,并将岗位偏好评分拆成可独立验证的纯计算器。 |
|||
|
|||
## 行为边界 |
|||
|
|||
- 评分引擎继续按固定 8 维判断完整性,且 OK/ESTIMATED 必须有非空分数才可计算; |
|||
- 竞争排名继续按当前快照集合大小判断完整性,并保持历史“按状态可计算”的口径; |
|||
- 推荐偏好权重保持城市30、用工类型20、最低薪资20、行业15、岗位族15; |
|||
- 薪资边界继续使用 `salaryMax >= expectedSalaryMin`; |
|||
- 百分比继续保留两位小数并使用 HALF_UP; |
|||
- 不改数据库、接口、DTO、排序、分页、权限、审计或开关语义; |
|||
- 不进入 R01-D/E,不暂存、不提交、不推送。 |
|||
|
|||
必须先用特征测试锁定两套 overview 输入适配差异和偏好评分边界,再替换生产调用点。 |
|||
@ -0,0 +1,24 @@ |
|||
# R01-C 纯计算与重复规则收束实施报告 |
|||
|
|||
> 状态:R01-C 已实现并通过自动化与干净构建,待独立代码复验。 |
|||
|
|||
## 实现结果 |
|||
|
|||
新增 `CandidateOverviewStatusPolicy`,用一个纯决策内核产出 OK/PARTIAL/PENDING/INSUFFICIENT_DATA。两个入口显式保留历史适配差异:评分引擎固定 8 维并要求 score 非空;竞争排名按已有快照数量和状态判断。原两个业务类中的 `overviewStatus` 实现均已删除。 |
|||
|
|||
新增 `JobPreferenceScoreCalculator`,把原先压缩在单行方法中的 30/20/20/15/15 权重计算提取为无 Spring、无数据库依赖的纯函数。`JobRecommendationService` 仅负责解析已有 JSON 字段并组装输入;权重、匹配条件、空信号返回 null、薪资相等边界及 HALF_UP 两位小数全部保持。 |
|||
|
|||
## 测试 |
|||
|
|||
- `CandidateOverviewStatusPolicyTest`:5 条,覆盖两个适配入口的 OK/PARTIAL/PENDING/INSUFFICIENT_DATA、空集合、固定八维、null score 差异,以及评分结果列表为 null 时的历史快速失败语义; |
|||
- `JobPreferenceScoreCalculatorTest`:7 条,覆盖无信号、全匹配、全不匹配、权重与舍入、薪资相等/null、null 集合,以及历史 JSON 数组中 null 元素的兼容性; |
|||
- 独立复核发现 `List.copyOf` 会拒绝历史 JSON 数组中的 null 元素,可能把原先可计算的数据变成 500;已改为允许 null 的防御性只读拷贝,并先补回归测试锁定旧语义; |
|||
- 定向关联集合:33/33; |
|||
- 第一次定向测试因夹具让城市和行业同时匹配却期望 66.67 而失败,修正为行业不匹配后同一断言通过;未改生产公式或放宽断言。 |
|||
|
|||
## 全量结果 |
|||
|
|||
- `mvn clean verify`:2094/2094,Failures=0、Errors=0、Skipped=20,BUILD SUCCESS;20 项均为既有环境条件跳过项,R01-C 定向与关联测试 Skipped=0; |
|||
- OpenAPI:176 paths / 214 operations,All checks passed; |
|||
- V1–V43 未修改,无新增接口、ErrorCode、依赖、表或索引; |
|||
- 暂存区为空,未提交、未推送,历史未跟踪产物保留。 |
|||
@ -0,0 +1,437 @@ |
|||
# R01-C 评分与推荐规则结构收束报告 |
|||
|
|||
日期:2026-09-18 |
|||
分支:`refactor/r01-structure-performance` |
|||
HEAD:`9954600650c3e7cd40a5f4fa55bc38595973f214`(R01-B 提交 `70b9e10` + 夹具修复 `9954600`) |
|||
本轮性质:**结构重构** —— 未暂存、未提交、未推送,待独立代码复验 |
|||
铁律:**所有输入对应的评分、状态、排序、推荐结果、解释内容、规则版本与舍入结果必须与重构前逐项一致** |
|||
|
|||
--- |
|||
|
|||
## 0. 结论与一处必须先说明的口径差异 |
|||
|
|||
> **R01-C评分与推荐规则结构收束完成,待独立代码复验。** |
|||
|
|||
**权威库存(§2)推翻了指令的两项前提,已按你的裁决实施:** |
|||
|
|||
| 指令主题 | 库存实际 | 本轮处置 | |
|||
|---|---|---| |
|||
| 1. 消除**重复的 overviewStatus** 规则 | **不存在重复**:全仓 `"PARTIAL"` 只出现在 `CandidateOverviewStatusPolicy`(状态聚合器必须能产出 PARTIAL),3 个调用点已全部委托它。该策略由既有提交 `b68519e` 创建 | **无可合并项,不做任何改动**;以结构门禁测试锁定"唯一产出点" | |
|||
| 2. 拆分 `JobRecommendationService.preferenceScore()` | 该目标方法**已是 13 行编排方法**,重逻辑已在 `JobPreferenceScoreCalculator`(同为 `b68519e` 产物)。**但** `TalentDiscoveryService.preferenceScore` 内联了**第二套**完全相同的 30/20/20/15/15 规则 | 按裁决**收束该真实重复**:改委托共用计算器,**规则实现处 2 → 1** | |
|||
| 3. 高校名单/阈值/权重强类型化 | **真实待办**:`CandidateScoringCalculator` 内为裸常量,无规则集类 | **已完成**:新增 `candidatescore/rules/CandidateScoringRuleSet` | |
|||
|
|||
**未为了"grep 归零"制造改动**;主题 1 的"已唯一"结论附结构门禁测试可证。 |
|||
|
|||
--- |
|||
|
|||
## 1. 分支、HEAD、Git 状态 |
|||
|
|||
| 项 | 结果 | |
|||
|---|---| |
|||
| `git branch --show-current` | `refactor/r01-structure-performance` | |
|||
| `git rev-parse HEAD` | `9954600650c3e7cd40a5f4fa55bc38595973f214` ✔ | |
|||
| `git rev-list --left-right --count HEAD...@{upstream}` | `0 0` ✔ | |
|||
| `git diff --cached --name-only` | **空** ✔ | |
|||
| `git diff --check` | exit 0,无空白错误 ✔ | |
|||
| 开始前未提交的已跟踪修改 | **无**(工作区干净)✔ | |
|||
| 历史未跟踪文件 | 完整保留 ✔ | |
|||
|
|||
--- |
|||
|
|||
## 2. 上游: 三份权威库存 |
|||
|
|||
### 2.1 overviewStatus 库存 |
|||
|
|||
| 项 | 事实 | |
|||
|---|---| |
|||
| 实现处 | **唯一**:`candidatescore/CandidateOverviewStatusPolicy`(`final`、纯静态、无 Spring 依赖、无 DB、无系统时间、无副作用) | |
|||
| 入口 | `fromComputedResults(List<DimensionResult>, int expectedDimensions)`、`fromSnapshots(List<CandidateDimensionScore>)` | |
|||
| 调用方 | `CandidateScoringEngineService`(2 处)、`CompetitionRankingCalculator`(1 处)—— 全部为委托 | |
|||
| 判断顺序 | ① 空/null → `emptyStatus`(计算结果入口为 `PENDING`,快照入口为 `INSUFFICIENT_DATA`);② 可计算数 == 期望数 → `OK`;③ 可计算数 > 0 → `PARTIAL`;④ 全为 `PENDING` → `PENDING`;⑤ 否则 `INSUFFICIENT_DATA` | |
|||
| 可计算定义 | `valueAvailable && ("OK".equals(status) || "ESTIMATED".equals(status))` | |
|||
| 阈值 | 无数值阈值(纯计数比较,边界为"相等"与"大于 0") | |
|||
| null 行为 | 计算结果入口:`results == null` → **NPE**(`Objects.requireNonNull`);快照入口:`null` → `INSUFFICIENT_DATA` | |
|||
| 边界相等 | `computableCount == completeCount` 判 OK(相等即 OK,不是严格大于) | |
|||
| 返回值 | 字符串状态(沿用既有字符串契约,**未新建语义重复的枚举**) | |
|||
| 是否参与响应/快照/解释/排名/监控 | 参与:候选评分概览响应、竞争力排名卡状态;不参与快照版本推进 | |
|||
| 两份实现是否等价 | **不适用 —— 只有一份实现** | |
|||
|
|||
### 2.2 preferenceScore 库存(重构前,两套) |
|||
|
|||
| 项 | `JobPreferenceScoreCalculator`(共用) | `TalentDiscoveryService`(内联,已收束) | |
|||
|---|---|---| |
|||
| 输入 | `Criteria(cities, employmentTypes, expectedSalaryMin, industries, jobFamilies)` + `Target(city, employmentType, salaryMax, industryCode, jobFamilyCode)` | `PreferenceSignal(cities, employmentTypes, expectedSalaryMin, industries, jobTypes, …)` + `Job` + `JobRecommendationProfile` | |
|||
| 维度与权重 | 城市 30 / 用工类型 20 / 最低薪资 20 / 行业 15 / 岗位族 15 | **完全相同** | |
|||
| 缺失维度 | 该维度不计入 available(等效"不计分") | 完全相同 | |
|||
| 空集合/null | `Criteria` 紧凑构造器把 `null` 归一化为 `List.of()`;`expectedSalaryMin == null` 时该维度不计 | `readStrings` 恒返回非 null 列表;`expectedSalaryMin == null` 同上 | |
|||
| 字符串规范化 | **无**(精确匹配,不 trim、不大小写折叠) | **无**(同) | |
|||
| 计算顺序 | 城市 → 用工类型 → 薪资 → 行业 → 岗位族(固定顺序累加) | **完全相同** | |
|||
| scale / RoundingMode | `divide(..., 2, HALF_UP)`,`BigDecimal.valueOf(matched * 100L)` | `BigDecimal.valueOf(got * 100L).divide(..., 2, HALF_UP)` —— **同一式子** | |
|||
| 上下界 | `available == 0 → null`;否则 ∈ [0.00, 100.00] | 同 | |
|||
| 对总分/排序影响 | `JobRecommendationService` 中偏好分权重 40(与 RIASEC 35、能力 25 加权平均后降序 + jobId 升序) | `TalentDiscoveryService` 中作为候选打分的一个分量 | |
|||
|
|||
### 2.3 硬编码规则库存 |
|||
|
|||
| 项 | 重构前位置 | 数量/值 | |
|||
|---|---|---| |
|||
| 985 高校集合 | `CandidateScoringCalculator.C985`(`new HashSet<>(List.of(...))`) | **38 项**(38 唯一) | |
|||
| 211 高校集合 | `CandidateScoringCalculator.C211` | **62 条字面量 / 61 唯一**(`北京科技大学` 重复一次) | |
|||
| 层级判定顺序 | `isBlank → 985 → 211 → endsWith("大学"/"学院") → 兜底` | 985 优先于 211 | |
|||
| 学历层次分数 | 内联 switch | 博士 100 / 硕士 85 / 本科 70 / 大专 55 / 其他 50 | |
|||
| 学校档次分数 | 内联 | 985=85 / 211=75 / 其他=65 / 缺失=50 | |
|||
| 教育合成权重 | 内联 | 0.55 / 0.45;子项满分 55 / 45 | |
|||
| 默认维度权重 | `DEFAULT_WEIGHT` | `new BigDecimal("12.50")` | |
|||
| 上界常量 | `HUNDRED` | `new BigDecimal("100")` | |
|||
| rulesVersion | `RULES_VERSION` | `"candidate-scoring-v0.1"`(**另有** `AbilityScoreMapper.MAPPING_RULES_VERSION = "ability-assessment-scoring-v0.1"`,语义不同) | |
|||
| 消费者 | `CandidateScoringEngineService`、`CompetitionRankingCalculator`、`TalentDiscoveryService`、`CandidateScoreCardService` 等 | |
|||
| 同数不同义 | 存在且**保持独立命名**:`85`(学历硕士)≠ `85`(学校档次 985);`50`(学历其他)≠ `50`(学校缺失/兜底) | |
|||
|
|||
--- |
|||
|
|||
## 3. 重构前特征测试(先建基线) |
|||
|
|||
目录 `mydreamjob-server/src/test/java/com/mydreamjob/common/r01c/`,**40 个用例**, |
|||
期望值均为**手工推算或独立抄录**(不调用被测实现生成),使用 `isEqualByComparingTo` 并额外断言 `scale` 与 `toPlainString()`,**未使用 `isCloseTo` 之类宽松误差**。 |
|||
|
|||
| 测试类 | Tests | 覆盖 | |
|||
|---|---|---| |
|||
| `OverviewStatusPolicyCharacterizationTest` | 8 | 四种状态、空集合、null、`expectedDimensions` 门禁、OK 但分数缺失、两入口语义差异、**唯一产出点结构门禁** | |
|||
| `PreferenceScoreCharacterizationTest` | 17 | 全匹配/全不匹配/部分匹配/单维度、薪资相等边界与 null、四维加权 82.35、空集合与 null、大小写与脏值、空串、上下界 | |
|||
| `CandidateScoringRuleSetCharacterizationTest` | 10 | 985 首/中/末、211 首/中/末与重复项、两名单无交集、精确匹配(空白/简繁/大小写不匹配)、学历与档次分数、版本、八维度、只读集合 | |
|||
| `RecommendationOrderingCharacterizationTest` | 5 | 分数不同降序、同分按 jobId、完全同分确定性、score=null 抛 NPE、分页切片越界 | |
|||
|
|||
**基线运行(重构前):`Tests run: 40, Failures: 0, Errors: 0, Skipped: 0`** —— 见 `docs/acceptance/r01c/baseline.log`。 |
|||
基线期间发现并如实修正了一处**我方测试假设错误**:`isValidDimension(null)` 实际抛 `NullPointerException`(`Set.of` 契约),并非返回 `false`;已按真实行为锁定,未改生产代码。 |
|||
|
|||
--- |
|||
|
|||
## 4. overviewStatus 唯一策略 |
|||
|
|||
**未新增组件**:`CandidateOverviewStatusPolicy` 已是唯一策略且无 Spring/DB/时间依赖。 |
|||
本轮仅**新增结构门禁测试**:扫描 `src/main/java/com/mydreamjob` 下所有 `.java`, |
|||
断言 `"PARTIAL"` 的出现文件**恰好**为 `CandidateOverviewStatusPolicy.java`。 |
|||
若将来出现第二个聚合器,该测试立即失败。 |
|||
|
|||
--- |
|||
|
|||
## 5. 两份旧实现是否完全等价 |
|||
|
|||
**不存在两份 overviewStatus 实现**,故不适用"合并前等价性比对"。§六 的停止条件(两实现不等价)**未触发**。 |
|||
|
|||
**preferenceScore 的两套实现经逐行比对为等价**: |
|||
|
|||
| 比对项 | 结论 | |
|||
|---|---| |
|||
| 维度顺序 | 城市 → 用工类型 → 薪资 → 行业 → 岗位族,**一致** | |
|||
| 权重 | 30/20/20/15/15,**一致** | |
|||
| 缺失维度处理 | 不计入分母,**一致** | |
|||
| 薪资判定 | `job.salaryMax != null && salaryMax >= expectedSalaryMin`,**一致** | |
|||
| 岗位族取值 | `profile.getJobFamilyCode()`,**一致** | |
|||
| 计算式 | `BigDecimal.valueOf(x * 100L).divide(BigDecimal.valueOf(total), 2, HALF_UP)`,**一致** | |
|||
| 零分母 | `null`,**一致** | |
|||
| null 列表 | 计算器归一化为空列表;内联版由 `readStrings` 保证非 null —— **观察行为一致** | |
|||
|
|||
--- |
|||
|
|||
## 6. preferenceScore 拆分 |
|||
|
|||
- `JobRecommendationService.preferenceScore()`:**13 行**(重构前后一致),职责为"提取 Criteria/Target → 委托计算器",已是可快速读懂的编排方法。 |
|||
- `TalentDiscoveryService.preferenceScore()`:由 **12 行内联公式** 改为 **17 行编排**(含 javadoc),规则实现移出。 |
|||
- 拆分粒度按**实际存在的维度**(5 个),**未为匹配示例创造不存在的维度**;未引入 Stream;未改变可见性;未产生无意义的一行包装。 |
|||
- **30/20/20/15/15 规则实现处:2 → 1**(`grep "total += 30\|CITY_WEIGHT"`)。 |
|||
|
|||
--- |
|||
|
|||
## 7. BigDecimal 运算顺序与舍入证明 |
|||
|
|||
| 项 | 重构前 | 重构后 | 一致 | |
|||
|---|---|---|---| |
|||
| 偏好分构造 | `BigDecimal.valueOf(matched * 100L)` | 同一表达式(改为计算器内执行) | ✔ | |
|||
| 除法 | `divide(BigDecimal.valueOf(available), 2, RoundingMode.HALF_UP)` | 同 | ✔ | |
|||
| 教育维度 | `eduLevel*0.55 + schoolTier*0.45`,`setScale(2, HALF_UP)` | 同一表达式(权重改读规则集常量,**值相同**) | ✔ | |
|||
| `MathContext.DECIMAL128` | 未使用 | 未引入 | ✔ | |
|||
| scale | 全部不变(断言 `toPlainString()`) | 同 | ✔ | |
|||
| 新引入 double/float | — | **无** | ✔ | |
|||
|
|||
特征测试 `PreferenceScoreCharacterizationTest` 逐项断言 `scale == 2` 与精确字符串(如 `"82.35"`、`"66.67"`、`"33.33"`)。 |
|||
|
|||
--- |
|||
|
|||
## 8. 强类型规则设计 |
|||
|
|||
新增 `candidatescore/rules/CandidateScoringRuleSet`: |
|||
|
|||
| 要求 | 落实 | |
|||
|---|---| |
|||
| `final` / 不可变 / 无 setter | ✔ `public final class`,私有构造,全部 `static final` | |
|||
| 不可变集合 | ✔ `Collections.unmodifiableSet(new HashSet<>(...))` | |
|||
| 唯一当前规则实例 | ✔ 静态常量(随代码发布变更) | |
|||
| 版本常量 | ✔ `VERSION = "candidate-scoring-v0.1"`,**与重构前逐字一致** | |
|||
| 不接数据库/外部动态配置 | ✔ 无 Spring、无 JDBC、无配置读取 | |
|||
| BigDecimal 字符串构造 | ✔ 全部 `new BigDecimal("…")` | |
|||
| 同数不同义不合并 | ✔ `EDUCATION_LEVEL_MASTER(85)` 与 `SCHOOL_TIER_985(85)` 独立命名;`EDUCATION_LEVEL_UNKNOWN(50)` 与 `SCHOOL_TIER_MISSING(50)` 独立命名 | |
|||
| 按业务语义组织 | ✔ 版本与汇总 / 学历层次 / 学校档次 / 高校名单 四组 | |
|||
| 对外只读 | ✔ `c985()` / `c211()` 返回不可修改视图(测试断言 add/remove/clear 均抛 `UnsupportedOperationException`) | |
|||
|
|||
**未建立"杂物常量类"**:只收纳本轮明确要求的四组,未把计算器内其余维度阈值一并塞入。 |
|||
|
|||
--- |
|||
|
|||
## 9. 高校名单:数量与逐项一致证明 |
|||
|
|||
| 项 | 重构前(计算器内) | 重构后(规则集) | |
|||
|---|---|---| |
|||
| 985 定义处 | 1 | 1(位置变更) | |
|||
| 985 唯一项 | 38 | **38** ✔ | |
|||
| 211 定义处 | 1 | 1(位置变更) | |
|||
| 211 字面量条数 | 62 | **62** ✔ | |
|||
| 211 唯一项 | 61 | **61** ✔ | |
|||
| `北京科技大学` 重复 | 存在 | **原样保留**(按裁决不去重) | |
|||
| 字符串 | — | 逐项未改动(首/中/末抽样断言) | |
|||
| 判定优先级 | 985 → 211 | 985 → 211 ✔ | |
|||
| 模糊匹配 | 无 | **无**(新增测试断言"北京大学 "、"北京大学"、"北京大學" 均不匹配) | |
|||
| 对外只读 | `HashSet`(可变,仅 private) | 不可修改视图 ✔ | |
|||
|
|||
**计算器内名单定义数:2 → 0**;名单只在唯一规则源定义。 |
|||
|
|||
--- |
|||
|
|||
## 10. 阈值和权重逐项一致 |
|||
|
|||
| 规则 | 重构前值 | 重构后常量 | 值 | |
|||
|---|---|---|---| |
|||
| 学历 博士 | 100 | `EDUCATION_LEVEL_DOCTOR` | 100 ✔ | |
|||
| 学历 硕士 | 85 | `EDUCATION_LEVEL_MASTER` | 85 ✔ | |
|||
| 学历 本科 | 70 | `EDUCATION_LEVEL_BACHELOR` | 70 ✔ | |
|||
| 学历 大专 | 55 | `EDUCATION_LEVEL_JUNIOR_COLLEGE` | 55 ✔ | |
|||
| 学历 其他 | 50 | `EDUCATION_LEVEL_UNKNOWN` | 50 ✔ | |
|||
| 档次 985 | 85 | `SCHOOL_TIER_985` | 85 ✔ | |
|||
| 档次 211 | 75 | `SCHOOL_TIER_211` | 75 ✔ | |
|||
| 档次 其他 | 65 | `SCHOOL_TIER_OTHER` | 65 ✔ | |
|||
| 档次 缺失 | 50 | `SCHOOL_TIER_MISSING` | 50 ✔ | |
|||
| 教育缺失维度分 | 100 | `EDUCATION_MISSING_SCORE` | 100 ✔ | |
|||
| 学历权重 | 0.55 | `EDUCATION_LEVEL_WEIGHT` | 0.55 ✔ | |
|||
| 档次权重 | 0.45 | `EDUCATION_SCHOOL_TIER_WEIGHT` | 0.45 ✔ | |
|||
| 子项满分 | 55 / 45 | `EDUCATION_LEVEL_FULL_SCORE` / `…TIER…` | 55 / 45 ✔ | |
|||
| 默认维度权重 | 12.50 | `DEFAULT_DIMENSION_WEIGHT` | 12.50 ✔ | |
|||
| 满分上界 | 100 | `FULL_SCORE` | 100 ✔ | |
|||
|
|||
**裸教育规则 BigDecimal 命中数(计算器内):23 → 6**(其余 6 处为非教育维度阈值,本轮未纳入规则集)。 |
|||
数值一律为字符串构造,**未把整数阈值错误转换为百分比小数**。 |
|||
|
|||
--- |
|||
|
|||
## 11. rulesVersion |
|||
|
|||
| 项 | 值 | |
|||
|---|---| |
|||
| 常量 | `CandidateScoringRuleSet.VERSION` | |
|||
| 值 | **`candidate-scoring-v0.1`**(与重构前 `CandidateScoringCalculator.RULES_VERSION` **逐字一致**) | |
|||
| 是否新增版本 | **否** | |
|||
| 响应中的版本值 | **未改变**(`CandidateScoringCalculator.RULES_VERSION` 现指向同一常量) | |
|||
| 同义词区分 | `AbilityScoreMapper.MAPPING_RULES_VERSION = "ability-assessment-scoring-v0.1"` 保持独立,未合并 | |
|||
| 快照历史一致性 | 快照写入仍使用同一 `RULES_VERSION`,未变 | |
|||
|
|||
--- |
|||
|
|||
## 12. 排名顺序对账 |
|||
|
|||
`JobRecommendationService` 排序比较器**未改动**: |
|||
`Comparator.comparing(JobRecommendationItem::getScore).reversed().thenComparing(JobRecommendationItem::getJobId)`。 |
|||
`slice` 的下标计算未改动。特征测试锁定:分数降序、同分按 jobId 升序、完全同分确定性、越界切片为空。 |
|||
|
|||
**偏好分对排序的影响路径未变**:偏好分仍以权重 40 参与 `top/bottom` 加权平均(RIASEC 35、能力 25), |
|||
`divide(bottom, 2, HALF_UP)`。收束只替换了偏好分的**计算处**,未触碰分值本身。 |
|||
|
|||
--- |
|||
|
|||
## 13. 解释字段与贡献求和 |
|||
|
|||
| 校验 | 结论 | |
|||
|---|---| |
|||
| `CandidateScoringExplanationAssembler` 贡献求和 | 未修改该链路;`addContribution` 的入参在收束后**取值相同**(改读常量,值不变) | |
|||
| 各维度贡献之和 = 总分 | 教育维度仍为 `eduLevel*0.55 + schoolTier*0.45`,与两个 contribution 的求和一致 ✔ | |
|||
| overviewStatus 在响应/解释/快照/看板一致 | 唯一策略未被改动,仍由三处同一入口产出 ✔ | |
|||
| rulesVersion 在响应/快照/历史一致 | 同一常量 ✔ | |
|||
| 推荐解释中的偏好分 = 排序实际使用的分 | `TalentDiscoveryService` 收束后仍把同一计算结果用于打分与解释字段(同一局部变量 `p`) ✔ | |
|||
| "展示一套、排序另一套" | **未出现** ✔ | |
|||
|
|||
--- |
|||
|
|||
## 14. 查询数与副作用 |
|||
|
|||
| 项 | 结论 | |
|||
|---|---| |
|||
| 新增数据库查询 | **无**。规则集为静态常量;偏好分计算为纯函数 | |
|||
| 推荐一次查询的 SQL 数量 | **不增加**:`TalentDiscoveryService.preferenceScore` 收束后**零查询**(输入已由批量读取提供);`JobRecommendationService` 未变 | |
|||
| 评分计算新增写入 | **无** | |
|||
| GET 评分接口零写快照 | **未改动**该迁移 | |
|||
| RabbitMQ / 审计 / 通知 | 未新增 | |
|||
| 缓存键 | 未改变 | |
|||
|
|||
--- |
|||
|
|||
## 15. 变异测试(三类,均恢复原字节) |
|||
|
|||
| # | 变异 | 目标文件 | 结果 | |
|||
|---|---|---|---| |
|||
| 1 | `isComputable` 中 `"ESTIMATED"` → `"OK"`(改变状态边界) | `CandidateOverviewStatusPolicy` | **8 run / 2 failures** ✔ | |
|||
| 2 | `SALARY_WEIGHT` 20 → 25(改变偏好权重) | `JobPreferenceScoreCalculator` | **17 run / 1 failure** ✔ | |
|||
| 3 | 985 名单删除 `"北京大学"` | `CandidateScoringRuleSet` | **10 run / 2 failures** ✔ | |
|||
|
|||
**恢复核验**:三个文件均已还原,其中 #1 因还原脚本误替换了同一行中的另一处 `"OK".equals`, |
|||
已定位并**逐字节修正**,`git diff --quiet` 确认与 HEAD 完全一致;#2、#3 的 SHA-256 恢复一致。 |
|||
**未使用 `git restore/checkout`**;变异内容未留在工作区(见 §20 的差异范围核对)。 |
|||
|
|||
--- |
|||
|
|||
## 16. 定向、关联、全量测试数 |
|||
|
|||
| 命令 | 结果 | 日志 | |
|||
|---|---|---| |
|||
| R01-C 定向(4 个特征类) | **40 / 40**,F=0 E=0 S=0 ✔ | `baseline.log` / `after-characterization.log` | |
|||
| 关联回归(`*CandidateScoring*Test,*ScoringDashboard*Test,*Fairness*Test,*JobRecommendation*Test,*Recommendation*Test,*CompetitionRanking*Test,*Appeal*Test,*TalentDiscovery*Test`) | **331 / 331**,F=0 E=0,**Skipped=6** | `related.log` | |
|||
| `mvn test` | 见 §16.1 | `full-mvntest.log` | |
|||
| `mvn clean verify` | 见 §16.1 | `clean-verify.log` | |
|||
|
|||
### 16.1 全量结果与 Docker 说明(如实记录) |
|||
|
|||
``` |
|||
mvn test → Tests run: 2725, Failures: 0, Errors: 0, Skipped: 26 BUILD SUCCESS |
|||
mvn clean verify → Tests run: 2725, Failures: 0, Errors: 0, Skipped: 26 BUILD SUCCESS |
|||
``` |
|||
|
|||
| 项 | 数值 | |
|||
|---|---| |
|||
| 测试总数 | **2725** = 本轮基线 **2685** + 本轮新增 **40** ✔(**不低于 2685** 的要求满足) | |
|||
| Failures / Errors | **0 / 0** ✔ | |
|||
| **Skipped** | **26 —— 未达"0"要求**,原因如下 | |
|||
|
|||
#### Skipped=26 的准确原因 |
|||
|
|||
26 个跳过**全部**来自 4 个 MySQL / Testcontainers 类(与 R01-B 复验时同一批): |
|||
`JobSearchSpecificationMySqlIntegrationTest`(15)、`CandidateScoringRefreshMySqlIntegrationTest`(6)、 |
|||
`JobSearchQuerySideEffectMySqlIntegrationTest`(4)、`JobSearchQueryExplainMySqlIntegrationTest`(1)。 |
|||
它们标注 `@Testcontainers(disabledWithoutDocker = true)`,Docker 不可用时按设计跳过。 |
|||
|
|||
**环境状态变化(非代码原因)**: |
|||
|
|||
- 本轮**开始时** `docker info` 成功:`ServerVersion=29.6.2`(满足 §三 的"Docker 可用"前置要求); |
|||
- 执行到关联回归时再次检查,Docker 守护进程已停止: |
|||
`failed to connect to the docker API at npipe:////./pipe/dockerDesktopLinuxEngine`; |
|||
- `wsl -l -v` 显示 `docker-desktop` 发行版为 **Stopped**; |
|||
`C:\Program Files\Docker\Docker\Docker Desktop.exe` **不存在**(该目录下仅有 `cli-plugins`), |
|||
`%LOCALAPPDATA%\Docker` 下只有 log/run/tasks/wsl 等运行期目录,**没有可启动的 Desktop 前端**。 |
|||
|
|||
**处置**:未强行以非受支持方式拉起 Docker(`wsl -d docker-desktop` 明确不被支持), |
|||
**不声称 Skipped=0**。除这 26 个 Docker 条件用例外的 **2699 个测试全部执行且全部通过**。 |
|||
**不构成 R01-C 阻断项**。 |
|||
|
|||
#### 独立复验补齐(2026-09-18,本段为复验追加,不覆盖上述事实) |
|||
|
|||
独立复验发现了**正确的 Docker Desktop 安装路径**并据此启动: |
|||
|
|||
| 项 | 结果 | |
|||
|---|---| |
|||
| 路径 | `D:\DevApps\DockerDesktop\Docker Desktop.exe`(存在,15,142,832 字节) | |
|||
| 启动 | `Start-Process -FilePath '…\Docker Desktop.exe' -WindowStyle Hidden`,轮询上限 180 秒 | |
|||
| `docker info` | **约 15 秒后成功** → `ServerVersion=29.6.2` | |
|||
| 原先跳过的 26 项 | **26 / 26 实际执行**,`Tests run: 26, Failures: 0, Errors: 0, Skipped: 0` | |
|||
|
|||
**最终全量(复验阶段)**: |
|||
|
|||
``` |
|||
mvn test → Tests run: 2743, Failures: 0, Errors: 0, Skipped: 0 BUILD SUCCESS |
|||
mvn clean verify → Tests run: 2743, Failures: 0, Errors: 0, Skipped: 0 BUILD SUCCESS |
|||
``` |
|||
|
|||
`2743 = 2725(本报告阶段总数,含 26 项跳过)+ 18(独立复验新增测试)`; |
|||
其 **Skipped=0** 正是因为那 26 项已真实执行。**本阶段未改动任何代码即补足了这 26 项。** |
|||
|
|||
--- |
|||
|
|||
## 17. OpenAPI |
|||
|
|||
``` |
|||
python ..\docs\verify_openapi.py |
|||
→ paths: 176 / operations: 214 |
|||
→ All checks passed! |
|||
``` |
|||
|
|||
`git diff -- docs/openapi-rc.json` → **空** ✔ |
|||
|
|||
--- |
|||
|
|||
## 18. 精确修改文件 |
|||
|
|||
**生产代码变更总数:新增 1 个生产类、修改 2 个生产类,共 3 个生产文件。** |
|||
|
|||
**新增生产类(1)** |
|||
|
|||
``` |
|||
mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/rules/CandidateScoringRuleSet.java |
|||
``` |
|||
|
|||
**修改生产类(2)** |
|||
|
|||
``` |
|||
candidatescore/CandidateScoringCalculator.java ← 版本/权重改读规则集;删除 C985/C211;教育数值改读常量 |
|||
talentdiscovery/TalentDiscoveryService.java ← preferenceScore 委托 JobPreferenceScoreCalculator |
|||
``` |
|||
|
|||
> **独立复验补充(2026-09-18)**:`git status` 会在 Windows 上把 |
|||
> `CandidateOverviewStatusPolicy.java` 与 `JobPreferenceScoreCalculator.java` 标为 ` M` |
|||
> (变异实验重写过这两个文件、mtime 变化)。经 `git hash-object` 与 `git rev-parse HEAD:<file>` |
|||
> 比对,两者 blob **完全相同**,属 **Windows stat 缓存假阳性**,**不在本提交范围内**。 |
|||
|
|||
**新增测试(4)**:`common/r01c/` 下 4 个特征测试类 |
|||
|
|||
**文档(2)**:`docs/r01-c-scoring-recommendation-rules-refactor-report.md`(新增)、 |
|||
`docs/backend-development-roadmap.md`(更新) |
|||
|
|||
> **未新增**:`CandidateOverviewStatusPolicy` 相关改动(已唯一)、`JobRecommendationService` 改动(已是编排)。 |
|||
|
|||
--- |
|||
|
|||
## 19. Flyway / ErrorCode / DTO / 接口变化 |
|||
|
|||
| 项 | 结果 | |
|||
|---|---| |
|||
| Flyway / 迁移 | **未改动** ✔ | |
|||
| `ErrorCode` | **未改动** ✔ | |
|||
| DTO / 响应结构 | **未改动** ✔ | |
|||
| 公网接口 / OpenAPI | **未改动**(176/214,`openapi-rc.json` 未变) ✔ | |
|||
| Token / 权限 / requestId | **未改动** ✔ | |
|||
| 依赖 / `pom.xml` | **未改动** ✔ | |
|||
| 评分公式数值 / 排序 / 同分规则 / 小样本与互补风险抑制 / 申诉回滚 / 快照版本推进 / Fairness / 消息与 Offer 等 | **均未改动** ✔ | |
|||
|
|||
--- |
|||
|
|||
## 20. Git 状态 |
|||
|
|||
| 项 | 结果 | |
|||
|---|---| |
|||
| 分支 / HEAD | `refactor/r01-structure-performance` / `9954600…`(未变) | |
|||
| `git diff --cached --name-only` | **空** ✔ | |
|||
| `git diff --check` | exit 0 ✔ | |
|||
| 生产代码改动范围 | **恰为 2 个文件**,与 §18 一致(变异内容未残留)✔ | |
|||
| 已暂存 / 已提交 / 已推送 | **均无** ✔ | |
|||
| 历史未跟踪文件 | 未删除 ✔ | |
|||
|
|||
--- |
|||
|
|||
## 21. 未暂存、未提交、未推送 |
|||
|
|||
**确认。** 未执行 `git add`;未创建提交;未推送;未执行 `reset`/`restore`/`checkout`/`clean`; |
|||
未进入 R01-E。 |
|||
|
|||
--- |
|||
|
|||
## 22. 未进入 R01-E |
|||
|
|||
**确认。** 本轮未处理 N+1、会话未读数、`overviewStatus` 之外的其他查询收束、 |
|||
数据库迁移或任何 R01-E 范围内的主题。 |
|||
|
|||
--- |
|||
|
|||
> **R01-C评分与推荐规则结构收束完成,待独立代码复验。** |
|||
@ -0,0 +1,25 @@ |
|||
# R01-D1 百分位性能收束报告 |
|||
|
|||
> 状态:R01-D1 已实现并通过自动化与干净构建,待独立复验。 |
|||
|
|||
## 修改范围 |
|||
|
|||
- 新增纯函数 `CandidatePercentileCalculator`; |
|||
- `CompanyScoringDashboardService` 保留原调用位置,仅将百分位计算委托给纯函数; |
|||
- 未修改候选人综合分、排序、分页、过滤、披露门槛或响应结构。 |
|||
|
|||
## 算法与兼容性 |
|||
|
|||
旧实现对每个候选分数再次扫描全部分数,复杂度为 O(n²)。新实现先排序,再以每个分数第一次出现的位置作为“严格低于该分数的数量”,复杂度为 O(n log n)。分母仍为 `有效分数数 - 1`,仍使用 10 位中间精度、HALF_UP,并最终保留两位小数。 |
|||
|
|||
等价测试覆盖空集合、单样本、全 null、输入顺序、null 位置、并列分数,以及由 `{null, 0, 1.0, 1.00, 2.5}` 生成的长度 0–6 全组合,并逐项与旧 O(n²) 公式比较。 |
|||
|
|||
首次实现使用 `HashMap<BigDecimal, ...>`,测试立即发现 `1.0` 与 `1.00` 被错误视为不同键;改为按 `BigDecimal.compareTo` 排序的 `TreeMap` 后恢复旧语义。另一个首次失败来自手写并列夹具把最高分期望误写为 66.67;旧公式预言机证明应为 100.00,修正夹具期望,未改变生产公式。 |
|||
|
|||
## 验证 |
|||
|
|||
- 定向与看板关联测试:28/28,Failures=0、Errors=0、Skipped=0; |
|||
- `mvn clean verify`:2099/2099,Failures=0、Errors=0、Skipped=20,BUILD SUCCESS;20 项为既有环境条件跳过项,R01-D1 定向及关联测试 Skipped=0; |
|||
- OpenAPI:176 paths / 214 operations,All checks passed; |
|||
- 无 Flyway、ErrorCode、公网接口、表、索引或依赖变化; |
|||
- 未暂存、未提交、未推送。 |
|||
@ -0,0 +1,29 @@ |
|||
# R01-D2 个人数据导出查询收束报告 |
|||
|
|||
> 状态:R01-D2 已完成批量查询整改、自动化验证与干净构建,待独立复验。 |
|||
|
|||
## 原问题 |
|||
|
|||
`PersonalDataCollector` 在遍历父记录时逐条查询子记录:投递状态历史、反馈、面试历史、Offer 历史、会话、消息和评分申诉历史均形成 N+1;投递列表还被重复查询两次。 |
|||
|
|||
## 整改 |
|||
|
|||
- 每类父记录先收集 ID,再用 Repository `IN` 查询一次性读取子记录; |
|||
- 在内存中按父 ID 分组,并继续按原父记录顺序组装,保留时间线内部升序、消息内部降序和每会话最多 10000 条的既有语义; |
|||
- 投递列表只读取一次,同时供投递、时间线和反馈三个 section 使用; |
|||
- 会话实体改用 `findAllById`,消息按全部 conversationId 一次批量读取; |
|||
- 未改变导出 section 名称、字段、脱敏、简历文件读取或 ZIP 构建语义。 |
|||
|
|||
## 测试证据 |
|||
|
|||
- `PersonalDataCollectorQueryBudgetTest`:3 条; |
|||
- 投递规模 1 与 50 时,投递主查询、状态历史查询、反馈查询均固定各 1 次; |
|||
- 面试历史、Offer 历史、会话实体、消息、申诉历史均固定为每类 1 次批量查询;旧逐条 Repository 方法明确断言为 0 次; |
|||
- 父记录顺序与每个父记录下的时间线顺序有独立断言; |
|||
- 定向及数据导出关联回归:19/19,Failures=0、Errors=0、Skipped=0; |
|||
- `mvn clean verify`:2102/2102,Failures=0、Errors=0、Skipped=20,BUILD SUCCESS;20 项为既有环境条件跳过项,R01-D2 定向与关联测试 Skipped=0; |
|||
- OpenAPI:176 paths / 214 operations,All checks passed。 |
|||
|
|||
## 边界 |
|||
|
|||
无 Flyway、表、索引、ErrorCode、公网接口或依赖变化;未暂存、未提交、未推送。 |
|||
@ -0,0 +1,48 @@ |
|||
# R01-D3 会话未读数查询收束报告 |
|||
|
|||
## 结论 |
|||
|
|||
`MessagingService.listConversations` 已从逐会话查询最近消息和已读状态,收束为一次分组未读统计。会话数量从 1 增长到 20、50 时,消息相关业务 SQL 数保持固定;本次未改变公网接口、响应字段、权限、消息撤回或已读语义。 |
|||
|
|||
## 修复前问题 |
|||
|
|||
会话列表先查询成员关系和会话,再对每个会话调用 `countUnread`。每次调用都会查询最近 500 条消息及其已读记录,因此总查询数随会话数线性增长。 |
|||
|
|||
## 实现 |
|||
|
|||
- `MessageRepository.countUnreadByConversationIds` 使用一条分组 SQL 批量统计全部会话。 |
|||
- 使用 `ROW_NUMBER() OVER (PARTITION BY conversation_id ORDER BY created_at DESC)` 保留每个会话“最近 500 条”的历史窗口。 |
|||
- `RECALLED` 消息不计入未读,`status IS NULL` 仍按历史语义计入。 |
|||
- 存在当前用户 `message_reads` 的消息不计入未读。 |
|||
- 没有未读行的会话由 Service 映射为 `0`。 |
|||
- `getConversation` 的单会话路径未改动,避免扩大本批范围。 |
|||
|
|||
## 查询预算证据 |
|||
|
|||
`MessageListQueryBudgetIntegrationTest.listConversationsUsesFixedQueriesForOneTwentyAndFiftyConversations` 使用真实 Spring/JPA/H2 数据源代理统计业务 SQL,并混合 DIRECT、TALENT_INVITATION、SENT、RECALLED、已读和未读消息。 |
|||
|
|||
| 会话数 | 业务查询数 | 结果 | |
|||
| ---: | ---: | --- | |
|||
| 1 | 3 | PASS | |
|||
| 20 | 3 | PASS | |
|||
| 50 | 3 | PASS | |
|||
|
|||
三条查询分别为成员关系、会话批量读取、未读分组统计,查询数不随会话数增长。 |
|||
|
|||
## 语义边界 |
|||
|
|||
`groupedUnreadCountKeepsHistoricalLatestFiveHundredWindow` 构造 501 条顺序明确的消息,证明最旧消息位于窗口外时不被统计。测试首次编写时,实体的 `@CreatedDate` 覆盖夹具传入时间,造成 501 条消息时间并列;随后改为保存后按精确消息主键写入确定时间。该失败属于夹具问题,不是生产缺陷,也未通过放宽断言处理。 |
|||
|
|||
## 验证 |
|||
|
|||
- 定向边界测试:3/3,Failures=0、Errors=0、Skipped=0。 |
|||
- 关联消息测试:此前同批运行 42/42 通过。 |
|||
- `mvn clean verify`:2104 项,Failures=0、Errors=0、Skipped=20,BUILD SUCCESS;20 项均为既有环境条件测试。 |
|||
- 静态 OpenAPI:176 paths / 214 operations,校验通过。 |
|||
|
|||
## 边界 |
|||
|
|||
- 未新增或修改 Flyway、表、索引、ErrorCode、公网接口。 |
|||
- 未改变 DIRECT / TALENT_INVITATION 响应隐私语义。 |
|||
- 未改变消息发送、撤回、已读、分页和权限语义。 |
|||
- 原生窗口 SQL 仍需在独立真实 MySQL 验收中复核执行计划与结果。 |
|||
@ -0,0 +1,87 @@ |
|||
# R01-D4-1「候选人评分快照可靠物化基础设施」实施报告 |
|||
|
|||
日期:2026-09-16 |
|||
分支:`refactor/r01-structure-performance`;HEAD 检查点:`b10c4a7 docs: design scoring snapshot materialization`(后于 `b56f792`) |
|||
性质:D4-1 实施(**GET 兜底未删除**,三 GET 仍经 upsertSnapshots 写库) |
|||
|
|||
## 1. Git 基线 |
|||
- 分支正确;设计检查点已提交 `b10c4a7`(3 个设计文件)。 |
|||
- 暂存区空;未 commit/push 本轮实现;V1–V43 未修改;V44 为唯一新增迁移。 |
|||
- `GlobalExceptionHandler.java` 保持行尾符标记现象(内容 diff 为空)未动。 |
|||
|
|||
## 2. V44 表结构(`V44__candidate_scoring_refresh_tasks.sql`) |
|||
`candidate_scoring_refresh_tasks`: id PK、student_user_id、target_period(7)、status、requested_at、processing_started_at、processed_at、next_attempt_at、attempts、last_error_code(40)、version、created_at、updated_at;`uk_refresh_task_student_period(student_user_id,target_period)`、`ck_refresh_task_status`(PENDING/PROCESSING/RETRY/COMPLETED/FAILED)、`idx_refresh_task_claim(status,next_attempt_at)`、FK→users(id) ON DELETE CASCADE。不保存 Token/手机号/简历/评分源数据。 |
|||
|
|||
## 3. 任务唯一键与状态机 |
|||
唯一键 (student_user_id, target_period)。状态机:PENDING→PROCESSING→COMPLETED;失败→RETRY(attempts+1、next_attempt_at 退避)→达上限 FAILED;COMPLETED/FAILED 再登记→PENDING+attempts=0;RETRY 再登记→PENDING(attempts 保留)。 |
|||
|
|||
## 4. 登记事务语义(`CandidateScoringRefreshRequestService#requestRefresh`) |
|||
原生参数化 upsert 合并;REQUIRED 传播——随调用方业务事务,**源业务提交失败则登记一并回滚**;登记不触发线程内计算、不发 RabbitMQ、不写审计、无敏感值;period 取 capturedNow 单次读取。 |
|||
|
|||
## 5. Worker 领取与并发(`CandidateScoringRefreshWorker`) |
|||
数据库条件更新领取(`status IN (PENDING,RETRY) AND next_attempt_at 到期` 置 PROCESSING,0 行即被并发方领取);无 JVM 锁/Thread.sleep。领取使用独立短事务;计算、8 维 Writer 与完成状态使用另一个 `REQUIRES_NEW` 原子事务;失败状态再用独立事务持久化。因此单维写失败会回滚该任务全部快照,但不会污染同批其他任务。并发双 Worker 仅一次有效物化。 |
|||
|
|||
## 6. PROCESSING 期间新请求防丢失 |
|||
登记对 PROCESSING 保留状态并推进任务 version;Worker 领取时记录 claimedVersion,完成时仅在 version 未变化时置 COMPLETED;处理期间有新请求导致 version 变化时回到 PENDING。另有 10 分钟(可配置)PROCESSING 租约超时回收:置 RETRY/WORKER_TIMEOUT 并允许其他实例重新领取,达到重试上限转 FAILED,避免进程退出造成永久卡死。 |
|||
|
|||
## 7. 重试与 FAILED |
|||
失败捕获→固定分类(COMPUTE_FAILED/WRITE_FAILED/UNKNOWN_REFRESH_FAILURE;租约超时为 WORKER_TIMEOUT)入 last_error_code(不含异常原文/SQL/手机号/堆栈)→独立事务 attempts+1→RETRY+next_attempt_at;达上限 FAILED;失败不回抛、不回滚同批其他任务;补偿扫描可重新登记。 |
|||
|
|||
## 8. Writer 比较字段(`CandidateScoringSnapshotWriter`) |
|||
score、status、sub_scores_json、weight、rules_version 全部相同→version/updated_at 不变;任一变化→version+1;calculated_at 不触发;JSON 写入前 canonical(字段字典序)防伪变化;null-safe 相等用 `(= ? OR (IS NULL AND ? IS NULL))`(MySQL/H2 双兼容)。 |
|||
|
|||
## 9. version 精确语义 |
|||
普通物化:值变才 +1(D4-1 已授权语义收敛——重复相同 GET/重放不再制造版本噪声);rulesVersion 变化强制 +1。 |
|||
|
|||
## 10. Governance 强制路径 |
|||
`recomputeAndSnapshot`(申诉决定/回滚)委托 `upsertForcingVersion` 无条件 +1,保持 `afterVersion=beforeVersion+1`、snapshot_history 与 scoreAtAppeal/snapshotVersionAtAppeal/回滚限额语义不变;普通物化不写 snapshot_history。 |
|||
|
|||
## 11. 实际接入的真实输入源(写侧 Service,同事务登记) |
|||
ProfileService#updateProfile;ResumeVersionService#createVersion/updateVersion/deleteVersion;PreferenceService#savePreferences;AbilityAttemptService#submit;ApplicationService#apply/withdraw/updateStatus;OfferService#transitionStatus;CandidateFeedbackService#createFeedback;MessagingService#sendMessage(仅 STUDENT 发送者登记)。补偿扫描兜底 audit/时间窗口。 |
|||
|
|||
## 12. 明确未接入 |
|||
RIASEC(AssessmentAttemptService);InterviewScoreService;JobReviewService;AbilityAttemptService#saveAnswers;ResumeVersionService#setDefault(公式未读默认标记);消息撤回;只读查询;AuditService 实时登记(防 刷新→审计→刷新 循环,审计变化由补偿扫描覆盖)。 |
|||
|
|||
## 13. 时间窗口与补偿扫描(`CandidateScoringRefreshCompensationScanner`) |
|||
基础版:按 user id 做 keyset 分页(页大小默认 100)遍历近 90 天投递/近 30 天审计活跃学生并登记刷新任务;一次扫描会跨越全部页面,不再反复只处理首批 ID;不直接写快照。rulesVersion 切换由事件/重扫覆盖。**补偿扫描仍是 D4-1 简化版,尚无持久化扫描游标与单轮总预算,完整窗口和大数据量调度规则留待 D4-2 前完善;GET 兜底因此必须保留**。 |
|||
|
|||
## 14. 修复前失败测试(TDD 证据) |
|||
实现前基线即检查点后现状(b10c4a7 上证据测试 5/5 全绿,其中"重复 GET version+1"断言证明旧行为);实施中 Writer 两次 H2 语法不兼容(`<=>`、VALUES() 表达式)→ Errors=7→4→0 收敛;@InjectMocks 缺新依赖 → Errors=55→30→0;Persistence 旧断言与新契约冲突 → 1 Failure 被改写为幂等断言。最终全绿。 |
|||
|
|||
## 15. H2 与真实 MySQL 结果 |
|||
H2 定向集成已覆盖:登记合并、并发领取、处理中重登记、租约超时恢复、跨三页以上补偿扫描、单任务中途写失败全回滚、失败状态独立提交、同批任务事务隔离及外层业务回滚。2026-09-16 Docker 恢复后新增 `CandidateScoringRefreshMySqlIntegrationTest`,使用独立 `mysql:8.4` Testcontainers、正式 V1–V44 Flyway 与 `ddl-auto=validate`,5/5 通过:V44/schema、Writer 幂等与强制版本、双线程领取唯一胜者、部分写失败全回滚且同批成功任务不受影响、PROCESSING 重登记及超时恢复。未复用或修改开发库。 |
|||
|
|||
## 16. 查询预算 |
|||
登记:1 条 upsert;Worker:到期 ID 查询 + 每任务独立 claim/load + computeAll + 8 维锁定比较/条件写入 + complete;失败另有 1 次独立状态写。补偿扫描每页 1 条 keyset 查询并为命中学生逐条登记;总查询数随符合条件学生数增长,尚未满足最终查询预算,不能据此进入 D4-2。 |
|||
|
|||
## 17. RabbitMQ 与数据库副作用 |
|||
Worker/登记不发布 RabbitMQ、不写 notification、不写 snapshot_history、不写 audit;测试 20/22 验证。快照仅 candidate_dimension_scores 与任务表。 |
|||
|
|||
## 18. 全量测试 |
|||
Docker 恢复后的最终实测 `mvn clean verify`:**2130/2130,Failures=0、Errors=0、Skipped=0,BUILD SUCCESS**。其中既有 Q01 MySQL 20 项与新增 D4-1 MySQL 5 项均真实执行。OpenAPI **176 paths / 214 operations**,校验通过。 |
|||
|
|||
## 19. OpenAPI |
|||
176 paths / 214 operations,Actuator 不进业务契约;无新增公网刷新接口。 |
|||
|
|||
## 20. 精确修改文件 |
|||
新增:V44 迁移;`candidatescore/refresh/`:CandidateScoringRefreshTask、CandidateScoringRefreshRequestService、CandidateScoringSnapshotWriter、CandidateScoringRefreshWorker、CandidateScoringRefreshTaskExecutor、CandidateScoringRefreshCompensationScanner、CandidateScoringRefreshScheduler;测试 `CandidateScoringRefreshInfrastructureTest`、`CandidateScoringRefreshFailureTransactionTest`。 |
|||
修改:CandidateScoringCalculator(upsert 委托 Writer + recompute 强制路径);写侧 8 Service(登记接入);application.yml(app.scoring.refresh.* 默认关闭);测试适配:R01D4GetWritesSnapshotEvidenceTest/CandidateScoringPersistenceIntegrationTest(version 幂等契约演进)、CandidateScoringCalculatorTest/ClockConsistencyTest/FairnessMonitoringPersistenceIntegrationTest(构造/委托验证)、6 个 Service 测试补 Mock 依赖(未放宽断言)。 |
|||
|
|||
## 21. Git 状态 |
|||
未 commit/push;暂存区空;V44 唯一新增迁移;历史未跟踪文件未删未移;GlobalExceptionHandler 行尾现象保持。 |
|||
|
|||
## 22. 进入 D4-2 的网关(当前:**未达到**) |
|||
- [x] 唯一 Writer + 幂等 version 语义(H2 验证) |
|||
- [x] 任务登记/防丢失/并发领取/重试/failure 分类(H2 验证) |
|||
- [x] 写侧 8 类事件登记 + 不接入项确认 |
|||
- [x] 真实 MySQL 8.4 核心基础设施验收(V44/validate、Writer、并发领取、事务隔离、重登记与超时恢复) |
|||
- [ ] D4-2 前消费者黑盒(GET 零写切换、趋势/申诉/排名/看板/监控、副作用与 RabbitMQ) |
|||
- [ ] 补偿扫描完整窗口规则 + 查询预算真实库验证 |
|||
- [ ] 写侧覆盖证明(滚动运行期对账) |
|||
→ 未满足 → **不得移除 GET 写入** |
|||
|
|||
## 23. 剩余风险 |
|||
补偿扫描仍缺持久化游标与单轮总预算;POST/PUT 申诉决定/回滚在真实库对 version 语义的回归未验(H2 已验);普通 Writer 的 MySQL 幂等、差异更新、强制版本、`SELECT FOR UPDATE` 与 no-op upsert 已有专用真实库证据。 |
|||
|
|||
## 最终状态 |
|||
> **R01-D4-1 核心基础设施及真实 MySQL 8.4 验证已完成;GET 兜底仍保留。** 进入 D4-2 前仍需把补偿扫描收束为可持续的持久游标/单轮预算,并在 D4-2 同批完成消费者、副作用及 RabbitMQ 黑盒,不得直接删除 GET upsert。 |
|||
@ -0,0 +1,277 @@ |
|||
# R01-D4-2 阻断缺陷定向整改报告(F1–F4 及两项次生缺陷) |
|||
|
|||
日期:2026-09-16 |
|||
分支:`refactor/r01-structure-performance` |
|||
HEAD:`b10c4a732740e42353ca826f63141c3dd2a49915`(整改全程未变) |
|||
来源:`docs/r01-d4-2-independent-mysql-acceptance-20260916.md` 判定的 4 项 FAIL + 2 项次生缺陷 |
|||
整改性质:**仅修复已确认缺陷**,未进入其他 R01 工作 |
|||
|
|||
--- |
|||
|
|||
## 1. 结论 |
|||
|
|||
> **R01-D4-2 阻断缺陷定向整改完成,待最终独立增量复验;GET 评分快照写入迁移尚未冻结。** |
|||
|
|||
| 缺陷 | 状态 | 修复前失败证据 | 修复后 | |
|||
|---|---|---|---| |
|||
| F1 Offer 自动过期不登记 | 已修复 | 4 个断言失败 | 全通过 + 真实 MySQL 复验 | |
|||
| F2 反馈 update/delete 不登记 | 已修复 | 4 个断言失败 | 全通过 + 真实 MySQL 复验 | |
|||
| F3 简历版本与任务提交边界不对称 | 已修复 | 1 个边界断言失败 | 全通过 + 真实 MySQL 复验 | |
|||
| F4 ABILITY 敏感字段断言缺失 | 已恢复(并增强) | 原断言已删(本次新增更强版本) | 全通过 | |
|||
| 次生:claim fence 缺失 | 已修复 | — | 全通过(含双 Worker 并发) | |
|||
| 次生:单任务失败阻断批次 | 已修复 | — | 全通过(含循环隔离单测) | |
|||
|
|||
--- |
|||
|
|||
## 2. 修复前失败测试结果(TDD 证据) |
|||
|
|||
命令: |
|||
``` |
|||
mvn -Dtest="OfferExpiryRefreshRegistrationTest,CandidateFeedbackRefreshRegistrationTest,ResumeVersionRefreshAtomicityTest,CandidateScoringRefreshClaimFenceTest,CandidateScoringRefreshBatchIsolationTest,CandidateScoringRefreshInfrastructureTest" test |
|||
``` |
|||
日志:`docs/acceptance/r01-d4-2-20260916/fix-before.log` |
|||
|
|||
| 测试方法 | 失败信息(节选) | |
|||
|---|---| |
|||
| `CandidateFeedbackRefreshRegistrationTest.updateFeedback_registersRefreshTask:125` | `expected: <1> but was: <0>` —— updateFeedback 未登记 | |
|||
| `CandidateFeedbackRefreshRegistrationTest.deleteFeedback_registersRefreshTask_afterRowRemoved:137` | `expected: <1> but was: <0>` —— deleteFeedback 未登记 | |
|||
| `CandidateFeedbackRefreshRegistrationTest.updateFeedback_ratingUnchanged_...:149` | `expected: <1> but was: <0>` | |
|||
| `CandidateFeedbackRefreshRegistrationTest.feedbackLifecycle_...:182` | `update 登记 ==> expected: <1> but was: <0>` | |
|||
| `ResumeVersionRefreshAtomicityTest.createVersion_outerTransactionRollsBack_...:102` | 外层事务回滚后版本行存在(1)而刷新任务为 0 —— 提交边界不对称 | |
|||
| `OfferExpiryRefreshRegistrationTest`(4 个) | 首轮先因夹具 FK 报错,修正夹具后 `expireOne` 未登记 → 断言失败 | |
|||
|
|||
> 说明:F1/claim-fence 的首轮运行还包含**测试自身夹具缺陷**(H2 不支持 `DATE_FORMAT`、FK 删除顺序、简历文件缺 PDF 签名、offer 夹具缺列)。这些已先行修正,产品缺陷证据以上表为准。 |
|||
|
|||
--- |
|||
|
|||
## 3. 各写侧的事务边界 |
|||
|
|||
| 写侧入口 | 登记位置 | 事务边界 | 原子性依据 | |
|||
|---|---|---|---| |
|||
| `OfferExpiryWorker#expireOne` | `OfferExpiryWorker.java`(`saveAndFlush` 之后、`notifyExpired` 之前) | 方法自身的 `REQUIRES_NEW` | 状态变更、还价过期标记、刷新登记、历史行同属一个事务;任一步失败全部回滚 | |
|||
| `CandidateFeedbackService#createFeedback` | 既有(`:82` 之后) | 方法 `@Transactional` | 与 feedback 插入同事务 | |
|||
| `CandidateFeedbackService#updateFeedback` | 新增(`save` 之后) | 方法 `@Transactional` | 与 rating 更新同事务 | |
|||
| `CandidateFeedbackService#deleteFeedback` | 新增(`delete` 之后;先固定 `studentUserId`) | 方法 `@Transactional` | 与删除同事务 | |
|||
| `ResumeVersionWriter#saveNewVersion` | 新增(`save` 之后) | 组件自身的 `REQUIRES_NEW` | **与版本行同一提交边界**(F3 核心) | |
|||
| `ResumeVersionService#createVersion` | 已移除(原 `:71`) | 外层 `@Transactional` | 不再重复登记,避免外层事务耦合 | |
|||
| `ResumeVersionService#updateVersion / #deleteVersion` | 既有 | 方法 `@Transactional` | 与版本更新/删除同事务 | |
|||
|
|||
**F3 设计选择**:保留 `ResumeVersionWriter` 的 `REQUIRES_NEW`(既有并发冲突重试防护,移除会破坏"单次冲突只回滚自身、外层可安全重试"的设计),按任务书推荐把 `requestRefresh` 放入该写事务组件,`CandidateScoringRefreshRequestService` 以 `REQUIRED` 加入。外层不再重复登记。 |
|||
|
|||
--- |
|||
|
|||
## 4. 简历版本与任务的原子性证据 |
|||
|
|||
测试:`ResumeVersionRefreshAtomicityTest#createVersion_outerTransactionRollsBack_versionRowAndTaskCommitTogether` |
|||
|
|||
场景:用 `TransactionTemplate` 包裹 `createVersion`,返回后抛异常使外层回滚。 |
|||
|
|||
断言: |
|||
- `resume_versions` 行数 = 1(既有设计:`REQUIRES_NEW` 独立提交,外层回滚不使其消失) |
|||
- `candidate_scoring_refresh_tasks` 行数 = 1 —— **版本行存在则刷新任务必须同样存在** |
|||
|
|||
修复前:`expected: <1> but was: <0>`(任务丢失)。 |
|||
修复后:通过。 |
|||
|
|||
真实 MySQL 复验:`F3 createVersion 成功 st=200` → `版本行与任务同时存在(无一半提交)versions=3 tasks=1`(三次调用收敛为一条任务)。 |
|||
|
|||
**文件系统操作未被纳入数据库原子性承诺**:测试 mock 了 `ResumeStorageService`;生产代码中"存量文件清理"仍由 `createVersion` 既有的 `catch(RuntimeException)` 负责,与数据库事务边界解耦,未做改动。 |
|||
|
|||
--- |
|||
|
|||
## 5. claim fence 的字段与 SQL 条件 |
|||
|
|||
**所有权标识**:`processing_started_at`(每次 claim 写入的领取时刻)。 |
|||
|
|||
不采用"仅校验 `version`":同一 claim 期间到达的新请求会合法推进 `version`,此时仍需回到 `PENDING`,所以 `version` 不能单独充当所有权闸门。 |
|||
|
|||
| 操作 | SQL 条件 | |
|||
|---|---| |
|||
| `claim` | `WHERE id=? AND status IN ('PENDING','RETRY') AND (next_attempt_at IS NULL OR next_attempt_at <= ?)`;随后 `SELECT ... processing_started_at` 记入 `ClaimedTask.claimedAt` | |
|||
| `materialize` → COMPLETED | `WHERE id=? AND status='PROCESSING' AND processing_started_at=? AND version=?` | |
|||
| `materialize` → PENDING 回退 | `WHERE id=? AND status='PROCESSING' AND processing_started_at=?`(不带 version:新请求推进 version 正是回退的触发条件) | |
|||
| `recordFailure` | `WHERE id=? AND status='PROCESSING' AND processing_started_at=?` | |
|||
|
|||
API 变更:`ClaimedTask` 新增 `LocalDateTime claimedAt` 字段;`recordFailure(...)` 新增 `claimedAt` 形参。 |
|||
|
|||
--- |
|||
|
|||
## 6. stale worker 的处置结果 |
|||
|
|||
三种情形与处置: |
|||
|
|||
| 情形 | 条件命中 | 处置 | |
|||
|---|---|---| |
|||
| 同一 claim,无新请求 | COMPLETED 更新 1 行 | 置 `COMPLETED` | |
|||
| 同一 claim,期间有新请求 | COMPLETED 0 行 → PENDING 更新 1 行 | **回到 `PENDING`**,下一轮重算 | |
|||
| 已被其他 Worker 重新领取 | COMPLETED 0 行 → PENDING 0 行 | **安静返回**,不覆盖新持有者(不抛异常) | |
|||
| 失败登记遇 stale claim | `recordFailure` 更新 0 行 | **安静返回**,不抛异常(原先抛 `IllegalStateException`) | |
|||
|
|||
`processOne` 的失败登记另加一层保护:`recordFailure` 自身异常仅告警,不向外抛。 |
|||
|
|||
测试证据(`CandidateScoringRefreshClaimFenceTest`): |
|||
- `staleWorkerCompletion_cannotOverwriteNewWorkerClaim` —— 旧 claim 完成路径 `assertDoesNotThrow`,且任务保持新持有者的 `PROCESSING` 与 `processing_started_at` |
|||
- `staleWorkerFailure_cannotOverwriteNewWorkerClaim` —— 旧 claim 失败登记同样安静退出 |
|||
- `requestDuringSameClaim_completionFallsBackToPending` —— 同 claim 期间新请求 → 回到 `PENDING` |
|||
- `claim_exposesProcessingStartedAtAsOwnershipFence` —— `claimedAt` 已暴露 |
|||
|
|||
不使用 `sleep`,不使用 JVM 锁。 |
|||
|
|||
--- |
|||
|
|||
## 7. 单任务失败是否影响同批后续任务 |
|||
|
|||
**修复前**:`recordFailure` 在 `updated != 1` 时抛 `IllegalStateException`,异常点位于 Worker 的 catch 块内,而 `processDue` 的 `for` 循环无保护 → 同批剩余任务本轮全部不处理。 |
|||
|
|||
**修复后**: |
|||
- `recordFailure` 的 stale claim 改为安静返回(不再抛) |
|||
- `processOne` 捕获失败登记自身异常 |
|||
- `processDue` 的循环体增加 `try/catch`(兜底,日志只记录 taskId 与异常类名) |
|||
|
|||
测试证据: |
|||
- `CandidateScoringRefreshWorkerLoopIsolationTest`(纯单元,精确构造"第一项失败且失败登记也抛异常"): |
|||
- `recordFailureThrowing_doesNotAbortRemainingTasksInBatch` —— 后续第 2、3 项仍被 `materialize` |
|||
- `recordFailureThrowingInMiddle_doesNotAbortRemainingTasks` —— 中间项失败不阻断前后 |
|||
- `claimLosing_skipsTaskWithoutRecordingFailure` —— claim 失败(被并发抢走)不记失败、继续下一项 |
|||
- `CandidateScoringRefreshBatchIsolationTest`(真实 H2 + mock 计算器): |
|||
- `firstTaskFails_secondTaskStillProcessed`、`failingTaskInMiddle_doesNotBlockRemainingTasks` |
|||
- `lastErrorCode_neverLeaksExceptionMessage` —— 分类为固定值 `COMPUTE_FAILED`,不含手机号/Token/异常原文 |
|||
- `batchTerminalState_matchesRealOutcome` —— 批次结束无残留 `PROCESSING` |
|||
|
|||
--- |
|||
|
|||
## 8. 恢复后的 ABILITY 敏感字段断言清单 |
|||
|
|||
位置:`CandidateScoringRefreshInfrastructureTest#abilitySnapshotPayload_hasNoSensitiveFields_andKeepsWhitelistedKeys` |
|||
方式:**经真实 `CandidateScoringSnapshotWriter` 写入 → 从真实数据库读回 `sub_scores_json` → 递归断言**(不是检查 DTO 定义,也不复用旧测试文本)。 |
|||
|
|||
**正向断言(允许字段必须存在且取值正确)**: |
|||
`source`(= `V36_ABILITY_ASSESSMENT`)、`sourceResultId`(= 123)、`validUntil`、`questionSetVersion`、`assessmentRulesVersion`、`mappingRulesVersion`、`weights`、`dimensionScores` |
|||
|
|||
**负向断言(递归、大小写不敏感、覆盖嵌套对象与数组)** —— 以下 key 一律禁止: |
|||
``` |
|||
answer, answers, rawanswer, rawanswers, |
|||
question, questions, questionid, questioncode, questionstem, |
|||
option, options, optionid, selectedoption, selectedoptionid, |
|||
answerkey, competencytag, stem, |
|||
scoringrule, awardedscore, explanation, |
|||
phone, email, mobile, token, password, accesstoken |
|||
``` |
|||
(在任务书要求的 10 项之上,保留了原验收报告中的 `answerKey/questionCode/competencyTag/stem/scoringRule/awardedScore/explanation/phone/email`,并补充 `mobile/token/password/accessToken`) |
|||
|
|||
**大小写策略**:key 小写化后**精确匹配**(避免白名单字段 `questionSetVersion` 因含子串 `question` 而误报);另有一层原始 JSON 文本层断言,禁止出现 `"<banned>"` 形式的 key 字面量。 |
|||
|
|||
--- |
|||
|
|||
## 9. 自动化测试数量 |
|||
|
|||
| 命令 | 结果 | |
|||
|---|---| |
|||
| 定向(12 个类,含 6 个新增) | **104 / 104**,F=0 E=0 S=0 | |
|||
| 关联回归(`*CandidateScoring*Test,*Scoring*Test,*Offer*Test,*Feedback*Test,*ResumeVersion*Test,*Application*Test,*Appeal*Test,*Ranking*Test,*Fairness*Test`) | **558 / 558**,F=0 E=0 S=0,BUILD SUCCESS | |
|||
| `mvn clean verify` | **2163 / 2163**,Failures=0、Errors=0、**Skipped=0**,BUILD SUCCESS(3:47) | |
|||
| `python docs\verify_openapi.py` | **176 paths / 214 operations**,All checks passed | |
|||
|
|||
较整改前(2131)新增 **32** 个测试(6 个新测试类:Offer 过期登记 7、反馈登记 8、简历原子性 4、claim fence 6、批次隔离 4、循环隔离 3)。 |
|||
|
|||
> 过程中出现并已修复的**测试自身缺陷**(非产品):新测试类未清理 H2 共享内存库残留,导致 `InterviewScoreControllerTest` / `MessageRecallPersistenceIntegrationTest` / `MessagingBlockIntegrationTest` 的 tearDown 遇 FK 冲突(23 errors)。已为全部新测试类补 `@AfterEach` 精确清理,复跑全绿。 |
|||
|
|||
--- |
|||
|
|||
## 10. 真实 MySQL / Docker 结果 |
|||
|
|||
实例:最新 JAR(`mvn clean verify` 产出,82,441,449 bytes),`SCORING_REFRESH_ENABLED=true`、`OFFER_EXPIRY_INTERVAL_MS=3000`、compensation-interval 5s、limit 2。PID 2856,验收后已停止。 |
|||
|
|||
`docs/acceptance/r01-d4-2-20260916/v2-out.txt`:**PASS=18 / FAIL=0 / SKIP=0** |
|||
|
|||
| 项 | 真实 MySQL 结果 | |
|||
|---|---| |
|||
| F1 Offer 自动过期 | 夹具 offer(精确 INSERT,id=15)被定时任务置为 `EXPIRED` → **生成刷新任务 1 条** → `COMPLETED`(0.1s) | |
|||
| F2 create | `POST /applications/88/feedback` 200 → 任务 `COMPLETED`(0.7s) | |
|||
| F2 update | `PUT /applications/88/feedback`(rating 5→2)200 → 任务 `COMPLETED`(1.3s),无失败分类、attempts=0 | |
|||
| F2 delete | `DELETE /applications/88/feedback` 200 → 任务 `COMPLETED`(1.3s) | |
|||
| F3 简历版本 | `POST /profile/me/resume-versions` 200 → 版本行与任务**同时存在**,三次调用收敛为 1 条任务 | |
|||
| 三个 GET 零写 | 审计/通知/消息/申诉/用户/业务表**逐字段零变化**(豁免表仅限后台 Worker 与补偿扫描合法改动的 3 张) | |
|||
| 趋势/排名/看板 | 4 个端点全部 200 | |
|||
| 快照完整性 | 学生 A 当前周期 **8 维**快照齐备 | |
|||
| RabbitMQ 终态 | 4 个队列(`queue.sms.verification` / `queue.notification` / `queue.notification.dlq` / `queue.audit`)**ready=0、unacked=0** | |
|||
| 日志与审计 | 未出现手机号、Token、评分原始输入或 SQL 绑定参数;错误分类仅固定枚举值 | |
|||
|
|||
--- |
|||
|
|||
## 11. OpenAPI 数量 |
|||
|
|||
静态 `docs/openapi-rc.json` 与 `docs/verify_openapi.py`:**176 paths / 214 operations**,`All checks passed!` —— 与本轮整改前一致,**未新增公网刷新、任务、补偿或管理接口**。 |
|||
|
|||
--- |
|||
|
|||
## 12. 精确修改文件 |
|||
|
|||
**生产代码(6 个,全部在任务书允许清单内)** |
|||
|
|||
| 文件 | 改动 | |
|||
|---|---| |
|||
| `offer/OfferExpiryWorker.java` | 注入 `CandidateScoringRefreshRequestService`;`expireOne` 在状态提交后同事务登记 | |
|||
| `feedback/CandidateFeedbackService.java` | `updateFeedback` / `deleteFeedback` 同事务登记(删除前固定 `studentUserId`) | |
|||
| `resume/ResumeVersionWriter.java` | 注入登记服务;`saveNewVersion` 在与版本行同一 `REQUIRES_NEW` 事务内登记 | |
|||
| `resume/ResumeVersionService.java` | 移除 `createVersion` 中的重复登记(登记已移入 Writer) | |
|||
| `candidatescore/refresh/CandidateScoringRefreshTaskExecutor.java` | `ClaimedTask` 增 `claimedAt`;`claim` 读回 `processing_started_at`;`materialize` 的 COMPLETED/PENDING 两分支与 `recordFailure` 全部加所有权闸门;stale claim 改为安静返回 | |
|||
| `candidatescore/refresh/CandidateScoringRefreshWorker.java` | `processOne` 失败登记加保护;`processDue` 循环体加 `try/catch` | |
|||
|
|||
**测试(新增 6 个类;修改 2 个既有类仅补依赖/调用签名)** |
|||
|
|||
新增:`OfferExpiryRefreshRegistrationTest`、`CandidateFeedbackRefreshRegistrationTest`、`ResumeVersionRefreshAtomicityTest`、`CandidateScoringRefreshClaimFenceTest`、`CandidateScoringRefreshBatchIsolationTest`、`CandidateScoringRefreshWorkerLoopIsolationTest` |
|||
修改:`CandidateScoringRefreshInfrastructureTest`(新增 F4 测试 + 增补 `Map` import + `recordFailure` 新签名)、`OfferExpiryWorkerTest`(补 `@Mock` 依赖) |
|||
|
|||
**未超出允许范围**:无需修改清单外的生产文件(清单中的 `CandidateScoringRefreshTaskRepository.java` 在项目内**不存在** —— D4-1 采用 JdbcTemplate 而非 JPA Repository,属清单笔误,无需报告偏离)。 |
|||
|
|||
--- |
|||
|
|||
## 13. 是否修改迁移 / ErrorCode / 接口 |
|||
|
|||
| 项 | 结论 | |
|||
|---|---| |
|||
| V1–V44 迁移 | **未修改**(`git diff --name-only -- db/migration` 为空);V44 仍为唯一新增迁移 | |
|||
| 新增迁移 | **无** | |
|||
| 表 / 索引 | **未新增** | |
|||
| `ErrorCode` 枚举 | **未修改**(0 个文件) | |
|||
| 公网接口 | **未新增**(OpenAPI 保持 176/214) | |
|||
| 评分公式 / 权重 / 排名规则 / 消费者行为 | **未修改** | |
|||
| RabbitMQ 消息 / 通知 | 未引入 | |
|||
|
|||
--- |
|||
|
|||
## 14. Git 状态 |
|||
|
|||
``` |
|||
分支:refactor/r01-structure-performance |
|||
HEAD:b10c4a732740e42353ca826f63141c3dd2a49915(未变) |
|||
暂存区:空 |
|||
git add -A / commit / push:均未执行 |
|||
删除文件:无 |
|||
git diff --check:无空白错误 |
|||
``` |
|||
|
|||
验收数据已按精确主键清理(`users` 残留 0;`offers`/`resume_versions`/`applications`/`jobs`/`companies`/`dimension_scores`/`refresh_tasks` 全部归零)。`audit_logs` 中 actor 为本轮 4 个隔离用户的 **14 行**属 append-only 合规记录,保留并登记(关联 ID:`user_id ∈ {8006, 8007, 8008, 8009}`)。 |
|||
|
|||
唯一测试夹具(精确 INSERT,已登记并已清理):`user_roles` 为 8008/8009 各插入 1 行 `COMPANY`(项目无公网接口可授予该角色);`offers` 插入 1 条已过期 SENT 记录用于触发 F1 定时路径(id=15)。 |
|||
|
|||
--- |
|||
|
|||
## 15. 明确声明 |
|||
|
|||
- **未暂存、未提交、未推送。** |
|||
- 未进入其他 R01 工作。 |
|||
- 未修改评分公式、权重、状态、规则版本、排名、申诉或公平性语义。 |
|||
- 已按要求「先写失败测试 → 保存修复前失败结果 → 再改生产代码」,失败证据见 `fix-before.log`。 |
|||
|
|||
--- |
|||
|
|||
## 16. 待最终独立增量复验项 |
|||
|
|||
1. F1–F4 的修复在**独立复验**中重新验证(本轮为整改方自测)。 |
|||
2. `claim fence` 的双 Worker 真实并发复验(本轮已有真实 MySQL 层的 stale claim 单线程构造 + 纯单测并发场景;**真实双 JAR 竞争仍未做**)。 |
|||
3. PROCESSING 防丢失的真实 HTTP 窗口复验(沿用 D4-2 验收的方式引用真实 MySQL 并发集成测试)。 |
|||
4. 申诉审核/回滚的真实黑盒(本轮未改动申诉路径,仍属 D4-2 遗留 SKIP)。 |
|||
|
|||
> R01-D4-2 阻断缺陷定向整改完成,待最终独立增量复验;GET 评分快照写入迁移尚未冻结。 |
|||
@ -0,0 +1,234 @@ |
|||
# R01-D4-2 claim fence 定向整改报告(不可复用 claim token) |
|||
|
|||
日期:2026-09-16 |
|||
分支:`refactor/r01-structure-performance` |
|||
HEAD:`b10c4a732740e42353ca826f63141c3dd2a49915`(整改全程未变) |
|||
来源:`docs/r01-d4-2-final-independent-incremental-verification-20260916.md` 的 FAIL 项(R-1) |
|||
整改范围:**仅修复 fence 唯一性**,未进入其他 R01 项目 |
|||
|
|||
--- |
|||
|
|||
## 1. 结论 |
|||
|
|||
> **R01-D4-2 claim fence 定向整改完成,待最终独立增量复验;GET 评分快照写入迁移尚未冻结。** |
|||
|
|||
采用任务书指定的**不可复用 claim token** 方案。`processing_started_at` 秒级精度导致的 fence 可重复问题已消除 —— 所有权判定不再依赖任何时间值、时间精度或配置下界。 |
|||
|
|||
--- |
|||
|
|||
## 2. 修复前失败测试(TDD 证据) |
|||
|
|||
新增 `CandidateScoringRefreshClaimTokenTest`(13 个测试),在**只加入 V45 列、未改 Java**时运行: |
|||
|
|||
命令:`mvn -Dtest="CandidateScoringRefreshClaimTokenTest" test` |
|||
日志:`docs/acceptance/r01-d4-2-20260916/fence-before.log` |
|||
|
|||
**结果:Tests run: 10, Failures: 6** |
|||
|
|||
任务书要求复现的三条,全部复现成功: |
|||
|
|||
| 要求 | 测试方法 | 失败信息 | |
|||
|---|---|---| |
|||
| ① 两次领取拥有完全相同的 `processing_started_at` | `sameTimestamp_staleWorkerPendingRevert_mustNotTouchNewClaim` | 断言 `assertEquals(SAME_INSTANT, processingStartedAt(taskId))` **通过**,证明两次领取时间值相同 | |
|||
| ② Worker B 已重新领取 | 同上 | 断言 `assertEquals("PROCESSING", status(taskId))` **通过** | |
|||
| ③ **Worker A 的 PENDING 回退可以错误修改 B 的任务** | 同上 | **`expected: <PROCESSING> but was: <PENDING>`** ← 核心缺陷复现 | |
|||
|
|||
同时复现了失败登记的同类覆盖: |
|||
`sameTimestamp_staleWorkerFailure_mustNotTouchNewClaim` → **`expected: <PROCESSING> but was: <RETRY>`** |
|||
|
|||
其余 4 条失败为 `claim_token` 尚未写入(值为 `null`)。 |
|||
|
|||
**关键点**:测试使用常量 `SAME_INSTANT = LocalDateTime.of(2026, 9, 16, 12, 0, 0)`,两次领取传入**完全相同**的时间值,**未使用任何 sleep 或跨秒等待**。 |
|||
|
|||
--- |
|||
|
|||
## 3. 实现 |
|||
|
|||
### 3.1 迁移 V45 |
|||
|
|||
`mydreamjob-server/src/main/resources/db/migration/V45__add_scoring_refresh_claim_token.sql` |
|||
|
|||
```sql |
|||
ALTER TABLE candidate_scoring_refresh_tasks |
|||
ADD COLUMN claim_token VARCHAR(36) NULL |
|||
COMMENT '本次领取的不可复用令牌(UUID),终态/待处理时清空;仅用于并发所有权判定'; |
|||
``` |
|||
|
|||
- **仅新增列**,未增加索引 —— 查询依据:`claim_token` 只出现在既有的**按主键 `id` 定位**的 UPDATE 中(`WHERE id=? AND ...`),不参与扫描或排序,因此无需新索引。 |
|||
- 未修改 V1–V44,未修改业务表数据,未新增公网接口或 ErrorCode。 |
|||
|
|||
### 3.2 `CandidateScoringRefreshTaskExecutor` |
|||
|
|||
| 操作 | 关键改动 | |
|||
|---|---| |
|||
| `claim` | 生成 `java.util.UUID.randomUUID().toString()`,在领取 SQL 中**原子写入** `claim_token`;读回并放入 `ClaimedTask` | |
|||
| `materialize` → COMPLETED | `WHERE id=? AND status='PROCESSING' AND claim_token=? AND version=?`;`SET claim_token=NULL` | |
|||
| `materialize` → PENDING 回退 | `WHERE id=? AND status='PROCESSING' AND claim_token=?`;`SET claim_token=NULL`;0 行 → 安静返回 | |
|||
| `recordFailure` | 参数由 `LocalDateTime claimedAt` 改为 `String claimToken`;`WHERE id=? AND status='PROCESSING' AND claim_token=?`;`SET claim_token=NULL`;0 行 → 安静返回 | |
|||
| `recoverStale` | `SET claim_token=NULL`(回收即释放所有权) | |
|||
| `ClaimedTask` | 新增 `String claimToken`;保留 `claimedAt` 但**仅用于超时扫描与观测**,不再参与任何 WHERE 条件 | |
|||
|
|||
`processing_started_at` 的用途收敛为:超时扫描(`WHERE status='PROCESSING' AND processing_started_at < ?`)与观测。所有权证明完全由 `claim_token` 承担。 |
|||
|
|||
### 3.3 语义保持 |
|||
|
|||
- 同一 claim 期间出现新请求 → `version` 前进 → COMPLETED 更新 0 行 → **PENDING 回退**(`claim_token` 未变,闸门放行)✔ |
|||
- 新 Worker 已重新领取 → `claim_token` 已变 → 完成、失败、回退**全部更新 0 行并安静退出** ✔ |
|||
- 进入 PENDING / COMPLETED / RETRY / FAILED 均清空 `claim_token` ✔ |
|||
- 不依赖 `Thread.sleep`、时间精度或配置下界 ✔ |
|||
|
|||
--- |
|||
|
|||
## 4. 测试覆盖(任务书清单逐项) |
|||
|
|||
`CandidateScoringRefreshClaimTokenTest`(13 个测试,全部通过): |
|||
|
|||
| 任务书要求 | 测试方法 | |
|||
|---|---| |
|||
| 同一时间戳下两次领取的 claim token 不同 | `twoClaimsAtSameInstant_getDifferentTokens` | |
|||
| 旧 Worker 不能完成新 claim | `sameTimestamp_staleWorkerPendingRevert_mustNotTouchNewClaim`(含完成尝试) | |
|||
| 旧 Worker 不能将新 claim 回退 PENDING | 同上(核心断言) | |
|||
| 旧 Worker 不能记录新 claim 失败 | `sameTimestamp_staleWorkerFailure_mustNotTouchNewClaim` | |
|||
| 当前 Worker 可以正常完成 | `currentHolder_canCompleteNormally` | |
|||
| 同一 claim 期间 version 前进会正确回到 PENDING | `requestDuringSameClaim_versionAdvances_completionFallsBackToPending` | |
|||
| stale recovery 后生成新 token | `staleRecovery_generatesNewTokenOnNextClaim` | |
|||
| terminal/PENDING 状态 token 被清空 | `terminalAndPendingStates_clearClaimToken`、`failedTerminalState_clearsClaimToken` | |
|||
| 双 Worker 循环至少 100 次无覆盖、无永久 PROCESSING | `doubleWorkerLoop_100Rounds_noOverwrite_noStuckProcessing_noLostRequest` | |
|||
| token 不进入日志、审计、响应或错误信息 | `claimToken_doesNotLeakIntoAuditOrNotificationTables`、`claimToken_rowNeverExposedInTaskStatusQueriesUsedByObservability` | |
|||
|
|||
补充:`claim_repeatedOnDifferentTasks_producesDistinctTokens`、`tokenIsUuidShaped_andNotDerivedFromIds`(UUID 形态、不可复用、不由 ID 派生)。 |
|||
|
|||
**全部使用固定时间值**,无 sleep、无跨秒等待。 |
|||
|
|||
--- |
|||
|
|||
## 5. 回归 |
|||
|
|||
| 命令 | 结果 | |
|||
|---|---| |
|||
| `mvn -Dtest="*CandidateScoringRefresh*Test" test` 等 7 个 fence 相关类 | **49 / 49**,F=0 E=0 S=0 | |
|||
| 关联回归(9 组通配符) | **571 / 571**,F=0 E=0 S=0 | |
|||
| `mvn clean verify` | **2176 / 2176**,Failures=0、Errors=0、**Skipped=0**,BUILD SUCCESS | |
|||
| `python docs\verify_openapi.py` | **176 paths / 214 operations**,All checks passed | |
|||
|
|||
较整改前(2163)新增 **13** 个测试,全部为 claim token 相关。 |
|||
|
|||
**过程中修正的测试侧问题(非产品缺陷)**: |
|||
|
|||
1. `CandidateScoringRefreshMySqlIntegrationTest#v44MigrationAndSchemaValidationAreApplied` 原断言任务表 13 列 —— V45 后为 14 列,同步更新并**新增** V45 成功性、唯一性、`claim_token` 存在性断言(属本次授权迁移的计划内演进,非放宽)。 |
|||
2. `OfferExpiryRefreshRegistrationTest#expireOne_refreshTaskCarriesNoSensitiveFields` 原禁止列名含 `TOKEN` —— `claim_token` 是随机 UUID 而非用户凭据,禁止列表改为 `ACCESS_TOKEN/REFRESH_TOKEN/PASSWORD/SECRET` 等真实凭据列,并**新增**断言要求 `claim_token` 必须存在。 |
|||
3. 本人新增测试中的两处断言缺陷(UUID 随机字符集几乎必然含任务 ID 数字;`newTask()` 复用同学生导致任务合并),已改为有意义的断言。 |
|||
|
|||
--- |
|||
|
|||
## 6. 真实 MySQL 最小增量复验 |
|||
|
|||
使用本轮 `clean verify` 产出的最新 JAR。物化实例 PID 11104(`SCORING_REFRESH_ENABLED=true`),只读实例 PID 18372(`false`),均已停止,8080/8081 空闲。 |
|||
|
|||
### 6.1 V45 迁移与 schema validation |
|||
|
|||
``` |
|||
version | description | success |
|||
44 | candidate scoring refresh tasks | 1 |
|||
45 | add scoring refresh claim token | 1 |
|||
v45_rows = 1 failed_migrations = 0 |
|||
claim_token | varchar(36) | YES |
|||
task_cols = 14 |
|||
``` |
|||
Hibernate `ddl-auto=validate` 通过,日志中 `schema-validation`/`SchemaManagementException` 命中 **0** ✔ |
|||
|
|||
### 6.2 claim fence 100 轮竞争(固定相同 processing_started_at) |
|||
|
|||
脚本 `iv-fence-loop2.py`:使用与生产 `claim`/`recoverStale`/`materialize`/`recordFailure` **逐字一致**的 SQL,两次领取的 `processing_started_at` **固定为 `2026-09-16 12:00:00`**。 |
|||
|
|||
``` |
|||
轮次 = 100(两次领取的 processing_started_at 固定为 2026-09-16 12:00:00) |
|||
[PASS] 100 轮:A 的完成/失败/回退全部 0 行、未覆盖 B、B 均完成、无永久 PROCESSING |
|||
``` |
|||
|
|||
每轮断言: |
|||
- A 用旧 token 的完成 / 失败 / PENDING 回退 → **受影响行数均为 0** |
|||
- A 未改动 B 的 `status`、`claim_token`、`attempts`、`last_error_code` |
|||
- B 用新 token 完成 → 1 行 |
|||
- 终态无残留 `PROCESSING` |
|||
|
|||
(脚本首轮运行暴露了自身缺陷:`ROW_COUNT()` 跨 `docker exec` 连接失效、回收步骤的 `WORKER_TIMEOUT` 被误判为污染;已修正后重跑。) |
|||
|
|||
### 6.3 三个 GET 零写 |
|||
|
|||
**PASS=24 / FAIL=0 / SKIP=0** —— 三个目标 GET ×3 + 趋势 GET ×3 全部 HTTP 200,**22 张表逐字段零变化** ✔ |
|||
|
|||
### 6.4 无快照学生 |
|||
|
|||
新学生 8019:`before=0` → 3 次 GET(均 200)→ `after=0` ✔ |
|||
|
|||
### 6.5 五条写侧链路 |
|||
|
|||
**PASS=44 / FAIL=0 / SKIP=0** —— Offer 自动过期、Feedback create/update/delete、简历版本创建;每条均验证「业务变化 → 唯一任务 → 周期正确 → Worker COMPLETED → 无失败分类 → 八维齐备 → 不写 snapshot_history」;相同输入重放不推进 version ✔ |
|||
|
|||
### 6.6 消费者最小回归 |
|||
|
|||
**PASS=33 / FAIL=0 / SKIP=0** —— 趋势(当前周期 2026-09)、排名(rank/percentile/cohortSize)、企业看板 4 端点 + 越权 403、公平性 5 端点(抑制语义保留)、申诉取证非空;消费者 GET 逐字段零写 ✔ |
|||
|
|||
### 6.7 RabbitMQ |
|||
|
|||
4 个队列(`queue.sms.verification` / `queue.notification` / `queue.notification.dlq` / `queue.audit`)**ready=0、unacked=0** ✔ |
|||
|
|||
### 6.8 token 不泄露 |
|||
|
|||
- 测试断言:token 不出现在 `audit_logs` / `notifications` / `messages` / `candidate_dimension_scores.sub_scores_json`;`last_error_code` 仅固定枚举 ✔ |
|||
- 运行日志:未出现 token 形态内容;`claim_token` 不参与任何日志输出语句 ✔ |
|||
- 数据库:token 仅存于 `candidate_scoring_refresh_tasks.claim_token`,且在终态/PENDING 时被清空 ✔ |
|||
|
|||
--- |
|||
|
|||
## 7. 精确修改文件 |
|||
|
|||
**生产代码(3 个)** |
|||
|
|||
| 文件 | 改动 | |
|||
|---|---| |
|||
| `db/migration/V45__add_scoring_refresh_claim_token.sql` | **新增**:仅加 `claim_token VARCHAR(36) NULL` | |
|||
| `candidatescore/refresh/CandidateScoringRefreshTaskExecutor.java` | `claim` 生成并原子写入 UUID;COMPLETED / PENDING 回退 / `recordFailure` 的全部 WHERE 由 `processing_started_at` 改为 `claim_token`;四处(含 `recoverStale`)`SET claim_token=NULL`;`ClaimedTask` 增 `claimToken`;`recordFailure` 形参改 `String claimToken` | |
|||
| `candidatescore/refresh/CandidateScoringRefreshTask.java` | 实体新增 `claimToken` 映射;`processingStartedAt` 注释标明不再作为所有权证明 | |
|||
|
|||
**配置/接口/数据**:未修改 `application.yml`;未新增 ErrorCode;未新增公网接口;未修改评分公式、权重、快照内容、任务合并语义;未把 `DATETIME(6)` 当作最终修复(`processing_started_at` 精度保持不变)。 |
|||
|
|||
**测试(新增 1 个类;适配 4 个既有类)** |
|||
|
|||
新增:`CandidateScoringRefreshClaimTokenTest`(13 个测试) |
|||
适配:`CandidateScoringRefreshMySqlIntegrationTest`(列数 13→14 + V45 断言)、`OfferExpiryRefreshRegistrationTest`(禁止列名调整 + `claim_token` 存在性断言)、`CandidateScoringRefreshClaimFenceTest` 与 `CandidateScoringRefreshInfrastructureTest`(`recordFailure` 传 `claimToken()`)、`CandidateScoringRefreshWorkerLoopIsolationTest`(mock 签名 + `ClaimedTask` 构造) |
|||
|
|||
--- |
|||
|
|||
## 8. Git 状态 |
|||
|
|||
``` |
|||
分支:refactor/r01-structure-performance |
|||
HEAD:b10c4a732740e42353ca826f63141c3dd2a49915(未变) |
|||
暂存区:空 |
|||
git add -A / commit / push:均未执行 |
|||
V1–V44:未修改 |
|||
新增迁移:V45(唯一本轮新增;V44 为 D4-1 产物) |
|||
删除文件:无 |
|||
git diff --check:无空白错误 |
|||
``` |
|||
|
|||
验收数据已按精确主键清理(`users` 残留 0:8015/8016/8017/8018/8019 均删除;公司 33/34、岗位 50、投递 92/93、offer、简历版本、申诉及关联行全部清除)。`audit_logs` 中 actor 为本轮 5 个隔离用户的 **15 + 2 = 17 行**属 append-only 合规记录,保留并登记(关联 ID:`user_id ∈ {8015, 8016, 8017, 8018, 8019}`)。 |
|||
|
|||
唯一测试夹具(精确 INSERT,已登记并已清理):`user_roles` 为 8017/8018 各插入 1 行 `COMPANY`。 |
|||
|
|||
Docker 保持运行(三容器 healthy);仅停止本轮记录 PID 的实例;8080/8081 空闲。 |
|||
|
|||
--- |
|||
|
|||
## 9. 明确声明 |
|||
|
|||
- **未暂存、未提交、未推送。** |
|||
- 未进入其他 R01 工作。 |
|||
- 原始验收产物继续排除在后续提交之外。 |
|||
- 已按要求「先补失败测试 → 保存修复前失败结果 → 再改生产代码」。 |
|||
|
|||
--- |
|||
|
|||
> R01-D4-2 claim fence 定向整改完成,待最终独立增量复验;GET 评分快照写入迁移尚未冻结。 |
|||
@ -0,0 +1,188 @@ |
|||
# R01-D4-2 claim token 最终独立复验报告(冻结前最后一次) |
|||
|
|||
日期:2026-09-16 |
|||
分支:`refactor/r01-structure-performance` |
|||
HEAD:`b10c4a732740e42353ca826f63141c3dd2a49915`(复验全程未变) |
|||
复验对象:`docs/r01-d4-2-claim-fence-remediation-20260916.md` 的 claim token 整改 |
|||
性质:**仅复验** —— 未修改生产代码、未修改既有测试、未暂存、未提交、未推送 |
|||
|
|||
--- |
|||
|
|||
## 0. 最终结论 |
|||
|
|||
> **R01-D4-2独立代码复验和真实MySQL/Docker增量黑盒全部通过,GET评分快照写入迁移可以冻结并进入精确暂存检查点。** |
|||
|
|||
**FAIL = 0,SKIP = 0。** claim token 闸门在代码审查、自动化测试与真实 MySQL 100 轮黑盒三个层面均确证有效:旧 Worker 无法以任何形式修改新持有者。 |
|||
|
|||
--- |
|||
|
|||
## 1. 基线 |
|||
|
|||
| 检查项 | 结果 | |
|||
|---|---| |
|||
| 分支 | `refactor/r01-structure-performance` ✔ | |
|||
| HEAD | `b10c4a732740e42353ca826f63141c3dd2a49915` ✔(= b10c4a7) | |
|||
| 暂存区 | 空 ✔ | |
|||
| `git diff --check` | 无空白错误 ✔ | |
|||
| V1–V44 | 未修改 ✔ | |
|||
| 新增迁移 | **V45 为唯一本轮新增**(V44 为 D4-1 产物)✔ | |
|||
| JAR | `mydreamjob-server-0.0.1-SNAPSHOT.jar`,2026-09-16 **17:26:51**(最新 `clean verify` 产出)✔ | |
|||
|
|||
--- |
|||
|
|||
## 2. 独立代码审查 |
|||
|
|||
`CandidateScoringRefreshTaskExecutor.java`(153 行,逐行核对): |
|||
|
|||
| 核查项 | 结论 | 证据 | |
|||
|---|---|---| |
|||
| token 在领取 SQL 中原子生成并写入 | ✔ | `:53` `UUID.randomUUID().toString()`;`:56` `SET status='PROCESSING', processing_started_at=?, claim_token=?, ...` 同一 UPDATE | |
|||
| token 不由调用方复用 | ✔ | `claim(Long, LocalDateTime)` 无 token 形参,每次调用新生成 | |
|||
| COMPLETED 条件 = id + PROCESSING + claim_token + version | ✔ | `:92` `WHERE id=? AND status='PROCESSING' AND claim_token=? AND version=?` | |
|||
| PENDING 条件 = id + PROCESSING + claim_token | ✔ | `:104` | |
|||
| RETRY / FAILED 条件 = id + PROCESSING + claim_token | ✔ | `:135`(`recordFailure` 依 `attempts+1 >= maxAttempts` 落到 RETRY 或 FAILED) | |
|||
| 离开 PROCESSING 后 token 全部清空 | ✔ | `recoverStale` `:38`;COMPLETED `:89`;PENDING `:102`;RETRY/FAILED `:133` —— **四处全覆盖** | |
|||
| recoverStale 清空旧 token | ✔ | `:38` `claim_token=NULL` | |
|||
| 下一次领取生成全新 token | ✔ | `:53` 每次调用重新生成 | |
|||
| `processing_started_at` 不再出现在所有权判断 WHERE 中 | ✔ | 全文件唯一 WHERE 用法为 `:40` 的**超时扫描** `WHERE status='PROCESSING' AND processing_started_at < ?`;所有权判定全部使用 `claim_token` | |
|||
| 更新 0 行按 stale claim 处理 | ✔ | `claim` `:61-63` 返回 null;`materialize` PENDING 分支 `:106-110` 安静返回;`recordFailure` `:137-140` 安静返回 —— **均不抛异常、不误报成功** | |
|||
| 0 行时不覆盖现持有者 | ✔ | 三个更新语句的 WHERE 均含 `claim_token=?` | |
|||
| token 不进入日志/审计/响应/通知/消息/快照 JSON | ✔ | 全仓 grep:`claim_token`/`claimToken` 仅出现在实体映射(`CandidateScoringRefreshTask.java:73-74`)、Executor 的 SQL/注释/record、以及 `CandidateScoringRefreshWorker.java:91` 的传参;refresh 包内**无任何含 token 的日志语句** | |
|||
|
|||
--- |
|||
|
|||
## 3. 自动化复验 |
|||
|
|||
| 命令 | 实际 | 预期 | 结论 | |
|||
|---|---|---|---| |
|||
| `mvn -Dtest="*CandidateScoringRefresh*Test" test` | **49 / 49**,F=0 E=0 **S=0** | ≥ 49 | ✔ | |
|||
| 关联回归(9 组通配符) | **571 / 571**,F=0 E=0 **S=0**,BUILD SUCCESS | ≥ 571 | ✔ | |
|||
| `mvn clean verify` | **2176 / 2176**,F=0 E=0 **S=0**,BUILD SUCCESS | ≥ 2176 | ✔ | |
|||
| `python docs\verify_openapi.py` | **176 paths / 214 operations**,All checks passed | 176/214 | ✔ | |
|||
|
|||
日志:`fv-fence.log`、`fv-regression.log`、`fv-clean-verify.log` |
|||
|
|||
--- |
|||
|
|||
## 4. 真实 MySQL 最小黑盒 |
|||
|
|||
脚本 `fv-fence.py` —— 使用与生产 `claim`/`recoverStale`/`materialize`/`recordFailure` **逐字一致**的 SQL,**所有领取的 `processing_started_at` 固定为 `2026-09-16 12:00:00`**。 |
|||
|
|||
**结果:PASS** |
|||
|
|||
### A. 100 轮竞争 |
|||
|
|||
每轮:A 领取(token_A)→ 回收(token 清空)→ B 在**相同时间值**下重新领取(token_B)→ A 以 token_A 执行四种迟到写 → B 以 token_B 完成。 |
|||
|
|||
| 断言 | 结果 | |
|||
|---|---| |
|||
| A 的 **COMPLETED** 更新 0 行 | ✔ 100 轮均为 0 | |
|||
| A 的 **PENDING** 更新 0 行 | ✔ | |
|||
| A 的 **RETRY** 更新 0 行 | ✔ | |
|||
| A 的 **FAILED** 更新 0 行 | ✔ | |
|||
| B 的 token / status / version 未被 A 修改 | ✔ 逐轮比对 `status='PROCESSING'`、`claim_token=token_B`、`version=version_B` 均不变 | |
|||
| B 正常完成 | ✔ 每轮 B 完成后 `status='COMPLETED'` 且 token 清空 | |
|||
| 终态无永久 PROCESSING | ✔ | |
|||
| 无重复完成、无任务丢失 | ✔ 终态任务数 = 1 | |
|||
|
|||
### B. token 生命周期 |
|||
|
|||
- `recoverStale` 后 `claim_token` 为 **NULL** ✔ |
|||
- 再次领取后 token **非空且不同于此前所有 token** ✔ |
|||
|
|||
### C. 无效 token 不可更新 |
|||
|
|||
| 场景 | 行数 | |
|||
|---|---| |
|||
| 空 token(`claim_token=''`) | **0** ✔ | |
|||
| 伪造 token(随机新 UUID) | **0** ✔ | |
|||
| 旧 token(A 的 token_A) | **0** ✔ | |
|||
| NULL token | **0** ✔ | |
|||
|
|||
且四种尝试后任务状态**完全未变**(前后 `st()` 全等)✔ |
|||
|
|||
### D. version 推进 → PENDING → 下一轮新 token 完成 |
|||
|
|||
- 同一 claim 期间新请求推进 `version` → COMPLETED 更新 **0 行**(version 不匹配) |
|||
- 当前 Worker 走 PENDING 回退 → **1 行**,`status='PENDING'` 且 token 清空 ✔ |
|||
- 下一轮领取使用**新 token** ✔ → 完成 **1 行**,`status='COMPLETED'` ✔ |
|||
|
|||
--- |
|||
|
|||
## 5. 最小业务回归 |
|||
|
|||
实例:最新 JAR;物化实例 PID 11884(`SCORING_REFRESH_ENABLED=true`)、只读实例 PID 6988(`false`),均已停止。 |
|||
|
|||
| 项 | 结果 | |
|||
|---|---| |
|||
| **三个 GET 零写** | **PASS=24 / FAIL=0 / SKIP=0** —— 三个目标 GET ×3 + 趋势 GET ×3 全部 200,**22 张表逐字段零变化** ✔ | |
|||
| **无快照学生** | 新学生 8024:`before=0` → 3 次 GET(均 200)→ `after=0` ✔ | |
|||
| **五条写侧链路** | **PASS=44 / FAIL=0 / SKIP=0** —— Offer 自动过期、Feedback create/update/delete、简历版本创建;每条验证「业务变化 → 唯一任务 → 周期正确 → Worker COMPLETED → 无失败分类 → 八维齐备 → 不写 snapshot_history」;重放不推进 version ✔ | |
|||
| **消费者回归** | **PASS=33 / FAIL=0 / SKIP=0** —— 趋势 / 排名 / 企业看板 / 申诉 / 公平性监控;消费者 GET 逐字段零写 ✔ | |
|||
| **V45** | `success=1` 且 `v45_rows=1`(唯一);`failed_migrations=0` ✔ | |
|||
| **Hibernate validate** | 通过;日志中 `schema-validation`/`SchemaManagementException` 命中 **0** ✔ | |
|||
| **RabbitMQ** | 4 队列(`queue.sms.verification` / `queue.notification` / `queue.notification.dlq` / `queue.audit`)**ready=0、unacked=0** ✔ | |
|||
| **token 泄漏扫描** | **PASS** —— 日志非框架 UUID 命中 **0**;HTTP 响应 token 形态命中 **0**;数据库其它载体命中 **0**(当前无活跃 token,终态/PENDING 已全部清空)✔ | |
|||
| **扫描器探针** | 临时文件注入 UUID → 正则命中;向 `audit_logs` 插入探针 → DB 命中 1 → 清理后 0 —— **探针 PASS,证明扫描器非空转**;探针文件已删除 ✔ | |
|||
|
|||
--- |
|||
|
|||
## 6. 收尾 |
|||
|
|||
| 项 | 结果 | |
|||
|---|---| |
|||
| 本轮 Java 实例 | 仅停止本轮记录的两个 PID(11884、6988);`tasklist` java.exe 计数 = **0** ✔ | |
|||
| 8080 / 8081 | 空闲 ✔ | |
|||
| Docker | 保持运行,三容器 healthy ✔ | |
|||
| 数据清理 | `users` 残留 **0**(8020/8021/8022/8023/8024 及公司、岗位 51、投递 94/95、offer、简历版本全部按精确主键删除)✔ | |
|||
| append-only 审计 | `audit_logs` 中 actor 为本轮 5 个隔离用户的 **15 + 2 = 17 行**保留并登记(关联 ID:`user_id ∈ {8020, 8021, 8022, 8023, 8024}`)✔ | |
|||
| 测试夹具(精确 INSERT,已登记并已清理) | `user_roles` 为 8022/8023 各插入 1 行 `COMPANY` ✔ | |
|||
| Git | 分支未变、HEAD 未变、暂存区空、V1–V44 未改、V45 唯一新增、无删除文件、无空白错误 ✔ | |
|||
| 未暂存 / 未提交 / 未推送 | 是 ✔ | |
|||
|
|||
本轮新增产物(`docs/acceptance/r01-d4-2-20260916/`,未进入暂存区):`fv-fence.py` + `fv-fence-out.txt`、`fv-tokenscan.py`、`fv-fence.log`、`fv-regression.log`、`fv-clean-verify.log`、`v3-fv-out.txt`、`s1-fv-out.txt`、`s4-fv-out.txt`。 |
|||
|
|||
--- |
|||
|
|||
## 7. PASS / FAIL / SKIP 清单 |
|||
|
|||
**FAIL**:无。 |
|||
|
|||
**SKIP**:无。 |
|||
|
|||
**PASS**: |
|||
|
|||
1. 基线(分支/HEAD/暂存区/V1–V44/V45 唯一/最新 JAR) |
|||
2. 代码审查 12 项(token 原子生成、不复用、COMPLETED 四条件、PENDING/RETRY/FAILED 三条件、四处清空、recoverStale 清空、新 token、`processing_started_at` 退出所有权判定、0 行 stale 处理、不覆盖、不泄漏) |
|||
3. fence 定向 49/49 |
|||
4. 关联回归 571/571 |
|||
5. 全量 clean verify 2176/2176(S=0) |
|||
6. OpenAPI 176/214 |
|||
7. 真实 MySQL 100 轮竞争(四种迟到写全 0 行、B 未被修改、B 均完成、无永久 PROCESSING、无丢失) |
|||
8. token 生命周期(回收后 NULL、重领后新值) |
|||
9. 空/伪造/旧/NULL token 均无法更新 |
|||
10. version 推进 → PENDING → 新 token 完成 |
|||
11. 三个 GET 零写 24/24(22 表逐字段) |
|||
12. 无快照学生 0 行 |
|||
13. 五条写侧 44/44 |
|||
14. 消费者 33/33 |
|||
15. V45 成功且唯一 |
|||
16. Hibernate validate |
|||
17. RabbitMQ 四队列为 0 |
|||
18. token 泄漏扫描 0 + 探针有效 |
|||
|
|||
--- |
|||
|
|||
## 8. 复验过程中的自暴露问题(非产品缺陷) |
|||
|
|||
| # | 问题 | 处置 | |
|||
|---|---|---| |
|||
| 1 | `fv-fence.py` 的 `retry_sql`/`failed_sql` 存在两个 `%s` 占位符只传一个参数的 Python 格式化错误 | 改为 `% (TABLE, token)`;已修正 | |
|||
| 2 | `fv-tokenscan.py` 的 DB 探针使用 `messages` 表触发外键失败 | 改用无外键依赖的 `audit_logs`,并增加「插入前=0 / 插入后=1 / 清理后=0」三段验证;已修正 | |
|||
| 3 | 探针插入的 `audit_logs` 行 | 探针执行后立即删除,已确认清理后计数为 0 | |
|||
|
|||
以上均为**验收脚本自身**问题,与产品代码无关;修正后重跑全部通过。 |
|||
|
|||
--- |
|||
|
|||
> **冻结判定:R01-D4-2独立代码复验和真实MySQL/Docker增量黑盒全部通过,GET评分快照写入迁移可以冻结并进入精确暂存检查点。** |
|||
@ -0,0 +1,275 @@ |
|||
# R01-D4-2 最终独立增量复验报告(整改后) |
|||
|
|||
日期:2026-09-16 |
|||
分支:`refactor/r01-structure-performance` |
|||
HEAD:`b10c4a732740e42353ca826f63141c3dd2a49915`(复验全程未变) |
|||
复验对象:`docs/r01-d4-2-blocker-remediation-20260916.md` 的 F1–F4 与两项次生缺陷整改 |
|||
性质:**只复验**,未修改生产代码与既有测试 |
|||
|
|||
--- |
|||
|
|||
## 0. 最终结论 |
|||
|
|||
> **已复现具体缺陷,R01-D4-2 不可冻结;报告已包含下一轮定向整改指令。** |
|||
|
|||
判定 **FAIL**。唯一原因是任务书指定的硬性判据 —— **claim fence 的精度不足**: |
|||
|
|||
> 「特别核查 processing_started_at 的 Java/JDBC/MySQL 精度一致性。若新旧两次领取可能得到相同 fence,直接判 FAIL」 |
|||
|
|||
实测已证明**可能**(见 §3)。除此之外,**本轮全部核心项均通过**:五条写侧链路、简历事务故障、20 轮 fence 竞争、批次隔离、三个 GET 零写(22 表逐字段)、无快照学生零写、消费者全量回归、V44、RabbitMQ、日志脱敏。 |
|||
|
|||
--- |
|||
|
|||
## 1. 开始前基线 |
|||
|
|||
| 检查项 | 结果 | |
|||
|---|---| |
|||
| 分支 | `refactor/r01-structure-performance` ✔ | |
|||
| HEAD | `b10c4a732740e42353ca826f63141c3dd2a49915`(未变)✔ | |
|||
| 暂存区 | 空 ✔ | |
|||
| `git diff --check` | 无空白错误 ✔ | |
|||
| V1–V43 | 未修改 ✔ | |
|||
| V44 | 仍为唯一新增迁移 ✔ | |
|||
| 删除文件 | 无 ✔ | |
|||
| 生产代码改动 | 14 项(13 个 `M` + `refresh/` 目录),与整改后一致 ✔ | |
|||
| JAR | `mydreamjob-server-0.0.1-SNAPSHOT.jar`,**2026-09-16 16:43:14**(本轮 `mvn clean verify` 产出,未使用旧 JAR)✔ | |
|||
|
|||
--- |
|||
|
|||
## 2. 独立代码复验(六项重点) |
|||
|
|||
### 2.1 OfferExpiryWorker — **通过** |
|||
|
|||
- `OfferExpiryWorker.java:51` `saveAndFlush` → `:56` `requestRefresh(saved.getCandidateId(), LocalDateTime.now())` → `:57` `notifyExpired` → `:58` `log.info`,全部位于方法自身的 `@Transactional(REQUIRES_NEW)` 内 ✔ 状态变更与登记同事务。 |
|||
- 未变化时不登记:`:45-47` 的幂等早返回(状态非 `SENT`/`NEGOTIATING` 直接 `return`)位于 `:48` 状态改写**之前**,因此不会对已过期的 Offer 重复登记 ✔ |
|||
- 真实 MySQL 复验:`offer 17 -> EXPIRED`,任务唯一且 `COMPLETED` ✔ |
|||
|
|||
### 2.2 CandidateFeedbackService — **通过** |
|||
|
|||
- `createFeedback`(`:82` 后)、`updateFeedback`(`save` 后)、`deleteFeedback`(`delete` 后)三处均登记 ✔ |
|||
- `deleteFeedback` 在 `feedbackRepository.delete(fb)` **之前**取 `Long studentUserId = app.getStudentUserId()`,删除后仍用该局部变量登记 ✔ |
|||
- 失败路径不登记:越权(`assertCompanyOwner`)与 `FEEDBACK_NOT_FOUND` 均在任何登记语句之前抛出 ✔ 真实复验 `B3 失败路径不留孤立任务 tasks=[]` ✔ |
|||
|
|||
### 2.3 ResumeVersionWriter — **通过** |
|||
|
|||
- `ResumeVersionWriter.java` 的 `saveNewVersion` 为 `@Transactional(propagation = REQUIRES_NEW)`,`:38` `save` 与 `:40` `requestRefresh(userId, LocalDateTime.now())` 处于**同一事务** ✔ |
|||
- 外层不重复登记:`ResumeVersionService.createVersion` 中原 `:71` 的登记已移除,改为注释说明 ✔(`grep requestRefresh ResumeVersionService.java` 仅剩 `updateVersion`/`deleteVersion` 两处,二者与各自写操作同事务) |
|||
- 保留 `REQUIRES_NEW`:既有「单次唯一键冲突只回滚自身、外层可安全重试」设计未被破坏 ✔ |
|||
|
|||
### 2.4 ABILITY 脱敏测试 — **通过** |
|||
|
|||
`CandidateScoringRefreshInfrastructureTest#abilitySnapshotPayload_hasNoSensitiveFields_andKeepsWhitelistedKeys`: |
|||
- 经**真实 `CandidateScoringSnapshotWriter.upsertIfChanged`** 写入 → 从**真实数据库** `SELECT sub_scores_json` 读回 → `ObjectMapper.readTree` 后**递归**扫描 ✔ |
|||
- 正向:`source` / `sourceResultId` / `validUntil` / `questionSetVersion` / `assessmentRulesVersion` / `mappingRulesVersion` / `weights` / `dimensionScores` 全部存在且取值正确 ✔ |
|||
- 负向:`BANNED_KEYS` 共 **26 项**,逐项核对报告清单一致 —— `answer`, `answers`, `rawanswer`, `rawanswers`, `question`, `questions`, `questionid`, `questioncode`, `questionstem`, `option`, `options`, `optionid`, `selectedoption`, `selectedoptionid`, `answerkey`, `competencytag`, `stem`, `scoringrule`, `awardedscore`, `explanation`, `phone`, `email`, `mobile`, `token`, `password`, `accesstoken` ✔ |
|||
- 大小写:key 小写化后**精确匹配**(`questionSetVersion` 不会因含子串 `question` 误报);另有原始 JSON 文本层 `"<key>"` 字面量断言 ✔ |
|||
- 嵌套对象与数组:`assertNoBannedKeyDeep` 递归处理 `isObject` 与 `isArray` ✔ |
|||
|
|||
### 2.5 claim fence — **不通过(精度不足,见 §3)** |
|||
|
|||
- 语义正确性:完成(`CandidateScoringRefreshTaskExecutor.java`)校验 `id + status='PROCESSING' + processing_started_at + version`;失败与 PENDING 回退校验 `id + status='PROCESSING' + processing_started_at` ✔ |
|||
- **但** fence 字段本身精度不足,构成 FAIL。 |
|||
|
|||
### 2.6 批次隔离 — **通过** |
|||
|
|||
- `recordFailure` 在 `updated != 1` 时 `return`(安静退出),不再抛 `IllegalStateException` ✔ |
|||
- `processOne` 的 `catch` 内对 `recordFailure` 再包一层 `try/catch`,避免其异常外泄 ✔ |
|||
- `processDue` 的 `for` 循环体加 `try/catch` ✔ |
|||
- 真实数据库异常不被伪装成功:`CandidateScoringRefreshBatchIsolationTest#batchTerminalState_matchesRealOutcome` 断言批次结束为 2 个 `COMPLETED` + 1 个 `RETRY`,且无残留 `PROCESSING` ✔ |
|||
- 真实 MySQL:`CandidateScoringRefreshMySqlIntegrationTest#mysqlFailureRollsBackPartialSnapshotsAndDoesNotRollbackSibling` ✔ |
|||
|
|||
--- |
|||
|
|||
## 3. FAIL 项:claim fence 精度不足 |
|||
|
|||
### 3.1 事实链 |
|||
|
|||
| 环节 | 事实 | 证据 | |
|||
|---|---|---| |
|||
| 列定义 | `processing_started_at DATETIME NULL` —— **无小数秒** | `V44__candidate_scoring_refresh_tasks.sql:11` | |
|||
| 实际精度 | `DATETIME_PRECISION = 0` | `information_schema.columns` 查询 | |
|||
| 写入截断 | `'2026-09-16 12:00:00.123456'` 存入 `DATETIME` 后变 `'2026-09-16 12:00:00'`(与 `DATETIME(6)` 比较 `equal_after_store = 0`) | `iv-precision.sql` | |
|||
| Java 侧 | `LocalDateTime.now()`(纳秒)传入 JDBC | `CandidateScoringRefreshTaskExecutor.java` `claim(...)` | |
|||
| 读回 | `rs.getObject("processing_started_at", LocalDateTime.class)` —— 秒级 | 同上 | |
|||
|
|||
**结论:fence 的比较粒度是 1 秒。** |
|||
|
|||
### 3.2 最小复现(已在真实 MySQL 执行) |
|||
|
|||
脚本:`docs/acceptance/r01-d4-2-20260916/iv-fence2.sql`(与生产 `claim`/`recoverStale` SQL **逐字一致**) |
|||
|
|||
``` |
|||
after_claim_1 | 2026-09-16 16:36:09 | PROCESSING |
|||
after_recover(sub-second-timeout) | 2026-09-16 16:36:09 | PROCESSING ← 回收后立即被重领 |
|||
after_claim_2 | 2026-09-16 16:36:09 | PROCESSING |
|||
``` |
|||
|
|||
**两次领取的 fence 完全相同**(`16:36:09` == `16:36:09`),秒级精度无法区分新旧持有者。 |
|||
|
|||
同一脚本末段同时给出边界条件: |
|||
``` |
|||
earliest_new_fence_when_timeout_600s = 2026-09-16 12:10:00 |
|||
can_be_equal = 0 |
|||
``` |
|||
|
|||
### 3.3 触发条件 |
|||
|
|||
`recoverStale` 的条件是 `processing_started_at < now - timeout`,其中 `timeout = processingTimeoutMs`(`CandidateScoringRefreshWorker.java:55`:`processingTimeoutMs <= 0 ? 600_000L : processingTimeoutMs`)。 |
|||
|
|||
- **timeout ≥ 1000 ms**(含生产默认 600000):新领取时刻 ≥ 旧 fence + timeout > 旧 fence 所在秒 → **fence 必然不同**,机制可靠。 |
|||
- **timeout < 1000 ms**(运维显式配置 `SCORING_REFRESH_PROCESSING_TIMEOUT_MS` 为 1–999):回收与重领可落在同一秒 → **fence 相同**,闸门失效。 |
|||
|
|||
代码中**未对该配置做下界约束**,因此「可能得到相同 fence」成立。 |
|||
|
|||
### 3.4 影响范围(如实界定) |
|||
|
|||
即使 fence 相同,**COMPLETED 分支仍校验 `version`**,因此旧 Worker 不会把新 Worker 的 `PROCESSING` 误置为 `COMPLETED`。实际失效路径只有 PENDING 回退分支:旧 Worker 把新持有者的 `PROCESSING` 改成 `PENDING`,导致新 Worker 完成时更新 0 行而安静退出。后果是**多算一次**(任务回到 PENDING 待下轮重算),不产生数据损坏,也不会留下永久 `PROCESSING`。 |
|||
|
|||
但按任务书给定的判据 —— 「若新旧两次领取**可能**得到相同 fence,直接判 FAIL;不能用测试等待规避」—— 判定为 **FAIL**。 |
|||
|
|||
### 3.5 下一轮定向整改指令 |
|||
|
|||
**R-1(阻断冻结)**:让 claim fence 具备亚秒分辨力,二选一: |
|||
|
|||
- **(a) 提升列精度**:新增迁移 `V45` 将 `processing_started_at` 改为 `DATETIME(6)`(**不得修改 V44**),并把 Java 侧传入值统一到微秒;同时确认 `recoverStale` 的比较与索引仍有效。 |
|||
- **(b) 改用不依赖时间的 fence**:使用既有 `version` 列的**单调快照**作为所有权标识 —— 在 `claim` 时把 `version` 的当次结果记入 `ClaimedTask`,完成/失败/回退时要求 `version` 属于「本次领取之后、下一次领取之前」的区间。若选此路,需明确区分「同 claim 期间新请求推进 version」(应回退 PENDING)与「被重新领取推进 version」(应安静退出)—— 可借助 `attempts` 或 `processing_started_at` 的组合判定。 |
|||
|
|||
**R-2(同批处理)**:为 fence 增加**边界测试**:显式构造 `timeout < 1s` 场景,断言旧 Worker 不得覆盖新持有者;或对 `processing-timeout-ms` 增加「必须 ≥ 1000」的配置校验(若选 (a)/(b) 后仍有时间依赖)。 |
|||
|
|||
**R-3(建议)**:`iv-fence2.sql` 与 `iv-precision.sql` 可作为该轮复验的前置失败证据复用。 |
|||
|
|||
--- |
|||
|
|||
## 4. 自动化复验结果 |
|||
|
|||
| 命令 | 实际 | 任务书预期 | 结论 | |
|||
|---|---|---|---| |
|||
| 定向(5 组通配符) | **184 / 184**,F=0 E=0 S=0 | 104 | 数量差异见下 | |
|||
| 关联回归(9 组通配符) | **558 / 558**,F=0 E=0 S=0 | 558 | 一致 ✔ | |
|||
| `mvn clean verify` | **2163 / 2163**,F=0 E=0 **S=0**,BUILD SUCCESS | 2163 | 一致 ✔ | |
|||
| `python docs\verify_openapi.py` | **176 paths / 214 operations**,All checks passed | 176/214 | 一致 ✔ | |
|||
|
|||
**定向数量差异解释(184 vs 104)**:任务书给定的通配符 `*OfferExpiry*Test,*CandidateFeedback*Test,*ResumeVersion*Test,*CandidateScoringRefresh*Test,*CandidateScoringCalculatorTest` 比整改自测时使用的 12 个类名更宽,额外匹配了 `OfferControllerTest`(30)、`OfferServiceTest`(21)、`OfferPersistenceIntegrationTest`(16)、`CandidateFeedbackControllerTest`(8)、`ResumeExportServiceTest`(8)、`ResumeVersionPersistenceIntegrationTest` 等既有类。**差异全部为多覆盖,无遗漏**。 |
|||
|
|||
逐类(18 个类):30 / 4 / 6 / 3 / 14 / 6 / 3 / 8 / 7 / 16 / 7 / 7 / 8 / 21 / 6 / 8 / 4 / 26 = 184 ✔ |
|||
|
|||
--- |
|||
|
|||
## 5. 真实 MySQL / Docker 增量黑盒 |
|||
|
|||
实例:本轮 `clean verify` 产出的最新 JAR;物化实例 PID 12592(`SCORING_REFRESH_ENABLED=true`、`OFFER_EXPIRY_INTERVAL_MS=3000`、补偿 5s/limit 2),只读实例 PID 11060(`false`)。二者均已停止,8080/8081 空闲。 |
|||
|
|||
### 5.1 A. 五条写侧链路 — **PASS=44 / FAIL=0 / SKIP=0** |
|||
|
|||
每条均断言:业务数据变化 → 同学生同周期唯一任务 → 周期正确 → Worker `COMPLETED` → 无失败分类 → 八维齐备 → 不写 `snapshot_history`。 |
|||
|
|||
| 写侧 | 业务变化 | 任务 | Worker | 八维 | history | |
|||
|---|---|---|---|---|---| |
|||
| A1 Feedback create | `active=1` | 1 条 | `COMPLETED` 1.1s | 8 | 0→0 | |
|||
| A2 Feedback update | `rating=2` | 1 条 | `COMPLETED` 0.6s | 8 | 0→0 | |
|||
| A3 Feedback delete | `active=0` | 1 条 | `COMPLETED` 0.6s | 8 | 0→0 | |
|||
| A4 简历版本创建 | `versions=1` | 1 条 | `COMPLETED` 0.7s | 8 | 0→0 | |
|||
| A5 Offer 自动过期 | `offer 17 → EXPIRED` | 1 条 | `COMPLETED` 1.1s | 8 | 0→0 | |
|||
|
|||
**幂等**:完全相同输入重放 → 八维 version 完全一致 |
|||
`{ABILITY:0, ACTIVITY:1, COMPLIANCE:0, EDUCATION:0, GROWTH:1, INTEGRITY:4, INTERNSHIP:0, STABILITY:1}` 前后不变,且不写 `snapshot_history` ✔ |
|||
|
|||
### 5.2 B. 简历事务故障 — **通过** |
|||
|
|||
| 场景 | 结果 | |
|||
|---|---| |
|||
| B1 内层成功后外层回滚 → 版本与任务均存在 | `versions=1 tasks=1` ✔(精确断言见 §4 的 `ResumeVersionRefreshAtomicityTest`) | |
|||
| B2 版本写入失败(非法文件被 `ResumeFileValidator` 拒绝,st=400) | 版本行 `1→1` 未创建;**未登记任务** `tasks=[]` ✔ | |
|||
| B3 失败路径不留孤立任务(学生越权调用企业接口,st=403) | 学生 A `tasks=[]` ✔ | |
|||
| 文件补偿与旧文件保护语义 | 未改动 `ResumeVersionService` 的文件生命周期代码(仅移除一行登记),既有 `catch(RuntimeException)` 清理与 `TransactionSynchronization` 逻辑保持不变 ✔ | |
|||
|
|||
> 注:B 场景中「版本写入失败」与「任务登记失败」在真实 HTTP 下无法分别构造(`ResumeVersionWriter` 是内部组件),前者用非法文件触发校验失败,后者由 `ResumeVersionRefreshAtomicityTest` 的事务回滚用例覆盖。 |
|||
|
|||
### 5.3 C. Claim fence 竞争(20 轮) — **PASS** |
|||
|
|||
脚本 `iv-fence-loop.py`,每轮:A 领取 → 期间新请求推进 version → A 完成 → A 回退 PENDING → B 重新领取 → **延迟的 A 再尝试完成/失败/回退**。 |
|||
|
|||
20 轮全部满足: |
|||
- A 未覆盖 B(B 的 `status` 与 `processing_started_at` 均未被改动) |
|||
- 无新请求丢失(`requested_at` 已推进) |
|||
- 无永久 `PROCESSING` |
|||
- 无重复完成 |
|||
- B 均正确完成 |
|||
- A 的失败登记未污染 `last_error_code` 与 `attempts` |
|||
|
|||
> **重要限定**:该循环通过是因为运行中 A/B 两次领取**恰好跨秒**(每条 SQL 之间耗时累积)。它验证的是 fence **在值不同时**的语义正确性;**不构成** fence 精度足够的证据 —— 精度不足正是 §3 的 FAIL 项。 |
|||
|
|||
### 5.4 D. 批次失败隔离 — **通过** |
|||
|
|||
三个任务「正常 → 故障 → 正常」: |
|||
- `failingTaskInMiddle_doesNotBlockRemainingTasks` —— 前后两个正常任务均 `COMPLETED` |
|||
- 故障任务状态为 `RETRY`(未伪装成功),`last_error_code = COMPUTE_FAILED` |
|||
- Worker 未中断整批;`batchTerminalState_matchesRealOutcome` 断言无残留 `PROCESSING` |
|||
- 后续重试:`RETRY` + `next_attempt_at` 由 Worker 正常再处理 |
|||
- 真实 MySQL:`mysqlFailureRollsBackPartialSnapshotsAndDoesNotRollbackSibling` ✔ |
|||
|
|||
--- |
|||
|
|||
## 6. D4-2 核心回归 |
|||
|
|||
| 项 | 结果 | |
|||
|---|---| |
|||
| 三个目标 GET ×3 + 趋势 GET ×3 | 全部 **HTTP 200**,契约稳定,无敏感字段泄露 ✔ | |
|||
| **22 张表逐字段零变化** | `[OK] 全部 22 张表逐字段零变化`;`candidate_dimension_scores` 1480 行、`refresh_tasks` 180 行、`checkpoints` 1 行、`audit_logs` 2667 行、`notifications` 164 行、`messages` 8 行、`snapshot_history` 4 行全部不变 ✔ | |
|||
| **无快照学生 GET 后仍 0 行** | 新建学生 8014:`before=0` → 3 次 GET(均 200,`overall=30.0`)→ `after=0` ✔ | |
|||
| 趋势回归 | 当前周期 `2026-09`、`overallScore=62.55`、多次调用稳定、零写 ✔ | |
|||
| 排名回归 | `rank=1`、`cohortSize=2`、`percentile=100.0`;学生 A/B 各 8 维快照 ✔ | |
|||
| 企业看板 | overview / job-rankings / candidate-rankings / trend 均 200;越权 403 ✔ | |
|||
| 申诉回归 | `appealId=4290`、`scoreAtAppeal=67.75`、`snapshotVersionAtAppeal=0`(非空);副作用仅限契约表 ✔ | |
|||
| 公平性监控 | 5 个端点均 200;`drift`/`ranking-stability` 的 `UNAVAILABLE` 抑制语义保留 ✔ | |
|||
| V44 迁移 | `version=44`、`success=1`、`row=1`(唯一)、`failed_migrations=0` ✔ | |
|||
| Hibernate validate | 无 `schema-validation` / `SchemaManagementException` 错误 ✔ | |
|||
| RabbitMQ | 4 队列(`queue.sms.verification`/`queue.notification`/`queue.notification.dlq`/`queue.audit`)**ready=0、unacked=0** ✔ | |
|||
| 日志/响应/审计 | 应用日志(`r01d42-*.log`)手机号、Token、验证码、JWT、SQL 参数、简历路径命中 **均为 0**;222 处「口令」命中全部为 Spring Boot `Using generated security password: <UUID>`(框架提示,非项目凭据);3 处手机号命中为既有测试 `AdminBootstrapConcurrencyLoggingTest` 的扫描器探针;响应扫描无 `phone/email/resumeUrl/storagePath/accessToken` ✔ | |
|||
|
|||
副作用证明方式:全部使用**显式列 + 稳定 ORDER BY 的逐字段行快照比对**(`snap.py`),未使用 `COUNT`/`SUM`/`CRC32` 替代;并附有效性反证(`snap.selftest()` 对单字段内存改动可检出,PASS)。 |
|||
|
|||
--- |
|||
|
|||
## 7. 收尾 |
|||
|
|||
| 项 | 结果 | |
|||
|---|---| |
|||
| 本轮 Java 实例 | 仅停止本轮记录的两个 PID(12592、11060);`tasklist` java.exe 计数 = **0** ✔ | |
|||
| 8080 / 8081 | 空闲 ✔ | |
|||
| Docker | 保持运行,三容器 healthy ✔ | |
|||
| 数据清理 | `users` 残留 **0**;本轮用户 8010/8011/8012/8013/8014、公司 31/32、岗位 49、投递 90/91、offer 17、简历版本、申诉 4290 及全部关联行均按精确主键删除 ✔ | |
|||
| append-only 保留 | `audit_logs` 中 actor 为本轮 5 个隔离用户的 **15 + 2 = 17 行**保留并登记(关联 ID:`user_id ∈ {8010,8011,8012,8013,8014}`)✔ | |
|||
| 测试夹具(精确 INSERT,已登记并已清理) | `user_roles` 为 8012/8013 各插入 1 行 `COMPANY`;`offers` 插入 1 条已过期 SENT 记录(id=17)用于触发 F1 定时路径 ✔ | |
|||
| Git | 分支未变、HEAD 未变、暂存区空、V1–V43 未改、V44 唯一新增、无删除文件、`git diff --check` 无空白错误 ✔ | |
|||
| 未暂存 / 未提交 / 未推送 | 是 ✔ | |
|||
|
|||
本轮新增产物(均在 `docs/acceptance/r01-d4-2-20260916/`,未进入暂存区):`iv-batch1.log`、`iv-batch2.log`、`iv-clean-verify.log`、`iv-precision.sql`、`iv-fence.sql`、`iv-fence2.sql`、`iv-fence-loop.py`、`v3-writeside.py`、`v3-out.txt`、`s1-iv-out.txt`、`s4-iv-out.txt`。 |
|||
|
|||
--- |
|||
|
|||
## 8. PASS / FAIL 清单 |
|||
|
|||
**FAIL(1 项)** |
|||
|
|||
| # | 项 | 依据 | |
|||
|---|---|---| |
|||
| 1 | **claim fence 精度不足**:`processing_started_at` 为秒级 `DATETIME`,实测同一秒内新旧领取得到相同 fence | 任务书硬性判据「可能得到相同 fence 直接判 FAIL」;最小复现 `iv-fence2.sql` | |
|||
|
|||
**PASS(其余全部)** |
|||
|
|||
OfferExpiryWorker 同事务登记与幂等;Feedback create/update/delete 登记与删除前取标识;ResumeVersionWriter 同一 `REQUIRES_NEW` 边界且外层不重复登记;ABILITY 脱敏 26 项禁止 key 递归断言;批次隔离与 stale claim 安静退出;定向 184/184;关联 558/558;全量 2163/2163(S=0);OpenAPI 176/214;A 五条写侧;B 简历事务故障;C 20 轮 fence 竞争语义;D 批次隔离;22 表逐字段零写;无快照学生 0 行;趋势/排名/看板/申诉/公平性;V44;validate;RabbitMQ;日志脱敏。 |
|||
|
|||
**SKIP**:无(本轮无因环境或安全原因跳过项)。 |
|||
|
|||
--- |
|||
|
|||
## 9. 剩余风险 |
|||
|
|||
1. **fence 失效的后果有限但不为零**:即使触发,最坏结果是同一次刷新多算一遍(任务回到 PENDING),不会数据损坏或永久 PROCESSING。但对 SLA 敏感场景仍属正确性瑕疵。 |
|||
2. **`processing-timeout-ms` 无下界校验**:当前仅做 `<= 0 → 600000` 兜底,1–999 的取值未被拒绝。 |
|||
3. 任务书未要求、本轮也未执行的项:真实双 JAR 的多实例补偿扫描并发、申诉审核/回滚真实黑盒(延续 D4-2 首轮验收的 SKIP 记录,不属于本轮复验范围)。 |
|||
|
|||
--- |
|||
|
|||
> **已复现具体缺陷,R01-D4-2 不可冻结;报告已包含下一轮定向整改指令。** |
|||
@ -0,0 +1,57 @@ |
|||
# R01-D4-2 GET 评分快照停写实施报告 |
|||
|
|||
日期:2026-09-16 |
|||
状态:实现与自动化复验通过,待独立真实环境黑盒验收。 |
|||
|
|||
## 1. 变更结论 |
|||
|
|||
`CandidateScoringEngineService#getOverview`、`getDimensionDetail`、`getCompanyScoring` 已改为 |
|||
`@Transactional(readOnly = true)`,并移除 `CandidateScoringCalculator#upsertSnapshots` 调用。 |
|||
三个接口仍以同一次 `capturedNow` 实时计算并返回原响应结构,趋势接口继续读取已物化快照。 |
|||
|
|||
快照更新改由 D4-1 的写侧登记、后台 Worker 和补偿扫描负责。生产配置默认启用刷新与补偿; |
|||
test、integration-test、mysql-it profile 显式关闭调度,仅由测试主动驱动,避免后台线程干扰断言。 |
|||
|
|||
## 2. D4-1 网关补齐 |
|||
|
|||
V44 同时创建 `candidate_scoring_refresh_scan_checkpoints`。补偿扫描使用持久化游标、固定单轮预算和 |
|||
`SELECT ... FOR UPDATE` 串行推进;游标与任务登记同事务提交。扫描到尾部后从 0 开始下一轮,避免首 |
|||
页饥饿,也不会单次遍历全量用户。 |
|||
|
|||
真实 MySQL 8.4 Testcontainers 已验证:V1–V44 迁移和 schema validate、游标持久化、每轮预算、 |
|||
Writer 幂等与强制版本、并发唯一领取、处理中重登记、租约恢复、失败事务隔离。 |
|||
|
|||
## 3. 测试迁移 |
|||
|
|||
- `R01D4GetWritesSnapshotEvidenceTest` 改为逐字段快照,证明 overview、dimension、trend GET 零写入。 |
|||
- `CandidateScoringEngineServiceTest` 与时钟一致性测试显式断言三个 GET 从不调用 upsert。 |
|||
- `CandidateScoringPersistenceIntegrationTest` 显式调用 Calculator/Writer 验证物化幂等,不再借 GET 造数据。 |
|||
- `V38AbilityRankingIntegrationTest` 显式走真实物化入口,原排名公式和结果断言保持不变。 |
|||
|
|||
首次干净全量构建据此发现 V38 排名测试仍依赖 GET 副作用(1 Error);修正夹具后专项测试通过, |
|||
再次执行干净全量构建成功。 |
|||
|
|||
## 4. 实测结果 |
|||
|
|||
| 项目 | 结果 | |
|||
| --- | --- | |
|||
| D4-2 + D4-1 H2/MySQL 组合定向 | 38 / 38,0 failure,0 error,0 skip | |
|||
| 时钟与持久化定向 | 13 / 13,0 failure,0 error,0 skip | |
|||
| V38 排名回归 | 1 / 1,原断言不变 | |
|||
| `mvn clean verify` | 2131 / 2131,Failures=0,Errors=0,Skipped=0,BUILD SUCCESS | |
|||
| MySQL Testcontainers | Q01 与 V44 用例均真实执行,无跳过 | |
|||
| OpenAPI | 176 paths / 214 operations,校验通过 | |
|||
| `git diff --check` | 无空白错误,仅既有 LF/CRLF 提示 | |
|||
|
|||
## 5. 边界 |
|||
|
|||
V44 是唯一新增迁移,V1–V43 未修改;无新增公网接口、ErrorCode、RabbitMQ 消息或通知;未暂存、 |
|||
未提交、未推送;历史未跟踪文件未删除或移动。 |
|||
|
|||
## 6. 待独立验收 |
|||
|
|||
仍需使用最新 JAR 与项目真实 MySQL/Docker 环境完成:三条真实 HTTP GET 前后快照逐字段零变化; |
|||
写侧事件到任务登记再到快照可见;趋势、申诉、排名、看板、公平性监控不退化;RabbitMQ 队列终态 |
|||
为零;并精确清理或记录隔离验收数据。自动化与 Testcontainers 结果不能替代该黑盒步骤。 |
|||
|
|||
> R01-D4-2 实现及自动化复验完成,待独立真实 MySQL/Docker 黑盒验收。 |
|||
@ -0,0 +1,656 @@ |
|||
# R01-D4-2「GET 评分接口停写与快照可靠物化」独立代码复验与真实 MySQL/Docker/JAR/HTTP 黑盒验收 |
|||
|
|||
日期:2026-09-16 |
|||
分支:`refactor/r01-structure-performance` |
|||
HEAD:`b10c4a7 docs: design scoring snapshot materialization` |
|||
验收性质:**最终独立验收**(非开发)。审查正式生产路径 + 项目既有 Docker Compose + 真实 MySQL 8.4 + 最新构建 JAR + 真实 HTTP。 |
|||
验收前缀:`R01D42_ACC_20260916_K4GDNT` |
|||
|
|||
--- |
|||
|
|||
## 0. 最终结论 |
|||
|
|||
> **已复现具体缺陷,R01-D4-2 不可冻结;报告已包含下一轮定向整改指令。** |
|||
|
|||
判定为 **FAIL**。触发任务书第二十节 FAIL 判据中的第 4 条「**写侧成功但任务未登记**」,并另有一处测试断言弱化(触碰「禁止修改或放宽既有测试」红线)。 |
|||
|
|||
同时必须明确记录:**本次验收的核心目标项全部实测通过** —— 三个评分 GET 在真实 HTTP 下确凿零写、V44 在真实 MySQL 成功、`clean verify` 2131/2131 全绿、补偿扫描游标与预算正确、趋势/排名/看板/申诉/公平性监控均未退化、RabbitMQ 终态正常。FAIL 由**写侧事件覆盖不全**这一独立缺陷触发,与 GET 停写本身无关。 |
|||
|
|||
--- |
|||
|
|||
## 1. Git 基线与工作区状态 |
|||
|
|||
| 检查项 | 结果 | |
|||
|---|---| |
|||
| 分支 | `refactor/r01-structure-performance` ✔ | |
|||
| HEAD | `b10c4a7`(验收全程未变) | |
|||
| 暂存区 | 空(`git diff --cached --name-only` 无输出)✔ | |
|||
| `git add -A` / `commit` / `push` | 均未执行 ✔ | |
|||
| V1–V43 修改 | 无(`git diff --name-only -- db/migration` 为空)✔ | |
|||
| V44 | `V44__candidate_scoring_refresh_tasks.sql` 为唯一新增迁移 ✔ | |
|||
| 删除文件 | 无 ✔ | |
|||
| 历史未跟踪脚本/日志/JSON/SQL/xlsx/.env | 全部保留,未删未移 ✔ | |
|||
| `git diff --check` | 无空白错误(仅既有 LF/CRLF 提示)✔ | |
|||
| `AGENTS.md` | 全仓搜索**未发现** | |
|||
|
|||
本轮新增(未跟踪):`docs/acceptance/r01-d4-2-20260916/`(验收脚本与脱敏证据)、`docs/r01-d4-2-independent-mysql-acceptance-20260916.md`(本报告)。未修改任何 D4-1/D4-2 生产代码、未修改 V1–V44、未新增 Flyway/表/索引/ErrorCode/公网接口。 |
|||
|
|||
--- |
|||
|
|||
## 2. 独立代码审查 A1–A6 |
|||
|
|||
方法:9 个并行静态审查维度 + 对每条 critical/major 发现的对抗性复核(20 个独立子代理,全部复核结论为「发现成立」),另由本人逐条打开源码亲自确认关键项。 |
|||
|
|||
### A1 三个 GET 是否真正只读 —— **通过** |
|||
|
|||
- `CandidateScoringEngineService.java:52 / :61 / :106` 三个方法均为 `@Transactional(readOnly = true)`。 |
|||
- 三个方法体内仅含 `LocalDateTime.now(clock)` + `calculator.computeAll(...)` + 只读仓储/`JobService.getJob` 查询 + DTO 组装,无任何写语句。 |
|||
- `git diff HEAD -- .../CandidateScoringEngineService.java` 全部改动仅为 3 处注解变更 + 删除 3 行 `calculator.upsertSnapshots(...)`。 |
|||
- `CandidateScoringCalculator.computeAll`(`:122-146`)只含 `findById` + 6 个纯 SELECT(`queryApps/queryAudit/queryOffers/queryFeedback/queryResume/queryMessages`),无写路径;全 main 源码中 `upsertSnapshots` 已无生产调用方。 |
|||
- `explanation/` 包 grep `jdbc|Repository|EntityManager|@Transactional|save(` 无命中。 |
|||
- 响应仍来自实时 `computeAll`;`status`(`CandidateOverviewStatusPolicy`)、`weight`(`DEFAULT_WEIGHT = 12.50`)、`rulesVersion`(`currentRulesVersion()`)与 D4-1 前逐字节一致。 |
|||
- 企业授权校验(company→job 归属)保留,越权负例实测 403。 |
|||
|
|||
遗留(非缺陷,记录):`CandidateScoringCalculator.java:582` javadoc 仍写「普通物化入口(含 GET 兜底)」,与 GET 兜底已删除的现状不符(注释陈旧)。 |
|||
|
|||
### A2 写侧登记覆盖 —— **不通过(存在两处真实遗漏)** |
|||
|
|||
任务书列出的 8 个服务方法**全部正确接入**,且均为同事务 `REQUIRED` 登记: |
|||
|
|||
| 服务.方法 | 登记位置 | |
|||
|---|---| |
|||
| `ProfileService#updateProfile` | `ProfileService.java:75` | |
|||
| `ResumeVersionService#createVersion / updateVersion / deleteVersion` | `ResumeVersionService.java:71 / :163 / :189` | |
|||
| `PreferenceService#savePreferences` | `PreferenceService.java:76` | |
|||
| `AbilityAttemptService#submit` | `AbilityAttemptService.java:294` | |
|||
| `ApplicationService#apply / withdraw / updateStatus` | `ApplicationService.java:90 / :129 / :224` | |
|||
| `OfferService#transitionStatus` | `OfferService.java:304` | |
|||
| `CandidateFeedbackService#createFeedback` | `CandidateFeedbackService.java:82` | |
|||
| `MessagingService#sendMessage`(仅 STUDENT) | `MessagingService.java:197-200` | |
|||
|
|||
角色判定核实:`MessagingService.java:197-200` 用 `u.getRoles().stream().anyMatch(r -> r == User.Role.STUDENT)`,只有学生发送者登记,企业发送者不登记 ✔ |
|||
|
|||
**遗漏一:Offer 自动过期不登记(major,命中 FAIL 判据)** |
|||
|
|||
- `OfferExpiryWorker.java:41 expireOne(...)` 在 `:48` 直接 `offer.setStatus(OfferStatus.EXPIRED.name())` 后 `saveAndFlush`,**全文 `requestRefresh`/`scoringRefresh` 出现次数 = 0**。 |
|||
- 调用方为定时任务 `OfferExpiryService.java:34 worker.expireOne(...)`,不经过 `OfferService#transitionStatus`。 |
|||
- 同一行 `:59` 还调用 `counterOfferRepository.expirePendingByOfferId(...)`,`:68 notifyExpired(saved)`。 |
|||
- 该写入**直接影响评分输入**:`CandidateScoringCalculator.java:188-194 queryOffers` 的 SQL 为 |
|||
`SELECT SUM(CASE WHEN status='ACCEPTED' THEN 1 ELSE 0 END), SUM(CASE WHEN status='EXPIRED' THEN 1 ELSE 0 END) FROM offers WHERE candidate_id = ?`,`EXPIRED` 计数参与 INTEGRITY 维度。 |
|||
- `docs/r01-d4-get-scoring-snapshot-migration-design.md:105` 明确要求「Offer 状态变化 → Offer 状态机成功提交后 → 标记待刷新」,而 D4-1 报告「明确未接入」清单(§12,`:43`)**并未**把 Offer 自动过期列为有意不接入项 —— 属实现遗漏。 |
|||
|
|||
**遗漏二:候选人反馈的修改/删除不登记(major,命中 FAIL 判据)** |
|||
|
|||
- `CandidateFeedbackService.java:107 updateFeedback(...)` 与 `:124 deleteFeedback(...)` 均未调用 `requestRefresh`;仅 `:82 createFeedback` 登记。 |
|||
- 评分读取该表:`CandidateScoringCalculator.java:196-200 queryFeedback` 的 SQL 为 |
|||
`SELECT COUNT(*), COALESCE(AVG(rating),0) FROM candidate_feedback WHERE student_user_id = ?`。 |
|||
- 修改 `rating` 会改变 `AVG`,删除反馈会同时改变 `COUNT` 与 `AVG`,两者都不触发刷新。 |
|||
- 设计文档 `:106` 明确要求「候选人反馈创建/**修改**的真实写入口」触发刷新 —— 属实现遗漏。 |
|||
|
|||
**遗漏三:`ResumeVersionService.createVersion` 的事务边界不对称(major)** |
|||
|
|||
- `ResumeVersionService.java:71` 的登记位于 `createVersion` 的 `@Transactional` 内(REQUIRED,加入外层事务); |
|||
- 但版本行由 `versionWriter.saveNewVersion(...)`(`@Transactional(REQUIRES_NEW)`,文件内 javadoc `:52-55` 自述「写入成功即独立提交,外层事务回滚不会让版本行消失」)**独立提交**; |
|||
- 因此存在窗口:版本行已提交、外层事务在提交阶段失败 → **业务数据已落库但刷新任务登记丢失**。 |
|||
|
|||
其余 A2 结论:登记不触发线程内重算、不发 RabbitMQ、不写通知、不写审计、不记录敏感值 —— 全部成立。 |
|||
|
|||
### A3 唯一快照 Writer —— **通过** |
|||
|
|||
- `CandidateScoringSnapshotWriter.java:105-112`:`score / status / sub_scores_json / weight / rules_version` 全部相同则 early return,**不改 version 与 updated_at** ✔ |
|||
- `calculated_at` 不参与变化判定 ✔ |
|||
- `rules_version` 参与判定,变化必推进 version ✔ |
|||
- `canonicalJson`(`:183-214`)对 ObjectNode 递归按字典序重排、对 ArrayNode 逐元素递归 ✔ |
|||
- 并发安全:`:91-98` 先 `INSERT ... ON DUPLICATE KEY UPDATE id=id`,`:126-137` 再 `SELECT ... FOR UPDATE`,`:113-119` 条件更新 `WHERE id=? AND version=?`,冲突抛 `OptimisticLockingFailureException` —— 无 JVM 锁 ✔ |
|||
- Governance 强制路径 `:66-69 upsertForcingVersion` → `:75-88` 无条件 `version=version+1`;调用方 `CandidateScoringGovernanceService.java:294` 保持 `afterVersion = beforeVersion + 1` 语义 ✔ |
|||
- 普通物化不写 `candidate_scoring_snapshot_history` ✔(黑盒已证,见 §10) |
|||
- 单维写失败整体回滚:`upsertIfChanged`(`:51-60`)为单一 `@Transactional` 循环,`CandidateScoringRefreshTaskExecutor.materialize` 同为单个 `REQUIRES_NEW` —— 无部分提交 ✔ |
|||
|
|||
### A4 Worker 与失败恢复 —— **不通过(两处独立缺陷)** |
|||
|
|||
- `claim`(`:43-62`)、`materialize`(`:64-89`)、`recordFailure`(`:91-108`)、`recoverStale`(`:29-41`)分属 4 个独立 `REQUIRES_NEW` 事务 ✔ |
|||
- 并发领取唯一:`claim` 的 `WHERE id=? AND status IN ('PENDING','RETRY') AND (next_attempt_at IS NULL OR next_attempt_at <= ?)` + `updated != 1 → null` ✔(真实 MySQL 集成测试亦覆盖) |
|||
- `last_error_code` 只写固定分类(`WRITE_FAILED`/`COMPUTE_FAILED`/`UNKNOWN_REFRESH_FAILURE`/`WORKER_TIMEOUT`),`:82` 的 `log.warn` 只记录 taskId 与 category ✔ |
|||
- PROCESSING 超时回收、达上限转 FAILED ✔ |
|||
|
|||
**缺陷一:`materialize` 的「回到 PENDING」分支缺 version 闸门(major)** |
|||
|
|||
`CandidateScoringRefreshTaskExecutor.java:78-88`: |
|||
|
|||
``` |
|||
UPDATE ... SET status='COMPLETED' ... WHERE id=? AND status='PROCESSING' AND version=? -- 有闸门 |
|||
if (completed == 0) { |
|||
UPDATE ... SET status='PENDING' ... WHERE id=? AND status='PROCESSING' -- 无闸门 |
|||
``` |
|||
|
|||
当原 Worker 因超时已被 `recoverStale` 置为 RETRY/FAILED 并被另一个 Worker 重新领取(处于 PROCESSING)后,慢的原 Worker 返回时 `version` 不匹配走 PENDING 分支,会**把新 Worker 正在处理的任务改回 PENDING**;随后新 Worker 完成时 `completed=0`、`pending=0` → 抛 `IllegalStateException`。 |
|||
|
|||
**缺陷二:失败记录异常会打断同批任务(major)** |
|||
|
|||
`CandidateScoringRefreshWorker.java:77-86` 的 `catch (Exception e)` 块内调用 `taskExecutor.recordFailure(...)`;该方法在 `updated != 1` 时抛 `IllegalStateException`(`TaskExecutor.java:105-107`)。异常在 catch 块内抛出不会被自身捕获,将传播到 `processDue` 的 `for (Long id : due)` 循环(`Worker.java:63-67`,**循环体内无 try/catch**),导致**同批剩余任务本轮全部不被处理**,违反 `Worker.java:24` javadoc 声称的「异常不回抛,不中断其他任务」。 |
|||
|
|||
触发前提为物化耗时超过 `processing-timeout-ms`(默认 600000ms),概率低但路径真实。 |
|||
|
|||
### A5 补偿扫描 —— **通过** |
|||
|
|||
- 游标持久化于 `candidate_scoring_refresh_scan_checkpoints`(`:79-85 lockCursor` 用 `SELECT ... FOR UPDATE`),非内存字段 ✔ |
|||
- 单轮预算:`:48` `budget = max(1, min(limit, 500))`,`:98` `LIMIT ?` 绑定同一 `budget` ✔ |
|||
- 尾部回绕:`:59` `ids.size() < budget || ids.isEmpty() ? 0 : 末位 id`;`:52-55` 游标已在尾部且空结果时重置 0 重查 ✔ |
|||
- 游标推进与任务登记同事务:`scanDueStudents` 为 `@Transactional`,`requestService.requestRefresh` 为 `REQUIRED` ✔ |
|||
- 多实例串行:checkpoint 行 `FOR UPDATE` ✔ |
|||
- 查询预算:1 次聚合查询(`findEligibleAfter`)+ ≤budget 次 upsert,无逐用户 N+1 ✔ |
|||
- 不直接写 `candidate_dimension_scores` ✔ |
|||
|
|||
### A6 配置 —— **通过(无断档)** |
|||
|
|||
- `application.yml:156` `enabled: ${SCORING_REFRESH_ENABLED:true}`,`:158` `compensation-enabled: ${SCORING_REFRESH_COMPENSATION_ENABLED:true}` —— **生产默认启用** ✔ |
|||
- `application-test.yml:23-26` / `application-integration-test.yml:37-40` / `application-mysql-it.yml:26-29` 三处均显式 `enabled: false` + `compensation-enabled: false` ✔ |
|||
- 不依赖测试 profile 才能工作 ✔;Scheduler `@Value` 的 `:false` fallback 仅在该键完全缺失时生效,yml 提供 `true` 时实际生效值为 `true`(黑盒已验证补偿扫描确实运行)✔ |
|||
|
|||
遗留(记录):`CandidateScoringRefreshScheduler.java:15` javadoc 仍写「默认关闭」,与现状相反。 |
|||
|
|||
--- |
|||
|
|||
## 3. Docker / MySQL / Redis / RabbitMQ 版本与状态 |
|||
|
|||
| 组件 | 版本 | 地址 | 验收前 | 验收中/后 | |
|||
|---|---|---|---|---| |
|||
| Docker Engine | 29.6.2 | — | 已启动 | 未停止 ✔ | |
|||
| MySQL | **8.4.11** | 127.0.0.1:3307 | Exited(137) | healthy ✔ | |
|||
| Redis | 7.4-alpine | 127.0.0.1:6379 | Exited(0) | healthy ✔ | |
|||
| RabbitMQ | 3.13-management-alpine | 127.0.0.1:5672 / 15672 | Exited(137) | healthy ✔ | |
|||
|
|||
启动方式:`docker compose --env-file backend-infra/.env -f backend-infra/compose.yaml up -d`(仅使用项目既有 Compose)。未执行 `down -v`、未删除/重建 volume、未修改 `compose.yaml`、未新建业务数据库、未打印凭据。 |
|||
|
|||
volume 清单(`backend-infra_mysql_data` / `_redis_data` / `_rabbitmq_data`)与验收前一致,未删除。 |
|||
|
|||
--- |
|||
|
|||
## 4. V44 Flyway 与 schema validation(真实 MySQL) |
|||
|
|||
启动最新 JAR 前:`flyway_schema_history` 中 `max(version)=43`、`COUNT(*)=43`、V44 行数 0、V44 表不存在 —— 与「尚未执行 V44」的基线预期一致。 |
|||
|
|||
启动后: |
|||
|
|||
| 项 | 值 | |
|||
|---|---| |
|||
| `version` | **44** ✔ | |
|||
| `description` | `candidate scoring refresh tasks` | |
|||
| `type` | `SQL` ✔ | |
|||
| `success` | **1** ✔ | |
|||
| V44 行数 | **1**(唯一)✔ | |
|||
| `success=0` 的迁移数 | **0** ✔ | |
|||
| `max_version` / 总迁移数 | 44 / 44 ✔ | |
|||
|
|||
启动日志:`Started MyDreamjobApplication in 15.425 seconds`;主 profile 为 `default`;`ddl-auto=validate` 通过,**无 schema validation / SQL 语法 / Bean 装配错误**。 |
|||
|
|||
表结构(`SHOW COLUMNS`/`SHOW INDEX`): |
|||
|
|||
- `candidate_scoring_refresh_tasks`:13 列 `id, student_user_id, target_period(7), status(20), requested_at, processing_started_at, processed_at, next_attempt_at, attempts, last_error_code(40), version, created_at, updated_at` ✔ 状态/重试/租约/version/时间字段齐全 |
|||
- 唯一键 `uk_refresh_task_student_period (student_user_id, target_period)` ✔ |
|||
- 领取索引 `idx_refresh_task_claim (status, next_attempt_at)` ✔ |
|||
- 约束齐全:`PRIMARY`、`uk_refresh_task_student_period`(UNIQUE)、`fk_refresh_task_student → users(id) ON DELETE CASCADE`、`ck_refresh_task_status CHECK (status IN ('PENDING','PROCESSING','RETRY','COMPLETED','FAILED'))` ✔ |
|||
- `candidate_scoring_refresh_scan_checkpoints`(`scan_key` PK, `last_user_id`, `version`, `updated_at`)存在 ✔ |
|||
|
|||
V1–V43 全程未修改 ✔ |
|||
|
|||
--- |
|||
|
|||
## 5. 最新 JAR 启动与健康检查 |
|||
|
|||
- JAR:`mydreamjob-server/target/mydreamjob-server-0.0.1-SNAPSHOT.jar`(`mvn clean verify` 产出,82,440,778 bytes) |
|||
- 启动方式:`docs/acceptance/r01-d4-2-20260916/start-app.ps1`,显式提供 `DB_URL=127.0.0.1:3307`、`REDIS_HOST/PORT`、`RABBITMQ_*`、`SMS_MOCK_ENABLED=true`、`SCORING_REFRESH_*`;未修改 `application.yml` |
|||
- 验收前 8080/8081 空闲;验收后已全部释放(无 java 进程残留) |
|||
- `GET http://127.0.0.1:8080/api/v1/health` → **200** ✔;`http://127.0.0.1:8081/actuator/health` → **200** ✔ |
|||
- 启动耗时 14.9–15.4s,Flyway 成功,validate 成功 |
|||
|
|||
**实例使用(方案 A)**:三个实例互斥启动、PID 各自记录: |
|||
|
|||
| 用途 | PID | REFRESH | 说明 | |
|||
|---|---|---|---| |
|||
| 只读验证 #1 | 24624(失败,见下) / 22640(dev profile 失败) / **12656** | false | 三个目标 GET 零写验证 | |
|||
| 物化验证 | **23436** | true | 写侧链路 + 补偿扫描 | |
|||
| 只读验证 #2 | **28456** | false | 消费者回归 + 零写 | |
|||
| 重启验证 | **6576** | true | 跨重启游标延续 | |
|||
|
|||
启动调试记录(自我暴露,如实记录):首次启动因 `application-dev.yml` 硬编码短密钥覆盖环境变量而失败;修正为默认 profile 后又因**脚本以 UTF-8 无 BOM 保存、PowerShell 5.1 按 ANSI 解码中文注释导致换行被吞、`JWT_ACCESS_SECRET` 赋值行被并入注释**(诊断值为 `ACCESS_LEN=0`)而失败;加 BOM 后 `ACCESS_LEN=48` 正常启动。此为验收脚本自身缺陷,与产品无关。 |
|||
|
|||
--- |
|||
|
|||
## 6. 自动化实际测试数 |
|||
|
|||
| 命令 | 结果 | |
|||
|---|---| |
|||
| 定向批次一(8 个 D4 核心类) | **55 / 55**,F=0 E=0 **S=0**,BUILD SUCCESS(49.3s) | |
|||
| 定向批次二(`*CandidateScoring*Test,*Competition*Test,*ScoringDashboard*Test,*FairnessMonitoring*Test`) | **253 / 253**,F=0 E=0 **S=0**,BUILD SUCCESS | |
|||
| `mvn clean verify` | **2131 / 2131**,Failures=0、Errors=0、**Skipped=0**,BUILD SUCCESS(4:08,211 个测试类) | |
|||
| `python docs\verify_openapi.py` | **paths: 176 / operations: 214**,All checks passed ✔ | |
|||
|
|||
批次一逐类:`CandidateScoringRefreshInfrastructureTest` 13/13、`CandidateScoringRefreshFailureTransactionTest` 3/3、**`CandidateScoringRefreshMySqlIntegrationTest` 6/6(20.13s,真实 MySQL Testcontainers)**、`CandidateScoringEngineServiceTest` 14/14、`R01D4GetWritesSnapshotEvidenceTest` 5/5、`CandidateScoringPersistenceIntegrationTest` 3/3(17.51s)、`V38AbilityRankingIntegrationTest` 1/1、`CandidateScoringClockConsistencyTest` 10/10。 |
|||
|
|||
**Q01 与 V44 的 MySQL Testcontainers 用例真实执行**(耗时 20.1s / 17.5s 表明容器真实启动),Docker 全程可用,**无因环境缺失导致的 skip**。 |
|||
|
|||
--- |
|||
|
|||
## 7. 三个目标 GET 与趋势 GET 的真实 HTTP 结果 |
|||
|
|||
前提:被测实例 `SCORING_REFRESH_ENABLED=false`(后台 Worker 与补偿扫描关闭),因此任何表变化都只能来自 HTTP 请求本身。 |
|||
|
|||
每个接口连续调用 **3 次**,共 12 次真实 HTTP: |
|||
|
|||
| 接口 | HTTP | 契约 | 敏感字段 | |
|||
|---|---|---|---| |
|||
| `GET /api/v1/profile/me/candidate-scoring` ×3 | 200,200,200 | 稳定(10 字段) | 无泄露 | |
|||
| `GET /api/v1/profile/me/candidate-scoring/dimensions/EDUCATION` ×3 | 200,200,200 | 稳定(17 字段) | 无泄露 | |
|||
| `GET /api/v1/company/candidates/86/scoring` ×3 | 200,200,200 | 稳定(6 字段) | 无泄露 | |
|||
| `GET /api/v1/profile/me/candidate-scoring/trend?months=6` ×3 | 200,200,200 | 稳定(3 字段) | 无泄露 | |
|||
|
|||
- 响应不含 `phone` / `email` / `resumeUrl` / `storagePath` / `accessToken` / `refreshToken` / `passwordHash`,正则扫描无 11 位手机号。 |
|||
- **S1 零写验收:PASS=24 / FAIL=0 / SKIP=0** |
|||
|
|||
--- |
|||
|
|||
## 8. GET 前后逐字段数据库快照 |
|||
|
|||
快照覆盖 **22 张表**(显式列、稳定 `ORDER BY` 全列、逐行比对)。敏感列(手机号/邮箱/Token 摘要/文件路径/IP/UA/幂等键)以 `SHA2(...,256)` 参与比对以保持逐行可区分且不落真实值;非敏感列保留原始值。列不存在时 SQL 直接报错(不 catch)。 |
|||
|
|||
**结果:22 张表逐字段零变化** ✔ |
|||
|
|||
| 关键表 | 行数 | 结果 | |
|||
|---|---|---| |
|||
| `candidate_dimension_scores` | 88 | 零变化 ✔ | |
|||
| `candidate_scoring_refresh_tasks` | 2 | 零变化 ✔ | |
|||
| `candidate_scoring_refresh_scan_checkpoints` | 0 | 零变化 ✔ | |
|||
| `audit_logs` | 2620 | 零变化 ✔ | |
|||
| `notifications` | 158 | 零变化 ✔ | |
|||
| `messages` | 8 | 零变化 ✔ | |
|||
| `candidate_scoring_snapshot_history` | 4 | 零变化 ✔ | |
|||
|
|||
**最强证据**:该只读实例下,验收学生 A(userId 8002)的 `candidate_dimension_scores` 在多次 GET 后仍为 **0 行** —— 若 D4-2 前行为(GET 兜底写快照)仍存在,此处必为 8 行。同时任务表存在 1 行 `PENDING` 任务,证明写侧登记已发生但后台物化被正确关闭。 |
|||
|
|||
**反证(扫描器有效性)**:`snap.selftest()` 对内存中的一行仅改动单个字段,扫描器正确检出 `[('1', [('b','y','Z')])]` → PASS。 |
|||
|
|||
--- |
|||
|
|||
## 9. 写侧事件 → 任务 → Worker → 快照的真实链路 |
|||
|
|||
前提:物化实例 `SCORING_REFRESH_ENABLED=true`,`worker-interval=1500ms`。 |
|||
|
|||
**S2 结果:PASS=14 / FAIL=0 / SKIP=0** |
|||
|
|||
| 项 | 实测 | |
|||
|---|---| |
|||
| W1 `PUT /profile/me`(资料更新) | st=200;任务恰好 1 条 `(8002, 2026-09)`;**Worker 0.7s 处理为 COMPLETED**;`last_error_code` 为空;**八维快照齐备(8 行)**;`rulesVersion` 全部 `candidate-scoring-v0.1` | |
|||
| 同事务登记 | 请求成功后立即出现任务行,无需等待 | |
|||
| W2 `PUT /profile/me/preferences` | st=200 → COMPLETED → 八维仍 8 行(**无部分写**) | |
|||
| W3 `POST /applications` | st=409(本轮已投递,幂等)→ 仍合并为 1 条任务 | |
|||
| 重复同一事件 ×3 | 仍只有 1 条任务,`requested_at` 推进不新增 | |
|||
| **完全相同输入重放** | 八维 version 全等(`ABILITY:0, ACTIVITY:0, COMPLIANCE:1, EDUCATION:1, GROWTH:0, INTEGRITY:0, INTERNSHIP:0, STABILITY:0`)→ **version 不推进** ✔ | |
|||
| `candidate_scoring_snapshot_history` | 4 → 4 **零变化**(普通 Worker 不写历史)✔ | |
|||
| `notifications` | 158 → 158 零变化 ✔ | |
|||
| `audit_logs` | 2620 → 2620 零变化 ✔ | |
|||
| `messages` | 8 → 8 零变化 ✔ | |
|||
|
|||
**PROCESSING 防丢失**:本项在真实 HTTP 下难以稳定卡住窗口,按任务书允许的方式引用真实 MySQL 并发集成测试 —— `CandidateScoringRefreshInfrastructureTest#requestDuringProcessingIsNotLost` 走真实完成路径 `taskExecutor.claim` → 处理期间新请求 → `taskExecutor.materialize`,断言回到 `PENDING` 而非 `COMPLETED`;`CandidateScoringRefreshMySqlIntegrationTest` 6/6 在真实 MySQL 上覆盖重登记与租约恢复。写侧正常主链路(W1/W2/W3)**未跳过**。 |
|||
|
|||
**源业务失败不留残留任务**:`CandidateScoringRefreshFailureTransactionTest` 3/3 覆盖外层业务回滚时登记一并回滚 ✔(该项为 H2/集成层证据;A2 中 `ResumeVersionService.createVersion` 的独立提交边界为静态代码发现,见 §2)。 |
|||
|
|||
--- |
|||
|
|||
## 10. 重复登记与幂等 version |
|||
|
|||
- 同学生同周期重复登记合并为一行:`:56-71` 的 `ON DUPLICATE KEY UPDATE` 只推进 `requested_at`、`version`,不新增行 ✔(黑盒 W1b 实测 1 行) |
|||
- `COMPLETED`/`FAILED` 重新打开为 `PENDING` 且 `attempts` 归零;`RETRY` 回到 `PENDING` 保留 `attempts` ✔(`CandidateScoringRefreshInfrastructureTest#completedTaskCanBeRequestedAgain`) |
|||
- `PROCESSING` 期间新事件不丢失:登记保留 `status='PROCESSING'` 并推进任务 `version`;完成时 `version` 不匹配即回退 `PENDING` ✔ |
|||
- **幂等物化**:普通物化值完全不变时 `version` 与 `updated_at` 不变(黑盒 W1b 确证)✔ |
|||
|
|||
--- |
|||
|
|||
## 11. 租约、重试与失败分类 |
|||
|
|||
- `recoverStale`(`TaskExecutor.java:29-41`):`WHERE status='PROCESSING' AND processing_started_at < ?`,置 `RETRY`(或达上限 `FAILED`)、`attempts+1`、`last_error_code='WORKER_TIMEOUT'`、清空 `processing_started_at` ✔ |
|||
- `recordFailure`(`:91-108`):`attempts+1 >= maxAttempts → FAILED` 否则 `RETRY` + `next_attempt_at` 退避 ✔ |
|||
- 错误分类为固定字符串,不含异常原文/SQL/堆栈/手机号 ✔ |
|||
- 调度兜底:`CandidateScoringRefreshScheduler.java:49-57 / :66-70` 各自 try/catch,单轮失败不影响后续轮次 ✔ |
|||
- 真实 MySQL 证据:`CandidateScoringRefreshMySqlIntegrationTest` 6/6(含租约恢复、部分写失败全回滚且同批成功任务不受影响) |
|||
|
|||
**但**:§2-A4 的两处缺陷(PENDING 分支缺 version 闸门、`recordFailure` 抛出打断 for 循环)表明「同批兄弟任务互不影响」在租约竞争边界下**不成立**。 |
|||
|
|||
--- |
|||
|
|||
## 12. 补偿游标、跨重启、单轮预算、多实例 |
|||
|
|||
参数:`compensation-limit=2`,`compensation-interval=4000ms`。 |
|||
|
|||
| 验证项 | 实测证据 | |
|||
|---|---| |
|||
| 单轮登记数 ≤ limit | 连续 12 轮 `created_at` 分组计数**全部为 2**,无一超标 ✔ | |
|||
| 游标持久推进 | `last_user_id` 113 → 126 → 130 → 134 → 138 → 142 → 146…,`version` 每轮 +1 ✔ | |
|||
| **不会永远只扫首批用户** | 游标推进至 7948 → 7954 → 7963 → 7967 → 7973 → **8002**(接近学生最大 id 8005)✔ | |
|||
| **扫到尾部后从头继续** | 8002 之后下一轮 `last_user_id` 回到 **15** → 20 … ✔ | |
|||
| **跨重启从持久游标继续** | 重启前 `cursor=113 / version=104`;重启后首轮 `cursor=126 / version=105`(version 恰好 +1)—— **未回到 0** ✔ | |
|||
| 只读实例不扫描 | `SCORING_REFRESH_ENABLED=false` 下 `cursor=113/104` 12 秒内完全冻结 ✔ | |
|||
| 多实例并发不重复推进 | 代码层 `lockCursor` 的 `SELECT ... FOR UPDATE` 串行化 + 游标推进与登记同事务 ✔(**未做双 JAR 黑盒**,见 §23 SKIP) | |
|||
| 每轮查询预算有上限 | 1 次聚合查询 + ≤2 次 upsert,无 N+1 ✔ | |
|||
| 不直接写快照 | 扫描器文件内无 `candidate_dimension_scores` 写入 ✔ | |
|||
|
|||
任务终态:`candidate_scoring_refresh_tasks` 共 181 行,**181/181 全部 COMPLETED**,无 PENDING / RETRY / FAILED / 永久 PROCESSING ✔ |
|||
|
|||
--- |
|||
|
|||
## 13. 趋势回归 |
|||
|
|||
- `GET /profile/me/candidate-scoring/trend?months=6` → 200;当前周期 `2026-09` 存在;`overallScore=65.2`;`rulesVersion=candidate-scoring-v0.1`;维度数 5(趋势按既有语义只收录 `OK`/`ESTIMATED` 维度,非 8 —— 与 `CandidateScoringEngineService.java:91` 的过滤逻辑一致) |
|||
- 多次调用结果**逐字节稳定** ✔ |
|||
- **请求本身零写**(逐字段比对)✔ |
|||
- 能读到后台物化的当前周期快照 ✔(该数据即由 §9 的 Worker 物化产生) |
|||
|
|||
--- |
|||
|
|||
## 14. 排名回归 |
|||
|
|||
- `GET /jobs/47/competition-ranking` → 200;`rank=1`、`cohortSize=2`、`overallScore=65.2`、`percentile=100.0`、`asOfPeriod=2026-09` |
|||
- 验收学生(8002)与对照学生(8003)**各有 8 维快照**,排名**未因 GET 停写而缺失候选人** ✔ |
|||
- 快照经真实物化入口产生,未直接插入伪造 ✔ |
|||
- 只读 ✔ |
|||
|
|||
--- |
|||
|
|||
## 15. 企业看板回归 |
|||
|
|||
| 接口 | 结果 | |
|||
|---|---| |
|||
| `GET /company/scoring/dashboard/overview` | 200;`companyId=27, totalJobs=1, publishedJobs=1, totalCandidates=2, avgCandidateScore=61.7` | |
|||
| `GET .../job-rankings?page=0&size=20` | 200;含验收岗位 47 | |
|||
| `GET .../candidate-rankings?page=0&size=20` | 200;`applicationId=86 → overallScore=65.2, percentile=100.0`;`applicationId=87` 亦在列 | |
|||
| `GET .../trend?months=6` | 200;`2026-09, avgCandidateScore=61.7, candidateCount=2` | |
|||
|
|||
- 数据来自物化快照 ✔;多次 GET **零写** ✔ |
|||
- 越权负例:企业 B 访问企业 A 的投递评分 → **403** ✔ |
|||
|
|||
--- |
|||
|
|||
## 16. 申诉与强制 version 回归 |
|||
|
|||
- 学生创建隔离申诉 → 200;`appealId=4289`;`dimension=EDUCATION`、`period=2026-09`、`status=PENDING` |
|||
- **`scoreAtAppeal=76.00`、`snapshotVersionAtAppeal=1` 均非空** ✔ —— 关键:D4-2 停写后申诉取证仍能取到快照,因为写侧登记 + Worker 已先行物化 |
|||
- `GET /profile/me/candidate-scoring/appeals`(我的列表)与 `/appeals/4289`(详情)均 200 ✔ |
|||
- `GET /admin/candidate-scoring/appeals` → 200,`total=8`(历史 7 条 + 本轮 1 条)✔ |
|||
- 申诉副作用核对:相对上一快照仅 `candidate_scoring_appeals +1`、`candidate_scoring_appeal_history +1`、`audit_logs +1`,**其余表零变化**(含 `candidate_dimension_scores`、`notifications`、`messages`、`snapshot_history`、`refresh_tasks`)✔ |
|||
- **未执行**审核/回滚(避免改动非本轮治理数据),因此「rollback 后强制 version 恰好 +1」本项**未做黑盒**,改由 `CandidateScoringCalculatorTest#recomputeAndSnapshot_*` + `CandidateScoringGovernancePersistenceIntegrationTest`(第二批 253/253 内)覆盖,静态确认 `CandidateScoringSnapshotWriter.java:75-88` 无条件 `version=version+1`。见 §23 SKIP。 |
|||
- 普通 Worker 物化不写 `snapshot_history` ✔(黑盒 4 → 4) |
|||
|
|||
--- |
|||
|
|||
## 17. 公平性监控回归 |
|||
|
|||
以 ADMIN(复用既有活跃管理员 userId 91,**未恢复 V1_1 旧演示账号 id=1**)调用: |
|||
|
|||
| 接口 | 结果 | |
|||
|---|---| |
|||
| `GET /admin/scoring/fairness/overview?period=2026-09` | 200,`basis=CURRENT_STORED_SNAPSHOTS`、`scope=SCORED_SNAPSHOT_POPULATION` | |
|||
| `.../dimensions?period=2026-09` | 200 | |
|||
| `.../drift` | 200,`availability=UNAVAILABLE, reason=IMMUTABLE_SCORE_HISTORY_NOT_AVAILABLE`(既有抑制语义保留) | |
|||
| `.../ranking-stability` | 200,`availability=UNAVAILABLE, reason=RANKING_SNAPSHOT_HISTORY_NOT_AVAILABLE`(既有抑制语义保留) | |
|||
| `.../appeals` | 200,`basis=APPEAL_CREATION_QUEUE_CURRENT_STATE` | |
|||
|
|||
- 小样本抑制与互补风险抑制保持(以上 `UNAVAILABLE` 即为抑制输出)✔ |
|||
- 不泄露候选人身份(响应扫描无 `subjectUserId`/手机号/邮箱)✔ |
|||
- 当前快照聚合可用 ✔;GET **零写** ✔ |
|||
|
|||
--- |
|||
|
|||
## 18. 数据库副作用 |
|||
|
|||
**允许的变化(全部落入任务书允许范围)**: |
|||
|
|||
1. 本轮唯一前缀夹具(创建后已按主键清理,见 §22) |
|||
2. 写侧业务请求按契约产生的行(`student_profiles`、`student_preferences`、`applications`、`jobs`、`companies`、`notifications`) |
|||
3. **刷新任务与候选人快照**:`candidate_scoring_refresh_tasks` 共 181 行(其中 4 行为本轮学生,其余为补偿扫描按设计登记的其他活跃学生),`candidate_dimension_scores` 相应物化行 |
|||
4. `candidate_scoring_refresh_scan_checkpoints` 1 行(游标) |
|||
5. 本轮申诉 1 条 + 申诉历史 1 条 + 相应审计 1 条 |
|||
|
|||
**明确未发生**: |
|||
|
|||
- 非本轮用户的业务数据(`users`/`companies`/`jobs`/`applications` 等)未被删除或改写 —— 唯一受影响的是其**候选人快照**,且这是启用生产后台补偿扫描后的**设计内正常行为**(任务书明确允许「候选人快照」变化) |
|||
- 评分规则、权重、规则版本、排名规则未变 |
|||
- `candidate_scoring_snapshot_history` 在**普通 Worker 场景下为零变化**(4 → 4)✔ —— 该表的 4 行历史数据全程未被普通物化触碰 |
|||
- 既有历史快照未被改写(version 语义为「变化才 +1」) |
|||
- 非本轮审计未变 |
|||
|
|||
--- |
|||
|
|||
## 19. RabbitMQ 前后状态 |
|||
|
|||
队列(vhost `/`)在**验收前**与**验收后**分别查询: |
|||
|
|||
| 队列 | 验收前 ready/unacked | 验收后 ready/unacked | |
|||
|---|---|---| |
|||
| `queue.sms.verification` | 0 / 0 | 0 / 0 | |
|||
| `queue.notification` | 0 / 0 | 0 / 0 | |
|||
| `queue.notification.dlq` | 0 / 0 | 0 / 0 | |
|||
| `queue.audit` | 0 / 0 | 0 / 0 | |
|||
|
|||
4 个队列**真实存在**,终态 ready=0、unacked=0,**D4-2 的 GET、任务登记、Worker、补偿扫描全程未新增任何消息** ✔ |
|||
|
|||
代码层佐证:`candidatescore/refresh/` 全包对 `rabbit|Rabbit` 的匹配仅出现在 **3 处 javadoc 注释**(「不发送 RabbitMQ」),无任何 `RabbitTemplate`/`convertAndSend`/`NotificationService`/`AuditService` 引用 ✔ |
|||
|
|||
--- |
|||
|
|||
## 20. 静态与运行时 OpenAPI |
|||
|
|||
| 项 | 静态 `docs/openapi-rc.json` | 运行时 `/api/v1/v3/api-docs` | |
|||
|---|---|---| |
|||
| paths | **176** | **176** | |
|||
| operations | **214** | **214** | |
|||
| method+path 集合 | — | **完全一致**(`only-runtime=[]`, `only-static=[]`) | |
|||
|
|||
- `python docs\verify_openapi.py` → `All checks passed!`(含 V36/V37/V39–V43/N02/N07/S02/S03 各项契约校验) |
|||
- D4-2 **未新增**公网刷新、任务、补偿、管理接口 ✔ |
|||
- 四个评分 GET 响应 schema 不变(S1 实测响应字段集合与既有契约一致)✔ |
|||
|
|||
--- |
|||
|
|||
## 21. 日志脱敏 |
|||
|
|||
扫描对象:本轮 6 个 JAR 日志/err 文件 + 2 个 Maven 测试日志,共 8 个文件。 |
|||
|
|||
**探针有效性**:临时文件注入 `Authorization: Bearer <JWT>`、11 位手机号、「验证码 482913」、`DB_PASSWORD=…`,扫描器命中 **5 类** → **[PASS] 扫描器有效**;探针文件已删除 ✔ |
|||
|
|||
**真实命中与甄别**: |
|||
|
|||
| 类别 | 命中 | 甄别结论 | |
|||
|---|---|---| |
|||
| 「口令赋值」 | 63 | **全部为同一模式**:Spring Boot `UserDetailsServiceAutoConfiguration` 启动时打印的 `Using generated security password: <UUID>`。经字符集核验为大小写十六进制 + 连字符的 UUID,**非数据库口令、非 JWT 密钥、非项目任何凭据**,且项目采用 JWT 认证不使用该内存用户。判定为**误报**。 | |
|||
| 11 位手机号 | 1 | 位于 `clean-verify.log`,上下文为既有测试 `AdminBootstrapConcurrencyLoggingTest` 的 **`S03_SCANNER_PROBE_<值>` 扫描器探针**(测试夹具),非真实用户号码 | |
|||
| Bearer Token / JWT 形态串 / 验证码语义行 / 密钥赋值 / 私钥块 / 简历存储路径 | **0** | ✔ | |
|||
|
|||
**应用运行日志(`r01d42-*.log`)中手机号、Token、验证码、JWT、简历路径命中均为 0** ✔ |
|||
|
|||
报告与本文件均未复制任何真实凭据值。 |
|||
|
|||
--- |
|||
|
|||
## 22. 结束处理 |
|||
|
|||
**进程**:只停止本轮明确记录 PID 的实例(24624/22640/12656/23436/28456/6576),未触碰任何未知 Java 进程。验收后 `Get-Process java` 无输出,8080/8081 端口已释放,恢复至验收前空闲状态。**Docker 未停止**(按任务书要求)。 |
|||
|
|||
**数据清理**(仅本轮精确主键,无模糊 DELETE): |
|||
|
|||
| 表 | 删除前 | 删除后 | |
|||
|---|---|---| |
|||
| `users`(8002/8003/8004/8005) | 4 | **0** ✔ | |
|||
| `companies`(27/28) | 2 | **0** ✔ | |
|||
| `jobs`(47) | 1 | **0** ✔ | |
|||
| `applications`(86/87)+ `application_status_history` | 2 + 2 | **0** ✔ | |
|||
| `candidate_dimension_scores` | 32 | **0** ✔ | |
|||
| `candidate_scoring_refresh_tasks` | 4 | **0** ✔ | |
|||
| `candidate_scoring_appeals` / `appeal_history` | 1 / 1 | **0** ✔ | |
|||
| `student_profiles` / `student_preferences` | 2 / 1 | **0** ✔ | |
|||
| `notifications` | 2 | **0** ✔ | |
|||
| `auth_tokens` / `user_roles` / `sms_verification_codes` | 6 / 6 / 6 | **0** ✔ | |
|||
|
|||
清理前做了**归属校验**:4 个用户 id 对应的手机号必须全部等于本轮登记的隔离虚拟号(`phone IN (4 个号码)` 与 `id IN (…)` 双向匹配数均为 4,完全一致),否则脚本 `[ABORT]` 拒绝删除。删除按外键顺序执行(两轮:首轮被 `application_status_history` 阻断后补删)。 |
|||
|
|||
**保留并登记**:`audit_logs` 中 actor 为本轮 4 个用户的 **15 行** 属 append-only 合规记录,按任务书要求**不冒险删除**,保留在库中。关联 ID:`user_id ∈ {8002, 8003, 8004, 8005}`。 |
|||
|
|||
**唯一允许的测试夹具(精确 INSERT,已登记)**:`user_roles` 插入 `(8004,'COMPANY')`、`(8005,'COMPANY')` 各 1 行 —— 原因:项目**无任何公网接口**可授予 COMPANY 角色(`CompanyService.java:76 assertCompanyRole` 前置校验,角色仅由管理员初始化产生),企业创建因此返回 403。该 2 行已在 §22 清理中随 `user_roles WHERE user_id IN (…)` 一并删除。 |
|||
|
|||
**Git 收尾**:分支 `refactor/r01-structure-performance`;暂存区空;未 `git add -A`;未 commit;未 push;V1–V43 未修改;V44 仍为唯一新增迁移;无删除文件;`git diff --check` 无空白错误。未进入下一项 R01 工作。 |
|||
|
|||
--- |
|||
|
|||
## 23. PASS / FAIL / SKIP 清单 |
|||
|
|||
### PASS |
|||
|
|||
1. Git 基线(分支/HEAD/暂存区/V1–V43/V44/无删除/无空白错误) |
|||
2. A1 三个 GET 真正只读(代码 + 真实 HTTP 双证) |
|||
3. A3 唯一 Writer 语义(幂等 version / canonical JSON / 并发锁 / 强制版本 / 不写 history / 整体回滚) |
|||
4. A5 补偿扫描(持久游标 / 单轮预算 / 尾部回绕 / 同事务 / 不写快照) |
|||
5. A6 配置无断档(生产默认启用,测试 profile 显式关闭) |
|||
6. Docker/MySQL 8.4.11/Redis/RabbitMQ 全部 healthy |
|||
7. V44 在真实 MySQL 成功(version=44, success=1, 唯一, failed=0) |
|||
8. `ddl-auto=validate` 通过,无 schema/SQL/Bean 装配错误 |
|||
9. 最新 JAR 正常启动;健康检查 8080/8081 均 200 |
|||
10. `mvn clean verify` **2131/2131**,F=0 E=0 **S=0** |
|||
11. 定向批次一 55/55、批次二 253/253(MySQL Testcontainers 真实执行) |
|||
12. **三个目标 GET 真实零写**(22 张表逐字段不变;学生快照保持 0 行) |
|||
13. 写侧事件 → 任务 → Worker → 八维快照全链路(W1/W2/W3) |
|||
14. 重复登记合并 + 完全相同输入重放 version 不推进 |
|||
15. 普通 Worker 不写 `snapshot_history`、不写通知/审计/RabbitMQ |
|||
16. 补偿游标跨重启延续、单轮严格 ≤ limit、尾部回绕 |
|||
17. 趋势回归(可读物化快照、稳定、零写) |
|||
18. 排名回归(cohortSize=2、双学生均有快照) |
|||
19. 企业看板回归(4 端点在列、越权 403) |
|||
20. 申诉回归(取证字段非空、副作用限于契约表) |
|||
21. 公平性监控回归(5 端点、抑制语义保留、零写) |
|||
22. RabbitMQ 终态 4 队列 ready=0/unacked=0 |
|||
23. 静态与运行时 OpenAPI 均 176/214 且集合完全一致 |
|||
24. `git diff --check`、OpenAPI 静态校验、日志脱敏(探针有效,应用日志 0 泄露) |
|||
25. 隔离数据精确清理,无残留 |
|||
|
|||
### FAIL(4 项) |
|||
|
|||
| # | 缺陷 | 位置 | 命中判据 | |
|||
|---|---|---|---| |
|||
| F1 | **Offer 自动过期改变评分输入但不登记刷新任务** | `offer/OfferExpiryWorker.java:48`(登记计数=0);评分读取 `candidatescore/CandidateScoringCalculator.java:188-194` | 「写侧成功但任务未登记」 | |
|||
| F2 | **候选人反馈的修改/删除改变评分输入但不登记刷新任务** | `feedback/CandidateFeedbackService.java:107`、`:124`(仅 `:82` 创建时登记);评分读取 `CandidateScoringCalculator.java:196-200` | 「写侧成功但任务未登记」 | |
|||
| F3 | **`createVersion` 登记与独立提交的版本行事务边界不对称** | `resume/ResumeVersionService.java:71`(外层事务)vs 同文件 `:52-55` 自述的 `REQUIRES_NEW` 独立提交 | 「源事务失败却留下任务」的对偶形态:数据已提交而登记丢失 | |
|||
| F4 | **测试断言弱化(红线:禁止修改或放宽既有测试)** | `test/.../candidatescore/CandidateScoringCalculatorTest.java:248` 起,ABILITY 溯源 payload 的敏感字段黑名单断言(`answerKey/questionCode/competencyTag/stem/selectedOptionId/scoringRule/awardedScore/explanation/phone/email` 共 10 项)被删除,**无等价替代** | 「修改或放宽既有测试」 | |
|||
|
|||
### FAIL(次生,A4,未单独计入但需一并整改) |
|||
|
|||
| # | 缺陷 | 位置 | |
|||
|---|---|---| |
|||
| F5 | `materialize` 的「回到 PENDING」分支缺 version 闸门,租约竞争下会把新 Worker 处理中的任务改回 PENDING | `refresh/CandidateScoringRefreshTaskExecutor.java:78-88` | |
|||
| F6 | `recordFailure` 在 `updated != 1` 时抛异常,且异常点位于 Worker 的 catch 块内、`processDue` 的 for 循环无保护 → **同批剩余任务本轮全部不处理** | `refresh/CandidateScoringRefreshWorker.java:83` + `:63-67` + `CandidateScoringRefreshTaskExecutor.java:105-107` | |
|||
|
|||
### SKIP(2 项,均非核心判据) |
|||
|
|||
1. **多实例并发补偿扫描双 JAR 黑盒**:以代码层 `SELECT ... FOR UPDATE` 串行 + 游标与登记同事务作为证据;未启动第二个 JAR 做真实双实例竞争。 |
|||
2. **申诉审核/回滚黑盒**:为避免改动非本轮治理数据(`candidate_scoring_rollback_limits` 等)未执行 rollback,改由 `CandidateScoringCalculatorTest#recomputeAndSnapshot_*` 与 `CandidateScoringGovernancePersistenceIntegrationTest` 覆盖(第二批 253/253 内),静态确认强制 version 路径。 |
|||
|
|||
### BLOCKED |
|||
|
|||
无。 |
|||
|
|||
--- |
|||
|
|||
## 24. 剩余风险 |
|||
|
|||
1. **F1/F2 的实际影响受补偿扫描部分缓解**:补偿扫描的资格条件为「近 90 天有投递 **或** 近 30 天有审计」(`CandidateScoringRefreshCompensationScanner.java:89-99`)。有 Offer / 有反馈的学生必有投递,故其快照通常在下一轮扫描(默认间隔 1 小时)内被纠正。但若该学生**近 90 天无投递、近 30 天无审计**,则其快照将**永久停留在过期值**,无任何兜底路径。 |
|||
2. **F3 触发概率低但后果不可逆**:仅在事务提交阶段失败时发生,届时业务数据存在而刷新信号永久丢失,且无对账机制可发现。 |
|||
3. **F5/F6 触发前提为物化耗时超过 `processing-timeout-ms`(默认 600s)**,正常物化实测 0.7s,概率低;一旦触发,本轮同批任务的其余条目会被静默跳过(下一轮会重试,故不会永久丢失)。 |
|||
4. **补偿扫描会物化全库活跃学生的快照**:启用生产后台刷新后,单实例每轮最多 `compensation-limit` 个。本次验收实测 181 条任务、全部 COMPLETED,未观察到异常增长;但该行为对生产库的持续写入量需在容量规划中评估。 |
|||
5. **`CandidateScoringRefreshInfrastructureTest` 未验证 8 个写侧 Service 真的调用了 `requestRefresh`**:现有测试直接驱动 `requestService`/`taskExecutor`,各 Service 单测只补了 `@Mock` 依赖而无一处 `verify(...).requestRefresh(...)`。F1/F2 正是这类覆盖盲区的产物,建议本轮整改时一并补齐。 |
|||
6. **注释与现实不符(低危)**:`CandidateScoringCalculator.java:582`(「含 GET 兜底」)、`CandidateScoringRefreshScheduler.java:15`、D4-1 报告 §20 的「默认关闭」表述均已过时。 |
|||
|
|||
--- |
|||
|
|||
## 25. 是否允许冻结 D4-2 |
|||
|
|||
**不允许冻结。** |
|||
|
|||
R01-D4-2 的**核心目标**(三个评分 GET 停写)已在真实 MySQL/HTTP 下确凿达成,且未观察到任何消费端退化或回归。但依据任务书第二十节的 FAIL 判据,F1/F2 命中「**写侧成功但任务未登记**」,F4 触碰「禁止修改或放宽既有测试」红线,因此本轮不可冻结。 |
|||
|
|||
--- |
|||
|
|||
## 26. 下一轮定向整改指令 |
|||
|
|||
### 必做(阻断冻结) |
|||
|
|||
**R1. 补齐 Offer 自动过期的评分刷新登记** |
|||
- 文件:`mydreamjob-server/src/main/java/com/mydreamjob/offer/OfferExpiryWorker.java` |
|||
- 方法:`expireOne(Long offerId)`(`:41`),在 `:48 setStatus(EXPIRED)` 成功且 `:51 saveAndFlush` 之后、与该方法**同一 `REQUIRES_NEW` 事务内**,追加: |
|||
`scoringRefreshRequestService.requestRefresh(offer.getCandidateId(), LocalDateTime.now(clock))` |
|||
- 注入 `CandidateScoringRefreshRequestService`;`OfferExpiryWorker` 已使用 `clock`,`capturedNow` 应复用同一时间源 |
|||
- 不得因此改动 `OfferService#transitionStatus` 的既有语义 |
|||
- 补测试:真实驱动 `expireOne` 后断言 `candidate_scoring_refresh_tasks` 出现/合并一条 `(candidateId, 当期)` 任务 |
|||
|
|||
**R2. 补齐候选人反馈修改/删除的评分刷新登记** |
|||
- 文件:`mydreamjob-server/src/main/java/com/mydreamjob/feedback/CandidateFeedbackService.java` |
|||
- 方法:`updateFeedback(…)`(`:107`)与 `deleteFeedback(…)`(`:124`),在各自成功路径末尾、同一 `@Transactional` 内追加 `requestRefresh(app.getStudentUserId(), LocalDateTime.now())`(`deleteFeedback` 注意在删除前先取得 `studentUserId`) |
|||
- 与既有 `:82 createFeedback` 保持完全一致的调用形态 |
|||
- 补测试:`updateFeedback` 改变 `rating` 后断言任务被登记/合并 |
|||
|
|||
**R3. 修正 `createVersion` 的登记事务边界** |
|||
- 文件:`mydreamjob-server/src/main/java/com/mydreamjob/resume/ResumeVersionService.java` |
|||
- 问题:`:71` 的登记位于外层事务,而版本行经 `versionWriter.saveNewVersion`(`REQUIRES_NEW`)**独立提交**;外层提交阶段失败会造成「数据已提交、登记丢失」 |
|||
- 要求(二选一,须在报告中说明选择理由): |
|||
(a) 将 `requestRefresh` 增补为 `Propagation.REQUIRES_NEW` 的独立调用(放在版本行成功返回之后);或 |
|||
(b) 在 `createVersion` 返回后由调用方在**已提交**的上下文中登记(`TransactionSynchronization#afterCommit`) |
|||
- 补测试:模拟外层事务回滚,断言版本行存在时**必定**存在对应刷新任务 |
|||
|
|||
**R4. 修正 `materialize` 的 PENDING 回退闸门** |
|||
- 文件:`mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshTaskExecutor.java` |
|||
- 位置:`:78-88` |
|||
- 要求:`status='PENDING'` 的回退 `UPDATE` 增加 `AND version=?`(与 claim 时记录的 `claimedVersion` 比对);`updated != 1` 时应**静默返回**(该任务已被 `recoverStale` 或新 Worker 接管,属正常竞争),不得抛 `IllegalStateException` |
|||
|
|||
**R5. 隔离单任务失败对同批任务的影响** |
|||
- 文件:`mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshWorker.java` |
|||
- 位置:`:63-67`(`processDue` 循环)与 `:83`(catch 内 `recordFailure`) |
|||
- 要求:`processDue` 的 for 循环体加 `try/catch`,或让 `recordFailure` 自身吞掉状态冲突异常并仅告警 —— 保证单任务异常不阻断同批其余任务,与 `:24` javadoc 的承诺一致 |
|||
|
|||
**R6. 恢复被删除的敏感字段断言(红线项)** |
|||
- 文件:`mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoringCalculatorTest.java` |
|||
- 位置:`:248` 起(原 `for (String banned : List.of("answerKey","questionCode","competencyTag","stem","selectedOptionId","scoringRule","awardedScore","explanation","phone","email"))` 断言) |
|||
- 要求:在**新的委托架构下重建等价断言** —— 序列化 `CandidateScoringSnapshotWriter` 为 ABILITY 维度生成的 `sub_scores_json`(可通过 `CandidateScoringRefreshInfrastructureTest` 走真实 Writer,或在 Writer 测试中直接断言),并逐项断言不含上述 10 个禁止字段 |
|||
- 不得以「类型安全」为由省略:`weights`/`dimensionScores` 为 `Map`,其 key 不受编译期保护 |
|||
|
|||
**R7. 补齐写侧登记的行为验证** |
|||
- 文件:`mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshInfrastructureTest.java` |
|||
- 要求:新增断言,对 §A2 的 8 个服务方法 + R1/R2 新接入的两个入口,逐一 `verify(scoringRefreshRequestService, times(1)).requestRefresh(…)`;覆盖**失败回滚不登记**的负例 |
|||
|
|||
### 建议(不阻断,但应同批处理) |
|||
|
|||
**R8.** 修正过期注释:`CandidateScoringCalculator.java:582`、`CandidateScoringRefreshScheduler.java:15`,以及 `docs/r01-d4-1-scoring-materialization-implementation-report.md` §13/§20 中「补偿扫描为简化版/默认关闭」的表述。 |
|||
|
|||
**R9.** 补 `CandidateScoringRefreshInfrastructureTest` 中缺失的黑盒项:多实例并发扫描(双实例 `scanDueStudents` 断言游标不重复推进)、跨重启游标延续的自动化用例(当前仅有本次验收的真实证据)。 |
|||
|
|||
**R10.** 评估 `getTrend` 的时钟一致性:`CandidateScoringEngineService.java:78` 使用 `LocalDate.now()` 而非注入的 `clock`(预存在问题,非本轮引入)。 |
|||
|
|||
### 整改后复验要求 |
|||
|
|||
完成 R1–R7 后,重新执行:`mvn clean verify`(须 ≥2131 且 S=0)、静态与运行时 OpenAPI 176/214、以及本报告 §7/§9/§16 三项真实 HTTP 复核(GET 零写、写侧链路、申诉取证)。R1/R2 需各自提供「事件 → 任务 → COMPLETED → 快照」的真实 MySQL 证据。 |
|||
|
|||
--- |
|||
|
|||
## 附:本轮验收产出物(`docs/acceptance/r01-d4-2-20260916/`) |
|||
|
|||
| 文件 | 说明 | |
|||
|---|---| |
|||
| `acc_lib.py` | HTTP/DB/登录/前缀公共库 | |
|||
| `snap.py` | 22 表显式列快照 + 差异比较 + 自检反证 | |
|||
| `p1-provision.py` / `acc-context.json` / `acc-prefix.txt` | 隔离验收数据创建 | |
|||
| `s1-zerowrite.py` / `snap-before-get.json` / `snap-after-get.json` | 三个 GET 零写 | |
|||
| `s2-writepath.py` | 写侧链路 | |
|||
| `s4-consumers.py` / `s4-out.txt` / `snap-after-consumers.json` | 消费者回归 | |
|||
| `logscan.py` | 日志脱敏扫描(含探针) | |
|||
| `cleanup.py` | 精确主键清理 | |
|||
| `start-app.ps1` + `r01d42-*.log/.err.log/.pid/.envdiag.txt` | 实例启动与运行日志 | |
|||
| `q01`–`q13*.sql` | 只读 SQL 证据 | |
|||
| `runtime-openapi.json` | 运行时 OpenAPI | |
|||
| `clean-verify.log` / `batch2.log` | 自动化构建日志 | |
|||
|
|||
> 本报告不含数据库密码、JWT、验证码、完整手机号或邮箱、`.env` 内容、简历原文或真实存储路径;SQL 片段中无真实敏感参数。验收脚本输出中曾一次性打印隔离虚拟号(`p1-provision.py` 首版缺陷),已当场修正脚本,后续输出与报告均不再包含完整号码。 |
|||
@ -0,0 +1,246 @@ |
|||
# R01-D4「GET 评分接口写快照迁移方案设计与证据核查」 |
|||
|
|||
日期:2026-09-16 |
|||
分支:`refactor/r01-structure-performance` |
|||
HEAD:`b56f792 perf: batch conversation unread counts` |
|||
本轮性质:**独立现状核查 + 权威迁移设计**(未实施生产改造) |
|||
|
|||
## 1. Git 基线 |
|||
|
|||
- 分支 `refactor/r01-structure-performance`;HEAD `b56f792`;暂存区为空。 |
|||
- 迁移目录无任何改动(V1–V43 原样);`AGENTS.md` 未发现。 |
|||
- 已跟踪工作区存在 1 个仅被 Git 工作树元数据标记为修改的文件(`GlobalExceptionHandler.java`);普通 `git diff` 为空且 blob 与 HEAD 相同。本轮不触碰、不暂存该文件,也不把它计入 R01-D4 交付。 |
|||
- 历史未跟踪文件全部保留;未 commit、push。 |
|||
|
|||
## 2. 当前三个 GET 写库调用链 |
|||
|
|||
| 端点 | 服务方法 | 调用链 | 事务 | 写入 | 修改字段 | |
|||
|---|---|---|---|---|---| |
|||
| `GET /profile/me/candidate-scoring` | `CandidateScoringEngineService#getOverview` | computeAll → `upsertSnapshots` → buildOverview | `@Transactional`(默认 rw) | `candidate_dimension_scores` | score、status、sub_scores_json、weight、rules_version、calculated_at、**version+1**、**updated_at=NOW()** | |
|||
| `GET /profile/me/candidate-scoring/dimensions/{dimension}` | `#getDimensionDetail` | computeAll → filter → `upsertSnapshots`(单维度) | `@Transactional`(rw) | 同上 | 同上(仅该维度行) | |
|||
| `GET /company/candidates/{applicationId}/scoring` | `#getCompanyScoring` | 授权(company/job 归属) → computeAll → `upsertSnapshots` | `@Transactional`(rw) | 同上 | 同上(目标学生 8 行) | |
|||
| `GET /profile/me/candidate-scoring/trend` | `#getTrend` | `snapshotRepository.findTrendFrom` 只读 | `@Transactional(readOnly = true)` | **无** | — | |
|||
|
|||
写库 SQL(`CandidateScoringCalculator#upsertSnapshots`)为原生 `INSERT ... VALUES (...) ON DUPLICATE KEY UPDATE score=VALUES(score), status=..., sub_scores_json=..., weight=..., rules_version=..., calculated_at=..., version=version+1, updated_at=NOW()`。唯一键 `uk_candidate_dim_scores(student_user_id, dimension, period)`(V24 建表、实体 `@UniqueConstraint` 双重声明)。**触发条件为"存在唯一键冲突"即无条件版本自增——即使计算值与上次相同,重复 GET 仍使 version 单调递增、updated_at 改写。** |
|||
|
|||
## 3. 全部快照写入方 |
|||
|
|||
| 写入方 | 入口 | 触发 | 是否用户可重复触发 | |
|||
|---|---|---|---| |
|||
| `CandidateScoringCalculator#upsertSnapshots`(被下三处调用) | — | — | — | |
|||
| —— `CandidateScoringEngineService#getOverview` | `GET /profile/me/candidate-scoring` | **GET 读取请求** | 是(任何学生重复刷新) | |
|||
| —— `CandidateScoringEngineService#getDimensionDetail` | `GET .../dimensions/{dimension}` | **GET 读取请求** | 是 | |
|||
| —— `CandidateScoringEngineService#getCompanyScoring` | `GET /company/candidates/{id}/scoring` | **GET 读取请求** | 是(企业每次查看) | |
|||
| `CandidateScoringCalculator#recomputeAndSnapshot` | 见下 | — | — | |
|||
| —— `CandidateScoringGovernanceService#decideAppeal`(APPROVE 分支) | `POST /admin/candidate-scoring/appeals/{id}/decision` | 申诉决定 | 否(终态幂等) | |
|||
| —— `CandidateScoringGovernanceService#rollback` | `POST .../appeals/{id}/rollback` | 申诉回滚 | 否(回滚限额+终态) | |
|||
| ——(趋势/排名/看板不写) | — | — | — | |
|||
|
|||
## 4. 全部快照消费者 |
|||
|
|||
| 消费者 | 读取方式 | 依赖字段 | 影响 | |
|||
|---|---|---|---| |
|||
| `getTrend`(趋势) | `findTrendFrom(student, startPeriod)` | period/score/status/rulesVersion | 只读物化快照;**完全依赖快照** | |
|||
| `CandidateScoringGovernanceService#createAppeal` | `findSnapshot` | score(scoreAtAppeal)、**version(snapshotVersionAtAppeal)** | 申诉证据基于快照版本 | |
|||
| `decideAppeal/rollback` | `recomputeAndSnapshot` + `snapshotHistoryRepository.save` | before/after 分数与版本 | 申诉决定/回滚重算写新版本 | |
|||
| `CandidateScoringSnapshotHistory`(V25 表) | 仅由申诉决定/回滚写入,`getSnapshotHistory` 读取 | version/rulesVersion | 变更审计 | |
|||
| `CompetitionRankingCalculator.loadLatestSnapshots` | `findUpToPeriod(students, asOf)` | 最新 period 的 8 维分数 | 竞岗排名用**物化快照**(非实时计算) | |
|||
| `CompanyScoringDashboardService` | 读取快照聚合 | score/status | 企业看板/百分位(r01 优化为 CandidatePercentileCalculator) | |
|||
| `CareerProgressService` | 读取快照 | 各期分数 | 求职进度 | |
|||
| `FairnessMonitoringRepository` | 原生 SQL 按 period 聚合 candidate_dimension_scores + snapshot_history+appeal | score/status/样本数 | 公平性监控(当前快照 + 历史申诉双源) | |
|||
| `PersonalDataCollector` | `findByStudentUserId` | 全字段 | GDPR 数据导出 | |
|||
|
|||
## 5. 真实/集成副作用证据 |
|||
|
|||
### 5.1 集成证据(H2,本轮新增独立现状证据测试,见 §17) |
|||
`R01D4GetWritesSnapshotEvidenceTest` 结果 **5/5 全绿**(未依赖 Docker): |
|||
- 首调 `getOverview`:`candidate_dimension_scores` 新增 **8 行**;重复调用行数保持 8、**version 全部 +1**; |
|||
- 单维度 GET:行数不变、version 继续递增; |
|||
- trend GET:前后行数/version 完全一致(只读确认); |
|||
- 非法 dimension:校验先于 upsert,**零写入**; |
|||
- GET 评分不写 `audit_logs`、`notifications`。 |
|||
|
|||
证据边界:该类直接调用 Service,并非真实 HTTP/SecurityFilterChain 测试;它证明 overview、dimension、trend 的持久化行为,但**没有覆盖企业评分 GET、无权限、资源不存在、HTTP 响应或 RabbitMQ**。这些项目不得依据该 5/5 结果标为已验证,必须在实施验收中补齐。 |
|||
|
|||
### 5.2 真实 MySQL 证据 |
|||
**BLOCKED**:Docker Engine 本轮不可用(`docker version` 无法连接 Engine),按任务书纪律**不伪造通过**。已在 5.1 提供同级 H2 集成证据;真实 MySQL 行级快照(首/二次 GET 的行数、version、updated_at、返回 JSON 是否变化、错误路径零写、audit/notification/RabbitMQ 无副作用)由 D4-2 阶段实施前用真实 MySQL 补充复验(§12 验收清单已含)。 |
|||
|
|||
静态代码可直接确认的行为(无需运行):`upsertSnapshots` 的 `ON DUPLICATE KEY UPDATE ... version=version+1, updated_at=NOW()` 保证重复 GET 必使 version 单调递增、updated_at 刷新;趋势/申诉/监控/看板/排名全部消费该物化快照。 |
|||
|
|||
## 6. 首次与重复 GET 的行数/version/updated_at |
|||
|
|||
| 场景 | 行数 | version | updated_at | 返回 JSON | |
|||
|---|---|---|---|---| |
|||
| 首次 GET overview | 0 → 8 | 0(插入初值) | 写入 NOW() | 完整评分卡 | |
|||
| 第二次相同 GET | 8(不变) | **+1(8 行全部)** | **改写为新 NOW()** | 与首次等值(公式确定) | |
|||
| 单维度 GET(已有 8 行) | 8(不变) | 该维度行 +1 | 该行改写 | 明细不变 | |
|||
| trend GET | 不变 | 不变 | 不变 | 由物化快照导出 | |
|||
| 非法 dimension | 不变 | 不变 | 不变 | Service 抛业务异常(H2 已验证) | |
|||
| 无权限 / 资源不存在 | 静态代码预期不变 | 静态代码预期不变 | 静态代码预期不变 | **本轮未做真实 HTTP 证据** | |
|||
|
|||
结论:**"读取放大写入"确凿**——GET 对每次读取强制物化并推进版本,自增 version 不代表业务状态迁移,period 快照语义是"每次读取覆盖值"而非"首次值/最新值"。 |
|||
|
|||
## 7. trend 的现有依赖 |
|||
|
|||
`getTrend` 只读 `candidate_dimension_scores`(`findTrendFrom`,period>=start 升序)。它**不**调用 computeAll/upsert,因此**完全依赖**由其他路径(当前主要是三个 GET + 申诉重算)持续写入的快照。若直接删除 GET upsert 而未建立写侧物化,趋势将冻结在最后一次写入时刻。 |
|||
|
|||
## 8. 申诉、回滚与公平性监控依赖 |
|||
|
|||
- 申诉创建:`findSnapshot(student, dimension, period)` 读取当前行,固化 `scoreAtAppeal` 与 `snapshotVersionAtAppeal`(写入 `candidate_scoring_appeals`)。若快照过期,申诉取证基于旧版本。 |
|||
- 申诉决定(APPROVE):`recomputeAndSnapshot` 重算写新版本 + 写入 `candidate_scoring_snapshot_history`(before/after score、version、rules_version)。 |
|||
- 回滚:同决定路径,另扣 `candidate_scoring_rollback_limits`。 |
|||
- 公平性监控:`FairnessMonitoringRepository` 两条原生聚合:`candidate_dimension_scores WHERE period=?` 按学生分组(overview/dimensions 用),以及 `candidate_scoring_snapshot_history JOIN appeal`(appeals 用)——**当前快照 + 历史申诉双源**。 |
|||
- 申诉期间快照若被外部 GET 再次覆盖(version 再 +1),`snapshotVersionAtAppeal` 不再等于当前版本——审计可辨,但"申诉取证与当前快照差 version"是现状语义的一部分,迁移时不得破坏。 |
|||
|
|||
## 9. 写侧事件矩阵(按计算器真实数据源) |
|||
|
|||
| 评分输入变化 | 计算器读取证据 | 推荐刷新入口 | 处理方式 | 备注 | |
|||
|---|---|---|---|---|---| |
|||
| 学生资料更新 | `StudentProfileRepository`:学历、学校、技能及资料完整度 | `ProfileService#updateProfile` 成功提交后 | 标记学生快照为待刷新 | 仅实际影响字段变化时触发 | |
|||
| 多版本简历变化 | `resume_versions` ACTIVE 数量与 MAX(updated_at) | `ResumeVersionService#createVersion/updateVersion/deleteVersion` 成功提交后 | 标记待刷新 | `setDefault` 是否影响评分需按 SQL 判断;当前 SQL 不读取默认标记,不应无条件触发 | |
|||
| 求职偏好更新 | `StudentPreferenceRepository` | `PreferenceService#savePreferences` 成功提交后 | 标记待刷新 | 影响 COMPLIANCE | |
|||
| 能力测评结果完成 | `AbilityAssessmentScoreSource#findLatestValidCompletedResult` | `AbilityAttemptService#submit` 成功提交后 | 标记待刷新 | `saveAnswers` 尚未形成完成结果,不应刷新 | |
|||
| 投递创建/撤回 | applications 数量、WITHDRAWN 数、近 90 天数量 | `ApplicationService#apply/withdraw` 成功提交后 | 标记待刷新 | 企业侧状态更新只有在实际改变计算器读取字段时才触发;当前聚合只区分 WITHDRAWN | |
|||
| 登录及审计活动 | audit_logs 近 30 天数量与 LOGIN 数 | 统一审计写入成功后或补偿扫描 | 优先脏标记/补偿 | 入口很多,不能逐 Controller 机械接入 | |
|||
| Offer 状态变化 | offers 中 ACCEPTED/EXPIRED 数量 | Offer 状态机成功提交后 | 标记待刷新 | 原报告遗漏 | |
|||
| 候选人反馈变化 | candidate_feedback 数量与 AVG(rating) | 候选人反馈创建/修改的真实写入口 | 标记待刷新 | 不是 `JobReviewService`;原报告混淆了两类评价 | |
|||
| 学生发送消息 | messages.sender_user_id 数量 | 消息发送成功提交后 | 标记待刷新 | 撤回不改变总消息数,按现公式无需刷新 | |
|||
| 账户创建时间 | users.created_at | 无日常写入口 | 初次物化/补偿扫描 | 时间本身不变 | |
|||
| 申诉决定/回滚 | 已有 `GovernanceService#decideAppeal/rollback` | 保持现有合法写侧 | 同事务 | 不与普通刷新事件混用历史审计语义 | |
|||
| rulesVersion 或时间窗口跨界 | rulesVersion、90/30 日窗口、能力结果有效期 | 发布批次及定时补偿扫描 | 批处理 | 即使没有业务写事件,分数也会随时间变化,因此仅靠事件触发不完备 | |
|||
|
|||
明确排除:当前 `computeAll` **不读取 RIASEC 结果、面试评分或岗位评价**,不得为这三类入口增加刷新;传统 `ProfileService#uploadResume` 的文件字段也不是 `queryResume` 的数据源,除非同时改变资料完整度实际依赖字段,否则不应触发。 |
|||
|
|||
现状结论:除申诉决定/回滚外,真实输入源缺少统一物化机制;同时存在时间窗口自然变化,因此“把若干 Service 同步调用 refresh”仍不能证明覆盖 100%。 |
|||
|
|||
## 10. 三个迁移选项比较 |
|||
|
|||
| 维度 | 方案 A:同步写侧触发 | 方案 B:事务提交后异步刷新 | 方案 C:脏标记+定时物化 | |
|||
|---|---|---|---| |
|||
| 一致性 | 强(同一事务) | 最终一致(短窗口) | 最终一致(更长窗口) | |
|||
| 事务耦合 | 高(评分写并进业务事务) | 中(分离;核心事务不受失败连累) | 低 | |
|||
| 失败恢复 | 回滚即重来,无独立补偿 | 需重试/死信 + 补偿扫描 | 靠定时器重扫,天然补偿 | |
|||
| 幂等性 | 唯一键天然幂等(version 语义需调整) | 需幂等键 + 去重 | 需幂等键 + 去重 | |
|||
| 并发 | 数据库条件更新兜底 | 需"旧事件不得覆盖新事件"防护 | 需锁/版本比对 | |
|||
| 实施范围 | 小(线程内调用)但侵入全部写方法 | 中(需要可靠发布/订阅或 MQ) | 中(脏标记 + 调度器) | |
|||
| 对现有 RabbitMQ 依赖 | 无 | **强依赖**(需事务后发布、重试、去重、死信) | 无(可走内置调度) | |
|||
| 对趋势/申诉/监控影响 | 不变 | 短窗口内趋势滞后,申诉取证可能取旧版本 | 更明显滞后 | |
|||
| 发布/回滚难度 | 低 | 中(MQ 基建变化) | 中(调度器+补偿) | |
|||
| 是否需要 Flyway | 否 | 否(建议无) | 否(可选脏标记列则需迁移) | |
|||
| 是否新增公网接口 | 否 | 否 | 否 | |
|||
|
|||
## 11. 唯一推荐方案 |
|||
|
|||
**分两阶段,先收敛写入口并建立可靠的“待刷新”机制,后让 GET 停写。唯一推荐为方案 C(持久化脏标记/刷新任务 + 定时物化),写侧提交后只登记待刷新任务;物化 Worker 复用唯一计算器和快照 Writer。** |
|||
|
|||
理由: |
|||
1. 不能把评分刷新放入资料、投递、消息等核心业务事务;评分失败不应回滚这些业务,原报告在“同事务”和“失败不影响业务”之间自相矛盾。 |
|||
2. 当前 RabbitMQ 没有已验证的事务消息、持久去重和死信恢复链路,不能直接依赖纯异步 MQ。 |
|||
3. 评分包含 90/30 日窗口与能力结果有效期,仅靠业务事件不完备,必须有定时补偿。 |
|||
4. 幂等键若不持久化就无法跨进程、重启和重试去重;因此原报告“无表结构变更但用 sourceEventKey 去重”不可实现。 |
|||
5. 持久任务允许业务事务只做一次轻量 upsert 标记,Worker 失败可重试,不阻塞核心业务;GET 兜底在迁移期保留。 |
|||
|
|||
## 12. 分阶段实施顺序 |
|||
|
|||
**D4-1(可靠物化基础设施,GET 兜底保留)** |
|||
1. 新增唯一 `CandidateScoringSnapshotWriter`,集中快照差异比较、条件更新和版本规则;申诉专用 history 语义仍由 Governance 管理。 |
|||
2. 新增持久化刷新任务/脏标记(需要新的 Flyway,不能再声称“无需迁移”)。唯一键至少覆盖 student + target period;任务记录 requested_at、attempts、next_attempt_at、processed_at/version,不保存敏感评分输入。 |
|||
3. §9 的真实输入写侧仅登记任务,不在线程内计算八维评分;时间窗口和 rulesVersion 由定时扫描登记任务。 |
|||
4. Worker 在独立事务中领取任务、计算并写快照;失败可重试,成功后原子完成;不得依赖 JVM 锁。 |
|||
5. 保持 GET upsert 作为迁移兜底,完成真实 MySQL 并发、重试和覆盖证据。 |
|||
|
|||
**D4-2(GET 停写)** |
|||
1. 三个 GET 改为 `@Transactional(readOnly = true)`,删除 upsert 调用(computeAll 保留用于实时计算响应字段,与物化无关)。 |
|||
2. 开启补偿扫描,对时间窗口、rulesVersion 变化以及遗漏任务进行修复;与持久任务幂等协作。 |
|||
3. 真实 MySQL 验收:GET 重复请求前后 `candidate_dimension_scores` 完整不变、无 audit/notification/RabbitMQ 副作用、趋势/申诉/监控/排名表现与 D4-1 一致。 |
|||
4. 达标后方可切换/废弃 GET 兜底。 |
|||
|
|||
**阶段切换网关(未满足不得进入 D4-2)**:真实数据源覆盖完整、相同任务重放不造成无效 version 增长、并发领取恰好一次有效物化、失败可重试且不影响原业务提交、时间窗口可由补偿扫描推进、GET 停写后的趋势/申诉/排名/监控真实 MySQL 回归通过。 |
|||
|
|||
## 13. 并发与幂等设计 |
|||
|
|||
- **任务幂等键**:持久任务按 `(studentUserId, targetPeriod)` 合并;多次输入变化只需保证至少一次最新状态重算。rulesVersion 是计算结果属性,不用虚构的内存 `sourceEventKey` 充当持久去重。 |
|||
- **快照唯一性**:继续使用 `uk_candidate_dim_scores(student_user_id, dimension, period)`。 |
|||
- **无效重放**:Writer 比较 score、status、sub_scores_json、weight、rules_version 等业务值;完全相同则不更新 version/updated_at。`calculated_at` 是计算时间,不能单独作为“业务变化”依据,否则每次重算仍会制造版本。 |
|||
- **并发/旧任务**:任务不是携带完整旧数据的事件,而是“请按数据库当前状态重算”的合并信号,因此晚到任务只会重新读取最新状态,不会用旧载荷覆盖新快照。 |
|||
- **rulesVersion 变化强制刷新**:`rules_version` 参与唯一比较,升级批次以新版本全量重算。 |
|||
- **两个输入变化并发**:数据库任务唯一键合并;Worker 使用条件领取/乐观锁或 `SELECT ... FOR UPDATE SKIP LOCKED`,禁止 JVM 本地锁;必要时完成后检测 requested_at 是否在处理期间推进,若推进则保留待处理状态再算一次。 |
|||
- **失败重试**:普通输入变化只登记持久任务,实际评分由 Worker 独立事务完成;失败更新重试状态并由补偿扫描兜底。重试不得重复写 `candidate_scoring_snapshot_history`/audit(该表只在申诉决定/回滚写,普通物化不写它)。 |
|||
- **申诉期间快照变化**:`scoreAtAppeal/snapshotVersionAtAppeal` 保留申诉创建时刻值;写侧刷新不得改写申诉取证字段;决定/回滚的重算仍走 `recomputeAndSnapshot`(其版本语义与写侧事件在 `calculated_at/rules_version` 条件上一致,回滚目标版本 = beforeVersion+1 的既有确定性逻辑需在 D4-2 复核不冲突)。 |
|||
- **回滚后的新输入事件**:允许再次计算——回滚后新事件刷新按正常路径写入新版本,`snapshot_history` 只记录申诉动作,不记录普通事件。 |
|||
|
|||
## 14. 失败恢复策略 |
|||
|
|||
- 写侧业务事务只 upsert 持久刷新任务;任务登记失败应随该业务事务回滚,避免提交了源数据却永远没有刷新信号。评分计算不在核心业务事务中执行。 |
|||
- Worker 独立事务执行,失败记录 attempts/next_attempt_at 并指数退避;超过阈值保留 FAILED/PENDING 状态供告警和补偿,不伪装成功。 |
|||
- 定时补偿根据活跃用户、时间窗口与 rulesVersion 登记任务,不直接绕过 Writer 写快照。 |
|||
- 最终一致性:允许短暂滞后(分钟级),趋势/监控为展示性质;申诉取证在写侧事件刷新后基于新版本,行为与现状一致(现状也是"最新一次写入者胜")。 |
|||
|
|||
## 15. 数据迁移与兼容策略 |
|||
|
|||
- `candidate_dimension_scores` 本身优先保持结构不变;D4-1 需要新增持久刷新任务表及唯一/领取索引,因此必须新增下一号 Flyway(不得修改 V1–V43)。具体表设计在实施前独立评审。 |
|||
- 现有存量数据:保留;D4-2 上线前对活跃学生做一次全量 `upsert` 对齐(事件覆盖前不删 GET 兜底,因此存量自然收敛)。 |
|||
- version 语义从"每次读取递增"收敛为"每次**有效变更**递增 + rulesVersion 升级递增";历史 version 不重写。 |
|||
- 趋势/申诉/监控/排名/导出读取代码零改动。 |
|||
|
|||
## 16. 下一轮(D4)精确修改文件清单(预估,实施前以代码现状复核) |
|||
|
|||
- 新增:`candidatescore/CandidateScoringSnapshotWriter.java`、刷新任务实体/Repository/Worker/调度器,以及下一号 Flyway(具体命名先核对迁移目录最大版本)。 |
|||
- 修改(仅写侧事件化,不改变业务语义): |
|||
- `profile/ProfileService.java`、`resume/ResumeVersionService.java`、`preference/PreferenceService.java` |
|||
- `ability/AbilityAttemptService.java`、`application/ApplicationService.java` |
|||
- Offer、候选人反馈、消息与审计的真实写入 Service(实施前精确定位) |
|||
- `candidatescore/CandidateScoringCalculator.java`(upsert 委托 + 条件 version 递增) |
|||
- **D4-2 才改**:`candidatescore/CandidateScoringEngineService.java`(三 GET 改为 readOnly 并移除 upsert 调用)。 |
|||
- 文档:`docs/backend-development-roadmap.md`(本报告结论)、`docs/candidate-scoring-api.md`(若契约文字涉及写语义)。 |
|||
- **允许且预计需要新增**一个 Flyway、刷新任务表及必要索引;仍不新增 ErrorCode 和公网接口。 |
|||
|
|||
## 17. D4 必须先写的失败测试(D4 实施轮的第一批测试,用于驱动实现) |
|||
|
|||
1. `GET 重复请求前后 candidate_dimension_scores 完整内容不变`(行数/score/status/version/created_at/updated_at 全等)。 |
|||
2. 三个 GET 不写 audit、notification、RabbitMQ(消息计数前置后置相等)。 |
|||
3. GET 返回 JSON 与基线一致(黄金 JSON 对比)。 |
|||
4. §9 每个真实输入源变化都会登记或合并一条待刷新任务;明确排除的非输入事件不得登记任务。 |
|||
5. 同一学生/period 的重复登记合并;完全相同的计算结果不增加快照 version。 |
|||
6. 并发登记后最终至少执行一次基于数据库最新状态的重算,任务不丢失且快照行不损坏。 |
|||
7. 刷新失败可重试、不写重复历史/审计、不影响原业务事务。 |
|||
8. rulesVersion 升级触发全量重算(version 递增)。 |
|||
9. 申诉创建取证字段(scoreAtAppeal/snapshotVersionAtAppeal)在写侧刷新后仍正确。 |
|||
10. 越权/非法 dimension/资源不存在零写入。 |
|||
11. 真实 MySQL 行级副作用快照 + RabbitMQ ready/unacked 归零。 |
|||
12. OpenAPI 176 paths / 214 operations 保持;不新增公网刷新接口。 |
|||
> 现有 `CandidateScoringPersistenceIntegrationTest#upsertSnapshots_shouldBeIdempotentAndIncrementVersion` 的断言(重复 upsert version+1)在 D4-2 后语义变为"仅有效变更 +1",届时按新契约更新该既有断言(属于既定契约变更的测试演进,非草率改断言)。 |
|||
|
|||
## 18. 风险与回滚方案 |
|||
|
|||
| 风险 | 影响 | 缓解/回滚 | |
|||
|---|---|---| |
|||
| 写侧事件覆盖不全 → 某学生快照停滞 | 趋势/排名/监控滞后 | D4-1 保留 GET 兜底期间逐步补覆盖;D4-2 依赖补偿扫描兜底;若需开关必须在实施中明确新增并测试,不能假定现有开关存在 | |
|||
| 申诉取证版本语义漂移 | 申诉对比失真 | 事件刷新不触碰取证字段;D4-2 前在真实 MySQL 对申诉全链路回归 | |
|||
| 异步刷新丢失 | 快照缺失 | 事务后发布 + 死信 + 补偿扫描(幂等键避免重复写) | |
|||
| version 语义变化被下游误读 | 审计/导出版本含义差异 | 文档化;规则升级仍强制递增 | |
|||
| 回滚难度 | — | D4-1 可停止任务登记和 Worker,但新增表由 Flyway 保留不用;D4-2 可恢复 GET upsert。不得回滚或删除已执行迁移 | |
|||
|
|||
## 19. 自动化实际结果(本轮) |
|||
|
|||
| 命令 | 结果 | |
|||
|---|---| |
|||
| `mvn -Dtest="R01D4GetWritesSnapshotEvidenceTest" test`(新增现状证据) | **5/5**,BUILD SUCCESS | |
|||
| `mvn -Dtest="CandidateScoringEngineServiceTest,CandidateScoringPersistenceIntegrationTest,*CandidateScoring*Test,*ScoringMonitoring*Test" test` | **134/134**,F=0 E=0 S=0,BUILD SUCCESS | |
|||
| `mvn clean verify` | **2109/2109**(≥2104 预期),Failures=0、Errors=0、**Skipped=20**,BUILD SUCCESS | |
|||
| `python docs\verify_openapi.py` | **176 paths / 214 operations,All checks passed** | |
|||
|
|||
Skipped=20 逐项说明:全部来自 Q01 既有的 `JobSearchQueryExplainMySqlIntegrationTest` 与 `JobSearchQuerySideEffectMySqlIntegrationTest`(`@Testcontainers(disabledWithoutDocker = true)`,分别 15/5 例)。Docker Engine 本轮不可用 → Testcontainers 显式约定跳过,为既有机制而非本轮核心核查,且非"把环境缺失伪装为通过";R01-D4 核心副作用证据由 H2 集成测试 5/5 提供,真实 MySQL 证据单独标注 BLOCKED(§5.2)。 |
|||
|
|||
## 20. Git 状态与边界声明 |
|||
|
|||
- 未修改任何生产代码;未修改既有测试断言;未修改 V1–V43;未新增 Flyway/表/索引/ErrorCode/公网接口。 |
|||
- 新增(工作区未提交):`docs/r01-d4-get-scoring-snapshot-migration-design.md`、`R01D4GetWritesSnapshotEvidenceTest.java`(现状证据测试,非契约断言)。 |
|||
- 未 commit、push;未进入 R01-D5。历史未跟踪文件未删除未移动;暂存区为空。 |
|||
- 真实 MySQL 副作用证据标注 **BLOCKED(Docker Engine 不可用)**,未伪装通过;H2 集成证据 5/5 已提供。 |
|||
|
|||
## 21. 最终结论 |
|||
|
|||
> **R01-D4 GET评分快照写入迁移方案设计完成,待负责人确认后分阶段实施。** |
|||
|
|||
阻塞/依赖声明:当前 GET 兜底写快照服务多个真实输入域与 6 类消费者(趋势/申诉/监控/排名/看板/导出),且评分会因时间窗口自然变化;因此严禁直接删除 GET upsert。最小可行迁移路径 = D4-1(唯一 Writer + 持久刷新任务 + Worker/补偿扫描 + GET 兜底)→ 真实 MySQL 验证并发、重试与数据源覆盖 → D4-2(GET 转 readOnly)。 |
|||
@ -0,0 +1,352 @@ |
|||
# R01-E 既有批量查询与会话未读数性能实现:验收与测试加固报告 |
|||
|
|||
日期:2026-09-18 |
|||
分支:`refactor/r01-structure-performance` |
|||
HEAD:`b893ed67f078eedb0c4b8a03341311bd79342354`(== upstream) |
|||
本轮性质:**验收与测试加固** —— **生产代码 diff = 0**,未暂存、未提交、未推送 |
|||
|
|||
--- |
|||
|
|||
## 0. 结论 |
|||
|
|||
> **R01-E既有批量查询与会话未读数性能实现已完成验收和测试加固,待独立代码复验。** |
|||
|
|||
**观察项(本轮不改变既有语义)**: |
|||
|
|||
> **OBS-E01**:当前**本人发送且没有 `message_reads` 记录**的消息也计入**本人**未读数, |
|||
> 待产品负责人单独确认;本轮不改变既有语义。 |
|||
|
|||
**库存前置结论**:E1 与 E2 在 HEAD 上**均已处于目标终态**(分别由 R01-D2、R01-D3 交付), |
|||
故本轮**未修改任何生产代码**,改为以**查询预算特征测试 + 跨库一致性 + EXPLAIN + 副作用快照**做验收加固。 |
|||
|
|||
--- |
|||
|
|||
## 1. PersonalDataCollector 修复前调用链(E1) |
|||
|
|||
**调用链**:`PersonalDataExportWorker.run` → `PersonalDataCollector.collect(userId)` → 12 个 section 构建方法 → `PersonalDataExportBuilder.build(...)` → 落盘。 |
|||
|
|||
**查询形态(逐处核对)**:全部为**批量**读取 —— `findByStudentUserIdOrderByCreatedAtDesc` / |
|||
`findByUserId` / `findAllById` / `...In...OrderBy...`,且批量历史查询 |
|||
(`ApplicationStatusHistory` / `InterviewStatusHistory` / `OfferStatusHistory` / `AppealHistory`) |
|||
均使用 `findByXxxIdInOrderByXxxIdAscCreatedAtAsc(...)`。 |
|||
|
|||
**循环内是否再查**:**否**。11 处 `forEach`(行 169/199/241/290/349/368/382/409/420/440/460/472) |
|||
只消费**预先批量取回**的数据与 `groupBy(...)` 结果做内存组装,循环体内**零 Repository 调用**。 |
|||
|
|||
**N 增长公式**:不受记录条数影响(见 §4)。 |
|||
|
|||
--- |
|||
|
|||
## 2. 会话未读数修复前调用链(E2) |
|||
|
|||
**调用链**:`MessagingController` → `MessagingService.listConversations(userId)`: |
|||
1. `memberRepository.findByUserId(userId)`(1 条) |
|||
2. 空则 `return List.of()` —— **不执行 `IN ()`** |
|||
3. `conversationRepository.findAllById(convIds)`(1 条) |
|||
4. `messageRepository.countUnreadByConversationIds(convIds, userId)`(**1 条分组查询**) |
|||
5. 内存排序(`updatedAt` 降序)后 `getOrDefault(cid, 0L)` 补 0 |
|||
|
|||
`countUnread(convId, userId)`(私有)**仅被 `getConversation`(单会话详情)调用**; |
|||
会话列表路径**不再逐会话 count**。 |
|||
|
|||
--- |
|||
|
|||
## 3. 修复前后查询次数公式 |
|||
|
|||
| 场景 | 修复前(R01-D2/D3 之前) | 当前(HEAD) | |
|||
|---|---|---| |
|||
| E1 导出 | `O(N)`:每类数据在循环内按 ID 再查 | **`O(1)` 常数**(每个数据类别固定 1 条批量查询) | |
|||
| E2 会话列表 | `O(K)`:每会话 1 次 `countUnread` | **`O(1)` 常数 3 条**(成员、会话、分组未读各 1 条) | |
|||
|
|||
--- |
|||
|
|||
## 4. 1 / 10 / 50 数据规模下 E1 SQL 数(实测) |
|||
|
|||
`R01EQueryBudgetAcceptanceTest.personalDataCollectQueryCountIsFlat`, |
|||
经 `CountingDataSource` 统计**真实 JDBC 语句**(`prepareStatement`/`createStatement`): |
|||
|
|||
| 投递记录数 | 实测 SQL 条数 | |
|||
|---|---| |
|||
| **1** | **20** | |
|||
| **10** | **20** | |
|||
| **50** | **20** | |
|||
|
|||
**恒定 20 条**,与记录数**完全无关**(每次 `mvn` 为全新 JVM + 全新 H2 库;断言为精确相等)。 |
|||
|
|||
--- |
|||
|
|||
## 5. 1 / 20 / 100 会话规模下 E2 SQL 数(实测) |
|||
|
|||
`R01EQueryBudgetAcceptanceTest.unreadQueryCountIsFlat`: |
|||
|
|||
| 会话数 | 实测 SQL 条数 | 每会话未读 | |
|||
|---|---|---| |
|||
| **1** | **3** | 1 | |
|||
| **20** | **3** | 1(逐会话校验全部为 1) | |
|||
| **100** | **3** | 1(逐会话校验全部为 1) | |
|||
|
|||
**恒定 3 条**,不随会话数增长;三种规模下**逐会话 `unreadCount` 值全部正确**。 |
|||
|
|||
--- |
|||
|
|||
## 6. 批量 Repository / 投影契约 |
|||
|
|||
| 组件 | 契约 | |
|||
|---|---| |
|||
| E1 | 既有 `...In...` 批量方法 + `groupBy(values, idExtractor)` 内存分组;`Map<Long, List<T>>` 强类型键 | |
|||
| E2 | `MessageRepository.countUnreadByConversationIds(List<Long> conversationIds, Long readerUserId)` → `List<ConversationUnreadCount>` 投影(`conversationId` / `unreadCount`);**原生查询、全参数化**,无字符串拼接用户可控 ID | |
|||
|
|||
--- |
|||
|
|||
## 7. 未读数 WHERE / GROUP BY 的准确语义(逐字取自原生查询) |
|||
|
|||
```sql |
|||
SELECT ranked.conversation_id AS conversationId, COUNT(*) AS unreadCount |
|||
FROM ( SELECT m.id, m.conversation_id, m.status, |
|||
ROW_NUMBER() OVER (PARTITION BY m.conversation_id ORDER BY m.created_at DESC) AS row_no |
|||
FROM messages m |
|||
WHERE m.conversation_id IN (:conversationIds) ) ranked |
|||
LEFT JOIN message_reads mr ON mr.message_id = ranked.id AND mr.reader_user_id = :readerUserId |
|||
WHERE ranked.row_no <= 500 |
|||
AND (ranked.status IS NULL OR ranked.status <> 'RECALLED') |
|||
AND mr.id IS NULL |
|||
GROUP BY ranked.conversation_id |
|||
``` |
|||
|
|||
| 语义点 | 事实 | |
|||
|---|---| |
|||
| 可见范围 | 由**会话成员关系**界定(调用方先取 `findByUserId` 得到 `convIds`),**非**由用户传入 ID 决定 | |
|||
| 会话页之外 | **不查询**(只传当前用户的会话 id 列表) | |
|||
| 撤回消息 | **排除**(`status <> 'RECALLED'`) | |
|||
| 已读消息 | **排除**(`message_reads` 存在该读者的记录) | |
|||
| 窗口上限 | 每会话**最近 500 条**(`row_no <= 500`) | |
|||
| 发送者过滤 | **无** —— 见 **OBS-E01** | |
|||
| 未返回的会话 | Service 侧 `getOrDefault(cid, 0L)` 补 0 | |
|||
|
|||
**OBS-E01 的实测证据**:同一会话内「本人 1 条 + 对方 2 条、均未读」时 |
|||
`unreadCount = 3`(最初按"他人发送才计"预期的 2 被实测纠正)。 |
|||
本轮**如实锁定既有语义**,未做任何行为改变。 |
|||
|
|||
--- |
|||
|
|||
## 8. H2 / MySQL 逐值一致结果 |
|||
|
|||
`R01EMySqlQueryCountAndExplainTest`(Testcontainers `mysql:8.4`,**4/4 通过**,耗时 34.94s → 容器真实启动): |
|||
|
|||
| 用例 | 结论 | |
|||
|---|---| |
|||
| `groupedUnreadMatchesPerConversationSemantics` | MySQL 上分组 SQL 与 Service 路径**逐值一致**;`c1`: 3 条 SENT − 1 条 RECALLED − 1 条已读 = **1**;`c2`: **1** | |
|||
| `emptyAndForeignConversations` | 空 id 列表与不含该会话的 id 列表**均不返回** | |
|||
| `conversationScaleIsFlatAndCorrect` | 1 / 20 / 100 会话下 Service 结果条数与逐会话 `unreadCount`(各 3 条未读 = **3**)**全部正确** | |
|||
| `explainUnreadQuery` | 见 §9 | |
|||
|
|||
**H2 与 MySQL 一致性**:同一组夹具语义(撤回排除、已读排除、多会话聚合、缺失补 0)在两侧 |
|||
得到**相同数值**;H2 侧见 §4/§5/§13,MySQL 侧见上表。 |
|||
|
|||
--- |
|||
|
|||
## 9. MySQL EXPLAIN 摘要 |
|||
|
|||
对**与 `countUnreadByConversationIds` 逐字相同**的 SQL、同一批绑定参数执行 |
|||
`EXPLAIN FORMAT=JSON`(MySQL 8.4;提交前收口测试增加 10,000 条非目标背景消息, |
|||
使 1 个目标会话 × 200 条消息保持高选择性): |
|||
|
|||
| 项 | 值 | |
|||
|---|---| |
|||
| JSON 长度 | 3898 字符 | |
|||
| `using_temporary_table` | **true**(位于 `grouping_operation` 层,为 `GROUP BY conversation_id` 的固有代价) | |
|||
| `using_filesort` | 提交前实施夹具(1 会话 × 200 条、10,000 条背景)为 **false**;独立复验的 1/20/100/600 四档真实规模均为 **true** | |
|||
| 派生表 `ranked` | `access_type = ALL`,即对物化后的派生结果顺序扫描;不能表述成“没有全表扫描” | |
|||
| 基础表 `messages`(别名 `m`) | `access_type = ref/range`,命中 `idx_messages_conversation_created`,不是 `ALL` | |
|||
| 基础表 `message_reads`(别名 `mr`) | `access_type = eq_ref`,命中 `uk_message_reads` | |
|||
| 其它字段 | `possible_keys` / `key` / `cost_info` / `rows_examined_per_scan` 均已出现;摘要落盘 `target/r01e-mysql-explain.txt` | |
|||
|
|||
**判读**:分组所需的临时表及窗口函数排序属于既有 SQL 的固有代价;最小实施夹具与 |
|||
真实规模对 `using_filesort` 的代价选择不同,而独立复验四档真实规模均为 |
|||
`using_temporary_table=true`、`using_filesort=true`。`ranked` 的 `ALL` 是派生结果顺序扫描; |
|||
必须与基础表区分。基础表 `messages` 在高选择性场景命中既有复合索引且不是 `ALL`, |
|||
`message_reads` 以唯一键 `eq_ref` 访问。 |
|||
|
|||
独立复验发现原实施测试使用 |
|||
`doesNotContain("\"table_name\": \"messages\" ...")`,但 MySQL JSON 使用别名 `m`, |
|||
因此该断言不具鉴别力。提交前已改为解析 JSON 树,分别定位唯一的 `m`、`mr`、`ranked` |
|||
节点,并校验访问类型、索引、临时表和 filesort;错误索引、`m=ALL`、缺失 `mr` 三种变异 |
|||
都会使断言失败。 |
|||
|
|||
--- |
|||
|
|||
## 10. 使用到的索引 |
|||
|
|||
`V9__messaging.sql` 中**既有**索引(**本轮未新增任何迁移或索引**): |
|||
|
|||
| 索引 | 表 | 列 | |
|||
|---|---|---| |
|||
| `idx_messages_conversation_created` | `messages` | `(conversation_id, created_at)` | |
|||
| `idx_message_reads_message` | `message_reads` | `(message_id)` | |
|||
| `idx_message_reads_reader` | `message_reads` | `(reader_user_id)` | |
|||
| `idx_conv_members_user` | `conversation_members` | `(user_id)` | |
|||
| `idx_conversations_updated` | `conversations` | `(updated_at)` | |
|||
|
|||
未读查询的 `WHERE m.conversation_id IN (...)` 与 |
|||
`ROW_NUMBER() ... ORDER BY m.created_at DESC` 由 |
|||
`idx_messages_conversation_created (conversation_id, created_at)` 支撑; |
|||
`LEFT JOIN message_reads` 由 `idx_message_reads_message` / `idx_message_reads_reader` 支撑。 |
|||
|
|||
独立复验还确认:当目标会话消息占全表约 5.45% 时仍使用索引,约 13.63% 时优化器会 |
|||
基于代价转为基础表全扫;这是选择性变化,不是索引缺失。窗口排序、派生表物化及高占比 |
|||
场景仍是遗留性能风险,但 EXPLAIN 未证明必须新增索引,故本轮未新增 V46。 |
|||
|
|||
--- |
|||
|
|||
## 11. 行级副作用快照 |
|||
|
|||
`R01EQueryBudgetAcceptanceTest` 对 **11 张表**做稳定字段行级快照(按主键排序)并逐字段比对: |
|||
|
|||
| 表 | 快照列 | |
|||
|---|---| |
|||
| `users` | id, phone, nickname, role, is_active | |
|||
| `user_roles` | user_id, role | |
|||
| `conversations` | id, type, created_by, updated_at | |
|||
| `conversation_members` | conversation_id, user_id | |
|||
| `messages` | id, conversation_id, sender_user_id, status | |
|||
| `message_reads` | id, message_id, reader_user_id | |
|||
| `notifications` | id, user_id, type, title | |
|||
| `audit_logs` | id, user_id, action, entity_type | |
|||
| `auth_tokens` | id, user_id, token_type, revoked | |
|||
| `applications` | id, job_id, student_user_id, status | |
|||
| `job_favorites` | id, job_id, student_user_id | |
|||
|
|||
| 用例 | 结论 | |
|||
|---|---| |
|||
| `listConversationsIsReadOnly` | 会话列表 GET 前后 **11 张表逐字段零变化** ✔ | |
|||
| `personalDataCollectIsReadOnly` | `collector.collect()` 前后 **11 张表逐字段零变化** ✔ | |
|||
|
|||
--- |
|||
|
|||
## 12. RabbitMQ / 通知 / 审计结果 |
|||
|
|||
| 项 | 结论 | |
|||
|---|---| |
|||
| 通知 | `notifications` 行数在 `collect()` 前后**完全相等** ✔ | |
|||
| 审计 | `audit_logs` 行数在 `collect()` 前后**完全相等** ✔;且快照逐字段零变化 ✔ | |
|||
| RabbitMQ | 两条路径(`listConversations`、`collect`)均不发送消息;测试期间无新增 MQ 相关行 ✔ | |
|||
| 日志 | 未新增记录消息内容 / 手机号 / Token / 导出内容的日志语句(生产 diff = 0,无新增日志) | |
|||
|
|||
--- |
|||
|
|||
## 13. 新增测试及变异/反证 |
|||
|
|||
| 测试类 | Tests | 内容 | |
|||
|---|---|---| |
|||
| `common/r01e/R01EQueryBudgetAcceptanceTest` | **11** | E1/E2 查询预算(H2,真实 JDBC 计数)、既有未读语义(含 OBS-E01)、已读/撤回排除、多会话聚合与补 0、跨用户不泄漏、**11 张表行级副作用快照**、通知/审计零新增 | |
|||
| `common/r01e/R01EMySqlQueryCountAndExplainTest` | **4** | MySQL 8 上分组 SQL 与逐会话语义**逐值一致**、空列表与不属用户的 id 不返回、1/20/100 会话规模、`EXPLAIN FORMAT=JSON` | |
|||
|
|||
**反证设计**:查询预算断言为**精确相等**(非"不显著增长")——若任一实现退化为逐条查询, |
|||
`baseline[1] != baseline[0]` 会立即失败;副作用快照为**逐字段相等**,任何写入都会被捕获。 |
|||
测试类通过 `CountingDataSource` 统计**真实 JDBC 语句**,**不是** Mockito verify Repository。 |
|||
|
|||
**本轮自查发现并修复的测试缺陷(诚实记录)**:`R01EQueryBudgetAcceptanceTest` 初版只有 |
|||
`@BeforeEach` 清理、**缺 `@AfterEach`**,退场后遗留 `r01e-*` 用户/公司/会话,导致全量运行中 |
|||
`PersonalDataExportControllerTest`(15)、`CompetitionRankingControllerTest`(13) 等 **60 项失败**。 |
|||
已补 `@AfterEach` 精确清理,复跑 `R01EQueryBudgetAcceptanceTest,PersonalDataExportControllerTest, |
|||
CompetitionRankingControllerTest` → **39/39 通过**。**这是本轮新增测试自身的缺陷,非既有实现问题。** |
|||
|
|||
--- |
|||
|
|||
## 14. 定向、关联、全量和 clean verify 数量 |
|||
|
|||
| 命令 | 结果 | |
|||
|---|---| |
|||
| 关联回归(`*PersonalData*Test,*Export*Test,*Messaging*Test,*Message*Test,*Conversation*Test,*Recall*Test`) | **150 / 150**,F=0 E=0 S=0 ✔ | |
|||
| 性能/验收定向 | 见 §14.1 | |
|||
| `mvn test` | 见 §14.1 | |
|||
| `mvn clean verify` | 见 §14.1 | |
|||
|
|||
### 14.1 全量与 clean verify |
|||
|
|||
| 命令 | Tests | Failures | Errors | Skipped | BUILD | |
|||
|---|---|---|---|---|---| |
|||
| 性能/验收定向(含 MySQL Testcontainers) | **18** | 0 | 0 | **0** | SUCCESS | |
|||
| `mvn test` | **2761** | **0** | **0** | **0** | SUCCESS | |
|||
| `mvn clean verify` | **2761** | **0** | **0** | **0** | SUCCESS | |
|||
|
|||
**2761 = 2746(R01-C 阶段二基线)+ 本轮新增 15**(H2 11 + MySQL 4),与要求「不低于 2746 + 新增数」一致。 |
|||
**Skipped=0** 且 MySQL 用例耗时 34.94s,证明 **Testcontainers 真实执行、未被跳过**。 |
|||
|
|||
**关联回归**:`*PersonalData*Test,*Export*Test,*Messaging*Test,*Message*Test,*Conversation*Test,*Recall*Test` |
|||
→ **150 / 150**,F=0 E=0 S=0 ✔ |
|||
|
|||
--- |
|||
|
|||
## 15. OpenAPI 数量 |
|||
|
|||
``` |
|||
python ..\docs\verify_openapi.py → paths: 176 / operations: 214 / All checks passed! |
|||
``` |
|||
|
|||
`git diff -- docs/openapi-rc.json` → **空** ✔ |
|||
|
|||
--- |
|||
|
|||
## 16. 精确修改文件 |
|||
|
|||
**生产代码:0 个文件。** |
|||
|
|||
**新增测试(2)** |
|||
|
|||
``` |
|||
mydreamjob-server/src/test/java/com/mydreamjob/common/r01e/R01EQueryBudgetAcceptanceTest.java |
|||
mydreamjob-server/src/test/java/com/mydreamjob/common/r01e/R01EMySqlQueryCountAndExplainTest.java |
|||
``` |
|||
|
|||
**文档(1)**:本报告 |
|||
|
|||
--- |
|||
|
|||
## 17. 是否修改迁移、ErrorCode、接口、DTO、依赖 |
|||
|
|||
| 项 | 结果 | |
|||
|---|---| |
|||
| V1–V45 / Flyway | **未修改** ✔ | |
|||
| `ErrorCode` | **未修改** ✔ | |
|||
| 公网接口 / DTO / OpenAPI 契约 | **未修改**(176/214) ✔ | |
|||
| `pom.xml` / 依赖 | **未修改** ✔ | |
|||
| Security / Token / 权限 / requestId | **未修改** ✔ | |
|||
| 评分、排名、推荐、申诉、邀约、Offer、撤回与已读语义 | **未修改** ✔ | |
|||
| 分布式缓存 / 冗余未读列 / 异步最终一致未读数 | **未引入** ✔ | |
|||
|
|||
--- |
|||
|
|||
## 18. Git 状态 |
|||
|
|||
| 项 | 结果 | |
|||
|---|---| |
|||
| 分支 / HEAD | `refactor/r01-structure-performance` / `b893ed67f078eedb0c4b8a03341311bd79342354`(未变) | |
|||
| HEAD 与 upstream | **0 0** ✔ | |
|||
| `git diff --cached --name-only` | **空** ✔ | |
|||
| `git diff --check` | **exit 0** ✔ | |
|||
| 生产代码 diff | **0 个文件** ✔ | |
|||
| V1–V45 / `openapi-rc.json` / `pom.xml` / `ErrorCode` | **均未修改** ✔ | |
|||
| 历史未跟踪文件 | 完整保留 ✔ | |
|||
| 本轮新增(未跟踪) | `docs/r01-e-batch-query-unread-count-performance-report.md`、`mydreamjob-server/src/test/java/com/mydreamjob/common/r01e/`(2 个测试类) | |
|||
|
|||
--- |
|||
|
|||
## 19. 明确未暂存、未提交、未推送 |
|||
|
|||
**确认:未执行 `git add`;未创建提交;未推送。** 未执行 `reset`/`restore`/`checkout`/`clean`。 |
|||
|
|||
--- |
|||
|
|||
## 20. 遗留风险 |
|||
|
|||
| # | 风险 | 说明 | |
|||
|---|---|---| |
|||
| R1 | **OBS-E01 语义** | 本人发送且未读的消息计入本人未读数。当前实现如此,本轮**未改变**;若产品确认应排除,需另立批次(属业务语义变更,不属性能收束) | |
|||
| R2 | 500 条窗口 | 未读统计只看每会话**最近 500 条**;超过 500 条的会话其更早未读不计入。属既有设计,本轮未改动 | |
|||
| R3 | 派生表物化 | MySQL 8 对 `ROW_NUMBER()` 派生表可能物化;`ranked.row_no <= 500` 的过滤在物化后生效,大会话下存在额外扫描成本。EXPLAIN 证据见 §9 | |
|||
| R4 | 测试自污染 | 本轮新增测试的清理为**按前缀精确删除**;若将来有人新增同类夹具而不清理,仍可能污染共享 H2 库(R01-C 已就同类问题建立隔离门禁,本轮范围外) | |
|||
|
|||
--- |
|||
|
|||
> **R01-E既有批量查询与会话未读数性能实现已完成验收和测试加固,待独立代码复验。** |
|||
@ -0,0 +1,412 @@ |
|||
# R01-E 批查询与会话未读数:**独立复验报告**(第三~十一节) |
|||
|
|||
日期:2026-09-18 |
|||
分支:`refactor/r01-structure-performance` |
|||
HEAD / upstream:`b893ed67f078eedb0c4b8a03341311bd79342354`(ahead/behind = **0 / 0**) |
|||
本轮性质:**独立复验** —— 不改生产代码、不改迁移、不暂存、不提交、不推送 |
|||
上游依据:`docs/r01-e-batch-query-unread-count-performance-report.md`(实施与测试加固报告) |
|||
|
|||
--- |
|||
|
|||
## 0. 结论 |
|||
|
|||
> **R01-E独立代码与真实MySQL性能复验通过,可以进入精确暂存与独立提交检查点。** |
|||
|
|||
| 复验项 | 结论 | |
|||
|---|---| |
|||
| 独立查询预算(E1 = 20/20/20,E2 = 3/3/3) | **通过**(H2 与真实 MySQL 双侧实测) | |
|||
| E1 collector 完整输出 H2/MySQL 深比较 | **通过**(逐字段、含 null/时间/集合顺序/关联数据) | |
|||
| E2 会话未读 H2/MySQL 深比较(10 项语义) | **通过** | |
|||
| MySQL `EXPLAIN FORMAT=JSON` | **通过**(基础表命中既有索引,无需新增 V46) | |
|||
| 副作用与隔离(25 张表快照 + 反证 + 零残留) | **通过** | |
|||
| OBS-E01 | **保留既有语义**,作为产品负责人待决策项上报 | |
|||
| 独立复验时全量 `mvn test` / `mvn clean verify` | **2787 / 0 / 0 / 0** | |
|||
| 提交前收口 `mvn clean verify` | **2788 / 0 / 0 / 0**(增加 1 个 EXPLAIN 变异门禁) | |
|||
| OpenAPI | **176 paths / 214 operations**,`openapi-rc.json` 未改 | |
|||
| 生产代码 diff | **0 个文件** | |
|||
|
|||
**独立复验新发现(3 项,均如实记录)**: |
|||
**F1** 实施测试的 EXPLAIN 断言 `doesNotContain("\"table_name\": \"messages\", ...")` 是**空断言**—— |
|||
MySQL 的 EXPLAIN JSON 里 `table_name` 存的是**别名**(本语句为 `m` / `mr`), |
|||
`"table_name": "messages"` 永不出现,故该断言无论如何都会通过。结论本身(当时夹具下确实未全扫)成立, |
|||
但**断言不具备鉴别力**。提交前收口已将实施测试改为解析 JSON 树,按别名分别定位 |
|||
`m` / `mr` / `ranked`,校验 access_type、key、temporary 与 filesort,并以错误索引、 |
|||
基础表 `ALL`、缺失节点三类变异证明断言有效。 |
|||
**F2** H2 与 MySQL 对同一 JSON 列的回读**表示不同**(详见 §4.2),已记录原始值并按语义归一后比较。 |
|||
**F3** 实施报告 §9 记 `using_filesort=false`;本轮在**真实规模夹具**下实测 `using_filesort=true` |
|||
(窗口函数 `ROW_NUMBER()` 的排序代价),详见 §6.4。 |
|||
|
|||
--- |
|||
|
|||
## 1. 复验范围与不重叠声明 |
|||
|
|||
- 从第三~十一节执行,**未重做第一、二节库存**,未修改任何生产代码。 |
|||
- 复用既有文件(**未重复创建**): |
|||
- `mydreamjob-server/src/test/java/com/mydreamjob/common/r01e/R01EQueryBudgetAcceptanceTest.java`(实施测试,11 项) |
|||
- `mydreamjob-server/src/test/java/com/mydreamjob/common/r01e/R01EMySqlQueryCountAndExplainTest.java`(实施测试,4 项) |
|||
- `docs/r01-e-batch-query-unread-count-performance-report.md` |
|||
- 独立测试**不复用**实施测试的任何夹具、断言或辅助方法:夹具、快照(25 张表、列集合不同)、 |
|||
计数 DataSource、EXPLAIN 解析均为独立实现。 |
|||
|
|||
--- |
|||
|
|||
## 2. 本轮新增文件(未跟踪) |
|||
|
|||
``` |
|||
mydreamjob-server/src/test/java/com/mydreamjob/common/r01e/R01EIndependentCrossDatabaseVerificationTest.java 独立测试,16 项 |
|||
mydreamjob-server/src/test/java/com/mydreamjob/common/r01e/R01EIndependentSideEffectIsolationTest.java 独立测试,10 项 |
|||
docs/r01-e-independent-verification-20260918.md 本报告 |
|||
``` |
|||
|
|||
**生产代码:0 个文件**(`git diff --stat` 为空)。 |
|||
|
|||
--- |
|||
|
|||
## 3. 独立查询预算验证(任务 1) |
|||
|
|||
**方法**:独立实现的 `CountingDataSource` 包装真实 `DataSource`,记录**真实 JDBC 语句文本与条数**; |
|||
两侧在**夹具灌入完成之后**才 `reset()`,故计数**不含测试准备与清理 SQL**(断言实测语句全部为 `SELECT`)。 |
|||
|
|||
### 3.1 E1:1 / 10 / 50 条投递 |
|||
|
|||
| 夹具 | H2 实测 | MySQL 实测 | 目标 | |
|||
|---|---|---|---| |
|||
| 与实施报告同基线的最小夹具(2 用户 + 1 公司 + N 岗位 + N 投递) | **[20, 20, 20]** | **[20, 20, 20]** | **20/20/20 ✔** | |
|||
| 本类丰富夹具(12 个 section 全部有数据)+ N 投递 | [22, 22, 22] | [22, 22, 22] | 恒定 ✔ | |
|||
|
|||
- 两者都**不随记录数增长**;与实施报告「20/20/20」的结论**独立复现一致**。 |
|||
- 丰富夹具为 22(比最小夹具多 2):多出的是「本人持有公司 → offers 企业侧分支」以及 |
|||
「存在会话 → conversations/messages 两条批量查询」这两个**按分支固定**的查询,仍与 N 无关。 |
|||
- 证据:`target/r01e-independent/e1-budget.txt`、`e1-budget-rich.txt`。 |
|||
|
|||
### 3.2 E2:1 / 20 / 100 会话 |
|||
|
|||
| 会话数 | H2 实测 | MySQL 实测 | 目标 | |
|||
|---|---|---|---| |
|||
| 1 | **3** | **3** | 3 ✔ | |
|||
| 20 | **3** | **3** | 3 ✔ | |
|||
| 100 | **3** | **3** | 3 ✔ | |
|||
|
|||
- 实测 SQL 文本**恰好为 3 条**,形态固定且可辨认: |
|||
1. `select ... from conversation_members ... where user_id=?` |
|||
2. `select ... from conversations ... where id in (?,...)` |
|||
3. 分组未读查询(含 `ROW_NUMBER()`)——**只出现 1 次** |
|||
- 证据:`target/r01e-independent/e2-budget.txt`(含两侧完整 SQL 文本)。 |
|||
|
|||
### 3.3 空会话页不执行未读查询 |
|||
|
|||
| 断言 | H2 | MySQL | |
|||
|---|---|---| |
|||
| `listConversations(无会话用户)` 返回空 | ✔ | ✔ | |
|||
| 实测语句数 | **1** | **1** | |
|||
| 该语句不含 `ROW_NUMBER` | ✔ | ✔ | |
|||
| 含 `row_number` 的语句数 | **0** | **0** | |
|||
|
|||
即:空会话页只发出一条成员关系查询后短路返回,**不下发未读聚合查询**。 |
|||
|
|||
--- |
|||
|
|||
## 4. E1 H2 / MySQL 深比较(任务 2) |
|||
|
|||
### 4.1 方法 |
|||
|
|||
同一个 JVM 内同时持有两个 Spring 上下文: |
|||
|
|||
- **H2 侧**:测试自身上下文,`jdbc:h2:mem:r01e_ind;MODE=MySQL`(**本类独占**的内存库,Flyway 全量迁移); |
|||
- **MySQL 侧**:`@BeforeAll` 手工引导的第二个上下文(Testcontainers `mysql:8.4`,`mysql-it` 配置, |
|||
`WebApplicationType.NONE`,命令行参数覆盖数据源),Flyway 在容器内真实执行 45 个迁移。 |
|||
|
|||
两侧灌入**同一份等价夹具**(显式时间戳、无 `CURRENT_TIMESTAMP` 依赖),覆盖 **全部 12 个 section**, |
|||
并额外放入一个「外人」用户的同类数据。 |
|||
|
|||
**先决条件已实证**:`bothEnginesAreLiveAndFixtureIdsAgree` 断言两侧 27 个自然键 → 主键映射**完全相等** |
|||
(同一 JVM、两侧均为全新建库),故后续可直接比较含主键的完整输出。 |
|||
|
|||
### 4.2 深比较结果 |
|||
|
|||
`canonicalResult(...)` 把 collector 的**完整 section 输出**规范化为确定性文本:显式呈现 `null`、 |
|||
时间按 `LocalDateTime` 呈现、List 保持原序(集合顺序)、Map 按 key 排序、`BigDecimal` 去尾零。 |
|||
|
|||
``` |
|||
e1-h2.txt == e1-mysql.txt (均 6213 字节,逐字符相同) |
|||
``` |
|||
|
|||
**JSON 列的表示差异(F2,如实记录)**——`e1-raw-json.txt` 原文: |
|||
|
|||
``` |
|||
h2: profile.skills="{\"java\":3,\"sql\":2}" | preferences.preferredCities="[\"北京\",\"上海\"]" |
|||
mysql: profile.skills={"sql": 2, "java": 3} | preferences.preferredCities=["北京", "上海"] |
|||
``` |
|||
|
|||
- **H2(MODE=MySQL)**把 JSON 列回读为**被转义的 JSON 字符串标量**; |
|||
- **MySQL 8**回读为 **JSON 对象字面量**(键重排、冒号后加空格)。 |
|||
- 两者**语义相同、表示不同**:比较的是语义。规范化对确实是 JSON 的字符串递归解码后按键排序, |
|||
非 JSON 字符串原样保留(`content`、`coverLetter` 等不受影响)。 |
|||
|
|||
**反证**:把 dump 中任意一个字段值(`"张三"` → `"张四"`)置换后断言必须不再相等——通过, |
|||
说明规范化是**逐字段敏感**的,而不是只比条数、hash 或长度。 |
|||
|
|||
### 4.3 其他断言 |
|||
|
|||
| 断言 | 结果 | |
|||
|---|---| |
|||
| section 顺序 | 精确等于 `profile, preferences, resumes, applications, application-timelines, interviews, interview-timelines, offers, offer-timelines, feedback, favorites, notifications, conversations, messages, candidate-scoring, scoring-appeals` ✔ | |
|||
| 关联数据 | 应用↔时间线、面试↔时间线、Offer↔时间线、会话↔消息的分组关系随主键一并跨库一致 ✔ | |
|||
| 其他用户数据不得混入 | dump 中不含 `李四 / 外人的简介 / 外人简历 / 外人求职信 / 外人评价 / 外人通知 / 外人会话 / out-m1 / 外人Offer / 外人理由 / 外人面试 / outsider` ✔ | |
|||
| 跨库自有行数 | 23 张表的自有行数两侧完全一致 ✔ | |
|||
| 非 ACTIVE 简历、`read_at` 为 null / 非 null 的通知、`score` 为 null 的维度分、`tags` 为 null 的评价、`cover_letter` 为 null 的投递 | 均已覆盖并通过 ✔ | |
|||
|
|||
--- |
|||
|
|||
## 5. E2 H2 / MySQL 深比较(任务 3) |
|||
|
|||
10 项语义逐条覆盖,**两侧结果逐值一致**(会话列表完整视图规范化后逐字符相同): |
|||
|
|||
| # | 语义 | H2 | MySQL | 结果 | |
|||
|---|---|---|---|---| |
|||
| 1 | **本人发送未读按现状计入(OBS-E01)** | c1 = 2(本人 1 + 他人 1) | c1 = 2 | ✔ 一致 | |
|||
| 1b | 同上,把本人那条标记已读后 | 1 | 1 | ✔ | |
|||
| 2 | 他人发送未读计入 | ✔ | ✔ | ✔ | |
|||
| 3 | `message_reads` 排除 | 读 1 条后 2 → 1 | 同 | ✔ | |
|||
| 3b | 另一读者的已读记录不影响本人 | 不受影响 | 不受影响 | ✔ | |
|||
| 4 | `RECALLED` 排除 | 撤回 1 条后 2 → 1 | 同 | ✔ | |
|||
| 5 | 多会话分组 | c1=2, c2=1, c3=1 | 同 | ✔ | |
|||
| 6 | 非当前用户会话不返回 | 只返回 c1/c2/c3;cOutsider 不在 | 同 | ✔ | |
|||
| 7 | 聚合缺失补 0 | 全部撤回/已读时聚合缺行 → 0 | 同 | ✔ | |
|||
| 8 | **最近第 500 条计入、第 501 条排除** | 501 条未读 → **500**;把最旧一条标已读 → **仍 500**;把最新一条标已读 → **499** | 同 | ✔ | |
|||
| 9 | **`created_at` 相同的排序语义** | 同刻 3 条 → 3;其中 1 条已读 → 2;同刻 501 条 → **500** | 同 | ✔ | |
|||
| 10 | **空及重复 `conversationIds`** | 重复 `[c1,c1]` 只产 1 行且与 `[c1]` 逐值相同;空列表由 Service 短路、不下发 `IN ()` | 同 | ✔ | |
|||
|
|||
**第 8 项证明方式**:不止断言「等于 500」,而是通过「标已读最旧一条 → 不变」证明**最旧一条本就在窗口之外**, |
|||
「标已读最新一条 → 499」证明**窗口保留的是最新一侧**。 |
|||
|
|||
**第 9 项的诚实边界**:`ROW_NUMBER() ... ORDER BY m.created_at DESC` **没有 tiebreaker**, |
|||
故当 501 条 `created_at` 完全相同时,「保留哪 500 条」**由引擎决定**; |
|||
但**窗口计数与引擎无关,恒为 500**——两侧实测一致。此点已计入遗留风险(R2)。 |
|||
|
|||
--- |
|||
|
|||
## 6. MySQL `EXPLAIN FORMAT=JSON`(任务 4) |
|||
|
|||
**被测语句**与 `MessageRepository.countUnreadByConversationIds` **逐字相同**,绑定同一批参数。 |
|||
**夹具**:先铺入 **500 个背景会话 × 20 条 = 10 000 条消息**(不属于查询范围),再替换目标会话—— |
|||
否则 `messages` 表几乎只含查询范围内的行,MySQL 选择全扫是**正确**的代价决策, |
|||
却会让 EXPLAIN 失去验收意义。 |
|||
|
|||
### 6.1 逐表结果(`messages` / `message_reads` / 派生结果 `ranked` 分开报告) |
|||
|
|||
> **注意**:MySQL 的 `EXPLAIN FORMAT=JSON` 中 `table_name` 是**别名**。 |
|||
> 被测语句的别名为 `messages m`、`message_reads mr`、派生表 `ranked`, |
|||
> 故表节点分别为 `m` / `mr` / `ranked`。 |
|||
|
|||
| 规模 | selectivity | `ranked`(派生结果) | `messages`(别名 `m`) | `message_reads`(别名 `mr`) | temporary | filesort | |
|||
|---|---|---|---|---|---|---| |
|||
| 1 会话 × 3 条 | 0.01% | **ALL**(派生结果顺序扫描)· rows=3 | **ref** · `idx_messages_conversation_created` · rows=3 | **eq_ref** · `uk_message_reads` · rows=1 | true | true | |
|||
| 20 会话 × 3 条 | 0.27% | **ALL** · rows=60 | **range** · `idx_messages_conversation_created` · rows=60 | **eq_ref** · `uk_message_reads` · rows=1 | true | true | |
|||
| 100 会话 × 3 条 | 1.36% | **ALL** · rows=300 | **range** · `idx_messages_conversation_created` · rows=300 | **eq_ref** · `uk_message_reads` · rows=1 | true | true | |
|||
| **1 会话 × 600 条(> 500 窗口)** | 2.73% | **ALL** · rows=600 | **ref** · `idx_messages_conversation_created` · rows=600 | **eq_ref** · `uk_message_reads` · rows=1 | true | true | |
|||
|
|||
**判读**: |
|||
|
|||
- **派生结果 `ranked` 的 `access_type = ALL` 必须准确表述为「派生结果顺序扫描」**—— |
|||
这是 MySQL 对 `ROW_NUMBER()` 派生表**物化**后顺序读取物化结果的固有形态, |
|||
`possible_keys`/`key` 均为 `null`(派生表上不存在索引)。**不得**笼统写成「无全表扫描」。 |
|||
- **`messages` 基础表在四个验收断言规模下命中既有索引 `idx_messages_conversation_created`(`ref` / `range`)**; |
|||
更高选择性探测会转为 `ALL`,详见下一节,不能笼统表述为“没有全表扫描” ✔ |
|||
- **`message_reads` 基础表在所有规模下均为 `eq_ref` 命中 `uk_message_reads`** ✔ |
|||
(`possible_keys` 同时列出 `idx_message_reads_message` / `idx_message_reads_reader`) |
|||
- `possible_keys`、`key`、`rows_examined_per_scan`、`filtered`、`using_temporary_table`、`using_filesort` |
|||
均已逐规模记录,原文见 `target/r01e-independent/e2-explain.txt`。 |
|||
|
|||
### 6.2 代价拐点(选择性探测,只记录不判定) |
|||
|
|||
| 规模 | selectivity | `messages` 访问方式 | |
|||
|---|---|---| |
|||
| 2 会话 × 600 条(1 200 行) | 5.45% | **range** · `idx_messages_conversation_created`(命中索引) | |
|||
| 5 会话 × 600 条(3 000 行) | 13.63% | **ALL** · key=null · rows=13 093(转为全扫) | |
|||
| 20 会话 × 600 条(12 000 行) | 54.53% | **ALL** · key=null · rows=22 016(转为全扫) | |
|||
|
|||
**结论**:拐点被夹逼在 **5.45% 与 13.63%** 之间。也就是说, |
|||
只有当「被查询的会话集合占 `messages` 全表 6% 以上」时,MySQL 才会放弃索引改走全扫—— |
|||
这是**代价决策,不是缺索引**。真实场景中一个用户的会话列表远低于此比例, |
|||
故本项判定 **PASS**,**不新增 V46**(严格遵守指令)。 |
|||
|
|||
### 6.3 `using_temporary_table = true` 的归属 |
|||
|
|||
`true` 出现在 `grouping_operation` 层,是 `GROUP BY ranked.conversation_id` 的固有代价, |
|||
与派生表物化同源,**不是**新增索引可以消除的。 |
|||
|
|||
### 6.4 `using_filesort` 的更正(F3) |
|||
|
|||
实施报告 §9 记录 `using_filesort=false`(其夹具为 1 会话 × 200 条)。 |
|||
本轮在**带背景数据的真实规模夹具**下,1/20/100/600 四档**全部实测 `using_filesort=true`**—— |
|||
来源是窗口函数 `ROW_NUMBER() OVER (PARTITION BY conversation_id ORDER BY created_at DESC)` 的排序。 |
|||
这是**既有实现的固有代价**,本轮不改动,仅如实更正记录。 |
|||
|
|||
--- |
|||
|
|||
## 7. 副作用与隔离(任务 5) |
|||
|
|||
独立测试 `R01EIndependentSideEffectIsolationTest`,运行在**共享的 integration H2 库**上(与实施测试同库, |
|||
因此可以直接观测实施测试的夹具残留)。 |
|||
|
|||
### 7.1 逐字段快照(**25 张表**,独立于实施测试的 11 张) |
|||
|
|||
`users`(6 列)、`user_roles`、`companies`、`jobs`、`applications`、`application_status_history`、 |
|||
`interviews`、`interview_status_history`、`offers`、`offer_status_history`、`candidate_feedback`、 |
|||
`job_favorites`、`notifications`、`audit_logs`、`auth_tokens`、`conversations`、`conversation_members`、 |
|||
`messages`(含 `version`)、`message_reads`、`student_profiles`、`student_preferences`、`resume_versions`、 |
|||
`candidate_dimension_scores`、`candidate_scoring_appeals`、`candidate_scoring_appeal_history` |
|||
|
|||
| 用例 | 结论 | |
|||
|---|---| |
|||
| `collector.collect()` 前后 | **25 张表逐字段零变化** ✔ | |
|||
| `listConversations()` 前后 | **25 张表逐字段零变化** ✔ | |
|||
| `listConversations + getConversation + collect` 组合 | **25 张表逐字段零变化** ✔ | |
|||
|
|||
**读取路径零写**:夹具覆盖全部 12 个 section(collect 走满所有分支),快照仍零变化。 |
|||
|
|||
### 7.2 通知 / 审计 / RabbitMQ |
|||
|
|||
| 项 | 结论 | |
|||
|---|---| |
|||
| `notifications` 行数与逐字段内容 | 读前后**完全相等** ✔ | |
|||
| `audit_logs` 行数与逐字段内容 | 读前后**完全相等** ✔ | |
|||
| **RabbitMQ** | 以 `@MockBean RabbitTemplate` 注入,读路径结束后 `verifyNoInteractions(rabbitTemplate)` **通过**——**零 AMQP 交互** ✔ | |
|||
| MQ 配置 | `mysql-it` / `integration-test` 均置 `app.notification.mq-enabled: false`、`spring.rabbitmq.enabled: false`,`NotificationConsumer` 条件装配关闭 ✔ | |
|||
|
|||
### 7.3 反证:快照必须真的能发现变化 |
|||
|
|||
| 反证用例 | 期望 | 结果 | |
|||
|---|---|---| |
|||
| 修改一行(`users.nickname`) | 断言必须失败 | **AssertionError,含 `users`** ✔ | |
|||
| 新增一行(`notifications`) | 断言必须失败 | **AssertionError,含 `notifications`** ✔ | |
|||
| 删除一行(`message_reads`) | 断言必须失败 | **AssertionError,含 `message_reads`** ✔ | |
|||
| **仅单列变化**(`messages.status` → `RECALLED`) | 断言必须失败 | **AssertionError,含 `messages`** ✔ | |
|||
|
|||
若无此反证,「零变化」可能只是**没在比**。 |
|||
|
|||
### 7.4 夹具零残留 |
|||
|
|||
| 检查 | 结果 | |
|||
|---|---| |
|||
| 本类自有夹具(`r01eiso-%`)退场后残留 | **0**(`@AfterEach` 精确清理 + `@AfterAll` 门禁)✔ | |
|||
| **实施测试的夹具(`r01e-%`,排除本类前缀)残留** | **0** ✔ | |
|||
| 实施测试遗留的 `messages` / `r01e-co` / `r01e-job` | **0** ✔ | |
|||
|
|||
**关于此前「缺 `@AfterEach` 导致 60 项失败」的专项复验**:H2 内存库按 JVM 共享, |
|||
只有让 `R01EQueryBudgetAcceptanceTest` **先于**独立类运行,残留检查才具备鉴别力。 |
|||
为消除运行顺序的不确定性,本轮**追加一次逆序运行**: |
|||
|
|||
``` |
|||
mvn -o -Dsurefire.runOrder=reversealphabetical -Dtest="R01EQueryBudgetAcceptanceTest,R01EMySqlQueryCountAndExplainTest,R01EIndependentCrossDatabaseVerificationTest,R01EIndependentSideEffectIsolationTest" test |
|||
→ 实际顺序:R01EQueryBudgetAcceptanceTest(11) → R01EMySqlQueryCountAndExplainTest(4) |
|||
→ R01EIndependentSideEffectIsolationTest(10) → R01EIndependentCrossDatabaseVerificationTest(16) |
|||
→ 独立复验:Tests run: 41, Failures: 0, Errors: 0, Skipped: 0 |
|||
→ 提交前收口:Tests run: 42, Failures: 0, Errors: 0, Skipped: 0(新增 EXPLAIN 变异门禁) |
|||
``` |
|||
|
|||
即:实施测试跑完后,独立类的 `implementationFixturesLeaveNoResidue` 与 `@AfterAll` 门禁 |
|||
**直接确认其夹具残留为 0**,旧问题已修复且可被独立观测。 |
|||
|
|||
--- |
|||
|
|||
## 8. OBS-E01(任务 6) |
|||
|
|||
**观察项(本轮不改变既有语义,作为产品负责人待决策项上报)**: |
|||
|
|||
> **本人发送且没有当前用户 `message_reads` 记录的消息,计入本人未读数。** |
|||
|
|||
| 证据 | H2 | MySQL | |
|||
|---|---|---| |
|||
| 会话内「本人 1 条 + 他人 2 条,均未读」 | 未读 = **3**(不是 2) | **3** | |
|||
| 会话内「他人 1 条 + 本人 1 条 + 1 条撤回」 | 未读 = **2** | **2** | |
|||
| 把本人发送的那条写入 `message_reads` | 未读减为 **1** | **1** | |
|||
|
|||
- 该行为源自未读查询的 `WHERE` 中**没有发送者过滤**(只按 `conversation_id` 与读者 `message_reads` 判定)。 |
|||
- 本轮**未修改 SQL、未修改任何生产代码**,仅将既有语义**如实锁定并双侧取证**。 |
|||
- 若产品确认应排除本人发送的消息,属**业务语义变更**,需另立批次,不在本次性能收束范围内。 |
|||
|
|||
--- |
|||
|
|||
## 9. 门禁结果(任务完成后的规定命令) |
|||
|
|||
| # | 命令 | Tests | Failures | Errors | Skipped | 结果 | |
|||
|---|---|---|---|---|---|---| |
|||
| 1 | `mvn -Dtest="R01EQueryBudgetAcceptanceTest,R01EMySqlQueryCountAndExplainTest,R01EIndependentCrossDatabaseVerificationTest,R01EIndependentSideEffectIsolationTest" test` | **41** | 0 | 0 | **0** | SUCCESS | |
|||
| 1b | 同上 + `-Dsurefire.runOrder=reversealphabetical`(残留门禁) | **41** | 0 | 0 | **0** | SUCCESS | |
|||
| 2 | `mvn -Dtest="*PersonalData*Test,*Export*Test,*Messaging*Test,*Message*Test,*Conversation*Test,*Recall*Test" test` | **150** | 0 | 0 | **0** | SUCCESS | |
|||
| 3 | 独立复验 `mvn test` | **2787** | **0** | **0** | **0** | SUCCESS | |
|||
| 4 | 独立复验 `mvn clean verify` | **2787** | **0** | **0** | **0** | SUCCESS | |
|||
| 5 | 提交前收口 `mvn clean verify` | **2788** | **0** | **0** | **0** | SUCCESS | |
|||
| 5 | `python ..\docs\verify_openapi.py` | paths **176** / operations **214** | — | — | — | `All checks passed!` | |
|||
|
|||
**测试计数对账**:独立复验为 `2761(R01-C 阶段二基线)+ 26(独立新增:16 跨库 + 10 副作用)= 2787`; |
|||
提交前收口再增加 1 个 EXPLAIN 变异门禁,最终为 **2788** ✔ |
|||
(实施测试的 11 + 4 = 15 已含在 2761 内,本轮未新增实施测试。) |
|||
|
|||
### 9.1 实施测试 / 独立测试 明细 |
|||
|
|||
| 类别 | 测试类 | 项数 | 引擎 | |
|||
|---|---|---|---| |
|||
| **实施测试** | `R01EQueryBudgetAcceptanceTest` | 11 | H2 | |
|||
| **实施测试** | `R01EMySqlQueryCountAndExplainTest` | 4 | **MySQL(Testcontainers)** | |
|||
| **独立测试** | `R01EIndependentCrossDatabaseVerificationTest` | 16 | **H2 + MySQL(同 JVM 双上下文)** | |
|||
| **独立测试** | `R01EIndependentSideEffectIsolationTest` | 10 | H2(共享 integration 库) | |
|||
| | **合计** | **41** | | |
|||
|
|||
### 9.2 Testcontainers / MySQL **实际执行**证据 |
|||
|
|||
- Docker Server **29.6.2**;`mysql:8.4` 容器日志实测:`Container mysql:8.4 started in PT7.4093471S`。 |
|||
- `R01EMySqlQueryCountAndExplainTest` 标注 `@Testcontainers(disabledWithoutDocker = true)`—— |
|||
若 Docker 不可用该类的 4 项会被 **skip**;实测 **Skipped = 0**,故容器**真实启动并执行**。 |
|||
- `R01EIndependentCrossDatabaseVerificationTest` 断言 `MySql.container.isRunning()` 为 `true`, |
|||
并在 MySQL 上真实执行了 Flyway 全量迁移、跨库深比较、`ANALYZE TABLE` 与 `EXPLAIN FORMAT=JSON`。 |
|||
- 两类 MySQL 用例耗时分别为 **122.5 s** 与 **17.2 s**(`clean verify` 单次运行),与容器冷启动 + 万级夹具灌入相符。 |
|||
|
|||
--- |
|||
|
|||
## 10. 生产代码与契约不变性 |
|||
|
|||
| 项 | 结果 | |
|||
|---|---| |
|||
| 生产代码 diff | **0 个文件**(`git diff --stat` 空) | |
|||
| V1–V45 迁移 / Flyway | **未修改**(目录 `git status` 0 项,仍为 45 个版本文件) ✔ | |
|||
| `pom.xml` / 依赖 | **未修改** ✔ | |
|||
| `ErrorCode` | **未修改** ✔ | |
|||
| 公网接口 / DTO / OpenAPI 契约 | **未修改**(176 / 214) ✔ | |
|||
| `docs/openapi-rc.json` | **未修改** ✔ | |
|||
| 新增迁移 V46 | **未新增**(EXPLAIN 未发现必须新增索引的情形) ✔ | |
|||
| Security / Token / 权限 / requestId | **未修改** | |
|||
| 评分、排名、推荐、申诉、邀约、Offer、撤回与已读语义 | **未修改** | |
|||
| 分布式缓存 / 冗余未读列 / 异步最终一致未读数 | **未引入** | |
|||
|
|||
--- |
|||
|
|||
## 11. Git 状态(未暂存 / 未提交 / 未推送) |
|||
|
|||
| 项 | 结果 | |
|||
|---|---| |
|||
| 分支 / HEAD | `refactor/r01-structure-performance` / `b893ed67f078eedb0c4b8a03341311bd79342354`(**未变**) | |
|||
| HEAD 与 upstream | **0 / 0** ✔ | |
|||
| 暂存区 `git diff --cached --name-only` | **空** ✔ | |
|||
| 本轮新增(未跟踪) | 2 个独立测试类 + 本报告 | |
|||
| 既有未跟踪文件 | 完整保留,未删除、未移动 | |
|||
|
|||
**确认:全程未执行 `git add` / `commit` / `push`,未执行 `reset` / `restore` / `checkout` / `clean`。** |
|||
|
|||
--- |
|||
|
|||
## 12. 遗留风险 |
|||
|
|||
| # | 风险 | 说明 | |
|||
|---|---|---| |
|||
| R1 | **OBS-E01 语义** | 本人发送且无本人 `message_reads` 的消息计入本人未读数。既有实现如此,本轮未改;如需变更属业务语义变更,须另立批次 | |
|||
| R2 | **`created_at` 并列无 tiebreaker** | 未读窗口的 `ORDER BY m.created_at DESC` 无二级排序键。并列跨越 500 边界时「保留哪 500 条」由引擎决定(**计数恒为 500**,两侧一致)。`messages.created_at` 为秒级 `DATETIME`,高并发同秒写入可触发 | |
|||
| R3 | **派生表物化** | MySQL 对 `ROW_NUMBER()` 派生表物化,`ranked` 为派生结果顺序扫描(`access_type=ALL`),且 `using_temporary_table=true`、`using_filesort=true` 为固有代价。大会话(> 每表 6% 选择性)下 `messages` 转为全扫,属代价决策 | |
|||
| R4 | **测试自污染** | 本轮测试均按前缀精确清理;若将来新增同类夹具而不清理,仍可能污染共享 H2 库(R01-C 已建隔离门禁) | |
|||
| R5 | **实施测试断言鉴别力(F1)** | 原 EXPLAIN 断言为空断言;提交前收口已改为 JSON 结构断言,并以三类变异证明有效 | |
|||
|
|||
--- |
|||
|
|||
> **R01-E独立代码与真实MySQL性能复验通过,可以进入精确暂存与独立提交检查点。** |
|||
@ -0,0 +1,81 @@ |
|||
# R01 结构与性能收束权威设计 |
|||
|
|||
## 1. 目标与边界 |
|||
|
|||
R01 在 S01、S02、S03、Q01 已冻结行为之上收束重复代码和可证明的性能热点。所有改动必须保持 HTTP 契约、业务错误码、权限、事务、审计、隐私和数据副作用不变。 |
|||
|
|||
禁止在 R01 中新增业务功能、公开接口、Flyway、表或索引;禁止改变评分公式、推荐权重、百分位定义、消息未读定义、简历权限、Token 语义或岗位搜索语义。索引和全文搜索仅形成后续提案,不在本轮实施。 |
|||
|
|||
基线:分支 `refactor/r01-structure-performance`,起点 `77dc624`;OpenAPI 176 paths / 214 operations;全量测试 1890/1890。 |
|||
|
|||
## 2. 已确认问题 |
|||
|
|||
- 41 个生产文件包含私有身份解析方法;不同实现对匿名主体、非数字主体和溢出主体的处理不一致。 |
|||
- 46 个生产文件包含私有 requestId 获取方法;部分直接取请求头,未复用 RequestIdFilter 的规范化结果。 |
|||
- 角色断言以 `assertStudentRole`、`requireStudent`、`requireAdmin` 等形式分散,存在重复查用户和语义漂移风险。 |
|||
- `GlobalExceptionHandler.resolveHttpStatus` 是大型 if 链,并含重复分支。 |
|||
- `overviewStatus` 在评分引擎和竞争力排名中存在两套等价规则。 |
|||
- `JobRecommendationService.preferenceScore` 被压缩为单行,权重含义难以评审。 |
|||
- `CompanyScoringDashboardService.computePercentiles` 对每名候选人扫描全部分数,复杂度 O(n²)。 |
|||
- `MessagingService.listConversations` 对每个会话分别加载最多 500 条消息和 read 状态,查询次数随会话数线性增长。 |
|||
- `PersonalDataCollector` 存在可批量化的数据读取。 |
|||
- 岗位关键字 `%keyword%` 与排序 filesort 是已记录风险,但未经产品和容量决策,不在 R01 直接改搜索技术栈。 |
|||
|
|||
## 3. 分批策略 |
|||
|
|||
### R01-A:请求身份与 requestId 基础设施 |
|||
|
|||
扩展既有 `CurrentUserAccessor`,使所有迁移入口统一拒绝 null、匿名、失效、空白、非 ASCII 数字、溢出和非正 userId。不得在 Controller 接受 userId 参数。 |
|||
|
|||
新增单一 `RequestIdAccessor`:优先读取 RequestIdFilter 已规范化的 request attribute;缺失时使用现有安全归一化逻辑,而不是信任任意长度请求头。响应、审计和日志必须使用同一 requestId。 |
|||
|
|||
按小批次迁移 Controller,每批最多 8 个类。每个调用点逐项锁定原 401/403 和资源归属语义;禁止机械全仓替换。 |
|||
|
|||
角色授权暂不与身份解析混在同一提交。先盘点后新增 `UserRoleGuard`,保持 USER_NOT_FOUND 与 FORBIDDEN 的现有差异,不增加隐式缓存。 |
|||
|
|||
### R01-B:异常映射和业务规则唯一来源 |
|||
|
|||
将业务错误码到 HTTP 状态的关系提取为不可变映射,启动或测试时检测重复和遗漏;默认仍为 400。不得更改任何现有映射。 |
|||
|
|||
提取唯一 `CandidateOverviewStatusPolicy`。评分引擎与竞争排名调用同一实现,并用参数化契约测试证明旧、新结果完全一致。 |
|||
|
|||
### R01-C:纯计算与可读性 |
|||
|
|||
将百分位改为“排序一次 + 分数组到 lower-rank 的映射”,复杂度 O(n log n)。并列分数必须获得相同百分位;null、小于 2 个有效分数、精度和 HALF_UP 规则保持不变。 |
|||
|
|||
拆分 `preferenceScore` 为命名明确的五个维度贡献函数。城市 30、用工类型 20、最低薪资 20、行业 15、岗位族 15 及缺失维度不计分的规则不得变化。 |
|||
|
|||
### R01-D:数据库查询收束 |
|||
|
|||
消息会话列表新增固定次数聚合查询,一次性按 conversationIds 计算未读数。定义保持:排除 RECALLED;发送者消息是否未读沿用当前 read-row 语义;不存在 read 行即未读。1/20/50 个会话查询次数必须固定。 |
|||
|
|||
`markRead` 是否突破 500 条属于独立语义问题,本轮先写失败证据和决策,不与列表未读优化混改。 |
|||
|
|||
PersonalDataCollector 按关联键批量读取;导出 JSON 字段、顺序、空值、脱敏与表范围不变。以 1/20/50 数据规模记录查询数和完整导出结果摘要。 |
|||
|
|||
### R01-E:真实环境复验 |
|||
|
|||
在 MySQL 8.4 上执行查询预算、结果等价和 EXPLAIN。不得把 H2 结果当成 MySQL 证据;不得为了 EXPLAIN 修改迁移或索引。 |
|||
|
|||
## 4. 关键契约 |
|||
|
|||
| 模块 | 必须保持 | |
|||
|---|---| |
|||
| 身份 | 非法主体为 401,不回显 principal,不产生业务写入 | |
|||
| 角色 | 原有 USER_NOT_FOUND/FORBIDDEN、角色组合与归属判断不变 | |
|||
| requestId | 最大长度、生成规则、响应头、响应体、MDC 与审计同源 | |
|||
| 异常 | 每个 ErrorCode 的 HTTP 状态与响应 body 不变 | |
|||
| 百分位 | `lower / (n-1) * 100`、并列同值、scale=2、HALF_UP | |
|||
| 推荐 | 五项权重和缺失项分母规则逐字保持 | |
|||
| 未读 | RECALLED 不计入;已有 read 行语义不变 | |
|||
| 导出 | 字段、隐私、顺序、内容与错误语义不变 | |
|||
|
|||
## 5. 证据要求 |
|||
|
|||
每一批必须先建立特征测试或失败测试,再改生产代码。性能结论必须同时包含结果等价断言、SQL 次数和反证测试;仅写“无 N+1”不算证据。 |
|||
|
|||
自动化最低门槛:定向测试、关联回归、`mvn test`、`mvn clean verify`、静态 OpenAPI;Failures/Errors/Skipped 均为 0。涉及 SQL 的批次还必须有真实 MySQL 黑盒。 |
|||
|
|||
## 6. 提交边界 |
|||
|
|||
每个子阶段独立分支检查点或独立提交;不得将 R01-A 到 R01-E 压成一个大提交。所有历史未跟踪日志和验收产物保留但不提交。不得使用 `git add -A`、reset、restore、checkout 或 clean。 |
|||
@ -0,0 +1,483 @@ |
|||
# S01 认证安全上线阻断整改 · 独立代码复验与真实 MySQL/Docker 黑盒验收报告 |
|||
|
|||
- **验收日期**:2026-09-11 |
|||
- **验收性质**:独立验收(非功能开发);使用真实 MySQL 8、最新 JAR、真实 HTTP 请求 |
|||
- **验收结论**:**FAIL**(已复现具体缺陷,S01 不可冻结) |
|||
- **报告文件**:`docs/s01-independent-mysql-acceptance-20260911.md` |
|||
- **验收产物目录**:`docs/acceptance/s01-20260911/`(脚本、请求体、日志、结果快照) |
|||
|
|||
> 本报告不包含数据库密码、.env 内容、JWT、验证码、完整手机号、明文测试口令与非法 principal 敏感标记原文。 |
|||
|
|||
--- |
|||
|
|||
## 1. Git 基线 |
|||
|
|||
| 项 | 实测值 | |
|||
|---|---| |
|||
| 分支 | `fix/resume-upload-safety` | |
|||
| HEAD | `cdafbf8ac93544a3ef89d0f3014f822a0d575f8f`(docs: update README for latest delivery baseline) | |
|||
| 最近 5 次提交 | cdafbf8 / 277b3c0 / dbd27e6 / cf39737 / 498512d | |
|||
| 暂存区 | **空**(未执行 `git add`) | |
|||
| `git diff --check` | 无空白错误(仅 CRLF 提示) | |
|||
| Flyway 迁移改动 | **无**(V1–V41 原样,共 41 个迁移文件,最大版本 41) | |
|||
| 新增迁移 | **无** | |
|||
| 工作区修改 | 20 个(S01 8 个 + 简历上传安全 12 个,均未提交) | |
|||
| 未跟踪文件 | 基线 106 个,全部保留;本轮新增验收产物 | |
|||
| AGENTS.md | **未发现**(仓库根、上级目录、`.claude/` 均无 AGENTS.md/CLAUDE.md/GEMINI.md/.cursorrules) | |
|||
| `.env` | 保留(365B) | |
|||
|
|||
**与 S01 无关的未提交修改(简历上传安全)已完整保护**:`profile/ProfileController.java`、`profile/ProfileService.java`、`profile/storage/LocalResumeStorageService.java`、`resume/ResumeVersionController.java`、`resume/ResumeVersionService.java`、`docs/frontend-integration.md`、`docs/resume-api.md` 及 5 个测试文件——本轮**未读取改写、未覆盖**,时间戳仍为 2026-09-10。 |
|||
|
|||
--- |
|||
|
|||
## 2. 独立代码审查结果(阶段 A) |
|||
|
|||
### A1 验证码/手机号日志 —— **PASS** |
|||
|
|||
| 检查项 | 结果 | 证据 | |
|||
|---|---|---| |
|||
| INFO/WARN/ERROR 不输出验证码 | ✅ | `SmsVerificationService` 仅 2 处 `log.warn`,参数为 `maskPhone(phone)`;`MockSmsCodeSender` 仅 1 条无参固定文本 | |
|||
| 不输出完整手机号 | ✅ | 实测 7 个验收手机号在全部日志中命中 **0** 次 | |
|||
| Mock 实现仅固定安全文本 | ✅ | `log.info("SMS verification code generated (mock sender)")` | |
|||
| Service 不把验证码传入日志参数 | ✅ | 全量检索无 `log.*code` 参数传递 | |
|||
| AuthService 不输出完整手机号 | ✅ | 仅 `log.info("Auto-created user: id={}")` | |
|||
| JWT 过滤器不记录 Token/subject/异常原文 | ✅ | 3 条固定文本,无 `e.getMessage()`、无 subject | |
|||
| 审计 details 不含完整手机号/验证码 | ✅ | 实测唯一 SMS_SEND 明细为 `phone=199****9999`(脱敏) | |
|||
| 异常消息不含敏感值 | ✅ | `SERVICE_UNAVAILABLE` 固定文本 `sms sender unavailable` | |
|||
| 不通过 toString/getMessage 间接泄露 | ✅ | 捕获的异常与堆栈均不含非法 principal 标记 | |
|||
|
|||
**唯一敏感标记复验**(真实 Logback 捕获 + 真实运行日志双通道):标记手机号、真实验证码(3 个 DB 实际值)、JWT 前缀 `eyJ`、`Bearer ey`、mock 正文、非法 principal 标记、`NumberFormatException` —— 命中数**全部为 0**。 |
|||
|
|||
### A2 短信发送器装配 —— **PASS(3 种配置)+ 发现 1 处边界缺陷(见 F3)** |
|||
|
|||
| 检查项 | 结果 | |
|||
|---|---| |
|||
| Service 只依赖 `SmsCodeSender` 接口 | ✅ 单构造参数注入,无 if 分支复制双流程 | |
|||
| `true` → 仅 `MockSmsCodeSender` | ✅ 实测(`SmsSenderConfigurationTest` 4/4 + 运行期行为) | |
|||
| `false` → 仅 `UnavailableSmsCodeSender` | ✅ 实测 | |
|||
| 缺失 → 默认 `UnavailableSmsCodeSender` | ✅ 实测(D3a) | |
|||
| 单一 Bean / 无装配歧义 | ✅ 三次启动均成功(缺/多 Bean 会直接启动失败) | |
|||
| mock=false 不回退 Mock | ✅ 实测无 mock 事件 | |
|||
| 生产不意外启用 Mock | ✅ `application.yml` 默认 `SMS_MOCK_ENABLED:false` | |
|||
| `@ConditionalOnProperty` 精确性 | ✅ Mock=`havingValue="true"`(**无** matchIfMissing);Unavailable=`havingValue="false", matchIfMissing=true`;name 与配置项 `app.sms.mock.enabled` 完全一致 | |
|||
|
|||
### A3 mock=false 失败事务 —— **PASS(MySQL 回滚)+ 1 项非阻断观察(F4)** |
|||
|
|||
- `@Transactional` 位于 `sendCode`;顺序:校验 → 锁定/限流 → 生成 → **`codeRepository.save`** → Redis 限流 → **`smsCodeSender.sendVerificationCode`** → 审计 |
|||
- `BusinessException extends RuntimeException` → 默认回滚;发送异常**未被捕获**,不转换为空成功 |
|||
- 真实 MySQL 实测:失败后 `sms_verification_codes` 完整字段前后**完全一致**(见第 6 节) |
|||
- 观察项:限流键在发送**之前**写入,失败后残留 60s(既有排序,非 S01 引入)→ F4 |
|||
|
|||
### A4 `CurrentUserAccessor` —— **PASS** |
|||
|
|||
拒绝:null / `!isAuthenticated` / `AnonymousAuthenticationToken` / 空或空白 name / `anonymousUser` / 非 ASCII 纯数字(含 `+42`、`" 42"`、`"42 "`、Unicode 数字)/ Long 溢出 / `userId<=0`。 |
|||
固定抛 `InsufficientAuthenticationException`(既有 `@ExceptionHandler(AuthenticationException)` → **HTTP 401**),**不回显、不记录**非法 principal,不新增 ErrorCode,不承担角色/归属判断,身份仅来自 SecurityContext。 |
|||
|
|||
### A5 `JwtAuthenticationFilter` —— **PASS** |
|||
|
|||
合法数字 subject 正常认证;缺失 Authorization → 匿名;无效签名/过期 → 既有 401;非数字 subject → **401 而非 500**;日志无 Token/subject/异常原文;`JwtTokenProvider` 未改动(Token 兼容语义不变);**未声称修复 Refresh Token**。 |
|||
|
|||
### A6 `/jobs` 安全规则 —— **PASS** |
|||
|
|||
`SecurityConfig:119` 由 `.requestMatchers("/jobs").permitAll()` 改为 `.requestMatchers(HttpMethod.GET, "/jobs").permitAll()`,且该规则**先于** `.requestMatchers("/jobs/**").authenticated()` 求值,写请求确实命中 authenticated。 |
|||
|
|||
**全部 /jobs 映射与最终权限(21 个 operation,真实 HTTP 实测)** |
|||
|
|||
| 方法 | 路径 | 匿名 | STUDENT | COMPANY | ADMIN | 归属要求 | |
|||
|---|---|---|---|---|---|---| |
|||
| GET | `/jobs` | **200** | 200 | 200 | 200 | 无 | |
|||
| GET | `/jobs/search`、`/jobs/filters` | **200** | 200 | 200 | 200 | 无 | |
|||
| GET | `/jobs/public/{id}` | **200** | 200 | 200 | 200 | 无 | |
|||
| GET | `/jobs/{id}/reviews`、`/summary`、`/{reviewId}`、`/jobs/compare` | **200** | 200 | 200 | 200 | 无 | |
|||
| POST | `/jobs` | **401** | **403/2002** | **200** | **403/2002** | 需绑企业 | |
|||
| PUT | `/jobs/{id}` | **401** | 403/2002 | 200 | 403/2002 | **本企业**(他企业 400/7002) | |
|||
| PATCH | `/jobs/{id}/status` | **401** | 403/2002 | 200 | 403/2002 | **本企业**(他企业 400/7002) | |
|||
| DELETE | `/jobs/{id}` | **401** | — | — | — | 无该端点 | |
|||
| GET | `/jobs/{id}` | **401** | 403/2002 | 200 | 403/2002 | 本企业 | |
|||
| GET | `/jobs/{id}/match` | **401** | — | — | — | 登录 | |
|||
| GET | `/jobs/{id}/competition-ranking` | **401** | — | — | — | 登录 | |
|||
| POST/DELETE | `/jobs/{id}/favorite`、GET `/jobs/favorites` | **401** | — | — | — | 登录 | |
|||
| GET | `/jobs/{id}/personalized-review-score` | **401** | — | — | — | 登录 | |
|||
|
|||
**不依赖 Controller 解析 anonymousUser 失败来保护写接口**:匿名请求在安全层即被拒(响应无 `requestId` 字段,符合既有 `AuthenticationEntryPoint` 固定载荷)。 |
|||
|
|||
### A7 剩余直接身份解析 —— **PASS(无 permitAll 可达)= 但存量 43 处** |
|||
|
|||
- 全量检索(任意变量名):`Long.(valueOf|parseLong)(*.getName())` 共 **43 处**,分布于 43 个文件 |
|||
- `.isAuthenticated()` 共 44 处;`anonymousUser` 字面量 5 处(`PersonalDataExportController`、`DeletionGuardInterceptor`×2、`AdminReviewReplyController`、`CompanyReviewManagementController`) |
|||
- **逐端点核验 permitAll 可达性**(按 SecurityConfig 匹配顺序,而非"路径要求认证"默认安全): |
|||
- permitAll 路径共 19 条规则,涉及的控制器为 `AuthController`、`CompanyController`、`JobReviewController`、`JobController`、`MatchController`、`JobRecommendationController` |
|||
- **匿名可达的 8 个 GET 方法**(`listJobs`、`searchJobs`、`getFilters`、`getPublicJob`、`list`、`get`、`summary`、`compare`)**均不调用身份解析** |
|||
- `CompanyController` 的 `resolveUserId` 仅被 `/companies/me`(POST/GET/PUT)使用 → authenticated;`JobReviewController` 的 `resolveUserId` 仅被 create/update/delete 使用 → authenticated |
|||
- **结论:43 处中 0 处处于 permitAll 可达路径**,不构成 S01 发布阻断。`JobController` 已改用 `CurrentUserAccessor`(裸解析 0 处) |
|||
- 存量风险提示:`DeletionGuardInterceptor:51` 使用 `chars().allMatch(isDigit)` 预检,可挡 `anonymousUser`,但对 **20 位纯数字溢出**仍会抛 `NumberFormatException`(需合法签名 Token 才能到达)→ 列为后续轮次加固项 |
|||
|
|||
### A8 OpenAPI 与响应兼容 —— **PASS** |
|||
|
|||
- 路径/operation 数量:静态 173/211;**运行时 173/211**,一致 |
|||
- 唯一变更:`docs/openapi-rc.json` 2 行描述文本(移除"验证码会打印到日志"表述),无结构变更 |
|||
- 登录、发码、岗位请求/响应 Schema 未变(S01 未改任何 DTO) |
|||
- 无新增短信调试接口、无新增获取验证码接口 |
|||
|
|||
--- |
|||
|
|||
## 3. 自动化独立复验(阶段 B,实测重新执行) |
|||
|
|||
| 命令 | Tests run | Failures | Errors | Skipped | 结果 | |
|||
|---|---|---|---|---|---| |
|||
| `-Dtest="SmsVerificationSecurityTest,SmsSenderConfigurationTest,CurrentUserAccessorTest,AuthenticationBoundaryIntegrationTest"` | **40** | 0 | 0 | 0 | BUILD SUCCESS | |
|||
| `-Dtest="*Auth*Test,*Security*Test,*Sms*Test,*Job*Test"` | **214** | 0 | 0 | 0 | BUILD SUCCESS | |
|||
| `mvn test` | **1658** | 0 | 0 | 0 | BUILD SUCCESS(3:45) | |
|||
| `mvn clean verify` | **1658** | 0 | 0 | 0 | **BUILD SUCCESS**(4:31,产出 JAR 79,965,063B) | |
|||
| `python ../docs/verify_openapi.py` | — | — | — | — | paths 173 / operations 211 / **All checks passed!** | |
|||
|
|||
--- |
|||
|
|||
## 4. Docker / MySQL 环境(阶段 C) |
|||
|
|||
**启动前发现**:`docker ps` 为空,三个业务容器处于 **Exited**(约 2 小时前,139/0/137 退出码),**volume 数据完好**。容器标签显示由 `Desktop\qinghe\backend-infra\compose.yaml` 创建(项目名 `backend-infra`)。 |
|||
|
|||
**本轮处置(非破坏性,已记录)**:仅执行 `docker start mydreamjob-mysql mydreamjob-redis mydreamjob-rabbitmq`——**未重建容器、未删除 volume、未清空数据库、未触碰其余 3 个无关孤立容器、未停止 Docker**。 |
|||
|
|||
| 项 | 实测值 | |
|||
|---|---| |
|||
| Docker Server | 29.6.2 | |
|||
| MySQL | **8.4.11**(`127.0.0.1:3307`,healthy) | |
|||
| Redis | 7.4-alpine(`6379`,healthy) | |
|||
| RabbitMQ | 3.13-management-alpine(`5672/15672`,healthy) | |
|||
| 验收数据库 | `mydreamjob` | |
|||
| Flyway | 41 条,**success=1 全部成功**,0 失败,最大版本 **41**(`41 message recall`) | |
|||
| S01 新增迁移 | **无** | |
|||
|
|||
**已修复的验收环境问题(脚本层,非项目缺陷)**:Git Bash(MSYS) 会把裸 `/` 的 `RABBITMQ_VHOST` 自动改写为 Git 安装根路径,导致 `530 NOT_ALLOWED vhost not found`。经在验收脚本中清除该变量(交由 `application.yml` 默认值 `/` 承接)后,RabbitMQ 错误数 **0**。 |
|||
|
|||
--- |
|||
|
|||
## 5. 三种配置启动验证(阶段 D,均为最新 `mvn clean verify` JAR) |
|||
|
|||
| 场景 | 启动 | SmsCodeSender 实际实现 | 发码行为 | 日志敏感值 | 判定 | |
|||
|---|---|---|---|---|---| |
|||
| **D1** `mock=true` | ✅ Started 26.5s,health 200,8080 监听 | **MockSmsCodeSender**(固定事件文本命中 1 次) | HTTP **200**(非 500) | 0 | **PASS** | |
|||
| **D2** `mock=false` | ✅ Started 26.4s | **UnavailableSmsCodeSender**(无 mock 事件) | HTTP **400** `{"code":400,"message":"sms sender unavailable"}` | 0 | **PASS** | |
|||
| **D3a** 属性完全缺失 | ✅ Started 27.5s | **UnavailableSmsCodeSender**(未回退 Mock) | HTTP **400** 同上 | 0 | **PASS** | |
|||
| **D3b** 属性存在但为空值 | ❌ **APPLICATION FAILED TO START** | **无 Bean** | — | — | **FAIL(F3)** | |
|||
|
|||
每次启动均记录 profile(无 profile,基础 `application.yml`)、PID、端口、实现、Started 日志、health 状态;**仅停止本轮启动并记录 PID 的 JAR**(每次均以 `Get-CimInstance Win32_Process` 校验命令行确为 `zhilai/mydreamjob-server/target/*.jar`)。 |
|||
|
|||
--- |
|||
|
|||
## 6. 真实 MySQL 验证码事务回滚(阶段 E) |
|||
|
|||
**mock=false(发送失败回滚)**——完整字段快照对比,非 COUNT(*): |
|||
|
|||
| 指标 | BEFORE | AFTER | 结论 | |
|||
|---|---|---|---| |
|||
| 该手机号记录数 | 0 | 0 | ✅ | |
|||
| 全局 MAX(id) | 990 | 990 | ✅ 无新增 | |
|||
| 全局总行数 | 917 | 917 | ✅ | |
|||
| 未使用(used=0)记录数 | 0 | 0 | ✅ 无可用残留 | |
|||
| SMS_SEND 审计总数 | 819 | 819 | ✅ 无虚假成功审计 | |
|||
| 完整字段差异 | — | — | ✅ **前后完全一致** | |
|||
|
|||
无半提交、无 attempts 异常、无错误锁定状态更新。HTTP **400**(非虚假成功)。 |
|||
|
|||
**mock=true(完整链路)**: |
|||
1. 发送 → HTTP 200,DB 生成 1 条记录(验证码值仅存于库内,报告不复制) |
|||
2. 从 DB 取码 → `POST /auth/login` → HTTP **200**,响应含 `accessToken/refreshToken/expiresIn/nickname/phone/role/userId`,响应内**不含验证码明文** |
|||
3. 登录后该记录 `used=1`、`attempts=0`(一次性消费语义不变) |
|||
4. **重放同一验证码 → HTTP 400**(拒绝) |
|||
5. 日志与审计仍不含验证码/完整手机号 |
|||
6. 长度/有效期/最大尝试次数/锁定时间语义未变(`SmsVerificationSecurityTest` 10/10 覆盖) |
|||
|
|||
--- |
|||
|
|||
## 7. 真实 HTTP 权限矩阵(阶段 F) |
|||
|
|||
见第 2 节 A6 表格。关键断言全部成立: |
|||
|
|||
- **匿名 POST /jobs = HTTP 401(`code=401`),不是 500** ✅ |
|||
- STUDENT / ADMIN POST /jobs = **403 / 2002** ✅ |
|||
- COMPANY POST /jobs = **200** ✅(生成 id=43) |
|||
- 他企业 PUT/PATCH = **400 / 7002**(既有语义,未擅改)✅ |
|||
- 公开 GET(`/jobs`、`/search`、`/filters`、`/public/{id}`、`/{id}/reviews`)语义不变 = 200 ✅ |
|||
- 每条请求均记录 method / path / HTTP / business code / requestId |
|||
|
|||
**jobs 表副作用(完整字段对比,非 COUNT)**: |
|||
|
|||
``` |
|||
差异仅:新增 JOB:43|company_id=22|S01_ACC_*_job|PUBLISHED|...|requirements=S01_ACC_ok |
|||
TOTAL_JOBS 27 → 28 |
|||
TAMPERED_TITLE=0 ← 他企业篡改尝试零落库 |
|||
其余全部 JOB 行逐字段未变 |
|||
``` |
|||
|
|||
--- |
|||
|
|||
## 8. 非法 Token / principal 黑盒(阶段 G) |
|||
|
|||
对受保护端点 `POST /jobs` 的真实 HTTP 请求(含合法请求体): |
|||
|
|||
| 用例 | HTTP | business code | 响应回显敏感标记 | 判定 | |
|||
|---|---|---|---|---| |
|||
| 无 Authorization | 401 | 401 | no | ✅ | |
|||
| 合法数字 subject(学生 7936) | **403** | **2002** | no | ✅ 正常进入既有权限判断 | |
|||
| `subject=anonymousUser` | **401** | 401 | no | ✅ | |
|||
| 受控非法 principal 标记 | **401** | 401 | no | ✅ 非 500 | |
|||
| subject 缺失(空) | 401 | 401 | no | ✅ | |
|||
| `subject=0` | 401 | 401 | no | ✅ | |
|||
| `subject=-1` | 401 | 401 | no | ✅ | |
|||
| subject 超出 Long 范围 | 401 | 401 | no | ✅ | |
|||
| 过期 Token | 401 | 401 | no | ✅ | |
|||
| 签名被篡改 | 401 | 401 | no | ✅ | |
|||
|
|||
**无任何用例返回 500;响应与日志均未回显 subject 或 Token;jobs 表完整内容零变化(无业务写入)** ✅ |
|||
|
|||
Token 生成方式:验收专用 HS256 离线生成(复用项目 `JWT_SECRET` 环境变量,**未落盘、未输出**);外部 HTTP 无需伪造即可安全覆盖全部用例,故未降级为纯集成测试。 |
|||
|
|||
--- |
|||
|
|||
## 9. 日志与审计全量脱敏(阶段 H) |
|||
|
|||
仅扫描**本轮**日志(D1/D1b/D2/D3a/D3b 的 app + stdout): |
|||
|
|||
| 禁止项 | 命中 | 说明 | |
|||
|---|---|---| |
|||
| 验证码明文(3 个 DB 实际值) | **0** | | |
|||
| 完整手机号(7 个验收号) | **0** | | |
|||
| JWT(`eyJhbGci`、`Bearer ey`) | **0** | | |
|||
| Authorization 头值 | **0** | 16 处 `authorization` 均来自 D3b 启动失败堆栈的 `AuthorizationFilter.doFilter` | |
|||
| secret | **0** | | |
|||
| 非法 principal 标记 | **0** | | |
|||
| `NumberFormatException` 堆栈 | **0** | | |
|||
| Mock 短信正文 | **0** | `MOCK SMS CODE` 命中 0 | |
|||
| `password` | ⚠️ 16 | 全部为 Spring Boot 默认 `Using generated security password: <uuid>` → **F5(既有行为,见第 12 节)** | |
|||
|
|||
**审计表(audit_logs)**:完整手机号命中 **0**、非法 principal 命中 **0**、Token 命中 **0**、脱敏手机号命中 **1**(唯一 SMS_SEND 明细 `phone=199****9999`)✅ |
|||
|
|||
**允许项**:脱敏手机号、固定事件名、requestId、固定 purpose 均正常。 |
|||
|
|||
--- |
|||
|
|||
## 10. 运行时 OpenAPI(阶段 I) |
|||
|
|||
| 项 | 值 | |
|||
|---|---| |
|||
| 访问地址 | `GET /api/v1/v3/api-docs`(应用 `context-path=/api/v1`;裸 `/v3/api-docs` 返回 404 属预期) | |
|||
| HTTP | 200 | |
|||
| 运行时 paths / operations | **173 / 211** | |
|||
| 静态 paths / operations | 173 / 211 | |
|||
| 一致性 | **PASS** | |
|||
| 短信相关路径 | 仅 `/auth/sms/send`(无新增调试/取码接口) | |
|||
| `/jobs` 路径数 | 15 | |
|||
| `/auth/sms/send` 描述 | 已不再声称验证码输出到日志/控制台 ✅ | |
|||
|
|||
--- |
|||
|
|||
## 11. 文档一致性核查(阶段 13)—— **FAIL** |
|||
|
|||
**仍指导从日志读取验证码的文档(精确文件:行号)**: |
|||
|
|||
| 文件 | 行号 | 内容(已脱敏) | 性质 | |
|||
|---|---|---|---| |
|||
| `docs/acceptance-week1.md` | 94 | `# Mock 登录(从日志获取验证码)` | 历史验收记录 | |
|||
| `docs/acceptance-week1.md` | 148 | 注: 验证码从应用日志中获取 | 历史验收记录 | |
|||
| `docs/acceptance-week1.md` | 152 | 验证码打印到日志,不发送真实短信 | 历史验收记录 | |
|||
| `docs/api-v0.1.md` | 27 | Mock 环境验证码打印到日志 | 历史 API 文档 | |
|||
| `docs/api-v0.1.md` | 174 | 短信验证码为 Mock 实现,验证码打印到日志 | 历史 API 文档 | |
|||
| `docs/frontend-integration.md` | 37,39,42,144 | "Mock 验证码日志位置"、"打印在服务控制台/stdout 日志中"、旧示例行 `MOCK SMS CODE: phone=138****0002, … code=******` | **前端接入文档(在途修改文件,本轮不可改)** | |
|||
| `docs/offer-api.md` | 262 | 测试账号(Mock 验证码,见日志) | 活文档 | |
|||
| `docs/resume-api.md` | 189 | 登录学生账号(Mock 验证码,见日志) | **在途修改文件** | |
|||
| **`docs/smoke-test.sh`** | **64-71** | **`sms_code()`:`grep -a "MOCK SMS CODE.*phone=$PHONE" $log \| … \| grep -oE 'code=[0-9]{6}'`** | **git 跟踪的既有可执行脚本(见 F1)** | |
|||
|
|||
`README.md:167` 表述正确(禁止写入敏感信息),**不计入违规**;`docs/openapi-rc.json:1449` 已修正 ✅。 |
|||
|
|||
--- |
|||
|
|||
## 12. RabbitMQ 与外围终态(阶段 14) |
|||
|
|||
| 项 | 实测值 | |
|||
|---|---| |
|||
| 队列 | `queue.sms.verification`、`queue.notification`、`queue.audit`、`queue.notification.dlq` | |
|||
| ready / unacked | **全部 0 / 0** | |
|||
| mock=false 失败后 notifications 增量 | **0** | |
|||
| mock=false 失败后 SMS_SEND 审计增量 | **0**(无虚假发送成功审计) | |
|||
| 匿名岗位写请求副作用 | jobs/notifications/audit 均无新增 | |
|||
| 无与 S01 无关的表变化 | ✅(仅本轮验收夹具,已精确清理) | |
|||
| 容器终态 | 3 容器仍 Up(healthy);**未停止 Docker** | |
|||
|
|||
--- |
|||
|
|||
## 13. PASS / FAIL / SKIP 清单 |
|||
|
|||
| # | 验收项 | 结果 | |
|||
|---|---|---| |
|||
| A1 | 验证码/手机号不进入日志与审计 | **PASS** | |
|||
| A2 | SMS 发送器装配(true/false/缺失) | **PASS** | |
|||
| A2′ | 空值边界下的 Bean 唯一性 | **FAIL**(F3) | |
|||
| A3 | mock=false 失败事务回滚(真实 MySQL) | **PASS**(F4 为非阻断观察) | |
|||
| A4 | CurrentUserAccessor 边界 | **PASS** | |
|||
| A5 | JwtAuthenticationFilter 边界 | **PASS** | |
|||
| A6 | /jobs 匹配顺序与权限矩阵 | **PASS** | |
|||
| A7 | 无 permitAll 可达的直接身份解析 | **PASS**(存量 43 处均不可匿名到达) | |
|||
| A8 | OpenAPI 与响应兼容 | **PASS** | |
|||
| B | 自动化复验(40/214/1658/clean verify) | **PASS** | |
|||
| C | Docker/MySQL/Flyway | **PASS** | |
|||
| D | 三种配置启动 | **PASS**(D1/D2/D3a)| |
|||
| E | 真实 MySQL 回滚 + mock=true 链路 | **PASS** | |
|||
| F | 真实 HTTP 权限矩阵 | **PASS** | |
|||
| G | 非法 Token/principal 黑盒 | **PASS** | |
|||
| H | 日志与审计脱敏(`password` 项除外) | **PASS** | |
|||
| I | 运行时 OpenAPI | **PASS** | |
|||
| 13 | 文档不再指导从日志读取验证码 | **FAIL**(F1、F2) | |
|||
| 14 | RabbitMQ 与外围绕 | **PASS** | |
|||
| — | SKIP | **0**(核心验收项无 SKIP) | |
|||
|
|||
**FAIL 计数:3**(F1、F2、F3) |
|||
|
|||
--- |
|||
|
|||
## 14. 缺陷详情(可复现) |
|||
|
|||
### F1(阻断)项目自带冒烟脚本因 S01 改动而失效 |
|||
|
|||
- **文件/方法**:`docs/smoke-test.sh` → `sms_code()`(第 64-71 行,git 跟踪,提交 `b01616d`) |
|||
- **缺陷**:该函数依赖被 S01 删除的日志行 `MOCK SMS CODE: phone=…, code=…` 提取验证码 |
|||
- **复现**: |
|||
```bash |
|||
LOG=<本轮 mock=true 应用日志> |
|||
grep -a "MOCK SMS CODE.*phone=<验收手机号>" "$LOG" | tail -n 1 | grep -a -oE 'code=[0-9]{6}' | cut -d= -f2 |
|||
``` |
|||
- **实际结果**:输出为空 → `SMS=""` → 后续登录步骤拿不到验证码,冒烟流程中断 |
|||
- **期望结果**:脚本应从数据库/受控夹具取得验证码(与 S01 新语义一致) |
|||
- **影响**:既有可执行验收工具**功能性回归**;S01 整改报告将其列为"文档遗留"但未识别此处为**可执行依赖** |
|||
|
|||
### F2(阻断,对应验收标准第 17 项)文档仍指导从日志读取验证码 |
|||
|
|||
- **文件/行号**:见第 11 节表格(`acceptance-week1.md:94,148,152`、`api-v0.1.md:27,174`、`frontend-integration.md:37,39,42,144`、`offer-api.md:262`、`resume-api.md:189`) |
|||
- **实际结果**:文档仍声称"验证码打印到日志/控制台"并指导据此取码 |
|||
- **期望结果**:明确"日志不再输出验证码";dev/test 通过**受控测试夹具或数据库内部验收方式**取码;禁止生产查询验证码表完成登录;说明 `mock=false` 且无真实供应商时返回服务不可用;真实短信供应商待单独接入 |
|||
- **约束**:`docs/frontend-integration.md`、`docs/resume-api.md` 属**在途修改文件**,本轮不得改动 |
|||
|
|||
### F3(非阻断但违反 A2 明确要求)空值配置导致启动失败 |
|||
|
|||
- **文件**:`auth/sms/MockSmsCodeSender.java`(`@ConditionalOnProperty(name="app.sms.mock.enabled", havingValue="true")`)与 `auth/sms/UnavailableSmsCodeSender.java`(`havingValue="false", matchIfMissing=true`) |
|||
- **缺陷**:属性**存在但为空值**(如 env 中 `SMS_MOCK_ENABLED=`)时,两个条件都不匹配 → **0 个 `SmsCodeSender` Bean** |
|||
- **复现**:`java -jar target/mydreamjob-server-*.jar --app.sms.mock.enabled=` (其余同 D2) |
|||
- **实际结果**: |
|||
``` |
|||
APPLICATION FAILED TO START |
|||
Parameter 3 of constructor in com.mydreamjob.auth.SmsVerificationService required |
|||
a bean of type 'com.mydreamjob.auth.sms.SmsCodeSender' that could not be found. |
|||
``` |
|||
- **期望结果**:任何取值(含空值/空白)都应至少装配一个实现,绝不因缺少 Bean 以模糊 Spring Bean 错误启动失败(原工作令第 11 条) |
|||
- **影响**:部署可用性风险(空 env 变量在生产会致全站不可启动);未造成数据泄露 |
|||
|
|||
### F4(观察,非阻断)失败后残留限流键 |
|||
|
|||
- 现象:mock=false 发送失败后 `sms:rate:<phone>` 残留(TTL 60s),60s 内重试返回 `code=400 sms rate limited` 而非 `sms sender unavailable` |
|||
- 定性:**既有排序**(限流键在发送前写入),S01 未改变该顺序;MySQL 无残留有效验证码;不属冻结阻断项 |
|||
- 建议:后续轮次评估失败时释放限流键或调整写入顺序 |
|||
|
|||
### F5(观察,非阻断,既有行为)Spring Boot 默认安全口令入日志 |
|||
|
|||
- 现象:日志出现 `Using generated security password: <uuid>` |
|||
- 定性:Spring Boot `UserDetailsServiceAutoConfiguration` 默认行为;**经历史日志比对确认 S01 之前即存在**(`mydreamjob-server/logs/backend-startup.log` 等) |
|||
- 可利用性:应用未启用 httpBasic/formLogin,该口令无 HTTP 认证入口消费 → 不可用于登录 |
|||
- 建议:后续轮次排除 `UserDetailsServiceAutoConfiguration` 或显式声明 `AuthenticationManager` |
|||
|
|||
--- |
|||
|
|||
## 15. 剩余风险 |
|||
|
|||
1. **F1/F2 未修复前,开发/测试人员仍会按文档从日志找验证码 → 必然失败并误判"系统故障"** |
|||
2. 存量 43 处直接身份解析(均不可匿名到达,但缺乏统一 `CurrentUserAccessor` 收口);`DeletionGuardInterceptor` 对 20 位数字溢出仍可能 500 |
|||
3. 验证码仍以明文存储于 `sms_verification_codes.code`(本轮未改表结构,属已知遗留) |
|||
4. 真实短信供应商未接入;生产 `mock=false` 下所有发码请求均安全失败(设计如此,但需产品确认上线策略) |
|||
5. 安全层 401 响应不含 `requestId`(既有 `AuthenticationEntryPoint` 固定载荷),与业务层错误响应格式不一致,不利于链路追踪 |
|||
6. 本轮验收脚本依赖 Git Bash 环境变量语义(vhost 转换问题已规避并记录) |
|||
|
|||
--- |
|||
|
|||
## 16. 是否允许冻结 S01 |
|||
|
|||
**不允许冻结。** |
|||
|
|||
自动化指标(1658/1658、clean verify、静态与运行时 OpenAPI 173/211)全部达成,核心安全目标(日志脱敏、`SMS_MOCK_ENABLED` 生效、匿名 POST /jobs 401、非法 subject 401 不回显、真实 MySQL 回滚)**均已独立复验通过**;但存在 3 项 FAIL(F1 可执行脚本回归、F2 文档违规指导、F3 空值启动失败),其中 F2 直接命中验收标准第 17 项,F1 为可执行功能回归。 |
|||
|
|||
--- |
|||
|
|||
## 17. S01 下一轮定向整改指令 |
|||
|
|||
### R1 —— 修复冒烟脚本取码方式(对应 F1) |
|||
|
|||
- **文件**:`docs/smoke-test.sh` |
|||
- **方法**:`sms_code()` |
|||
- **错误数据/逻辑**:`grep -a "MOCK SMS CODE.*phone=$PHONE" "$log_path" | tail -n 1 | grep -a -oE 'code=[0-9]{6}' | cut -d= -f2` |
|||
- **复现请求**:`bash docs/smoke-test.sh`(mock=true 环境,日志中已无该行) |
|||
- **实际结果**:返回空字符串,登录步骤失败 |
|||
- **期望结果**:改为受控来源取码,二选一—— |
|||
1. 数据库内部查询(推荐,仅限本地/测试): |
|||
`SELECT code FROM sms_verification_codes WHERE phone=? AND purpose='login' AND used=0 ORDER BY id DESC LIMIT 1;` |
|||
2. 或由测试夹具直接注入固定验证码记录后读取 |
|||
- **必须新增的失败测试**:新增独立脚本断言 `sms_code()` 在"日志不含 `MOCK SMS CODE`"时仍能返回 6 位码(当前实现必然失败) |
|||
- **禁止的绕过**:不得恢复 `MOCK SMS CODE` 日志行;不得让脚本读取生产日志取码 |
|||
|
|||
### R2 —— 修正文档中的取码指引(对应 F2,命中验收标准第 17 项) |
|||
|
|||
- **文件与行号**:`docs/acceptance-week1.md:94,148,152`;`docs/api-v0.1.md:27,174`;`docs/offer-api.md:262`;`docs/frontend-integration.md:37,39,42,144`;`docs/resume-api.md:189` |
|||
- **错误内容**:声称"验证码打印到日志/控制台"并指导据此取码(含旧示例行) |
|||
- **复现**:`rg -n "控制台|日志.*验证码|MOCK SMS|验证码.*日志|查看验证码" docs README.md` |
|||
- **实际结果**:上述 10 处仍存在 |
|||
- **期望结果**:统一替换为—— |
|||
- 日志不再输出验证码; |
|||
- dev/test 环境通过**受控测试夹具或数据库内部验收方式**取得验证码; |
|||
- **禁止生产环境查询验证码表完成登录**; |
|||
- `mock=false` 且未接入真实供应商时返回"服务不可用",绝不返回虚假成功; |
|||
- 真实短信供应商仍待单独接入。 |
|||
- **协调要求**:`docs/frontend-integration.md`、`docs/resume-api.md` 属**简历上传安全**在途修改文件,需与该项变更方**合并处理**,避免互相覆盖 |
|||
- **必须新增的失败测试**:新增文档门禁测试(或 CI 检查)断言 `docs/**` 与 `README.md` 不再匹配 `MOCK SMS CODE|验证码.*打印.*日志|控制台.*验证码|见日志.*验证码` |
|||
- **禁止的绕过**:不得删除或改写为同义表述;不得仅在 README 声明而保留违规正文 |
|||
|
|||
### R3 —— 空值配置不再导致启动失败(对应 F3) |
|||
|
|||
- **文件**:`auth/sms/MockSmsCodeSender.java`、`auth/sms/UnavailableSmsCodeSender.java`(必要时新增无条件兜底实现) |
|||
- **错误配置**:`app.sms.mock.enabled=`(存在但空) |
|||
- **复现请求**:`java -jar target/mydreamjob-server-0.0.1-SNAPSHOT.jar --app.sms.mock.enabled=`(DB/Redis/RabbitMQ 同 D2) |
|||
- **实际结果**:`APPLICATION FAILED TO START … required a bean of type 'SmsCodeSender' that could not be found` |
|||
- **期望结果**:启动成功并装配 `UnavailableSmsCodeSender`(空值、空白值、缺失、`false` 四者行为一致;仅显式 `true` 才装配 Mock) |
|||
- **必须新增的失败测试**(`SmsSenderConfigurationTest` 内新增,不改动既有断言): |
|||
- `emptyPropertyStillProvidesUnavailableSender` |
|||
- `blankPropertyStillProvidesUnavailableSender` |
|||
- `exactlyOneSenderBeanForEveryValueIncludingEmpty` |
|||
- **禁止的绕过**:不得让 Mock 成为 `matchIfMissing` 默认;不得用 `@Primary` 掩盖歧义;不得让应用以空 Bean 启动(必须真实拒绝发码,不得伪造成功) |
|||
|
|||
### R4 —— 非阻断建议(可与上轮合并,不单独阻断冻结) |
|||
|
|||
1. `DeletionGuardInterceptor:60` 增加溢出安全解析(复用 `CurrentUserAccessor` 语义) |
|||
2. 评估失败时释放 `sms:rate:<phone>` 限流键(F4) |
|||
3. 排除 `UserDetailsServiceAutoConfiguration` 或显式声明 `AuthenticationManager`(F5) |
|||
4. 统一为安全层 401 响应补充 `requestId`(便于链路追踪) |
|||
|
|||
--- |
|||
|
|||
## 18. 验收产物清单(`docs/acceptance/s01-20260911/`) |
|||
|
|||
| 文件 | 用途 | |
|||
|---|---| |
|||
| `launch.sh` | 三种配置启动器(true/false/MISSING/EMPTY) | |
|||
| `gen-token.js` | 验收专用 HS256 Token 离线生成(密钥仅取自环境变量,不落盘) | |
|||
| `matrix.sh` / `matrix-results.txt` | 阶段 F 权限矩阵脚本与结果 | |
|||
| `phase-g.sh` / `phase-g-results.txt` | 阶段 G 非法 Token 边界脚本与结果 | |
|||
| `snapshot-jobs.sql` / `jobs-before.txt` / `jobs-after.txt` / `jobs-before-g.txt` / `jobs-after-g.txt` | jobs 表完整字段快照与对比 | |
|||
| `snapshot-sms.sql` / `before-mockfalse.txt` / `after-mockfalse.txt` | 阶段 E 验证码表回滚对比 | |
|||
| `job-body.json` / `tamper-body.json` / `status-body.json` / `ok-body.json` | 请求体夹具(ASCII,避免 shell 编码问题) | |
|||
| `d1*/d2*/d3*` 日志 | 三次配置启动的应用日志与 stdout | |
|||
| `runtime-openapi.json` | 运行时 OpenAPI 快照 | |
|||
| `acc-fixture.txt` / `acc-prefix.txt` | 验收夹具 ID 记录(已清理) | |
|||
| `sensitive-markers.txt` | 敏感标记清单(内容未写入报告) | |
|||
|
|||
**验收数据清理**:按精确 ID 与前缀删除 users(7933-7937)/companies(22,23)/jobs(42,43)/sms_verification_codes(1),Redis 验收键已清理;**保留**本轮窗口内 2 条 audit_logs(append-only 合规记录,不做删除);`jobs` 总数回到基线 26。清理前后均确认目标归属,无模糊 DELETE。 |
|||
|
|||
--- |
|||
|
|||
**已复现具体缺陷,S01不可冻结;验收报告已包含下一轮定向整改指令。** |
|||
@ -0,0 +1,288 @@ |
|||
# S01 最终真实 MySQL / Docker 增量复验报告(脱敏版) |
|||
|
|||
- **验收日期**:2026-09-14 |
|||
- **分支**:`fix/resume-upload-safety` |
|||
- **范围**:只验证 S01 第四轮整改(F6:`expires_at` → `expired_at`)后的**真实运行路径**;本轮**未修改任何代码** |
|||
- **结论**:**PASS** |
|||
- **本报告不含**:验证码值、完整手机号、JWT、`Authorization` 头、数据库口令、`.env` 内容 |
|||
|
|||
> 本轮为此前因 Docker Linux Engine 未运行而 BLOCKED 的复验。本次 Docker Engine 可用,全部真实黑盒已执行完成。 |
|||
|
|||
--- |
|||
|
|||
## 1. 基线与边界 |
|||
|
|||
| 项 | 实测 | |
|||
|---|---| |
|||
| 分支 | `fix/resume-upload-safety` | |
|||
| 暂存区 | **空**(`git diff --cached --name-only` 无输出) | |
|||
| `git diff --check` | 无空白错误(仅 CRLF 提示) | |
|||
| 迁移改动 | **无**(`git diff --name-only -- db/migration` 无输出;41 个文件,最大 `V41__message_recall.sql`) | |
|||
| 已跟踪改动 | 24 个(**全部来自此前几轮**:7 个 docs + 11 个 java main + 6 个 java test),本轮 **0 新增** | |
|||
| 删除文件 | **0** | |
|||
| 历史未跟踪文件 | 未删除、未移动、未覆盖 | |
|||
|
|||
本轮**未修改**:生产代码、现有测试、`docs/smoke-test.sh`、`V1–V41`;**未新增** Flyway/表/索引/`ErrorCode`/接口;**未恢复**验证码日志。 |
|||
|
|||
## 2. Docker 环境 |
|||
|
|||
| 项 | 值 | |
|||
|---|---| |
|||
| Docker Engine | 29.6.2(Docker Desktop 4.84.0,linux/amd64) | |
|||
| `mydreamjob-mysql` | **healthy**(mysql:8.4,127.0.0.1:3307→3306) | |
|||
| `mydreamjob-redis` | **healthy**(redis:7.4-alpine) | |
|||
| `mydreamjob-rabbitmq` | **healthy**(rabbitmq:3.13-management-alpine) | |
|||
|
|||
进入本轮时三个容器均为 `Exited`(2 天前停止)。经负责人明确授权后,对**已存在的具名容器**执行 `docker start`:**未重建容器、未改动配置、未触碰 volume(`mysql_data`/`redis_data`/`rabbitmq_data` 均保留)**。验收结束后容器保持运行,由负责人决定是否停止。 |
|||
|
|||
## 3. 真实 Schema 对账 |
|||
|
|||
真实 MySQL 执行 `SHOW COLUMNS FROM sms_verification_codes`(**应用账号 + `MYSQL_PWD`,非 root**): |
|||
|
|||
``` |
|||
id phone code purpose attempts max_attempts expired_at used created_at |
|||
``` |
|||
|
|||
三方对账**完全一致**: |
|||
|
|||
| 来源 | 列集合 | |
|||
|---|---| |
|||
| 真实 MySQL | `attempts code created_at expired_at id max_attempts phone purpose used` | |
|||
| 实体 `@Column` | 同上 | |
|||
| `V1__base_schema.sql` | 同上 | |
|||
|
|||
- **包含 `expired_at`** ✅ |
|||
- **不包含 `expires_at`** ✅ |
|||
|
|||
**反证**:`SELECT expires_at FROM sms_verification_codes LIMIT 1;` → `ERROR 1054 (42S22) Unknown column 'expires_at' in 'field list'`(退出码 1)。 |
|||
|
|||
## 4. 真实取码 SQL |
|||
|
|||
修复后的等价只读查询在真实 MySQL 执行: |
|||
|
|||
```sql |
|||
SELECT code FROM sms_verification_codes |
|||
WHERE phone = ? AND purpose = ? AND used = 0 |
|||
AND expired_at > NOW() |
|||
ORDER BY created_at DESC, id DESC LIMIT 1; |
|||
``` |
|||
|
|||
**退出码 = 0,无 `ERROR 1054`**。(查询返回行数按参数而定,本报告不展示任何取回的 `code`。) |
|||
|
|||
## 5. 最新 JAR 启动 |
|||
|
|||
| 项 | 值 | |
|||
|---|---| |
|||
| JAR | `target/mydreamjob-server-0.0.1-SNAPSHOT.jar`(`mvn clean verify` 产物) | |
|||
| 启动参数 | `--spring.jpa.hibernate.ddl-auto=validate`、`--app.sms.mock.enabled=true`、`--server.port=8080` | |
|||
| 启动结果 | **`Started MyDreamjobApplication`(14.9 s)** | |
|||
| Flyway | 无错误(仅一条 MySQL 8.4 版本提示 WARN) | |
|||
| Schema validation | **无错误** | |
|||
| `GET /api/v1/health` | **HTTP 200,`status=UP`** | |
|||
| 运行时 OpenAPI | **173 paths / 211 operations** | |
|||
|
|||
端口 8080 在启动前空闲,无未知进程被停止。日志写入本轮新目录 `docs/acceptance/s01-20260914/`,未覆盖历史日志。 |
|||
|
|||
## 6. 真实完整冒烟链 |
|||
|
|||
以唯一虚构前缀 `1990914xxxx` 与隔离 requestId 执行: |
|||
|
|||
```bash |
|||
SMS_MOCK_ENABLED=true ALLOW_TEST_OTP_DB_READ=true bash docs/smoke-test.sh |
|||
``` |
|||
|
|||
结果:**PASS=10 FAIL=0 SKIP=1,exit=0** |
|||
|
|||
| 步骤 | 结果 | |
|||
|---|---| |
|||
| `GET /health` | PASS(200) | |
|||
| `POST /auth/sms/send` + `POST /auth/login` | **PASS**(200 / 200) | |
|||
| `GET /jobs?size=5` | PASS | |
|||
| `GET /jobs/public/{id}` | PASS | |
|||
| `GET /jobs/{id}/match` | PASS | |
|||
| `POST /applications` | PASS | |
|||
| `PATCH /applications/{id}/status` | **SKIP**(未提供 `COMPANY_TOKEN`,属脚本既有语义) | |
|||
| `GET /notifications` | PASS | |
|||
| `GET /notifications/unread-count` | PASS | |
|||
| `POST /notifications/{id}/read` | PASS | |
|||
| `POST /applications/{id}/withdraw` | PASS | |
|||
|
|||
逐项确认: |
|||
|
|||
1. 发码成功 ✅ |
|||
2. `smoke-test.sh` 经**真实 `docker exec`** 连真实 MySQL ✅ |
|||
3. 使用应用账号 `MYSQL_USER`,**未使用 root** ✅ |
|||
4. mysql argv **不含密码** ✅(见 §7) |
|||
5. SQL 使用 `expired_at` ✅ |
|||
6. 成功取得可用验证码,**未显示其值** ✅ |
|||
7. 真实 `POST /auth/login` 成功 ✅ |
|||
8. 获得 Access Token,**未输出** ✅ |
|||
9. 后续岗位/投递/通知步骤按夹具条件执行 ✅ |
|||
10. **未出现 `ERROR 1054`** ✅ |
|||
11. 未出现空验证码 ✅ |
|||
12. 未从日志读取验证码 ✅ |
|||
13. stdout/stderr 不含验证码、完整手机号、JWT、`Authorization`、数据库口令 ✅ |
|||
|
|||
**核心链路无 SKIP**:唯一 SKIP 是企业状态步骤,取码与登录核心链路均实际执行并通过。 |
|||
|
|||
**真实库侧证据**:该手机号在 `sms_verification_codes` 中产生 **1 行、`used=1`、`code` 长度 6** —— 真实 MySQL 中确实生成了 6 位验证码并被真实登录消费。 |
|||
|
|||
> **关键推论**:登录成功即证明真实执行的 SQL 使用了**有效列**。若仍是 `expires_at`,查询会报 `ERROR 1054`、取不到码,登录必然失败。 |
|||
|
|||
### `bash -x` 复验 |
|||
|
|||
以 `bash -x` 执行(新手机号):**exit=0,PASS=10 FAIL=0 SKIP=1**,捕获 stdout+stderr 后扫描,敏感命中数: |
|||
|
|||
| 标记 | 命中 | |
|||
|---|---| |
|||
| 实际验证码值 | **0** | |
|||
| 完整手机号 | **0** | |
|||
| `Authorization: Bearer` | **0** | |
|||
| `LOGIN_BODY=` | **0** | |
|||
| JWT 形态(`eyJ…`) | **0** | |
|||
| `ERROR 1054` | **0** | |
|||
|
|||
## 7. F5 真实环境 argv 证据 |
|||
|
|||
在真实查询执行期间,读取**容器内 mysql 进程的实际 argv**: |
|||
|
|||
``` |
|||
mysql -umydreamjob -N -B mydreamjob |
|||
``` |
|||
|
|||
- 使用**应用账号**(非 root)✅ |
|||
- **不含 `-p<密码>`** ✅ |
|||
- **不含 `--password`** ✅ |
|||
- 密码经 `MYSQL_PWD` 环境变量传递 ✅ |
|||
|
|||
## 8. mock=false 真实 MySQL 回滚 |
|||
|
|||
以 `--app.sms.mock.enabled=false` 重启后调用 `POST /auth/sms/send`: |
|||
|
|||
| 项 | 结果 | |
|||
|---|---| |
|||
| 响应 | `HTTP 400` + `{"code":400,"message":"sms sender unavailable"}` | |
|||
| 是否虚假成功 | **否** ✅ | |
|||
| 验证码表前后状态 | **完全一致**(该手机号 0 行;全局 `max_id` 994→994;总数 918→918) ✅ | |
|||
| 残留 `used=0` 验证码 | **无** ✅ | |
|||
| 新增虚假发送成功审计 | **0** 行 ✅ | |
|||
| 日志含手机号/验证码 | **0** 命中 ✅ | |
|||
|
|||
响应与上一轮(`docs/acceptance/s01-20260911/d2-sms-response.txt`)逐字一致。 |
|||
|
|||
## 9. 配置缺失 / 空值 / 非法值启动 |
|||
|
|||
| 用例 | 启动 | Bean 缺失错误 | 发码响应 | 是否落库 | |
|||
|---|---|---|---|---| |
|||
| 配置**缺失** | `Started` ×1 | **0** | 400 `sms sender unavailable` | 未落库(994→994) ✅ | |
|||
| `SMS_MOCK_ENABLED=`(**空值**) | `Started` ×1 | **0** | 400 `sms sender unavailable` | 未落库(994→994) ✅ | |
|||
| `SMS_MOCK_ENABLED=invalid-value`(**非法值**) | `Started` ×1 | **0** | 400 `sms sender unavailable` | 未落库(994→994) ✅ | |
|||
|
|||
**「恰好一个 `SmsCodeSender` 且为 `UnavailableSmsCodeSender`」在真实环境按排除法成立**: |
|||
|
|||
- 0 个 Bean → 启动会以 `NoSuchBeanDefinition` 失败;≥2 个 → 会以 `NoUniqueBeanDefinition` 失败;Mock 生效 → 发码成功且落库。 |
|||
- 实测:**启动成功** + **发码不可用** + **未落库**,三者互斥地锁定「恰好一个 `UnavailableSmsCodeSender`」。 |
|||
|
|||
(Bean 数量与类型的严格断言另由 `SmsSenderConfigurationTest`(真实 `ApplicationContextRunner`)在 §10 覆盖。) |
|||
|
|||
每次只停止本轮明确记录 PID 的 JAR,未触碰任何未知进程。 |
|||
|
|||
## 10. 日志脱敏扫描 |
|||
|
|||
扫描对象:**本轮全部新日志**(6 个应用日志 + stdout)**+ 冒烟 stdout/stderr**。 |
|||
|
|||
| 禁止项 | 命中 | |
|||
|---|---| |
|||
| 实际验证码(本轮真实产生的 2 个,逐个比对) | **0 / 0** | |
|||
| 完整手机号(6 个测试号逐个比对) | **0** | |
|||
| `Authorization: Bearer` | **0** | |
|||
| JWT 形态(`eyJ…`) | **0** | |
|||
| `MYSQL_PASSWORD` 实际值 / 数据库口令 | **0** | |
|||
| `LOGIN_BODY` | **0** | |
|||
| `MOCK SMS CODE` | **0** | |
|||
| `NumberFormatException` | **0** | |
|||
| `ERROR 1054` | **0** | |
|||
|
|||
**敏感标记命中合计 = 0。** |
|||
|
|||
扫描器有效性对照:注入探针(`MyDreamJob backend smoke test`)命中 **2**,证明扫描器在工作,而非空转的 0。 |
|||
|
|||
## 11. OpenAPI 与回归 |
|||
|
|||
| 项 | 结果 | |
|||
|---|---| |
|||
| `python docs/verify_openapi.py` | **All checks passed!** | |
|||
| 运行时 `/v3/api-docs` | **173 paths / 211 operations** | |
|||
| 静态 `docs/openapi-rc.json` | **173 paths / 211 operations** | |
|||
| 静/运行时一致 | **YES**(路径差集:静态独有 = 无,运行独有 = 无) | |
|||
| 是否新增取码接口 | **无**(含 "code" 的唯一路径是 `/admin/scoring/risk/rules/{ruleCode}`,为风控规则码,与验证码无关) | |
|||
| 接口结构变化 | **无** | |
|||
|
|||
Maven 安全专项: |
|||
|
|||
``` |
|||
mvn -Dtest="SmsSenderConfigurationTest,SmsVerificationSecurityTest,SmsVerificationServiceTest,CurrentUserAccessorTest,AuthenticationBoundaryIntegrationTest,SmsEmptyPropertyStartupIntegrationTest,SmsUnavailableSenderRollbackIntegrationTest" test |
|||
``` |
|||
|
|||
→ **Tests run: 61, Failures: 0, Errors: 0, Skipped: 0 — BUILD SUCCESS** |
|||
|
|||
## 12. 数据清理 |
|||
|
|||
本轮产生的数据可按唯一虚构前缀 `1990914` 精确圈定(时间戳均为 2026-09-14): |
|||
|
|||
| 表 | 行数 | |
|||
|---|---| |
|||
| `users` | 2(验收虚构号码:`199****0101`、`199****0102`) | |
|||
| `sms_verification_codes` | 2 | |
|||
| `applications` | 2(均为 `WITHDRAWN`) | |
|||
| `notifications` | 4 | |
|||
| `user_roles` | 2 | |
|||
| `audit_logs` | 8 | |
|||
|
|||
**决策:保留本轮数据,不执行删除。** 理由: |
|||
|
|||
1. `audit_logs` 有 **8 行** `user_id` 指向这两个用户,且该表**无外键约束**;删除用户会使审计行成为孤儿。 |
|||
2. 本轮任务边界明确**禁止删除 `audit_logs` 行**。 |
|||
3. 保留可维持审计链一致;边界亦明确允许「无法安全确认时保留数据并记录」。 |
|||
|
|||
未执行任何模糊 `DELETE`、未清空表、未重置自增、未修改真实账号。上述足迹为精确记录,供后续人工处置。 |
|||
|
|||
## 13. 最终 Git 检查 |
|||
|
|||
| 项 | 结果 | |
|||
|---|---| |
|||
| `git status --short` | 24 个已跟踪改动(**与本轮开始时同一批**)+ 98 个未跟踪(既有残骸 + 本轮验收产物) | |
|||
| `git diff --cached --name-only` | **空**(未暂存) | |
|||
| `git diff --check` | 无空白错误 | |
|||
| `git diff --name-only -- db/migration` | **空**(V1–V41 未修改) | |
|||
| 删除文件 | **0** | |
|||
| commit / push | **未执行** | |
|||
| S02 | **未进入** | |
|||
|
|||
## 14. 验收结论 |
|||
|
|||
| # | 判据 | 结果 | |
|||
|---|---|---| |
|||
| 1 | Docker/MySQL 可用 | ✅ | |
|||
| 2 | 真实表包含 `expired_at` | ✅ | |
|||
| 3 | 真实取码 SQL 执行成功 | ✅ | |
|||
| 4 | 完整学生登录链成功 | ✅ | |
|||
| 5 | `bash -x` 无敏感泄露 | ✅ | |
|||
| 6 | mock=false 真实 MySQL 完整回滚 | ✅ | |
|||
| 7 | 缺失/空/非法配置安全回退 | ✅ | |
|||
| 8 | 运行时 OpenAPI 为 173/211 | ✅ | |
|||
| 9 | 无日志泄露 | ✅ | |
|||
| 10 | 核心项无 SKIP | ✅ | |
|||
| 11 | FAIL = 0 | ✅ | |
|||
|
|||
**S01独立代码复验和真实MySQL/Docker黑盒全部通过,可以进入精确暂存检查点。** |
|||
|
|||
--- |
|||
|
|||
## 遗留与观察(不阻断本轮结论) |
|||
|
|||
1. **验证码仍为数据库明文存储**(未改表结构,属已知遗留)。 |
|||
2. **真实短信供应商未接入**;生产 `mock=false` 下发码一律安全失败,需产品确认上线策略。 |
|||
3. **本轮测试账号保留在库**(见 §12),与其审计记录一致;如需清理应由人工按精确 ID 处置。 |
|||
4. `/api/v1/api-docs` 返回 500(`/api/v1/v3/api-docs` 正常 200)。该行为与 S01 无关,非本轮引入,记录待查。 |
|||
5. 三个容器在验收结束后**保持运行**(本轮仅 `docker start`,未重建、未删除 volume),是否停止由负责人决定。 |
|||
@ -0,0 +1,786 @@ |
|||
# S01 定向整改报告(F1 / F2 / F3) |
|||
|
|||
- **整改日期**:2026-09-11 |
|||
- **分支**:`fix/resume-upload-safety` |
|||
- **范围**:仅修复独立验收确认的 F1、F2、F3,并完成对应自动化与干净构建 |
|||
- **本报告不包含**:数据库口令、.env 内容、JWT、验证码值、完整手机号、明文测试口令、非法 principal 敏感标记原文 |
|||
- **整改期间状态**:`S01独立验收未通过,定向整改中。` |
|||
|
|||
--- |
|||
|
|||
## 1. F1 的修复方式 |
|||
|
|||
`docs/smoke-test.sh` 的 `sms_code()` **彻底删除**了对应用日志的依赖(grep `MOCK SMS CODE`),改为**双重保护 + 容器内部只读 SQL 查询**。 |
|||
|
|||
同时删除:`SERVER_LOG` 变量、`resolve_log()` 函数、全部日志 grep。未恢复任何验证码日志。 |
|||
|
|||
| 项 | 整改前 | 整改后 | |
|||
|---|---|---| |
|||
| 取码来源 | `grep -a "MOCK SMS CODE.*phone=$PHONE" <日志>` | 容器内 `SELECT code FROM sms_verification_codes …` | |
|||
| 前置授权 | 无 | `SMS_MOCK_ENABLED=true` **且** `ALLOW_TEST_OTP_DB_READ=true` | |
|||
| 取码返回值 | stdout(进入 command substitution) | `REPLY` 变量约定(不打印) | |
|||
| 请求体传参 | `-d "<含验证码>"`(进入进程参数) | `--data-binary @-`(经 stdin) | |
|||
| 凭据处理 | 需 `SERVER_LOG` | 容器自身 `MYSQL_ROOT_PASSWORD` / `MYSQL_DATABASE`(不在本机命令行拼写口令) | |
|||
| 清理 | 无 | `unset REPLY/SMS/LOGIN_BODY` + `trap cleanup_secrets EXIT` | |
|||
|
|||
脚本仍为 Bash,兼容当前 Git Bash;`bash -n` 语法校验通过。 |
|||
|
|||
## 2. smoke-test.sh 的新取码机制 |
|||
|
|||
``` |
|||
sms_code() |
|||
├─ require_mock_sms_mode() # SMS_MOCK_ENABLED 必须为 true |
|||
├─ require_test_otp_db_read_permission() # ALLOW_TEST_OTP_DB_READ 必须为 true |
|||
├─ 校验 PHONE 为 11 位纯数字(防注入) |
|||
├─ 记录并关闭 xtrace(set +x),执行后精确恢复原状 |
|||
├─ IFS= read -r code < <(otp_db_query ...) # 不使用 command substitution 取值 |
|||
├─ 校验 code 为 6 位数字(当前 code-length / CODE_PATTERN) |
|||
├─ 失败 → unset code + 固定提示 + return 1(绝不携带空值继续登录) |
|||
└─ 成功 → REPLY="$code"; unset code; return 0 |
|||
``` |
|||
|
|||
`otp_db_query()` 关键点: |
|||
|
|||
- **容器名不硬编码**:`compose_mysql_container()` 用 `awk` 解析项目实际 `backend-infra/compose.yaml` 中 `mysql` 服务的 `container_name`;用 `BASH_SOURCE` 定位自身目录(被 `source` 时同样正确) |
|||
- SQL 经 **stdin**(heredoc 风格 `printf | docker exec -i` ),不出现在任何进程参数中 |
|||
- 查询条件完整:`phone` + `purpose` + `used = 0` + `expired_at > NOW()` |
|||
(更正:本行原写作 `expires_at`,该列在 `sms_verification_codes` 中**不存在**,是 F6 记录的错误字段名;已于第四轮一并更正,详见文末「第四轮定向整改(F6)」) |
|||
- `ORDER BY created_at DESC, id DESC LIMIT 1`,只取 `code` 列 |
|||
- 不使用 `curl -v`;失败诊断不打印请求体 |
|||
|
|||
## 3. 为什么不会把验证码输出到终端 / 日志 / 文件 |
|||
|
|||
1. 取码函数**从不 `echo`/`printf` 验证码**,只写 `REPLY`;调用方 `SMS="$REPLY"` 后立即 `unset REPLY` |
|||
2. `set +x` 包住取值与查询,且**按调用前状态精确恢复**,不无条件开启 xtrace |
|||
3. 登录请求体经 `--data-binary @-` 从 stdin 送入 curl,验证码不进入 argv |
|||
4. 请求体只在 `LOGIN_BODY` 局部变量中存在,用完 `unset`;**不落盘** |
|||
5. `trap cleanup_secrets EXIT` 兜底清理 `OTP/REPLY/LOGIN_BODY` |
|||
6. 脚本控制台只输出 `PHONE=$(mask_phone "$PHONE")`(脱敏为 `199****9999`),不打印完整手机号 |
|||
7. 应用侧本就不输出验证码(S01 已整改),Mock 发送器仅输出固定文本 |
|||
|
|||
## 4. `ALLOW_TEST_OTP_DB_READ` 的强制条件 |
|||
|
|||
```bash |
|||
is_true() { v=$(printf '%s' "${1:-}" | tr -d '[:space:]' | tr '[:upper:]' '[:lower:]'); [ "$v" = "true" ]; } |
|||
require_mock_sms_mode() { is_true "${SMS_MOCK_ENABLED:-}"; } |
|||
require_test_otp_db_read_permission() { is_true "${ALLOW_TEST_OTP_DB_READ:-}"; } |
|||
``` |
|||
|
|||
- 两者同时为 `true`(大小写/空白不敏感)才允许查询 |
|||
- 任一不满足:立即返回非 0,输出固定提示 `test OTP retrieval is disabled`,**不执行任何 docker/SQL 调用** |
|||
- 提示中不含手机号、验证码、数据库口令、SQL 结果或容器名 |
|||
|
|||
## 5. 未授权取码的实际结果 |
|||
|
|||
| 场景 | 实际结果 | |
|||
|---|---| |
|||
| `SMS_MOCK_ENABLED` 缺失 | 拒绝;输出 `test OTP retrieval is disabled`;**docker 未被调用** | |
|||
| `SMS_MOCK_ENABLED=false` | 拒绝;同上 | |
|||
| `ALLOW_TEST_OTP_DB_READ` 缺失 | 拒绝;同上 | |
|||
| `ALLOW_TEST_OTP_DB_READ=false` | 拒绝;同上 | |
|||
| 两者为 true 但无可用验证码 | `test OTP retrieval failed`,**不继续登录** | |
|||
| 两者为 true 但查询失败/结果非 6 位数字 | 同上,**不继续登录** | |
|||
|
|||
## 6. F2 修改的全部文档与行 |
|||
|
|||
| 文件 | 行(整改后) | 修改前 | 修改后要点 | |
|||
|---|---|---|---| |
|||
| `docs/acceptance-week1.md` | 94 | `# Mock 登录(从日志获取验证码)` | 指向受控取码方式 | |
|||
| `docs/acceptance-week1.md` | 148 | 注: 验证码从应用日志中获取 | 明确日志不输出;列出双开关;生产禁止查表 | |
|||
| `docs/acceptance-week1.md` | 152 | 验证码打印到日志 | 明确不输出;标注明文存储遗留 | |
|||
| `docs/api-v0.1.md` | 27 | Mock环境验证码打印到日志 | 明确不输出 + 受控取得 | |
|||
| `docs/api-v0.1.md` | 174 | 验证码打印到日志 | 明确不输出;未接入供应商返回服务不可用 | |
|||
| `docs/frontend-integration.md` | 37-49(§3 整节重写) | 「Mock 验证码日志位置」+ 旧示例行 `MOCK SMS CODE: … code=******` + 「看终端输出/看日志文件/取最新一条」 | 改为「本地获取测试验证码(不通过日志)」:双开关 + 受控脚本 + 生产禁止查表 + 明文遗留风险 | |
|||
| `docs/frontend-integration.md` | 149 | 验证码打印在服务日志 | 改为不再输出 + 受控取得 | |
|||
| `docs/offer-api.md` | 262 | 验证码见应用日志 | 改为受控取得 + 生产禁止查表 | |
|||
| `docs/resume-api.md` | 189 | 登录学生账号(Mock 验证码,见日志) | 改为受控取得 + 生产禁止查表 | |
|||
| `docs/smoke-test.sh` | 全文头部与取码段 | 依赖日志取码 | 见第 1-5 节 | |
|||
|
|||
**文档统一口径(已落实)**: |
|||
|
|||
1. 删除所有「从控制台/日志查看验证码」的操作指导 |
|||
2. 明确应用日志不会输出验证码 |
|||
3. dev/test 本地自动化使用受控测试夹具/数据库内部只读查询 |
|||
4. 冒烟脚本仅在 `mock=true` **且** `ALLOW_TEST_OTP_DB_READ=true` 时查表 |
|||
5. 真实生产环境**禁止**查询验证码表辅助登录 |
|||
6. `mock=false` 且无真实供应商时返回「服务不可用」 |
|||
7. 明确当前尚未接入真实短信供应商 |
|||
8. **未声称**验证码已加密/哈希存储 |
|||
9. 明确验证码当前仍为数据库**明文**遗留风险 |
|||
10. 示例中不含真实手机号、验证码、JWT 或数据库口令 |
|||
|
|||
**在途文件保护**:`docs/frontend-integration.md` 与 `docs/resume-api.md` 仅改动取码相关段落;`git diff` 复核确认简历上传加固内容(文件名净化、PDF/DOC/DOCX 结构校验、上传替换一致性、恶意软件扫描边界等)**逐字保留**,未覆盖、未还原、未重新格式化。 |
|||
|
|||
## 7. 全仓遗留搜索结果 |
|||
|
|||
``` |
|||
grep -rn -i "MOCK SMS CODE|控制台.*验证码|验证码.*打印到日志|从日志获取|查看.*验证码" |
|||
docs README.md mydreamjob-server/src |
|||
``` |
|||
|
|||
- **「打印到日志 / 从日志获取」命中数:0** |
|||
- `MOCK SMS CODE` 在 `mydreamjob-server/src` 与 `docs/` 中的命中数:**0** |
|||
- 剩余匹配全部为**禁止性表述或无关标识符**(如「日志不得包含验证码」「不得写入日志」「SMS_CODE_INVALID」枚举名) |
|||
- `README.md:167` 本身即「不写入敏感信息」的正确约定,未修改 |
|||
|
|||
## 8. F3 使用的 Bean 装配方案 |
|||
|
|||
采用**推荐的单 `@Configuration` + 单 `@Bean` 方案**(不再依赖两套互斥 `@ConditionalOnProperty`): |
|||
|
|||
```java |
|||
@Configuration |
|||
public class SmsSenderConfiguration { |
|||
private static final String MOCK_ENABLED_VALUE = "true"; |
|||
|
|||
@Bean |
|||
public SmsCodeSender smsCodeSender( |
|||
@Value("${app.sms.mock.enabled:false}") String rawEnabled) { |
|||
if (rawEnabled != null && MOCK_ENABLED_VALUE.equalsIgnoreCase(rawEnabled.trim())) { |
|||
return new MockSmsCodeSender(); |
|||
} |
|||
return new UnavailableSmsCodeSender(); |
|||
} |
|||
} |
|||
``` |
|||
|
|||
- `MockSmsCodeSender` / `UnavailableSmsCodeSender` 已移除 `@Component` 与 `@ConditionalOnProperty`,成为普通类 |
|||
- 满足:始终恰好一个 Bean;仅显式 `true` 启用 Mock;非法值不启用 Mock;**不根据 profile 偷偷启用**;`application-dev.yml` 仍显式 `true`;`application.yml` 默认 `false` |
|||
- 不把选择逻辑放回 `SmsVerificationService`;不新增第二套发送流程;不用 try/catch Bean 缺失兜底;不在每次发送时读配置;**不输出配置原值到日志** |
|||
|
|||
## 9. 各配置取值对应的实现 |
|||
|
|||
| `app.sms.mock.enabled` | 装配实现 | |
|||
|---|---| |
|||
| `true` / `TRUE` / `True` / `" true "` | `MockSmsCodeSender` | |
|||
| `false` | `UnavailableSmsCodeSender` | |
|||
| 缺失 | `UnavailableSmsCodeSender` | |
|||
| 空字符串(`=`) | `UnavailableSmsCodeSender` | |
|||
| 仅空白(`= `) | `UnavailableSmsCodeSender` | |
|||
| 非法值(`yes`/`1`/`on`/`enabled`/`null`/`mock`/`TRUE1`) | `UnavailableSmsCodeSender`(安全回退,**绝不启用 Mock**) | |
|||
|
|||
## 10. 每种配置的 Bean 数量 |
|||
|
|||
由 `SmsSenderConfigurationTest.everySupportedConfigurationCreatesExactlyOneSenderBean` 断言(真实 `ApplicationContextRunner`,含 9 种取值 + 缺失属性): |
|||
|
|||
**每种取值 `getBeanNamesForType(SmsCodeSender.class).length == 1`**。 |
|||
|
|||
## 11. 空值启动结果 |
|||
|
|||
| 层级 | 结果 | |
|||
|---|---| |
|||
| 快速装配测试(`ApplicationContextRunner`) | 上下文**启动成功**;恰好一个 Bean;类型 `UnavailableSmsCodeSender`;消费者构造注入成功;发送抛固定安全 `SERVICE_UNAVAILABLE` | |
|||
| **全应用上下文测试**(`SmsEmptyPropertyStartupIntegrationTest`,真实 H2 + Flyway + 全 Bean) | 应用上下文**启动成功**;恰好一个 `SmsCodeSender`;类型 `UnavailableSmsCodeSender`;`SmsVerificationService` **可注入**;发送返回固定安全服务不可用 ✅ | |
|||
|
|||
整改前该取值导致 `APPLICATION FAILED TO START … required a bean of type 'com.mydreamjob.auth.sms.SmsCodeSender' that could not be found`(独立验收阶段已复现并记录在验收报告 F3)。 |
|||
|
|||
> 本轮按要求**未操作 Docker / 未执行真实 MySQL 黑盒**,故未做真实 JAR 启动;全应用上下文启动已由 H2+Flyway 覆盖,真实环境启动仍待独立复验。 |
|||
|
|||
## 12. 是否恢复验证码日志 |
|||
|
|||
**未恢复。** `grep -rn "MOCK SMS CODE" mydreamjob-server/src` 命中 0;`MockSmsCodeSender` 仅输出固定文本 `SMS verification code generated (mock sender)`;`SmsVerificationService` 仅两处 `log.warn`,参数为 `maskPhone(phone)`;`AuthService` 仅记录 `id`。 |
|||
|
|||
## 13. 原 S01 权限与事务回归结果 |
|||
|
|||
| 原 S01 要求 | 结果 | |
|||
|---|---| |
|||
| 不恢复验证码日志 | ✅(第 12 节) | |
|||
| 不输出完整手机号 | ✅ `SmsVerificationSecurityTest` 10/10 | |
|||
| Mock 发送器不打印验证码 | ✅ | |
|||
| mock=false 不返回虚假成功 | ✅ `SmsSenderConfigurationTest` + 回滚集成测试 | |
|||
| mock=false 发送失败事务回滚 | ✅ `SmsUnavailableSenderRollbackIntegrationTest` 3/3(真实 Spring + H2 + Flyway:失败后该手机号**无任何验证码行**、总数不变、无虚假成功审计) | |
|||
| 匿名 POST /jobs = 401 | ✅ `AuthenticationBoundaryIntegrationTest` 15/15 | |
|||
| STUDENT/ADMIN 创建岗位 = 403/2002 | ✅ | |
|||
| COMPANY 合法创建保持成功 | ✅ | |
|||
| 非法 principal 返回 401 | ✅ `CurrentUserAccessorTest` 11/11 | |
|||
| 不返回 NumberFormatException / 内部 principal | ✅ | |
|||
| OpenAPI 173/211 | ✅ | |
|||
| 无新增公网取码接口 | ✅(OpenAPI 173/211 不变) | |
|||
| 无新 Flyway/表/索引/ErrorCode | ✅ | |
|||
|
|||
## 14. Shell 测试数量与结果 |
|||
|
|||
`docs/tests/smoke-test-sms-code-test.sh`(新增):**22 用例,PASS=22 / FAIL=0** |
|||
|
|||
覆盖:mock=true+ALLOW=true 可取码、mock=false 拒绝、配置缺失拒绝、ALLOW 缺失/为 false 拒绝、无可用验证码安全失败、查询失败不继续、结果非数字/长度非法拒绝、查询为空时不继续登录(无 login 请求)、stdout/stderr 不含验证码、不含完整手机号、生成文件不含验证码、不 grep 应用日志、不存在 `MOCK SMS CODE`、REPLY/SMS/LOGIN_BODY 清理与 EXIT trap 存在、未授权时不触碰数据库、拒绝提示不含手机号、**授权模式下可完成登录流程(登录请求已发出且携带 6 位码、登录步骤判定 PASS)**。 |
|||
|
|||
测试通过 PATH 注入假 `docker`/`curl` 实现,**不要求 Docker 或真实 MySQL**,仓库内不写入真实验证码,结束时扫描输出确认标记未泄露。 |
|||
|
|||
## 15. Maven 专项、关联与全量测试数 |
|||
|
|||
| 命令 | Tests run | |
|||
|---|---| |
|||
| `-Dtest="SmsSenderConfigurationTest,SmsVerificationSecurityTest,SmsVerificationServiceTest,CurrentUserAccessorTest,AuthenticationBoundaryIntegrationTest"` | **56** | |
|||
| `-Dtest="*Auth*Test,*Security*Test,*Sms*Test,*Job*Test"` | **222** | |
|||
| `mvn test`(最终,含本轮新增 2 个集成测试) | **1671** | |
|||
| `mvn clean verify` | **1671** | |
|||
|
|||
S01 相关类明细: |
|||
|
|||
| 测试类 | Tests | |
|||
|---|---| |
|||
| `SmsSenderConfigurationTest`(F3,由 4 扩展至 12) | 12 | |
|||
| `SmsVerificationSecurityTest` | 10 | |
|||
| `SmsVerificationServiceTest` | 8 | |
|||
| `CurrentUserAccessorTest` | 11 | |
|||
| `AuthenticationBoundaryIntegrationTest` | 15 | |
|||
| `SmsUnavailableSenderRollbackIntegrationTest`(本轮新增) | 3 | |
|||
| `SmsEmptyPropertyStartupIntegrationTest`(本轮新增) | 2 | |
|||
|
|||
## 16. Failures / Errors / Skipped |
|||
|
|||
**Failures=0、Errors=0、Skipped=0**(全量与 clean verify 一致) |
|||
|
|||
## 17. clean verify 结果 |
|||
|
|||
`mvn clean verify` → **BUILD SUCCESS**(4:34),产出 `target/mydreamjob-server-0.0.1-SNAPSHOT.jar`。 |
|||
|
|||
## 18. OpenAPI paths / operations |
|||
|
|||
| 来源 | paths | operations | |
|||
|---|---|---| |
|||
| 静态 `docs/verify_openapi.py` | **173** | **211** | |
|||
| 校验输出 | `All checks passed!` | — | |
|||
|
|||
未新增/删除路径或 operation;无新增测试取码端点。 |
|||
|
|||
## 19. 精确修改文件 |
|||
|
|||
**修改(已跟踪,本轮 F1/F2)** |
|||
|
|||
| 文件 | 变更 | |
|||
|---|---| |
|||
| `docs/smoke-test.sh` | +176/−…(取码机制整体替换、双保护、脱敏输出、库模式钩子) | |
|||
| `docs/acceptance-week1.md` | 3 处取码表述 | |
|||
| `docs/api-v0.1.md` | 2 处取码表述 | |
|||
| `docs/frontend-integration.md` | §3 整节重写 + 1 处(**在途文件,最小改动**) | |
|||
| `docs/offer-api.md` | 1 处取码表述 | |
|||
| `docs/resume-api.md` | 1 处取码表述(**在途文件,最小改动**) | |
|||
|
|||
**修改(S01 新建文件,本轮扩展,F3)** |
|||
|
|||
| 文件 | 变更 | |
|||
|---|---| |
|||
| `auth/sms/MockSmsCodeSender.java` | 移除 `@Component`/`@ConditionalOnProperty`,改为普通类 | |
|||
| `auth/sms/UnavailableSmsCodeSender.java` | 同上 | |
|||
| `auth/SmsSenderConfigurationTest.java` | 由 4 用例扩展至 12(真实 ApplicationContextRunner + 消费者探针) | |
|||
|
|||
**新增** |
|||
|
|||
| 文件 | 用途 | |
|||
|---|---| |
|||
| `auth/sms/SmsSenderConfiguration.java` | F3 单 Bean 装配 | |
|||
| `auth/SmsUnavailableSenderRollbackIntegrationTest.java` | 真实 Spring+H2:失败不残留验证码 | |
|||
| `auth/SmsEmptyPropertyStartupIntegrationTest.java` | 空值全应用上下文启动 | |
|||
| `docs/tests/smoke-test-sms-code-test.sh` | F1 shell 安全测试(22 用例) | |
|||
|
|||
## 20. Git 状态 |
|||
|
|||
- 分支:`fix/resume-upload-safety`(未变) |
|||
- **暂存区:空** |
|||
- `git diff --check`:无空白错误(仅 CRLF 提示) |
|||
- 迁移改动:**无**(V1–V41 共 41 个文件,最大版本 41) |
|||
- 删除文件:**0** |
|||
- 历史未跟踪文件:未删除、未移动、未覆盖(含 `docs/acceptance/s01-20260911/` 全部验收证据 43 个文件与验收报告,保持原样) |
|||
|
|||
## 21. 是否修改 Flyway、表、ErrorCode 或公网接口 |
|||
|
|||
**均未修改。** |
|||
|
|||
- Flyway:无新增/修改,V1–V41 原样 |
|||
- 数据库表/索引:未新增或修改(验证码仍明文存储于 `sms_verification_codes`,属已记录遗留风险) |
|||
- `ErrorCode`:未被改动 |
|||
- 公网接口:OpenAPI 保持 173 paths / 211 operations;未新增取码接口 |
|||
|
|||
## 22. 明确未执行 git add -A / commit / push |
|||
|
|||
**未执行** `git add -A`、**未 commit**、**未 push**(亦未 `reset`/`restore`/`checkout`/`clean`)。 |
|||
|
|||
## 23. 明确未操作 Docker、未执行真实 MySQL 黑盒 |
|||
|
|||
**未执行任何 Docker 命令**、**未执行真实 MySQL 黑盒**。本轮全部验证均为:shell 测试(假 docker/curl 注入)+ Maven 单元/集成测试(H2 + Flyway + 真实 Spring 上下文)。 |
|||
|
|||
## 24. 真实 MySQL / Docker 复验仍待独立验收 |
|||
|
|||
F1 的数据库取码路径(`docker exec` + 容器内 SQL)**尚未在真实 MySQL 环境端到端执行**;F3 的运行期 JAR 启动(含空值 / 缺失 / 非法值)亦未在真实环境启动验证。二者**必须由独立复验在真实 MySQL/Docker 环境完成**,不得以本轮 H2 结果代替。 |
|||
|
|||
--- |
|||
|
|||
## 遗留与后续 |
|||
|
|||
1. **验证码仍为数据库明文存储**(本轮未改表结构,属已知遗留) |
|||
2. **真实短信供应商未接入**;生产 `mock=false` 下发码一律安全失败,需产品确认上线策略 |
|||
3. **存量 43 处直接身份解析**未收口(均不可匿名到达;`DeletionGuardInterceptor` 对 20 位数字溢出仍可能 500)——非本轮范围 |
|||
4. **安全层 401 响应不含 requestId**(既有 `AuthenticationEntryPoint` 固定载荷),与业务层错误响应格式不一致 |
|||
5. 上一轮验收的其余观察项(失败后残留 `sms:rate` 限流键 60s;Spring Boot 默认 `generated security password` 入日志)**本轮未处理**,仍属非阻断遗留 |
|||
|
|||
--- |
|||
|
|||
# 第三轮定向整改(F4 / F5) |
|||
|
|||
- **整改日期**:2026-09-11(第二轮之后) |
|||
- **独立复验结论**:S01 仍 FAIL —— F2/F3 已通过,但新发现 F4(xtrace 泄露)与 F5(数据库密码进入 mysql argv)两个阻断项 |
|||
- **本轮范围**:仅修 F4/F5,并相应扩展 shell 测试、修正文档测试口径 |
|||
- **本轮修改文件**:`docs/smoke-test.sh`、`docs/tests/smoke-test-sms-code-test.sh`、本报告 |
|||
- **本轮未触碰**:S01 Java 生产代码(时间戳仍为 15:45,本轮为 17:38+ 仅改 docs) |
|||
|
|||
## T1. F4 修复前的泄露位置 |
|||
|
|||
以 `bash -x` 执行时,以下位置被 trace 到 stderr(原文来自复验报告,此处仅列位置): |
|||
|
|||
| 位置 | 泄露内容 | 原因 | |
|||
|---|---|---| |
|||
| `PHONE="${STUDENT_PHONE}"` / 生成分支 | 完整手机号 | 脚本开头未关闭 xtrace | |
|||
| `SMS="$REPLY"` | 验证码 | `sms_code()` 返回前 `set -x` 恢复,赋值被 trace | |
|||
| `LOGIN_BODY="{...code...}"` | 验证码 + 完整手机号 | 赋值被 trace | |
|||
| `printf '%s' "$LOGIN_BODY" \| curl ...` | 登录请求体(含验证码) | 命令与参数被 trace | |
|||
| `LOGIN="$(...)"` | 登录响应全文(含 JWT) | 赋值被 trace | |
|||
| `TOKEN="$(extract_token "$LOGIN")"` | JWT | 赋值被 trace | |
|||
| `AUTH="Authorization: Bearer $TOKEN"` | JWT | 赋值被 trace | |
|||
| 后续 `curl ... -H "$AUTH"` | JWT | 命令参数被 trace | |
|||
| `sms_code()` 内部 `set +x` / `set -x` | 只保护了取码阶段 | 保护范围不足 | |
|||
|
|||
## T2. 全局 xtrace 处理方式 |
|||
|
|||
采用「**开头立即关闭且全程不恢复**」(任务书推荐方案): |
|||
|
|||
```bash |
|||
#!/usr/bin/env bash |
|||
|
|||
# ── xtrace 防护(S01 第三轮 F4)── |
|||
# 必须在读取任何环境变量、组装任何请求之前关闭 xtrace。 |
|||
# 脚本是独立可执行程序,安全优先:全程不重新 set -x。 |
|||
case "$-" in |
|||
*x*) set +x ;; |
|||
esac |
|||
|
|||
set -u |
|||
``` |
|||
|
|||
- 位置:`set +x` 位于**任何环境变量读取与任何请求组装之前**(仅在 shebang 与注释之后) |
|||
- `sms_code()` 内部的 `xtrace_was_on` 与恢复 `set -x` 逻辑**已彻底删除**(`grep -c xtrace_was_on` = 0) |
|||
- 脚本中**不存在可执行的 `set -x`**(仅 1 处注释提及,测试 F4-9 已断言) |
|||
- 保护范围覆盖全脚本:验证码取码、手机号、请求体、登录响应、TOKEN、AUTH 及**后续所有携带 Authorization 的 curl 调用** |
|||
- 未使用 `curl -v`/`--trace`/`--trace-ascii`(测试第 20 条断言) |
|||
- 登录响应全文不再交给打印函数:新增 `extract_status_fields()` + `report_fields()`,登录步骤只把 http / business code / requestId 传出去(测试第 21 条断言) |
|||
|
|||
## T3. `bash -x` 真实执行结果 |
|||
|
|||
由测试脚本内部驱动(捕获被测脚本 stdout+stderr 后扫描标记,只输出 PASS/FAIL): |
|||
|
|||
| 用例 | 结果 | |
|||
|---|---| |
|||
| F4-1 bash -x 下不泄露验证码 | PASS | |
|||
| F4-2 bash -x 下不泄露完整手机号 | PASS | |
|||
| F4-3 bash -x 下不泄露 JWT | PASS | |
|||
| F4-4 bash -x 下不泄露 Authorization 头 | PASS | |
|||
| F4-5 bash -x 下不泄露数据库密码 | PASS | |
|||
| F4-6 bash -x 下不泄露 LOGIN_BODY | PASS | |
|||
| F4-7 bash -x 下正常流程仍可完成登录 | PASS | |
|||
| F4-8 bash -x 下登录步骤判定 PASS | PASS | |
|||
| F4-9 脚本中不存在可执行的 set -x | PASS | |
|||
| F4-10 失败流程(无可用验证码)同样不泄露 | PASS | |
|||
| F4-11 source 模式下 REPLY 契约可用 | PASS | |
|||
| F4-12 source 模式下清理后无敏感变量残留 | PASS | |
|||
| F4-13 source 模式下 EXIT trap 已登记清理函数 | PASS | |
|||
| F4-14 cleanup_secrets 覆盖 PHONE/TOKEN/AUTH/COMPANY_TOKEN | PASS | |
|||
|
|||
另外单独执行 `bash -x docs/tests/smoke-test-sms-code-test.sh` → exit=0、PASS=51/FAIL=0; |
|||
该模式下测试脚本自身的假值可能被 trace(任务书已明确属预期,不作为安全判定), |
|||
但**测试结论输出(stdout)中标记值命中数为 0**。 |
|||
|
|||
## T4. stdout/stderr 敏感标记命中数 |
|||
|
|||
以 `bash -x` 执行被测 `smoke-test.sh`(正常流程与失败流程各一次),对捕获的 stdout+stderr 递归扫描: |
|||
|
|||
| 标记类别 | 命中数 | |
|||
|---|---| |
|||
| 测试验证码 | **0** | |
|||
| 完整手机号 | **0** | |
|||
| 测试 JWT | **0** | |
|||
| `Authorization: Bearer` | **0** | |
|||
| 数据库密码标记 | **0** | |
|||
| `LOGIN_BODY=` | **0** | |
|||
|
|||
(报告不记录标记值本身;标记值均为测试用固定假值,仅存在于测试脚本内。) |
|||
|
|||
## T5. TOKEN / AUTH 生命周期 |
|||
|
|||
| 变量 | 生命周期 | |
|||
|---|---| |
|||
| `REPLY` | `sms_code()` 成功时写入;调用方立即 `SMS="$REPLY"; unset REPLY` | |
|||
| `SMS` | 组装 `LOGIN_BODY` 后立即 `unset SMS` | |
|||
| `LOGIN_BODY` | 经 `--data-binary @-` 送入 curl 后立即 `unset LOGIN_BODY` | |
|||
| `LOGIN`(响应全文) | 仅用于提取 http/code/requestId 与 token;`extract_token` 后立即 `unset LOGIN` | |
|||
| `LOGIN_FIELDS` | 只含可安全打印的三字段,用完 `unset` | |
|||
| `TOKEN` | **保留到流程结束**(后续接口需要),但不打印、不写文件、不进验收产物 | |
|||
| `AUTH` | 由 TOKEN 派生,仅作为 curl 头参数使用;EXIT trap 统一清理 | |
|||
| `COMPANY_TOKEN` | 调用方传入,仅作 curl 头参数;EXIT trap 统一清理 | |
|||
| `PHONE` / `STUDENT_PHONE` | EXIT trap 统一清理;控制台只输出脱敏形式 | |
|||
|
|||
`cleanup_secrets()` 现覆盖:`PHONE SMS OTP REPLY LOGIN_BODY LOGIN TOKEN AUTH` 与 `STUDENT_PHONE COMPANY_TOKEN`,由 `trap cleanup_secrets EXIT` 统一清理。 |
|||
注意:登记块**不再**给 `PHONE`/`STUDENT_PHONE`/`COMPANY_TOKEN` 赋空值(避免覆盖调用方传入值)。 |
|||
|
|||
## T6. F5 修复前后的 mysql 调用形式 |
|||
|
|||
**修复前** |
|||
|
|||
```bash |
|||
docker exec -i "$container" sh -c \ |
|||
'mysql -uroot -p"$MYSQL_ROOT_PASSWORD" -N -B "${MYSQL_DATABASE:-mydreamjob}"' |
|||
``` |
|||
容器内 shell 会把 `$MYSQL_ROOT_PASSWORD` 展开为 `-p<真实密码>`,进入 mysql 进程 argv。 |
|||
|
|||
**修复后** |
|||
|
|||
```bash |
|||
docker exec -i "$container" sh -c ' |
|||
if [ -z "${MYSQL_USER:-}" ] || [ -z "${MYSQL_PASSWORD:-}" ]; then |
|||
echo "db credentials unavailable" >&2 |
|||
exit 3 |
|||
fi |
|||
MYSQL_PWD="$MYSQL_PASSWORD" exec mysql \ |
|||
-u"$MYSQL_USER" \ |
|||
-N -B \ |
|||
"${MYSQL_DATABASE:-mydreamjob}"' 2>/dev/null \ |
|||
| head -n 1 |
|||
``` |
|||
|
|||
## T7. mysql argv 是否含密码 |
|||
|
|||
测试用假 `mysql` 记录自身真实 argv(它正是被检查的进程),实测结果形态为: |
|||
|
|||
``` |
|||
-u<应用账号> -N -B <数据库名> |
|||
``` |
|||
|
|||
| 断言 | 结果 | |
|||
|---|---| |
|||
| F5-1 mysql argv 不含数据库密码值 | PASS | |
|||
| F5-2 宿主机 docker 命令行不含密码值 | PASS | |
|||
| F5-3 容器侧脚本文本不含密码值 | PASS | |
|||
| F5-4 容器侧脚本以变量名引用 MYSQL_PASSWORD | PASS | |
|||
| F5-5 mysql argv 不含 `-p<密码>` | PASS | |
|||
| F5-6 mysql argv 不含 `--password` | PASS | |
|||
| F5-13 密码不经 argv 传递 | PASS | |
|||
| F5-14 密码经 `MYSQL_PWD` 环境变量传递 | PASS | |
|||
|
|||
## T8. 使用的数据库账号类型 |
|||
|
|||
- 使用**应用账号**(容器环境变量 `MYSQL_USER` / `MYSQL_PASSWORD` / `MYSQL_DATABASE`) |
|||
- **不使用 root**;`MYSQL_ROOT_PASSWORD` 在脚本中命中数为 0,`-uroot` 命中数为 0(F5-7 / F5-8 PASS) |
|||
- 凭据缺失时 `exit 3` 安全失败,**不回退 root**(F5-15 PASS) |
|||
- 未读取、未打印、未落盘凭据;未修改 compose.yaml;未修改 MySQL 用户权限;查询仅 `SELECT`(F5-11/F5-12 PASS) |
|||
|
|||
## T9. Shell 测试实际数量 |
|||
|
|||
`docs/tests/smoke-test-sms-code-test.sh`:**51 用例,PASS=51 / FAIL=0** |
|||
|
|||
构成:F1 取码保护 10 项 + F4 xtrace/源模式 14 项 + F5 argv 18 项 + 静态保证与文档门禁 9 项。 |
|||
|
|||
## T10. Maven 专项 / 关联 / 全量数量 |
|||
|
|||
| 命令 | Tests run | |
|||
|---|---| |
|||
| 7 类安全专项(含空值与回滚集成测试) | **61** | |
|||
| `*Auth*Test,*Security*Test,*Sms*Test,*Job*Test` | **227** | |
|||
| `mvn test` | **1671** | |
|||
| `mvn clean verify` | **1671** | |
|||
|
|||
## T11. Failures / Errors / Skipped |
|||
|
|||
**Failures=0、Errors=0、Skipped=0**(专项、关联、全量、clean verify 全部一致) |
|||
|
|||
## T12. clean verify |
|||
|
|||
`mvn clean verify` → **BUILD SUCCESS**(4:34),产出最新 JAR。 |
|||
|
|||
## T13. OpenAPI 数量 |
|||
|
|||
**173 paths / 211 operations**(`All checks passed!`);本轮未改 Controller 映射、未新增取码接口。 |
|||
|
|||
## T14. 精确修改文件(本轮) |
|||
|
|||
| 文件 | 变更 | |
|||
|---|---| |
|||
| `docs/smoke-test.sh` | 开头新增全局 xtrace 关闭(全程不恢复);删除 `xtrace_was_on`/恢复逻辑;`otp_db_query` 改为应用账号 + `MYSQL_PWD`(去 root、去 `-p`);`cleanup_secrets` 覆盖面扩展;新增 `report_fields`/`extract_status_fields`,登录响应不再交给打印函数 | |
|||
| `docs/tests/smoke-test-sms-code-test.sh` | 扩展至 51 用例:真实 `bash -x` 执行与标记扫描、假 docker 执行容器侧脚本 + 假 mysql 记录 argv、source 模式泄漏防护、活跃文件文档门禁 | |
|||
| `docs/s01-remediation-f1f2f3-20260911.md` | 新增本「第三轮定向整改」章节 | |
|||
|
|||
**未修改**:任何 Java 生产代码、任何既有测试、`docs/smoke-test.sh` 以外的文档正文、`.env`、compose.yaml、Flyway。 |
|||
|
|||
## T15. Git 状态 |
|||
|
|||
- 分支:`fix/resume-upload-safety`(未变) |
|||
- 暂存区:**空** |
|||
- `git diff --check`:无空白错误(仅 CRLF 提示) |
|||
- 迁移改动:**无**(V1–V41 共 41 个) |
|||
- 删除文件:**0**;历史未跟踪文件:**145 个全部保留** |
|||
- 本轮已跟踪文件改动仅 `docs/smoke-test.sh`(S01 Java 文件时间戳仍为 15:45,本轮未触碰) |
|||
|
|||
## T16. 是否修改 Flyway、表、ErrorCode 或接口 |
|||
|
|||
**均未修改**:无新增/修改迁移、无表/索引变更、`ErrorCode` 未改、OpenAPI 保持 173/211。 |
|||
|
|||
## T17. 明确未暂存、未 commit、未 push |
|||
|
|||
**未执行** `git add` / `git add -A`、**未 commit**、**未 push**(亦未 `reset`/`restore`/`checkout`/`clean`)。 |
|||
|
|||
## T18. 真实 MySQL / Docker 最终复验仍待独立执行 |
|||
|
|||
本轮**未执行任何 Docker 命令**,**未连接真实 MySQL**: |
|||
|
|||
- F5 的 `docker exec` + `MYSQL_PWD` 路径仅在**假 docker / 假 mysql** 下验证了 argv 与 stdin 形态; |
|||
真实容器中 `MYSQL_USER` / `MYSQL_PASSWORD` 是否存在、`MYSQL_PWD` 是否被 mysql 客户端接受,**必须由独立复验在真实 Docker/MySQL 环境确认**。 |
|||
- F4 的 xtrace 防护已由真实 `bash -x` 本地执行验证(无需容器),但**完整真实登录链路**仍待真实环境复验。 |
|||
|
|||
## T19. 文档测试口径修正(§7) |
|||
|
|||
历史验收报告 `docs/s01-independent-mysql-acceptance-20260911.md` 与本报告**合理保留修复前缺陷证据**(含修复前的日志依赖描述与旧命令),因此**不得**以「整个 docs 目录命中数为 0」作为门禁结论。 |
|||
|
|||
修正后的口径(已落入 shell 测试第 22-24 条断言): |
|||
|
|||
- **A. 活跃操作文档与正式代码**(必须无「从日志取码」有效指导): |
|||
`docs/acceptance-week1.md`、`docs/api-v0.1.md`、`docs/frontend-integration.md`、`docs/offer-api.md`、`docs/resume-api.md`、`docs/smoke-test.sh`、`README.md`、`mydreamjob-server/src/main/java` |
|||
- **B. 历史验收报告**(允许保留修复前失败证据,须明示为历史): |
|||
`docs/s01-independent-mysql-acceptance-20260911.md`、`docs/s01-remediation-f1f2f3-20260911.md` |
|||
|
|||
测试使用**精确活跃文件列表**扫描(不扫描全部 docs),并额外断言两份历史报告仍然存在。 |
|||
|
|||
## T20. 本轮遗留(非阻断,待后续) |
|||
|
|||
1. JWT 仍作为 `-H "Authorization: Bearer <token>"` 的 curl 参数存在于**进程 argv**(非 xtrace/stdout/stderr 问题;如需消除,应改用 `curl --config` 经 stdin 传头) |
|||
2. 验证码仍为数据库明文存储(未改表结构) |
|||
3. 真实短信供应商未接入 |
|||
4. 存量 43 处直接身份解析未收口;安全层 401 响应不含 requestId |
|||
5. 上轮观察项(失败后残留 `sms:rate` 限流键 60s;Spring Boot 默认 `generated security password` 入日志)本轮未处理 |
|||
|
|||
--- |
|||
|
|||
**S01第三轮定向整改完成,待独立代码复验及真实MySQL/Docker黑盒复验。** |
|||
|
|||
--- |
|||
|
|||
# 第四轮定向整改(F6) |
|||
|
|||
- **整改日期**:2026-09-14 |
|||
- **独立复验结论**:S01 仍 FAIL —— F4 与 F5 已通过(bash -x 不泄露 / Shell 51 项 / Maven 安全专项 61 项 / 密码不进 mysql argv 均通过),新阻断项 **F6**:冒烟脚本使用了 `sms_verification_codes` 不存在的列 `expires_at` |
|||
- **本轮范围**:仅修 F6,并补真实表结构契约测试 |
|||
- **本轮修改文件**:`docs/smoke-test.sh`、`docs/tests/smoke-test-sms-code-test.sh`、`docs/tests/smoke-test-schema-contract-test.sh`(新增)、本报告 |
|||
- **本轮未触碰**:全部 Java 生产代码、实体、Repository、Flyway、数据库表、`ErrorCode`、公网接口 |
|||
|
|||
## G1. F6 错误位置 |
|||
|
|||
| 项 | 内容 | |
|||
|---|---| |
|||
| 文件 | `docs/smoke-test.sh` | |
|||
| 行 | **L122** | |
|||
| 修复前 | `" AND expires_at > NOW()" \` | |
|||
| 修复后 | `" AND expired_at > NOW()" \` | |
|||
| 后果 | 真实 MySQL 报 ERROR 1054,冒烟脚本取不到验证码,**F1 未闭环** | |
|||
|
|||
## G2. 真实 MySQL SHOW COLUMNS 结果摘要 |
|||
|
|||
独立复验在真实 MySQL 执行 `SHOW COLUMNS FROM sms_verification_codes`,实际字段为: |
|||
|
|||
``` |
|||
id phone code purpose attempts max_attempts expired_at used created_at |
|||
``` |
|||
|
|||
**不存在 `expires_at`。** 该结论与两个契约来源一致: |
|||
|
|||
| 来源 | 位置 | 结果 | |
|||
|---|---|---| |
|||
| 实体 `@Column` | `auth/SmsVerificationCode.java:46` | `@Column(name = "expired_at", nullable = false)` | |
|||
| 建表 Flyway | `db/migration/V1__base_schema.sql:37` | `expired_at DATETIME NOT NULL` | |
|||
|
|||
## G3. 修复前的真实报错 |
|||
|
|||
``` |
|||
ERROR 1054 (42S22): Unknown column 'expires_at' in 'where clause' |
|||
``` |
|||
|
|||
## G4. 修复后的 SQL |
|||
|
|||
```sql |
|||
SELECT code FROM sms_verification_codes |
|||
WHERE phone = '<phone>' AND purpose = '<purpose>' AND used = 0 |
|||
AND expired_at > NOW() |
|||
ORDER BY created_at DESC, id DESC LIMIT 1; |
|||
``` |
|||
|
|||
除列名外**未改动任何查询口径**,以下均保持不变:phone 精确匹配、purpose 精确匹配、`used = 0`、`expired_at > NOW()`、`ORDER BY created_at DESC, id DESC`、`LIMIT 1`、仅 SELECT、使用应用数据库账号(非 root)、密码经 `MYSQL_PWD` 不进 argv、SQL 经 stdin、验证码不输出。 |
|||
|
|||
## G5. 新增的 Shell 断言 |
|||
|
|||
`docs/tests/smoke-test-sms-code-test.sh` 由 **51 项扩展至 69 项**,新增 Part 6 共 **18 条**: |
|||
|
|||
| 编号 | 断言 | |
|||
|---|---| |
|||
| F6-1 | `otp_db_query` 使用 `expired_at > NOW()` | |
|||
| F6-2 | `otp_db_query` 不再出现错误字段 `expires_at` | |
|||
| F6-3 | 查询只使用 `sms_verification_codes` 的真实列 | |
|||
| F6-4 | 真实列清单由 Flyway 派生且含 `expired_at`(非空、非自证) | |
|||
| F6-5 | 假 mysql 捕获到完整 stdin SQL(非无条件返回验证码) | |
|||
| F6-6 | SQL 字段错误时假 mysql 返回失败(非 0) | |
|||
| F6-6b | 字段错误时报 ERROR 1054 (42S22) | |
|||
| F6-7 | 字段错误时 `sms_code()` 不继续登录 | |
|||
| F6-7b | 字段错误时未发出任何 login 请求 | |
|||
| F6-8 | 正确字段时取码成功并完成登录 | |
|||
| F6-9 | `expired_at` 恰好等于 NOW() 时不得取出 | |
|||
| F6-10 | `expired_at` 晚于 NOW() 时可以取出 | |
|||
| F6-11 | `expired_at` 早于 NOW() 时不得取出 | |
|||
| F6-12 | `used=1` 不得取出 | |
|||
| F6-13 | `purpose` 不匹配不得取出 | |
|||
| F6-14 | `phone` 不匹配不得取出 | |
|||
| F6-15 | 多条有效记录只取 `created_at`/`id` 最新一条 | |
|||
| F6-15b | 与输入顺序无关,按 `created_at`/`id` 排序取值 | |
|||
|
|||
**另新增独立契约门禁** `docs/tests/smoke-test-schema-contract-test.sh`(**15 条**),契约来源为**实体 `@Column` + 建表 Flyway**,并交叉校验二者列集合一致: |
|||
|
|||
C1–C4 来源存在性与解析非空 → C5 两个独立来源列集合一致 → C6 `expired_at` 存在 → C7 **`expires_at` 不存在**(表限定)→ C8 实体映射存在 → C9 `expires_at` 确实是其他表的真实列 → C10–C15 脚本 SQL 的表名、列存在性、列不含 `expires_at`、所用列全部真实。 |
|||
|
|||
## G6. fake docker / fake mysql 是否校验 SQL |
|||
|
|||
**本轮之前:不校验。** 旧假 mysql 只是 `cat` 掉 stdin 后无条件 `printf "${FAKE_CODE}"`,因此错误的 `expires_at` 也能"取到"验证码并通过测试——这正是 F6 能逃过 51 项测试的原因。 |
|||
|
|||
**本轮之后:校验。** 假 mysql 改为: |
|||
|
|||
1. 完整读取并保存 stdin SQL(不再丢弃); |
|||
2. 若 SQL 命中 `sms_verification_codes` 且包含 `expires_at` → 如实复现真实 MySQL 的 `ERROR 1054 (42S22)` 并 `exit 1`; |
|||
3. 设置 `FAKE_ROWS` 时,按行套用 `WHERE phone/purpose/used` 与 `expired_at > NOW()` 语义、按 `created_at DESC, id DESC` 取值;未设置时保留历史行为供 F1/F5 既有用例使用。 |
|||
|
|||
## G7. 过期 / 有效 / used / purpose / phone 口径 |
|||
|
|||
| 场景 | 假表语义 | 期望 | 结果 | |
|||
|---|---|---|---| |
|||
| `expired_at` 恰好 = NOW() | offset = 0 | 不取出(查询为严格 `>`) | PASS | |
|||
| `expired_at` 晚于 NOW() | offset = +300 | 取出 | PASS | |
|||
| `expired_at` 早于 NOW() | offset = −300 | 不取出 | PASS | |
|||
| `used = 1` | — | 不取出 | PASS | |
|||
| `purpose` 不匹配 | 行 purpose=`register`,查 `login` | 不取出 | PASS | |
|||
| `phone` 不匹配 | 行 phone 为另一号码 | 不取出 | PASS | |
|||
| 多条有效 | 两条均有效,`created_at` 不同 | 只取最新一条 | PASS | |
|||
|
|||
## G8. F4 / F5 回归结果 |
|||
|
|||
全部保持通过(本轮未改动 xtrace 防护与 `MYSQL_PWD` 路径): |
|||
|
|||
| 回归项 | 结果 | |
|||
|---|---| |
|||
| `bash -x` 不泄露验证码 / 完整手机号 / JWT / Authorization / 数据库密码 / LOGIN_BODY | PASS(F4-1 ~ F4-6) | |
|||
| `bash -x` 下正常流程仍可完成登录 | PASS(F4-7、F4-8) | |
|||
| 脚本中不存在可执行的 `set -x` | PASS(F4-9) | |
|||
| 失败流程同样不泄露 | PASS(F4-10) | |
|||
| source 模式 REPLY 契约 / 无敏感变量残留 / EXIT trap | PASS(F4-11 ~ F4-14) | |
|||
| mysql argv 不含密码值、不含 `-p`/`--password` | PASS(F5-1 ~ F5-6) | |
|||
| 不使用 root / 使用应用账号与数据库名 | PASS(F5-7 ~ F5-10) | |
|||
| SQL 经 stdin 且仅 SELECT | PASS(F5-11、F5-12) | |
|||
| 密码经 `MYSQL_PWD` 传递、不经 argv | PASS(F5-13、F5-14) | |
|||
| 凭据缺失时安全失败(不回退 root) | PASS(F5-15) | |
|||
| 失败路径 stderr 不含数据库密码 | PASS(F5-18) | |
|||
| 不恢复 `MOCK SMS CODE` 日志、活跃文档不指导从日志取码 | PASS(14、22、23) | |
|||
|
|||
## G9. Shell 测试数量 |
|||
|
|||
| 文件 | 用例数 | 结果 | |
|||
|---|---|---| |
|||
| `docs/tests/smoke-test-sms-code-test.sh` | **69** | PASS=69 / FAIL=0 | |
|||
| `docs/tests/smoke-test-schema-contract-test.sh` | **15** | PASS=15 / FAIL=0 | |
|||
|
|||
合计 **84** 条,全部通过(真实 Git Bash 执行)。 |
|||
|
|||
**断言非空转的证据(测试自检)**:把 `smoke-test.sh` 还原为 buggy 版本(`expires_at`)放入镜像目录,用**同一套测试**执行: |
|||
|
|||
- `smoke-test-sms-code-test.sh` → `PASS=56 FAIL=13`,其中 **F6-1 / F6-2 / F6-3 翻红**,F6-3 直接点名不存在的列 `expires_at`;F6-8/10/15/15b 因取不到码一并失败; |
|||
- `smoke-test-schema-contract-test.sh` → `PASS=12 FAIL=3`,**C12 / C13 / C14 翻红**,C14 点名 `expires_at`。 |
|||
|
|||
即:本轮断言确实能识别**不存在的 SQL 列**,而非"改前改后都通过"。 |
|||
|
|||
## G10. Maven 专项 / 关联 / 全量测试数 |
|||
|
|||
| 命令 | Tests run | |
|||
|---|---| |
|||
| `-Dtest="SmsSenderConfigurationTest,SmsVerificationSecurityTest,SmsVerificationServiceTest,CurrentUserAccessorTest,AuthenticationBoundaryIntegrationTest,SmsEmptyPropertyStartupIntegrationTest,SmsUnavailableSenderRollbackIntegrationTest"` | **61** | |
|||
| `-Dtest="*Auth*Test,*Security*Test,*Sms*Test,*Job*Test"` | **227** | |
|||
| `mvn test` | **1671** | |
|||
| `mvn clean verify` | **1671** | |
|||
|
|||
本轮仅改 `docs/` 下的脚本与报告,未增删任何 Java 测试,故总数与上一轮一致(1671)。 |
|||
|
|||
## G11. Failures / Errors / Skipped |
|||
|
|||
**Failures=0、Errors=0、Skipped=0**(专项、关联、全量、clean verify 四者一致)。 |
|||
|
|||
## G12. clean verify |
|||
|
|||
`mvn clean verify` → **BUILD SUCCESS**(Total time 02:11),产出 `target/mydreamjob-server-0.0.1-SNAPSHOT.jar`。 |
|||
|
|||
## G13. OpenAPI 计数 |
|||
|
|||
`python docs/verify_openapi.py` → **All checks passed!**,**173 paths / 211 operations**(与上一轮一致,未新增/删除路径或 operation)。 |
|||
|
|||
## G14. Git 状态 |
|||
|
|||
- 分支:`fix/resume-upload-safety`(未变) |
|||
- **暂存区:空** |
|||
- `git diff --check`:无空白错误(仅 CRLF 提示) |
|||
- 迁移改动:**无**(`git diff --name-only -- mydreamjob-server/src/main/resources/db/migration` 无输出;目录内 41 个文件,最大版本 `V41__message_recall.sql`) |
|||
- 本轮文件时间戳:`docs/smoke-test.sh` 09:22、`smoke-test-sms-code-test.sh` 09:24、`smoke-test-schema-contract-test.sh` 09:27、本报告 09:32(均 2026-09-14) |
|||
- **S01 Java 生产代码时间戳仍为 2026-09-11 15:45**,本轮未触碰 |
|||
- 删除文件:**0**;历史未跟踪文件:未删除、未移动、未覆盖 |
|||
|
|||
## G15. 明确未修改 Java、Flyway、表、ErrorCode 或接口 |
|||
|
|||
**均未修改。** |
|||
|
|||
- Java 生产代码:**0 处改动**(时间戳可证) |
|||
- 实体与 Repository:未改 |
|||
- Flyway:未新增/修改,V1–V41 原样 |
|||
- 数据库表/列/索引:未新增或修改;**未为迁就脚本把列改成 `expires_at`** |
|||
- `ErrorCode`:未改 |
|||
- 公网接口:OpenAPI 保持 173/211,未新增取码接口 |
|||
- **未恢复验证码日志**(`grep -rn "MOCK SMS CODE" mydreamjob-server/src` 命中 0) |
|||
|
|||
## G16. 明确未暂存、commit、push |
|||
|
|||
**未执行** `git add` / `git add -A`、**未 commit**、**未 push**(亦未 `reset`/`restore`/`checkout`/`clean`)。**未操作 Docker**,未进入 S02。 |
|||
|
|||
## G17. 真实 MySQL 完整冒烟链仍待最终独立复验 |
|||
|
|||
本轮**未执行任何 Docker 命令、未连接真实 MySQL**。全部验证均为:shell 测试(假 docker / 假 mysql 注入)+ Maven H2/Flyway 测试。 |
|||
|
|||
因此以下**必须由独立复验在真实 MySQL/Docker 环境完成**: |
|||
|
|||
1. `SHOW COLUMNS FROM sms_verification_codes` 的 Field 集合与 Flyway 列集合对账(含 `expired_at`、不含 `expires_at`); |
|||
2. 修复后的取码 SQL 在真实 MySQL 端到端执行成功(不再出现 ERROR 1054); |
|||
3. F1 完整冒烟链(发码 → 取码 → 登录 → 后续接口)闭环。 |
|||
|
|||
**不得以本轮假库结果代替真实环境验收。** |
|||
|
|||
## G18. 一处必须记录的旁证(不在本轮修改范围) |
|||
|
|||
`docs/acceptance/s01-20260911/snapshot-sms.sql:4` 同样使用 `expires_at` 查询 `sms_verification_codes`: |
|||
|
|||
```sql |
|||
SELECT id, purpose, CHAR_LENGTH(code) AS code_len, |
|||
TIMESTAMPDIFF(SECOND, NOW(), expires_at) AS expire_delta_s, ... |
|||
FROM sms_verification_codes |
|||
``` |
|||
|
|||
这是同一字段错误的传播。该文件属上一轮独立验收证据,**不在本轮允许修改的文件范围内**,故保持原样未改,仅在此记录,供后续处理。 |
|||
|
|||
## G19. 字段名易混淆的说明(供后续维护) |
|||
|
|||
`expires_at` 并非拼写错误,它是本仓库中**三张表的真实列**: |
|||
|
|||
| 表 | 迁移 | |
|||
|---|---| |
|||
| `personal_data_export_tasks.expires_at` | V30 | |
|||
| `ability_attempts.expires_at` | V35 | |
|||
| `talent_invitations.expires_at`(含索引) | V40 | |
|||
|
|||
只有 `sms_verification_codes` 用 `expired_at`。因此本轮门禁**按表限定**,未写成"全库禁用 `expires_at`"——那样会误报并掩盖真实错误。契约门禁 C9 专门守卫这一区分。 |
|||
|
|||
--- |
|||
|
|||
**S01第四轮定向整改完成,待最终独立代码复验及真实MySQL/Docker黑盒复验。** |
|||
@ -0,0 +1,674 @@ |
|||
# S02 Access/Refresh Token 隔离、轮换与吊销设计 |
|||
|
|||
状态:S02 已实现并完成自动化验证与干净构建,待独立代码复验及真实 MySQL/Docker 黑盒验收。 |
|||
|
|||
文档类型:解释与技术参考。 |
|||
|
|||
目标读者:产品负责人、后端开发、前端联调、安全复验和真实环境验收人员。 |
|||
|
|||
解释版本:`s02-auth-token-isolation-v1` |
|||
|
|||
## 1. 目标 |
|||
|
|||
S02 修复当前 Refresh Token 可以作为 Access Token 使用的认证边界缺陷,并建立可轮换、可吊销、可检测重放的登录会话模型。 |
|||
|
|||
本设计必须保证: |
|||
|
|||
- 普通业务接口只接受 Access Token; |
|||
- 刷新接口只接受 Refresh Token; |
|||
- Token 原文不进入数据库、日志、审计、URL 或错误响应; |
|||
- 登录、刷新、登出、账号注销和用户禁用具有明确的吊销语义; |
|||
- 同一个 Refresh Token 在并发或重放场景中最多成功刷新一次; |
|||
- 旧的无 `type` Token 不被永久兼容; |
|||
- 不改变手机号验证码登录、角色、资源归属、评分、排名、推荐、邀约、申诉、消息撤回和简历上传语义。 |
|||
|
|||
## 2. 当前实现事实 |
|||
|
|||
截至基线提交 `96a920b`: |
|||
|
|||
1. `JwtTokenProvider.generateAccessToken` 没有强制写入 `type=access`。 |
|||
2. `generateRefreshToken` 写入了 `type=refresh`。 |
|||
3. `JwtAuthenticationFilter` 只验证 JWT 签名和过期时间,不验证 Token 类型。 |
|||
4. Access Token 与 Refresh Token 使用同一 `app.jwt.secret`。 |
|||
5. 因此签名有效且未过期的 Refresh Token 可以通过过滤器并建立普通业务登录态。 |
|||
6. 登录响应已经包含 `accessToken`、`refreshToken` 和 `expiresIn`。 |
|||
7. 当前没有 `/auth/refresh` 和 `/auth/logout`。 |
|||
8. `auth_tokens` 表、`AuthToken` 实体和 `AuthTokenRepository` 已存在,但未被登录、刷新、鉴权、登出或账号注销使用。 |
|||
9. 当前 JWT 没有 `sid` 和 `jti`,不能标识登录会话和单个 Token。 |
|||
10. 当前账号注销完成不会使已经签发的 JWT 立即失效。 |
|||
11. 当前 JWT 包含完整手机号;普通鉴权实际只需要用户 ID 和会话信息。 |
|||
12. `application.yml` 的 `refresh-expiration-ms` 当前错误地位于 `app.resume` 下,而 `application-dev.yml` 位于正确的 `app.jwt` 下。非 dev 环境可能依赖 Java 默认值,配置契约不一致。 |
|||
13. 现有 Access Token 默认有效期为 24 小时,Refresh Token 默认有效期为 7 天。 |
|||
14. 现有 `auth_tokens` 表没有会话标识、轮换父子关系、消费时间、撤销时间和乐观锁版本。 |
|||
|
|||
## 3. 威胁模型 |
|||
|
|||
本版重点防护以下风险: |
|||
|
|||
- Refresh Token 被复制到 `Authorization: Bearer` 后直接调用普通业务接口; |
|||
- 已登出或已注销用户继续使用旧 Access Token; |
|||
- Refresh Token 在日志、数据库、错误响应或 URL 中泄露; |
|||
- 同一个 Refresh Token 被并发刷新两次,产生两组有效 Token; |
|||
- 已消费 Refresh Token 被攻击者再次使用而系统无法识别; |
|||
- Token 类型缺失、大小写变化或未知值被宽松接受; |
|||
- 非法 subject、`sid` 或 `jti` 引发 500 或回显内部数据; |
|||
- 数据库或会话校验异常时过滤器错误放行; |
|||
- 多设备登录时,一个设备的普通登出误伤其他设备; |
|||
- 账号注销、用户禁用等全局安全事件没有撤销全部会话。 |
|||
|
|||
不在本版解决:终端设备指纹、地理异常检测、硬件绑定、OAuth/OIDC、短信供应商、验证码摘要化、密码登录和单点登录。 |
|||
|
|||
## 4. 设计决策 |
|||
|
|||
### D01:Token 类型强制隔离 |
|||
|
|||
- Access Token 必须包含精确字符串 `type=access`。 |
|||
- Refresh Token 必须包含精确字符串 `type=refresh`。 |
|||
- Token 类型区分大小写。 |
|||
- 类型缺失、空白、未知或不匹配均拒绝。 |
|||
- 普通业务过滤器只能解析 Access Token。 |
|||
- 刷新服务只能解析 Refresh Token。 |
|||
|
|||
### D02:Access 与 Refresh 使用独立签名密钥 |
|||
|
|||
新增配置: |
|||
|
|||
```yaml |
|||
app: |
|||
jwt: |
|||
access-secret: ${JWT_ACCESS_SECRET} |
|||
refresh-secret: ${JWT_REFRESH_SECRET} |
|||
token-hash-secret: ${JWT_TOKEN_HASH_SECRET} |
|||
``` |
|||
|
|||
三项密钥必须彼此独立。生产环境不允许自动回退到同一个默认密钥。测试配置使用固定的测试专用值,但不得进入生产配置。 |
|||
|
|||
独立密钥是类型 claim 之外的第二道隔离:即使未来某处遗漏类型判断,Refresh Token 也不能通过 Access Token 验签器。 |
|||
|
|||
### D03:旧 Token 采用强制重新登录 |
|||
|
|||
部署 S02 后: |
|||
|
|||
- 无 `type` 的旧 Access Token 拒绝; |
|||
- 旧 Refresh Token 虽有 `type=refresh`,但没有 `sid`、`jti` 和服务端记录,同样拒绝; |
|||
- 用户需要重新完成一次手机号验证码登录; |
|||
- 不提供永久兼容开关; |
|||
- 不把无 `type` Token 猜测为 Access Token。 |
|||
|
|||
前端必须统一处理 401:清除本地 Token,跳转登录页,不循环刷新。 |
|||
|
|||
### D04:保持现有有效期 |
|||
|
|||
- Access Token 默认 24 小时; |
|||
- Refresh Token 默认 7 天; |
|||
- 登录响应的 `expiresIn=86400` 保持现有含义; |
|||
- S02 不顺带缩短 Token 有效期; |
|||
- 有效期必须来自配置并经过正数、上限和单位校验。 |
|||
|
|||
### D05:每次登录创建独立设备会话 |
|||
|
|||
- 每次成功登录生成新的 `sessionId`; |
|||
- 同一用户可以同时拥有多个活动会话; |
|||
- 普通登出只撤销当前 `sessionId`; |
|||
- Refresh Token 重放只撤销对应 `sessionId`; |
|||
- 账号注销完成、用户禁用及未来的敏感认证信息变更撤销该用户全部会话。 |
|||
|
|||
本版不采集或保存设备指纹,不通过 User-Agent 判断设备身份。 |
|||
|
|||
### D06:JWT Claim 白名单 |
|||
|
|||
Access Token 必须包含: |
|||
|
|||
| Claim | 说明 | |
|||
|---|---| |
|||
| `sub` | 十进制正整数用户 ID | |
|||
| `type` | 固定为 `access` | |
|||
| `sid` | UUID 格式的会话 ID | |
|||
| `jti` | UUID 格式的 Token ID | |
|||
| `role` | 登录时角色,仅作响应和诊断辅助;资源权限仍以现有服务层校验为准 | |
|||
| `iat` | 签发时间 | |
|||
| `exp` | 过期时间 | |
|||
| `iss` | 固定发行者 `mydreamjob` | |
|||
| `aud` | 固定受众 `mydreamjob-api` | |
|||
|
|||
Refresh Token 必须包含: |
|||
|
|||
| Claim | 说明 | |
|||
|---|---| |
|||
| `sub` | 十进制正整数用户 ID | |
|||
| `type` | 固定为 `refresh` | |
|||
| `sid` | 与当前登录会话一致 | |
|||
| `jti` | 本次 Refresh Token 的唯一 ID | |
|||
| `iat` | 签发时间 | |
|||
| `exp` | 过期时间 | |
|||
| `iss` | 固定发行者 `mydreamjob` | |
|||
| `aud` | 固定受众 `mydreamjob-auth` | |
|||
|
|||
新 Token 不再包含手机号。不得加入验证码、邮箱、昵称、简历地址或其他个人资料。 |
|||
|
|||
### D07:Refresh Token 原文不落库 |
|||
|
|||
数据库只保存: |
|||
|
|||
```text |
|||
HMAC-SHA256(JWT_TOKEN_HASH_SECRET, rawRefreshToken) |
|||
``` |
|||
|
|||
- 不使用普通可逆加密; |
|||
- 不使用无密钥的快速摘要作为唯一保护; |
|||
- 比较摘要时使用常量时间比较; |
|||
- Token、摘要和签名密钥均不得写日志或审计; |
|||
- `token_hash` 的业务含义固定为 Refresh Token 摘要。 |
|||
|
|||
### D08:每次刷新强制轮换 |
|||
|
|||
刷新成功必须: |
|||
|
|||
1. 捕获一次 `capturedNow`; |
|||
2. 使用 Refresh 专用密钥验签; |
|||
3. 验证 `type`、`iss`、`aud`、`sub`、`sid`、`jti`、`iat` 和 `exp`; |
|||
4. 计算原始 Refresh Token 摘要; |
|||
5. 查询对应服务端记录; |
|||
6. 原子消费旧记录; |
|||
7. 生成新的 Access/Refresh Token 对; |
|||
8. 插入新的 Refresh Token 记录; |
|||
9. 提交事务后返回响应。 |
|||
|
|||
旧 Refresh Token 被消费后永久失效。 |
|||
|
|||
### D09:Refresh Token 重放关闭当前会话 |
|||
|
|||
若签名有效的 Refresh Token 对应记录已经被消费或撤销: |
|||
|
|||
- 本次刷新返回 HTTP 401; |
|||
- 撤销相同 `sessionId` 下的全部 Token 记录; |
|||
- 当前会话的所有 Access Token 随即失效; |
|||
- 不影响同一用户的其他设备会话; |
|||
- 写一条不含 Token、摘要或个人信息的安全审计; |
|||
- 错误响应不得说明记录是否存在、已消费还是已撤销。 |
|||
|
|||
### D10:Access Token 必须检查服务端会话状态 |
|||
|
|||
`JwtAuthenticationFilter` 在完成 Access JWT 验签后,还必须确认: |
|||
|
|||
- `sid` 属于 `sub` 对应用户; |
|||
- 会话存在活动、未消费且未撤销的 Refresh Token 记录; |
|||
- 会话未过期; |
|||
- 用户存在、处于 active 状态且未完成注销。 |
|||
|
|||
任一条件不满足时不得建立 `SecurityContext`。 |
|||
|
|||
数据库是第一版的事实来源。Redis 缓存不属于 S02 首版;后续若增加缓存,数据库仍是最终事实来源,缓存异常不得放行。 |
|||
|
|||
### D11:普通登出只撤销当前会话 |
|||
|
|||
`POST /auth/logout`: |
|||
|
|||
- 需要有效 Access Token; |
|||
- 从 Token 中读取 `sub` 和 `sid`,不接受客户端传 userId/sessionId; |
|||
- 撤销该用户当前 `sid` 的所有 Token 记录; |
|||
- 重复登出保持幂等; |
|||
- 不影响其他设备会话; |
|||
- 成功响应不返回 Token。 |
|||
|
|||
### D12:全局安全事件撤销全部会话 |
|||
|
|||
以下事件必须调用统一的 `revokeAllUserSessions(userId, reason, capturedNow)`: |
|||
|
|||
- 账号注销完成; |
|||
- 用户被禁用; |
|||
- 未来的手机号或认证凭据变更。 |
|||
|
|||
账号注销申请处于冷静期时不立即撤销全部会话,保持当前产品语义;注销完成时必须撤销。取消注销不恢复已经撤销的会话。 |
|||
|
|||
### D13:单次时间捕获 |
|||
|
|||
登录、刷新、登出、重放处置和全局吊销每个入口只允许调用一次 `Clock`: |
|||
|
|||
- JWT `iat`、`exp`; |
|||
- 数据库 `createdAt`、`expiredAt`、`consumedAt`、`revokedAt`; |
|||
- 审计时间判断; |
|||
- 响应有效期; |
|||
|
|||
均使用同一个 `capturedNow`。不得依赖 `new Date()`、`LocalDateTime.now()` 或系统默认时区散落取时。 |
|||
|
|||
固定业务时区为 `Asia/Shanghai`,数据库时间按项目现有约定落库。 |
|||
|
|||
### D14:失败关闭 |
|||
|
|||
以下情况一律不建立认证: |
|||
|
|||
- Token 解析异常; |
|||
- 类型、发行者或受众错误; |
|||
- subject、`sid` 或 `jti` 非法; |
|||
- 服务端记录不存在; |
|||
- 会话已撤销、已消费或过期; |
|||
- 用户不存在、被禁用或已注销; |
|||
- 数据库读取或摘要计算失败。 |
|||
|
|||
过滤器只记录固定安全文本和 requestId,不记录异常消息、Token、摘要或 claim 原值。 |
|||
|
|||
### D15:错误码与 HTTP 语义 |
|||
|
|||
优先复用现有: |
|||
|
|||
- `UNAUTHORIZED(2001)`; |
|||
- `TOKEN_EXPIRED(2003)`; |
|||
- `TOKEN_INVALID(2004)`; |
|||
- `SERVICE_UNAVAILABLE(9001)`。 |
|||
|
|||
公共认证失败统一表现为 HTTP 401。不得通过不同响应告诉客户端 Token 是“不存在”“已消费”“已撤销”还是“重放”。 |
|||
|
|||
除非实现阶段发现现有错误映射无法满足固定 401,才允许提出新增 ErrorCode;未获确认不得新增。 |
|||
|
|||
### D16:接口增量固定为两个 operation |
|||
|
|||
新增: |
|||
|
|||
- `POST /auth/refresh`; |
|||
- `POST /auth/logout`。 |
|||
|
|||
预计 OpenAPI 从 `173 paths / 211 operations` 变为 `175 paths / 213 operations`。 |
|||
|
|||
不得新增会话管理列表、全部设备登出、Token introspection 或管理员代操作接口。 |
|||
|
|||
### D17:并发由数据库条件更新保证 |
|||
|
|||
同一 Refresh Token 并发刷新: |
|||
|
|||
- 使用包含 `id`、`consumed_at IS NULL`、`revoked=0` 和 `version` 的条件更新; |
|||
- 只有一个事务可以消费成功; |
|||
- 其他事务不得生成第二组有效 Token; |
|||
- 失败方执行重放处置或返回固定 401; |
|||
- 不使用 JVM 本地锁作为正确性基础。 |
|||
|
|||
### D18:设计范围隔离 |
|||
|
|||
S02 不改变: |
|||
|
|||
- 短信验证码生成、过期、锁定和发送语义; |
|||
- 登录自动创建用户的现有规则; |
|||
- 用户角色及资源归属; |
|||
- 岗位、投递、Offer、评分、排名、推荐、人才发现、邀约; |
|||
- 申诉、消息撤回和简历上传; |
|||
- V1–V41 迁移内容。 |
|||
|
|||
## 5. Token 决策表 |
|||
|
|||
| Token/状态 | 普通业务接口 | `/auth/refresh` | `/auth/logout` | 注销完成后 | 过期后 | |
|||
|---|---|---|---|---|---| |
|||
| 有效 Access | 允许 | 拒绝 | 允许 | 拒绝 | 拒绝 | |
|||
| 有效 Refresh | 拒绝 | 允许并轮换 | 拒绝 | 拒绝 | 拒绝 | |
|||
| 无 `type` 旧 Token | 拒绝 | 拒绝 | 拒绝 | 拒绝 | 拒绝 | |
|||
| 未知 `type` | 拒绝 | 拒绝 | 拒绝 | 拒绝 | 拒绝 | |
|||
| 已消费 Refresh | 拒绝 | 401 并撤销当前会话 | 拒绝 | 拒绝 | 拒绝 | |
|||
| 已撤销 Token | 拒绝 | 拒绝 | 幂等 | 拒绝 | 拒绝 | |
|||
| 会话不存在 | 拒绝 | 拒绝 | 拒绝 | 拒绝 | 拒绝 | |
|||
| 用户被禁用 | 拒绝 | 拒绝 | 拒绝 | 拒绝 | 拒绝 | |
|||
|
|||
## 6. 状态机 |
|||
|
|||
单个 Refresh Token 记录状态: |
|||
|
|||
```text |
|||
ACTIVE -> CONSUMED |
|||
ACTIVE -> REVOKED |
|||
CONSUMED -> REVOKED (重放或全局吊销) |
|||
``` |
|||
|
|||
不允许从 `CONSUMED` 或 `REVOKED` 恢复为 `ACTIVE`。 |
|||
|
|||
会话状态由同一 `sessionId` 下的记录推导:存在一个未消费、未撤销且未过期的当前 Refresh Token 记录时,会话为活动状态。 |
|||
|
|||
## 7. 数据模型 |
|||
|
|||
### 7.1 迁移原则 |
|||
|
|||
- 编码前重新确认最新 Flyway;当前建议文件名为 `V42__auth_token_sessions.sql`; |
|||
- 若 V42 已被占用,停止并重新编号; |
|||
- 不修改 V1–V41; |
|||
- 不删除旧记录; |
|||
- 新字段先允许旧数据为空,新签发记录由应用强制非空; |
|||
- Hibernate 使用 `ddl-auto=validate`; |
|||
- 迁移必须兼容真实 MySQL 8.4 和项目 H2 测试模式。 |
|||
|
|||
### 7.2 `auth_tokens` 扩展字段 |
|||
|
|||
建议增加: |
|||
|
|||
| 字段 | 类型 | 说明 | |
|||
|---|---|---| |
|||
| `session_id` | VARCHAR(36) NULL | 每次登录的会话 UUID | |
|||
| `token_jti` | VARCHAR(36) NULL | Refresh Token 的 jti | |
|||
| `parent_token_id` | BIGINT NULL | 轮换前一条记录 ID | |
|||
| `consumed_at` | DATETIME NULL | 成功刷新时消费时间 | |
|||
| `revoked_at` | DATETIME NULL | 撤销时间 | |
|||
| `revocation_reason` | VARCHAR(40) NULL | 固定白名单原因 | |
|||
| `version` | BIGINT NOT NULL DEFAULT 0 | 乐观锁/条件更新版本 | |
|||
|
|||
固定含义: |
|||
|
|||
- `token_hash`:Refresh Token 的 HMAC-SHA256 摘要; |
|||
- `token_type`:新记录固定 `REFRESH`; |
|||
- `expired_at`:Refresh Token 到期时间; |
|||
- `revoked`:现有兼容布尔值; |
|||
- `refresh_token_hash`:旧字段保留但新逻辑不依赖,后续版本再评估移除。 |
|||
|
|||
索引建议: |
|||
|
|||
```sql |
|||
INDEX idx_auth_tokens_session_active (session_id, revoked, consumed_at, expired_at) |
|||
INDEX idx_auth_tokens_user_session (user_id, session_id) |
|||
UNIQUE INDEX uk_auth_tokens_token_jti (token_jti) |
|||
``` |
|||
|
|||
不得在本轮清理或改写旧 `auth_tokens` 数据。`session_id IS NULL` 的旧记录永远不能被新鉴权逻辑视为活动会话。 |
|||
|
|||
## 8. 组件职责 |
|||
|
|||
### `JwtTokenProvider` |
|||
|
|||
- 分别生成和解析 Access/Refresh Token; |
|||
- 使用不同签名密钥; |
|||
- 强制校验类型、发行者、受众和必需 claim; |
|||
- 接收外部传入的 `capturedNow`,不自行重复取时; |
|||
- 不访问数据库。 |
|||
|
|||
### `AuthTokenHasher` |
|||
|
|||
- 只负责 Refresh Token HMAC 摘要; |
|||
- 使用独立密钥; |
|||
- 支持常量时间比较; |
|||
- 不记录输入和输出。 |
|||
|
|||
### `AuthSessionService` |
|||
|
|||
- 创建会话和首个 Refresh Token 记录; |
|||
- 验证、消费、轮换 Refresh Token; |
|||
- 判断 Access Token 所属会话是否活动; |
|||
- 撤销当前会话或用户全部会话; |
|||
- 承担事务与并发控制。 |
|||
|
|||
### `JwtAuthenticationFilter` |
|||
|
|||
- 只接受 Access Token; |
|||
- 调用 Provider 完成密码学和 claim 校验; |
|||
- 调用 Session Service 验证服务端会话状态; |
|||
- 通过后建立 SecurityContext; |
|||
- 失败时保持匿名,由 SecurityFilterChain 返回既有 401。 |
|||
|
|||
### `AuthService` |
|||
|
|||
- 登录成功时创建服务端会话; |
|||
- 只在会话记录成功持久化后返回 Token; |
|||
- 编排刷新和登出; |
|||
- 保持现有用户登录/自动创建语义。 |
|||
|
|||
### `AuthController` |
|||
|
|||
- 接收白名单 DTO; |
|||
- 不解析 JWT、不计算摘要、不接收 userId/sessionId; |
|||
- 不在异常响应中回显 Token; |
|||
- 不返回 Entity 或数据库字段。 |
|||
|
|||
## 9. API 契约 |
|||
|
|||
### 9.1 登录 |
|||
|
|||
现有 `POST /auth/login` 请求不变。 |
|||
|
|||
响应字段保持: |
|||
|
|||
- `userId` |
|||
- `phone` |
|||
- `nickname` |
|||
- `role` |
|||
- `accessToken` |
|||
- `refreshToken` |
|||
- `expiresIn` |
|||
|
|||
新 Token 从 S02 起包含完整类型和会话 claim。旧客户端不需要解析 JWT 内部 claim。 |
|||
|
|||
### 9.2 刷新 |
|||
|
|||
`POST /auth/refresh` |
|||
|
|||
请求: |
|||
|
|||
```json |
|||
{ |
|||
"refreshToken": "<refresh-token>" |
|||
} |
|||
``` |
|||
|
|||
成功响应: |
|||
|
|||
```json |
|||
{ |
|||
"code": 1000, |
|||
"data": { |
|||
"accessToken": "<new-access-token>", |
|||
"refreshToken": "<new-refresh-token>", |
|||
"expiresIn": 86400 |
|||
}, |
|||
"requestId": "<request-id>" |
|||
} |
|||
``` |
|||
|
|||
要求: |
|||
|
|||
- 请求体只允许 `refreshToken`; |
|||
- 不能为空,设置合理长度上限; |
|||
- 不接受查询参数或路径参数中的 Token; |
|||
- 旧 Refresh Token 刷新成功后立即失效。 |
|||
|
|||
### 9.3 登出 |
|||
|
|||
`POST /auth/logout` |
|||
|
|||
- 使用 `Authorization: Bearer <access-token>`; |
|||
- 无 request body; |
|||
- 成功返回现有 `ApiResponse` 包装的固定消息; |
|||
- 重复登出按幂等成功处理,但已经撤销的 Access Token通常无法再通过过滤器,因此客户端不应依赖第二次请求; |
|||
- 不接收 Refresh Token、userId 或 sessionId 参数。 |
|||
|
|||
## 10. SecurityConfig |
|||
|
|||
- `/auth/login`、`/auth/sms/send` 和 `/auth/refresh` 允许匿名进入; |
|||
- `/auth/logout` 必须 authenticated; |
|||
- `permitAll` 只表示可进入 Controller,不代表 Refresh Token 校验成功; |
|||
- 其他接口规则不变; |
|||
- 不扩大 `/auth/**` 为整体公开; |
|||
- Refresh Token 放入 Authorization 头访问任何普通接口必须得到 401。 |
|||
|
|||
## 11. 事务与并发 |
|||
|
|||
### 登录 |
|||
|
|||
用户读取/创建、会话记录创建和登录审计在一个事务内完成。会话记录失败时不能返回 Token。 |
|||
|
|||
### 刷新 |
|||
|
|||
旧 Token 条件消费和新 Token 记录插入在一个事务内完成。提交失败时不能返回新 Token。 |
|||
|
|||
### 登出 |
|||
|
|||
同一事务撤销当前会话记录并写脱敏审计。 |
|||
|
|||
### 重放 |
|||
|
|||
发现已消费 Token 时,在独立明确事务中撤销对应会话,避免刷新事务回滚后撤销也丢失。实现必须通过测试证明最终会话已撤销。 |
|||
|
|||
## 12. 审计与日志 |
|||
|
|||
允许的审计动作建议: |
|||
|
|||
- `AUTH_SESSION_CREATED` |
|||
- `AUTH_TOKEN_REFRESHED` |
|||
- `AUTH_SESSION_REVOKED` |
|||
- `AUTH_REFRESH_REUSE_DETECTED` |
|||
|
|||
若新增审计动作不需要新增 ErrorCode,也不需要新表。 |
|||
|
|||
审计详情仅允许: |
|||
|
|||
- userId; |
|||
- sessionId 的 HMAC 摘要或固定截断不可逆标识; |
|||
- reason 白名单; |
|||
- requestId; |
|||
- 结果状态。 |
|||
|
|||
禁止: |
|||
|
|||
- Access Token; |
|||
- Refresh Token; |
|||
- Token 摘要; |
|||
- JWT claim 全文; |
|||
- 完整手机号、邮箱; |
|||
- Authorization 请求头; |
|||
- 异常对象 `toString()`。 |
|||
|
|||
## 13. 前端联调语义 |
|||
|
|||
前端必须: |
|||
|
|||
1. 登录后分别保存 Access 和 Refresh Token; |
|||
2. 普通请求只携带 Access Token; |
|||
3. 收到普通 401 时最多执行一次刷新; |
|||
4. 刷新成功后原子替换两个 Token; |
|||
5. 刷新失败时清除两个 Token并跳转登录; |
|||
6. 防止多个并发请求同时用同一 Refresh Token刷新; |
|||
7. 登出时调用 `/auth/logout`,随后无条件清除本地 Token; |
|||
8. S02 上线后接受一次强制重新登录。 |
|||
|
|||
不允许把 Refresh Token 放入 URL、本地日志、埋点或错误上报正文。 |
|||
|
|||
## 14. 自动化测试要求 |
|||
|
|||
至少覆盖: |
|||
|
|||
### Provider |
|||
|
|||
- Access 包含 `type=access`; |
|||
- Refresh 包含 `type=refresh`; |
|||
- 两类 Token 使用不同密钥; |
|||
- 交叉验签失败; |
|||
- issuer/audience/type/sub/sid/jti 缺失或错误时拒绝; |
|||
- 过期边界; |
|||
- 新 Token 不含手机号。 |
|||
|
|||
### Filter |
|||
|
|||
- Access Token 可访问普通接口; |
|||
- Refresh Token 访问普通接口为 401; |
|||
- 无 type、未知 type、非法 subject/sid/jti 为 401; |
|||
- 已撤销会话、用户禁用、注销完成为 401; |
|||
- 服务端检查异常时不放行; |
|||
- 响应和日志不回显 Token。 |
|||
|
|||
### 登录与存储 |
|||
|
|||
- 登录生成一条活动会话记录; |
|||
- 数据库只保存摘要; |
|||
- 登录响应结构不变; |
|||
- 会话持久化失败不返回 Token; |
|||
- 多次登录生成不同 sessionId。 |
|||
|
|||
### 刷新与并发 |
|||
|
|||
- 正常刷新轮换 Token; |
|||
- 原 Token 变为 consumed; |
|||
- 新旧 jti 不同、sid 相同; |
|||
- 同一 Refresh Token 第二次使用返回 401并撤销当前会话; |
|||
- 两线程并发刷新最多一个成功; |
|||
- 最终只有一个后继 Token; |
|||
- 无 500、死锁或重复活动记录。 |
|||
|
|||
### 登出与注销 |
|||
|
|||
- 登出撤销当前设备; |
|||
- 其他设备仍可用; |
|||
- 重复撤销幂等; |
|||
- 注销完成撤销全部设备; |
|||
- 注销取消不恢复旧会话; |
|||
- 被禁用用户 Token 无效。 |
|||
|
|||
### JSON 与日志脱敏 |
|||
|
|||
递归禁止出现 Token、摘要、Authorization、手机号、邮箱、secret、password 和内部对象。 |
|||
|
|||
## 15. 真实 MySQL/Docker 验收 |
|||
|
|||
必须验证: |
|||
|
|||
- 新迁移在真实 MySQL 8.4 成功; |
|||
- `ddl-auto=validate` 通过; |
|||
- 登录记录只含摘要; |
|||
- Refresh Token 无法调用普通接口; |
|||
- Access Token 无法调用刷新接口; |
|||
- 正常轮换、重放和两线程并发符合设计; |
|||
- 登出后旧 Access/Refresh 均失效; |
|||
- 多设备隔离; |
|||
- 注销完成全部失效; |
|||
- 运行时 OpenAPI 为预计的 `175/213`; |
|||
- 日志、审计和数据库无 Token 原文; |
|||
- RabbitMQ、通知、评分和其他业务表无新增副作用。 |
|||
|
|||
## 16. 发布与回滚 |
|||
|
|||
### 发布前 |
|||
|
|||
- 前端必须先支持刷新失败后重新登录; |
|||
- 配置三个独立强密钥; |
|||
- 在隔离环境完成真实 MySQL 迁移和黑盒; |
|||
- 确认所有节点同时升级,避免新旧 Token 策略混跑。 |
|||
|
|||
### 发布结果 |
|||
|
|||
- 所有旧 Token失效; |
|||
- 所有用户需要重新登录一次; |
|||
- 新登录产生服务端会话记录。 |
|||
|
|||
### 回滚 |
|||
|
|||
- 不回滚或修改已执行 Flyway; |
|||
- 应用回滚到旧版会重新接受只看签名的 Token,存在安全倒退; |
|||
- 因此 S02 发布后不允许直接回滚到未隔离版本; |
|||
- 紧急回滚应发布保持类型隔离的兼容修复版本,而不是恢复旧过滤器。 |
|||
|
|||
## 17. 验收门槛 |
|||
|
|||
只有全部满足才可冻结 S02: |
|||
|
|||
1. Refresh Token 不能访问任何普通业务接口; |
|||
2. Access Token 不能用于刷新; |
|||
3. 无 type 旧 Token 被拒绝; |
|||
4. Token 原文不落库、不入日志、不入审计; |
|||
5. 每次刷新完成轮换; |
|||
6. 重放撤销当前会话; |
|||
7. 并发刷新最多成功一次; |
|||
8. 登出立即使当前会话 Access/Refresh 失效; |
|||
9. 多设备会话互不误伤; |
|||
10. 注销完成和用户禁用使全部会话失效; |
|||
11. 旧登录响应字段保持兼容; |
|||
12. 自动化测试 0 Failures/Errors/Skipped; |
|||
13. `mvn clean verify` 成功; |
|||
14. 静态和运行时 OpenAPI 一致; |
|||
15. 真实 MySQL迁移和 schema validation成功; |
|||
16. 不改变其他业务模块结果。 |
|||
|
|||
## 18. 明确非目标与剩余风险 |
|||
|
|||
本版不实现: |
|||
|
|||
- 查看全部登录设备; |
|||
- 远程踢出单个其他设备; |
|||
- OAuth/OIDC; |
|||
- 短信验证码数据库摘要化; |
|||
- 真实短信供应商; |
|||
- Redis 会话缓存; |
|||
- Access Token 有效期缩短; |
|||
- 浏览器 HttpOnly Cookie 模式; |
|||
- 设备指纹和异常登录检测。 |
|||
|
|||
后续可以单独评估,但不得作为 S02 验收通过的替代条件。 |
|||
@ -0,0 +1,724 @@ |
|||
# S02 Access/Refresh Token 隔离与吊销:编码实施指令 |
|||
|
|||
> 本文可原样交给编码执行者。权威设计为 `docs/s02-auth-token-isolation-design.md`。任何与设计冲突、会改变其他业务语义或需要扩大公共接口的情况,必须停止并报告,不得自行改设计。 |
|||
|
|||
## 一、任务目标 |
|||
|
|||
执行 S02「Access/Refresh Token 隔离、轮换与吊销」。 |
|||
|
|||
项目根目录: |
|||
|
|||
```text |
|||
C:\Users\15642\Desktop\zhilai |
|||
``` |
|||
|
|||
基线要求: |
|||
|
|||
- 必须包含 `b5084fd`(S01 认证安全整改); |
|||
- 必须包含 `96a920b`(简历上传安全加固); |
|||
- 当前最新 Flyway 应为 `V41__message_recall.sql`; |
|||
- 当前静态 OpenAPI 应为 `173 paths / 211 operations`; |
|||
- S02 预计新增两个接口,完成后为 `175 paths / 213 operations`。 |
|||
|
|||
本轮只实现 S02,不进入 S03、Q01 或 R01。 |
|||
|
|||
## 二、硬性边界 |
|||
|
|||
允许: |
|||
|
|||
1. 修改认证 Token 生成、解析、过滤、登录、刷新和登出链路; |
|||
2. 扩展现有 `auth_tokens` 表; |
|||
3. 新增一个 Flyway,建议 `V42__auth_token_sessions.sql`; |
|||
4. 新增 `/auth/refresh` 和 `/auth/logout`; |
|||
5. 将账号注销完成、用户禁用接入会话吊销; |
|||
6. 更新认证文档、前端联调文档和 OpenAPI; |
|||
7. 新增自动化测试。 |
|||
|
|||
禁止: |
|||
|
|||
1. 修改 V1–V41; |
|||
2. 删除或重建 `auth_tokens` 表; |
|||
3. 在数据库、日志、审计、异常或 URL 中保存/输出原始 Token; |
|||
4. 永久兼容无 `type` 旧 Token; |
|||
5. 把 Refresh Token 当 Access Token; |
|||
6. 使用同一密钥签发 Access 和 Refresh; |
|||
7. 使用 JVM 本地锁代替数据库并发控制; |
|||
8. 修改短信验证码、登录自动注册、角色和资源归属语义; |
|||
9. 修改评分、排名、推荐、人才发现、邀约、Offer、申诉、消息撤回或简历上传; |
|||
10. 新增设备列表、全部设备登出、Token introspection 等接口; |
|||
11. 删除、禁用或放宽既有测试; |
|||
12. `git reset`、`git restore`、`git checkout`、`git clean`; |
|||
13. `git add -A`、commit、push; |
|||
14. 删除、移动或覆盖历史未跟踪脚本、日志、JSON、SQL、xlsx、`.env`; |
|||
15. 操作 Docker或执行真实 MySQL 黑盒,除非负责人另行授权。 |
|||
|
|||
## 三、开始前检查 |
|||
|
|||
执行并记录: |
|||
|
|||
```powershell |
|||
cd C:\Users\15642\Desktop\zhilai |
|||
git branch --show-current |
|||
git status --short |
|||
git diff --cached --name-only |
|||
git log -5 --oneline |
|||
git diff --check |
|||
git diff --name-only -- mydreamjob-server/src/main/resources/db/migration |
|||
``` |
|||
|
|||
要求: |
|||
|
|||
- 暂存区为空; |
|||
- 记录 HEAD; |
|||
- 确认基线包含 `96a920b`; |
|||
- 保护全部已有修改和未跟踪文件; |
|||
- 搜索并完整阅读适用的 `AGENTS.md`,不存在则记录; |
|||
- 若认证相关文件存在不属于 S02 的未提交修改,停止并报告; |
|||
- 建议从已包含 `96a920b` 的基线创建独立分支 `feat/s02-token-isolation`,但不得覆盖用户现有分支或工作树。 |
|||
|
|||
## 四、必须完整阅读 |
|||
|
|||
权威文档: |
|||
|
|||
```text |
|||
docs/s02-auth-token-isolation-design.md |
|||
docs/api-v0.1.md |
|||
docs/frontend-integration.md |
|||
docs/phase1-scope-and-acceptance.md |
|||
docs/backend-development-roadmap.md |
|||
docs/openapi-rc.json |
|||
docs/verify_openapi.py |
|||
``` |
|||
|
|||
生产代码: |
|||
|
|||
```text |
|||
mydreamjob-server/src/main/java/com/mydreamjob/auth/JwtTokenProvider.java |
|||
mydreamjob-server/src/main/java/com/mydreamjob/auth/JwtAuthenticationFilter.java |
|||
mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthService.java |
|||
mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthToken.java |
|||
mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthTokenRepository.java |
|||
mydreamjob-server/src/main/java/com/mydreamjob/auth/User.java |
|||
mydreamjob-server/src/main/java/com/mydreamjob/auth/UserRepository.java |
|||
mydreamjob-server/src/main/java/com/mydreamjob/controller/AuthController.java |
|||
mydreamjob-server/src/main/java/com/mydreamjob/security/CurrentUserAccessor.java |
|||
mydreamjob-server/src/main/java/com/mydreamjob/config/SecurityConfig.java |
|||
mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorCode.java |
|||
mydreamjob-server/src/main/java/com/mydreamjob/common/exception/GlobalExceptionHandler.java |
|||
mydreamjob-server/src/main/java/com/mydreamjob/deletion/AccountDeletionService.java |
|||
mydreamjob-server/src/main/java/com/mydreamjob/deletion/AccountDeletionCompletionWorker.java |
|||
mydreamjob-server/src/main/java/com/mydreamjob/deletion/DeletionGuardInterceptor.java |
|||
mydreamjob-server/src/main/java/com/mydreamjob/audit/AuditService.java |
|||
mydreamjob-server/src/main/resources/application.yml |
|||
mydreamjob-server/src/main/resources/application-dev.yml |
|||
mydreamjob-server/src/main/resources/db/migration/V1__base_schema.sql |
|||
``` |
|||
|
|||
测试: |
|||
|
|||
```text |
|||
mydreamjob-server/src/test/java/com/mydreamjob/auth/** |
|||
mydreamjob-server/src/test/java/com/mydreamjob/security/** |
|||
mydreamjob-server/src/test/java/com/mydreamjob/deletion/** |
|||
mydreamjob-server/src/test/resources/application-test.yml |
|||
mydreamjob-server/src/test/resources/application-integration-test.yml |
|||
``` |
|||
|
|||
搜索: |
|||
|
|||
```powershell |
|||
rg -n "generateAccessToken|generateRefreshToken|validateToken|parseUserId|AuthToken|AuthTokenRepository|refreshToken|Authorization|logout|type.*refresh|JWT_SECRET|refresh-expiration" mydreamjob-server/src docs |
|||
``` |
|||
|
|||
## 五、先补失败测试 |
|||
|
|||
编码前先增加失败测试,证明当前缺陷存在。至少包括: |
|||
|
|||
1. `refreshTokenCannotAuthenticateOrdinaryEndpoint`:当前 Refresh Token 访问 `/auth/me` 不应成功;修复前应失败。 |
|||
2. `accessTokenMustContainAccessType`:Access Token 必须有 `type=access`。 |
|||
3. `accessTokenCannotBeUsedAtRefreshEndpoint`。 |
|||
4. `legacyTokenWithoutTypeIsRejected`。 |
|||
5. `accessAndRefreshTokensCannotCrossVerify`。 |
|||
6. `revokedSessionCannotAuthenticate`。 |
|||
7. `sameRefreshTokenCannotBeConsumedTwice`。 |
|||
|
|||
保留修复前失败输出。不得通过修改期望状态码掩盖问题。 |
|||
|
|||
## 六、配置整改 |
|||
|
|||
修改 `application.yml`: |
|||
|
|||
1. 将错误位于 `app.resume.refresh-expiration-ms` 的配置移回 `app.jwt`; |
|||
2. 增加: |
|||
|
|||
```yaml |
|||
app: |
|||
jwt: |
|||
access-secret: ${JWT_ACCESS_SECRET} |
|||
refresh-secret: ${JWT_REFRESH_SECRET} |
|||
token-hash-secret: ${JWT_TOKEN_HASH_SECRET} |
|||
expiration-ms: ${JWT_EXPIRATION_MS:86400000} |
|||
refresh-expiration-ms: ${JWT_REFRESH_EXPIRATION_MS:604800000} |
|||
issuer: mydreamjob |
|||
access-audience: mydreamjob-api |
|||
refresh-audience: mydreamjob-auth |
|||
``` |
|||
|
|||
要求: |
|||
|
|||
- 生产配置不得提供秘密默认值; |
|||
- 三项密钥不得互相回退; |
|||
- 启动时校验密钥长度和互异性; |
|||
- 测试 profile 使用固定测试密钥; |
|||
- 不打印密钥; |
|||
- 原 `app.jwt.secret` 仅作为旧配置迁移项处理,S02 新 Token 不使用它; |
|||
- 缺失、空值或三项相同必须启动失败,并给出固定、不含密钥的安全配置错误。 |
|||
|
|||
新增配置测试: |
|||
|
|||
- 三项有效且互异时启动成功; |
|||
- 任一缺失、空白或过短时失败; |
|||
- access/refresh 相同时失败; |
|||
- hash secret 与签名密钥相同时失败; |
|||
- 错误日志不包含密钥值。 |
|||
|
|||
## 七、JWT Provider 重构 |
|||
|
|||
修改 `JwtTokenProvider`,禁止继续使用通用的 `validateToken + parseUserId` 组合来判断 Token 用途。 |
|||
|
|||
建议提供明确 API: |
|||
|
|||
```java |
|||
IssuedToken generateAccessToken(..., Instant capturedNow); |
|||
IssuedToken generateRefreshToken(..., Instant capturedNow); |
|||
AccessTokenClaims parseAndValidateAccessToken(String rawToken, Instant capturedNow); |
|||
RefreshTokenClaims parseAndValidateRefreshToken(String rawToken, Instant capturedNow); |
|||
``` |
|||
|
|||
要求: |
|||
|
|||
- Access 使用 access secret; |
|||
- Refresh 使用 refresh secret; |
|||
- 精确校验 `type`、`iss` 和 `aud`; |
|||
- 验证 `sub` 为正整数; |
|||
- 验证 `sid`、`jti` 为规范 UUID; |
|||
- Access 必须含 `role`; |
|||
- 新 Token 不含 `phone`; |
|||
- `iat`、`exp` 使用调用方传入的同一 `capturedNow`; |
|||
- 过期判断边界固定:`exp <= capturedNow` 视为过期; |
|||
- 不向调用方返回原始 Claims 对象; |
|||
- 异常消息不得包含 Token 或 claim 原值。 |
|||
|
|||
增加强类型内部 record/DTO,例如: |
|||
|
|||
```text |
|||
AccessTokenClaims |
|||
RefreshTokenClaims |
|||
IssuedToken |
|||
``` |
|||
|
|||
不要让 Controller 直接依赖 JJWT 类型。 |
|||
|
|||
## 八、Token 摘要 |
|||
|
|||
新增内部服务,例如: |
|||
|
|||
```text |
|||
mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthTokenHasher.java |
|||
``` |
|||
|
|||
要求: |
|||
|
|||
- `HmacSHA256`; |
|||
- 密钥为 `app.jwt.token-hash-secret`; |
|||
- 输出固定长度十六进制或 Base64URL; |
|||
- 不存原文; |
|||
- 不记录输入、输出或异常原值; |
|||
- 对固定输入输出稳定; |
|||
- 比较使用 `MessageDigest.isEqual`; |
|||
- 单元测试使用公开测试向量或确定性断言。 |
|||
|
|||
## 九、Flyway V42 |
|||
|
|||
编码前执行: |
|||
|
|||
```powershell |
|||
Get-ChildItem mydreamjob-server/src/main/resources/db/migration | Sort-Object Name | Select-Object -Last 10 |
|||
``` |
|||
|
|||
只有 V42 未被占用时才新增: |
|||
|
|||
```text |
|||
mydreamjob-server/src/main/resources/db/migration/V42__auth_token_sessions.sql |
|||
``` |
|||
|
|||
扩展 `auth_tokens`: |
|||
|
|||
```text |
|||
session_id VARCHAR(36) NULL |
|||
token_jti VARCHAR(36) NULL |
|||
parent_token_id BIGINT NULL |
|||
consumed_at DATETIME NULL |
|||
revoked_at DATETIME NULL |
|||
revocation_reason VARCHAR(40) NULL |
|||
version BIGINT NOT NULL DEFAULT 0 |
|||
``` |
|||
|
|||
新增索引: |
|||
|
|||
```text |
|||
idx_auth_tokens_session_active(session_id, revoked, consumed_at, expired_at) |
|||
idx_auth_tokens_user_session(user_id, session_id) |
|||
uk_auth_tokens_token_jti(token_jti) UNIQUE |
|||
``` |
|||
|
|||
要求: |
|||
|
|||
- 不修改旧列、不删除旧数据; |
|||
- `session_id IS NULL` 的旧行不参与新会话认证; |
|||
- 不新增其他表; |
|||
- 不添加级联删除; |
|||
- CHECK/索引语法必须通过 MySQL 8.4 和 H2 MODE=MySQL; |
|||
- 更新 `AuthToken` 实体并使用 `@Version`; |
|||
- `token_hash` 保存 Refresh Token HMAC 摘要; |
|||
- 新记录 `token_type=REFRESH`; |
|||
- `refresh_token_hash` 保留但新逻辑不依赖。 |
|||
|
|||
新增持久化测试: |
|||
|
|||
- 旧格式行仍可存在; |
|||
- 新记录字段正确; |
|||
- `token_jti` 唯一; |
|||
- 默认 version=0; |
|||
- 两个 EntityManager 的旧 version 更新冲突; |
|||
- 不保存原始 JWT。 |
|||
|
|||
## 十、Repository 原子操作 |
|||
|
|||
`AuthTokenRepository` 增加明确查询和条件更新,禁止 `findById -> set -> save` 作为并发消费保证。 |
|||
|
|||
至少实现: |
|||
|
|||
1. 按 `tokenHash` 查询候选 Refresh 记录; |
|||
2. 按 `userId + sessionId` 判断活动会话; |
|||
3. 原子消费:条件包含 `id`、`expectedVersion`、`revoked=false`、`consumedAt IS NULL`、`expiredAt > capturedNow`; |
|||
4. 按 `userId + sessionId` 撤销当前会话; |
|||
5. 按 `userId` 撤销全部会话。 |
|||
|
|||
所有更新返回受影响行数并严格检查。不得通过捕获异常后继续返回成功。 |
|||
|
|||
## 十一、AuthSessionService |
|||
|
|||
新增: |
|||
|
|||
```text |
|||
mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthSessionService.java |
|||
``` |
|||
|
|||
职责: |
|||
|
|||
- 创建登录会话; |
|||
- 保存首个 Refresh Token 摘要; |
|||
- 校验 Access 所属会话; |
|||
- 消费并轮换 Refresh Token; |
|||
- 撤销当前会话; |
|||
- 撤销用户全部会话; |
|||
- 处理重放。 |
|||
|
|||
### 登录 |
|||
|
|||
- 捕获一次时间; |
|||
- 生成随机 UUID `sid`; |
|||
- 生成 Access 和 Refresh 的独立 `jti`; |
|||
- 生成 Token; |
|||
- 保存 Refresh 摘要; |
|||
- 事务提交成功后才返回; |
|||
- 多次登录必须得到不同 `sid`。 |
|||
|
|||
### 刷新 |
|||
|
|||
- Controller 传入原始 Refresh Token,但原文只在内存短暂存在; |
|||
- Provider 校验 Refresh; |
|||
- 摘要查询记录; |
|||
- claim 中的 userId/sid/jti 必须与数据库一致; |
|||
- 原子消费旧记录; |
|||
- 插入同 sid 的新记录; |
|||
- 新旧 jti 不同; |
|||
- 新 Access 和 Refresh 使用同一次 `capturedNow`; |
|||
- 旧 Token 再次使用返回固定 401。 |
|||
|
|||
### 重放 |
|||
|
|||
- 已消费或已撤销 Token 被再次提交时撤销整个当前 session; |
|||
- 撤销必须在不会随当前失败事务回滚丢失的事务边界执行; |
|||
- 写一条脱敏审计; |
|||
- 不回显“已消费”“已撤销”或数据库状态。 |
|||
|
|||
### 活动会话判断 |
|||
|
|||
- session 必须属于 user; |
|||
- 存在未消费、未撤销、未过期的当前 Refresh 记录; |
|||
- 用户 active; |
|||
- 账号注销未完成; |
|||
- 数据库错误失败关闭。 |
|||
|
|||
## 十二、登录服务 |
|||
|
|||
修改 `AuthService.mockLogin`: |
|||
|
|||
- 保持验证码、自动注册、注销墓碑和响应字段不变; |
|||
- 改为创建 AuthSession; |
|||
- Access Token 必须带 `type=access`; |
|||
- Refresh Token 必须带 `type=refresh`; |
|||
- 响应仍包含 `userId/phone/nickname/role/accessToken/refreshToken/expiresIn`; |
|||
- 数据库只保存 Refresh 摘要; |
|||
- 会话持久化失败时整个登录事务失败,不得返回 Token或写成功审计; |
|||
- 不改变 `expiresIn=86400`。 |
|||
|
|||
## 十三、认证过滤器 |
|||
|
|||
修改 `JwtAuthenticationFilter`: |
|||
|
|||
- 只调用 `parseAndValidateAccessToken`; |
|||
- Refresh Token、无 type、未知 type、错误 issuer/audience 必须拒绝; |
|||
- 验证服务端 session; |
|||
- 验证用户 active 和注销状态; |
|||
- 通过后使用 userId 建立 SecurityContext; |
|||
- 不把 Token claim 直接作为权限真相; |
|||
- 任何失败均不得建立部分认证; |
|||
- 清理可能存在的 SecurityContext; |
|||
- 日志使用固定安全文本,不使用异常 `getMessage()`; |
|||
- 不回显 Token、subject、sid、jti。 |
|||
|
|||
真实 MockMvc 测试必须覆盖: |
|||
|
|||
- Access 访问 `/auth/me` 成功; |
|||
- Refresh 访问 `/auth/me` 为 401; |
|||
- Access/Refresh 交叉密钥失败; |
|||
- 无 type、未知 type、非法 subject/sid/jti 为 401; |
|||
- 撤销 session 为 401; |
|||
- 用户禁用为 401; |
|||
- 服务端校验抛异常时不放行、不返回 500 内部对象。 |
|||
|
|||
## 十四、刷新和登出接口 |
|||
|
|||
新增白名单 DTO: |
|||
|
|||
```text |
|||
auth/dto/RefreshTokenRequest.java |
|||
auth/dto/TokenPairResponse.java |
|||
``` |
|||
|
|||
### `POST /auth/refresh` |
|||
|
|||
- `SecurityConfig` 中精确 permitAll; |
|||
- 请求体只包含 `refreshToken`; |
|||
- `@NotBlank` 且设置合理长度上限; |
|||
- Token 不允许作为 query/path 参数; |
|||
- 成功返回新 Token 对和 `expiresIn`; |
|||
- Access Token 作为 refreshToken 提交必须 401; |
|||
- 所有认证失败统一安全响应。 |
|||
|
|||
### `POST /auth/logout` |
|||
|
|||
- 必须 authenticated; |
|||
- 无 requestBody; |
|||
- 从通过验证的 Access Token claims / SecurityContext 取得 userId 和 sid; |
|||
- 不接收 userId、sessionId、refreshToken; |
|||
- 撤销当前 session; |
|||
- 保持幂等语义; |
|||
- 成功响应不包含 Token。 |
|||
|
|||
`AuthController` 不得计算摘要、解析 JWT、访问 Repository 或重复实现会话逻辑。 |
|||
|
|||
## 十五、注销与用户禁用 |
|||
|
|||
在不改变账号注销状态机的前提下: |
|||
|
|||
- `AccountDeletionCompletionWorker` 成功完成注销时撤销用户全部会话; |
|||
- 状态更新和会话撤销必须具备一致事务语义; |
|||
- 重复完成不重复产生不受控副作用; |
|||
- 注销申请 PENDING 时不强制退出; |
|||
- 取消注销不恢复旧 session; |
|||
- Filter 每次认证必须拒绝 inactive 或 COMPLETED 用户; |
|||
- 不接受客户端指定需要撤销的 userId。 |
|||
|
|||
补测试证明: |
|||
|
|||
- 注销完成后所有设备 Access/Refresh 均失效; |
|||
- 其他用户会话不受影响; |
|||
- 禁用用户不能继续认证; |
|||
- candidate scoring、消息、投递等业务表不被修改。 |
|||
|
|||
## 十六、审计隐私 |
|||
|
|||
允许的固定动作: |
|||
|
|||
```text |
|||
AUTH_SESSION_CREATED |
|||
AUTH_TOKEN_REFRESHED |
|||
AUTH_SESSION_REVOKED |
|||
AUTH_REFRESH_REUSE_DETECTED |
|||
``` |
|||
|
|||
details 使用 ObjectMapper 序列化白名单 Map/record,禁止拼接不受控 JSON。 |
|||
|
|||
禁止进入日志、审计和响应: |
|||
|
|||
```text |
|||
accessToken |
|||
refreshToken |
|||
Authorization |
|||
tokenHash |
|||
refreshTokenHash |
|||
JWT 原文 |
|||
完整手机号 |
|||
email |
|||
password |
|||
secret |
|||
Claims 对象 |
|||
异常对象 toString |
|||
``` |
|||
|
|||
日志测试必须捕获真实 Logback 输出,不得只 Mockito verify logger。 |
|||
|
|||
## 十七、单次 Clock |
|||
|
|||
注入 `Clock`,使用 `ZoneId.of("Asia/Shanghai")` 的测试 Clock。 |
|||
|
|||
分别验证登录、刷新、登出、重放和注销完成: |
|||
|
|||
- 每个入口只读取 Clock 一次; |
|||
- JWT iat/exp; |
|||
- DB createdAt/expiredAt/consumedAt/revokedAt; |
|||
- 审计和响应; |
|||
|
|||
均使用首次捕获时间。 |
|||
|
|||
禁止使用 `Thread.sleep()`、系统默认时区或真实时间碰撞作为核心测试。 |
|||
|
|||
## 十八、并发测试 |
|||
|
|||
新增真实 H2 集成并发测试: |
|||
|
|||
- `@SpringBootTest`; |
|||
- integration-test profile; |
|||
- 真实 Service、Repository、事务和 EntityManager; |
|||
- 两个线程和独立事务; |
|||
- 使用 CountDownLatch 同时开始; |
|||
- 不 Mock 条件更新。 |
|||
|
|||
测试: |
|||
|
|||
1. `concurrentRefreshOnlyOneRequestSucceeds`; |
|||
2. `refreshReplayRevokesOnlyCurrentSession`; |
|||
3. `differentSessionsRefreshIndependently`; |
|||
4. `logoutRacingWithRefreshCannotLeaveActiveToken`。 |
|||
|
|||
断言: |
|||
|
|||
- 同一 Token 最多一个刷新成功; |
|||
- 不产生两个活动后继 Token; |
|||
- 失败方不返回 500; |
|||
- 当前 session 被安全撤销; |
|||
- 其他 session 不受影响; |
|||
- 无死锁和未处理乐观锁异常。 |
|||
|
|||
## 十九、完整测试矩阵 |
|||
|
|||
建议新增: |
|||
|
|||
```text |
|||
JwtTokenTypeIsolationTest.java |
|||
JwtAuthenticationTypeIntegrationTest.java |
|||
AuthTokenHasherTest.java |
|||
AuthSessionServiceTest.java |
|||
AuthSessionPersistenceIntegrationTest.java |
|||
AuthRefreshControllerTest.java |
|||
AuthRefreshHttpIntegrationTest.java |
|||
AuthRefreshConcurrencyIntegrationTest.java |
|||
AuthSessionRevocationIntegrationTest.java |
|||
AuthTokenClockConsistencyTest.java |
|||
AuthTokenRedactionTest.java |
|||
``` |
|||
|
|||
至少覆盖: |
|||
|
|||
### 类型和 claim |
|||
|
|||
- access/refresh 类型; |
|||
- 独立密钥; |
|||
- issuer/audience; |
|||
- subject/sid/jti; |
|||
- 无 type 和未知 type; |
|||
- 过期边界; |
|||
- Token 不含手机号。 |
|||
|
|||
### 生命周期 |
|||
|
|||
- 登录; |
|||
- 刷新轮换; |
|||
- 二次使用; |
|||
- 并发刷新; |
|||
- 当前设备登出; |
|||
- 多设备隔离; |
|||
- 注销完成; |
|||
- 用户禁用。 |
|||
|
|||
### HTTP |
|||
|
|||
- 匿名刷新携带有效 Refresh 成功; |
|||
- Access 调刷新 401; |
|||
- Refresh 调普通接口 401; |
|||
- 无 Token 登出 401; |
|||
- 有效 Access 登出成功; |
|||
- 错误响应不泄漏。 |
|||
|
|||
### 副作用 |
|||
|
|||
- 原始 Token 不落库; |
|||
- 不产生 RabbitMQ、通知或短信; |
|||
- 不修改评分、排名、推荐、邀约、消息和简历表; |
|||
- 只产生预期 auth_tokens 和白名单审计变化。 |
|||
|
|||
## 二十、OpenAPI 和文档 |
|||
|
|||
更新: |
|||
|
|||
```text |
|||
docs/openapi-rc.json |
|||
docs/verify_openapi.py |
|||
docs/api-v0.1.md |
|||
docs/frontend-integration.md |
|||
docs/backend-development-roadmap.md |
|||
docs/phase1-scope-and-acceptance.md |
|||
docs/s02-auth-token-isolation-design.md |
|||
docs/s02-auth-token-isolation-implementation-instruction.md |
|||
``` |
|||
|
|||
运行时生成 OpenAPI,不得手工伪造 schema。 |
|||
|
|||
`verify_openapi.py` 增加递归断言: |
|||
|
|||
1. 总数为 `175 paths / 213 operations`; |
|||
2. 只新增 `/auth/refresh` 和 `/auth/logout`; |
|||
3. Refresh 请求体只含 `refreshToken`; |
|||
4. Logout 无 requestBody; |
|||
5. 普通安全方案说明只接受 Access Token; |
|||
6. schema 不包含 tokenHash、refreshTokenHash、session 数据库对象、phone、secret; |
|||
7. 登录原有字段不删除; |
|||
8. 不新增其他认证或管理接口。 |
|||
|
|||
前端文档必须明确: |
|||
|
|||
- 上线时旧 Token 全部失效; |
|||
- 普通请求只使用 Access; |
|||
- 401 时最多刷新一次; |
|||
- 刷新请求必须串行; |
|||
- 刷新失败清除 Token并重新登录; |
|||
- 登出后无条件清除本地 Token; |
|||
- 不记录或上报 Token。 |
|||
|
|||
## 二十一、验证顺序 |
|||
|
|||
先运行新增定向测试,按实际类名调整: |
|||
|
|||
```powershell |
|||
cd C:\Users\15642\Desktop\zhilai\mydreamjob-server |
|||
mvn -Dtest="JwtTokenTypeIsolationTest,JwtAuthenticationTypeIntegrationTest,AuthTokenHasherTest,AuthSessionServiceTest,AuthSessionPersistenceIntegrationTest,AuthRefreshControllerTest,AuthRefreshHttpIntegrationTest,AuthRefreshConcurrencyIntegrationTest,AuthSessionRevocationIntegrationTest,AuthTokenClockConsistencyTest,AuthTokenRedactionTest" test |
|||
``` |
|||
|
|||
再运行认证和注销关联回归: |
|||
|
|||
```powershell |
|||
mvn -Dtest="*Auth*Test,*Jwt*Test,*Security*Test,*Deletion*Test,*Sms*Test" test |
|||
``` |
|||
|
|||
最后执行: |
|||
|
|||
```powershell |
|||
mvn test |
|||
mvn clean verify |
|||
python ..\docs\verify_openapi.py |
|||
cd C:\Users\15642\Desktop\zhilai |
|||
git diff --check |
|||
git status --short |
|||
git diff --cached --name-only |
|||
git diff --name-only -- mydreamjob-server/src/main/resources/db/migration |
|||
``` |
|||
|
|||
要求: |
|||
|
|||
- Failures=0; |
|||
- Errors=0; |
|||
- Skipped=0; |
|||
- `mvn clean verify` BUILD SUCCESS; |
|||
- OpenAPI=`175/213`; |
|||
- 暂存区为空; |
|||
- 唯一新迁移为实际确认编号的 auth token migration; |
|||
- 未修改 V1–V41; |
|||
- 未 commit、未 push。 |
|||
|
|||
如果 clean verify 因正在运行的 JAR 锁定: |
|||
|
|||
- 查询实际 PID、端口和完整命令行; |
|||
- 不结束未知进程; |
|||
- 报告并等待负责人确认。 |
|||
|
|||
## 二十二、禁止通过的绕过方式 |
|||
|
|||
- 只给 Refresh 增加 claim,却不让 Filter 检查; |
|||
- 仍用同一签名密钥,只依赖字符串类型; |
|||
- 继续接受无 type 旧 Token; |
|||
- 把 Refresh Token 黑名单只放在 JVM 内存; |
|||
- 数据库存原始 JWT; |
|||
- 日志打印 Token 便于调试; |
|||
- 刷新后旧 Token仍有效; |
|||
- 并发刷新两次都成功; |
|||
- 登出只删前端 Token,不做服务端吊销; |
|||
- Access Token 不检查 session,导致登出后仍可用; |
|||
- 数据库异常时跳过 session 检查; |
|||
- 用 Mockito 模拟 Repository 代替全部并发和持久化证据; |
|||
- 修改测试期望值掩盖旧行为; |
|||
- 修改旧 Flyway; |
|||
- 为完成 S02 重构全仓身份解析或异常映射。 |
|||
|
|||
## 二十三、完成状态 |
|||
|
|||
编码和自动化全部通过后,将设计及路线图状态写为: |
|||
|
|||
```text |
|||
S02 已实现并完成自动化验证与干净构建,待独立代码复验及真实 MySQL/Docker 黑盒验收。 |
|||
``` |
|||
|
|||
不得写“已冻结”“已完成生产验收”或“S03 已开始”。 |
|||
|
|||
## 二十四、最终报告 |
|||
|
|||
必须包含: |
|||
|
|||
1. 修复前 Refresh Token 访问普通接口的复现; |
|||
2. Access/Refresh 的 claim 和密钥隔离; |
|||
3. 无 type 旧 Token 结果; |
|||
4. 登录响应兼容结果; |
|||
5. Token 摘要算法和数据库脱敏结果; |
|||
6. `auth_tokens` 迁移和实体变化; |
|||
7. 正常刷新与轮换结果; |
|||
8. 二次使用和重放处置; |
|||
9. 并发刷新结果; |
|||
10. 当前设备登出和多设备隔离; |
|||
11. 注销完成、用户禁用后的结果; |
|||
12. 单次 capturedNow 证据; |
|||
13. 日志、审计和完整 JSON 脱敏; |
|||
14. 数据库与外围副作用; |
|||
15. 新增接口和 OpenAPI 实际计数; |
|||
16. 专项、关联、全量测试数; |
|||
17. Failures、Errors、Skipped; |
|||
18. clean verify 结果; |
|||
19. 精确修改文件; |
|||
20. Git 状态; |
|||
21. 是否修改 V1–V41、ErrorCode 或其他业务接口; |
|||
22. 明确未执行 `git add -A`、commit、push; |
|||
23. 明确真实 MySQL/Docker 黑盒仍待独立验收。 |
|||
|
|||
任何失败或 SKIP 必须如实报告。完成后停止,不进入 S03。 |
|||
@ -0,0 +1,202 @@ |
|||
# S02 独立代码复验与真实 MySQL/Docker 黑盒验收报告(脱敏版) |
|||
|
|||
- **验收日期**:2026-09-14 |
|||
- **分支**:`feat/s02-token-isolation` |
|||
- **验收对象**:S02「Access/Refresh Token 隔离、轮换与吊销」 |
|||
- **结论**:**PASS** —— *S02独立代码复验和真实MySQL/Docker黑盒全部通过,可以进入精确暂存检查点。* |
|||
- **本报告不含**:JWT 原文、Token 摘要全文、验证码、完整手机号、数据库口令、JWT 密钥、`.env` 内容、`Authorization` 请求头 |
|||
|
|||
--- |
|||
|
|||
## 1. Git 基线与工作区状态 |
|||
|
|||
| 项 | 实测 | |
|||
|---|---| |
|||
| 分支 | `feat/s02-token-isolation` | |
|||
| HEAD | `96a920b fix: harden resume upload safety` | |
|||
| 暂存区 | **空** | |
|||
| `git diff --check` | 无空白错误 | |
|||
| 工作区 | 63 个已修改 + 116 个未跟踪 | |
|||
| 唯一新增迁移 | `V42__auth_token_sessions.sql`(V1–V41 未修改) | |
|||
| 删除文件 | 0 | |
|||
| AGENTS.md | **未发现** | |
|||
| 静态 OpenAPI | 175 paths / 213 operations(`All checks passed!`) | |
|||
|
|||
## 2. 独立代码审查逐项结论 |
|||
|
|||
| 项 | 结论 | 关键证据 | |
|||
|---|---|---| |
|||
| A1 Token 类型隔离 | **PASS** | Access 用 `accessKey`、带 `type=access`;Refresh 用 `refreshKey`、带 `type=refresh`、无 `role`;摘要用独立 `tokenHashSecret`;三项密钥经 `requireDistinct` 强制互异;两类 Token 均不含 phone/email/nickname/resumeUrl | |
|||
| A2 JWT claims | **PASS** | `JwtTokenProvider.parse()` 统一校验 `type`/`iss`/`aud`/`exp`/`iat`,`requirePositiveSubject` 与 `requireCanonicalUuid` 校验 sub/sid/jti;时间契约 `iat <= capturedNow < exp`;异常统一 `InvalidTokenException`(固定文本,不含 Token/claim) | |
|||
| A3 服务端会话状态 | **PASS** | `JwtAuthenticationFilter` 验签后调用 `isSessionActive`:用户存在 + active + session 下存在未消费/未撤销/未过期记录;`session_id IS NULL` 永不命中;异常失败关闭;`createSession` 对 inactive 用户先抛异常(不先发 Token) | |
|||
| A4 刷新轮换事务 | **PASS** | `refresh()` 无 `@Transactional`(编排入口);`executeRefreshAttempt` 在独立事务;消费与写后继同事务;条件更新含 id/version/revoked/consumedAt/expiredAt;`consumed != 1` 即判定重放;重放信息经 `RefreshAttempt.Replayed` 返回而非抛异常;重放撤销在尝试事务**之后**执行;无 JVM 锁、无 `Thread.sleep` | |
|||
| A5 并发重放 | **PASS** | `revokeReplayedSession` 用 REQUIRES_NEW,逐轮 `revokeSession` 后**校验活动 Token 数**,最多重试 3 次(无 sleep),仍失败则抛出(不静默)。日志中已无 `could not take locks` 分支 | |
|||
| A6 logout | **PASS** | 只撤销当前 session;幂等;其他设备不受影响;测试未使用 `catch (Throwable)`,未在最终断言前补 logout | |
|||
| A7 Token 摘要与脱敏 | **PASS** | HMAC-SHA256 + 独立密钥;64 位小写十六进制;`MessageDigest.isEqual` 常量时间比较;`hash(null)` 与 `hash("")` 同值但生产路径在校验后才调用 | |
|||
| A8 capturedNow | **PASS** | 登录/刷新/登出每个入口只读一次 Clock;`created_at`/`expired_at`/`consumed_at`/`revoked_at`/审计均来自该次捕获;固定时区 `Asia/Shanghai` | |
|||
| A9 用户禁用与注销 | **PASS** | `createSession` 拒绝 inactive;注销完成在同一业务事务内 `desensitize()`(active=false)+ `revokeAllUserSessions`;回退状态不恢复已撤销 Token;其他用户不受影响 | |
|||
| A10 配置安全 | **PASS**(代码层)| 三项密钥缺失/过短/相同/issuer 空/audience 空均启动失败,错误只含**密钥名**不含值;旧 `JWT_SECRET` 不再强制解析;`CandidateKeyGenerator` 在空密钥时**先抛异常**,不产生可预测 key | |
|||
|
|||
**观察项(非阻断)**:`AppJwtProperties` 的校验规则没有专门的单元测试(设计 §六 曾要求)。本轮改用阶段 O 的真实 JAR 启动矩阵实证,覆盖更强;仍建议后续补一个配置校验单测。 |
|||
|
|||
## 3. Docker / MySQL / Redis / RabbitMQ |
|||
|
|||
| 组件 | 版本 | 状态 | |
|||
|---|---|---| |
|||
| Docker Engine | 29.6.2(linux/amd64) | 可用 | |
|||
| `mydreamjob-mysql` | MySQL **8.4.11** | healthy(127.0.0.1:3307) | |
|||
| `mydreamjob-redis` | redis:7.4-alpine | healthy | |
|||
| `mydreamjob-rabbitmq` | rabbitmq:3.13-management-alpine | healthy | |
|||
|
|||
验收数据库名:`mydreamjob`。未重建容器、未删除 volume、未打印 `.env` 或口令。 |
|||
|
|||
## 4–6. Flyway / 表结构 / 启动 |
|||
|
|||
**Flyway**:接受验收前最新版本为 V41;启动最新 JAR 后 **V42 `auth token sessions` success=1**,失败迁移 0。 |
|||
|
|||
**`auth_tokens` 真实表结构**(新增 7 列 + 3 索引,旧索引保留): |
|||
|
|||
``` |
|||
session_id VARCHAR(36) NULL token_jti VARCHAR(36) NULL |
|||
parent_token_id BIGINT NULL consumed_at / revoked_at DATETIME NULL |
|||
revocation_reason VARCHAR(40) version BIGINT NOT NULL DEFAULT 0 |
|||
UNIQUE uk_auth_tokens_token_jti (token_jti) |
|||
KEY idx_auth_tokens_session_active (session_id, revoked, consumed_at, expired_at) |
|||
KEY idx_auth_tokens_user_session (user_id, session_id) |
|||
KEY idx_auth_tokens_user / idx_auth_tokens_hash (既有,保留) |
|||
``` |
|||
|
|||
无任何保存 Token 原文的列。 |
|||
|
|||
**启动**:`Started MyDreamjobApplication`(16.3 s),无 Flyway 错误、无 schema validation 错误;`GET /api/v1/health` → **HTTP 200 / status=UP**;运行时 OpenAPI 175/213。 |
|||
|
|||
## 7. 自动化实际测试数 |
|||
|
|||
| 命令 | Tests run | Failures | Errors | Skipped | BUILD | |
|||
|---|---|---|---|---|---| |
|||
| 原独立失败测试(2 类) | **3** | 0 | 0 | 0 | SUCCESS | |
|||
| S02 专项(13 类) | **84** | 0 | 0 | 0 | SUCCESS | |
|||
| 认证关联回归 | **208** | 0 | 0 | 0 | SUCCESS | |
|||
| `mvn test` | **1758** | 0 | 0 | 0 | SUCCESS | |
|||
| `mvn clean verify` | **1758** | 0 | 0 | 0 | SUCCESS(2:21) | |
|||
|
|||
(候选基线称"扩展专项 87/87" = 专项 84 + 独立 3,与实测一致。) |
|||
|
|||
## 8–9. Token claims 与类型矩阵(真实 HTTP) |
|||
|
|||
**claims**:登录响应字段兼容、`expiresIn=86400`、三段 JWT、两 Token 原文不同、jti 不同、sid 相同、type 正确、iss/aud 正确、Access 有 role、Refresh 无 role、均不含个人信息、有效期 24h/7d、均含 iat —— **全部 PASS**。 |
|||
|
|||
**类型矩阵**(每格记录 method / path / HTTP status / business code / requestId): |
|||
|
|||
| Token | `/auth/me` | `/auth/refresh` | |
|||
|---|---|---| |
|||
| Access | **200** | **401** | |
|||
| Refresh | **401** | **200**(并轮换) | |
|||
| 无 type 旧 Token | 401 | 401 | |
|||
| Access 用 Refresh 密钥伪造 | 401 | 401 | |
|||
| Refresh 用 Access 密钥伪造 | 401 | 401 | |
|||
| 过期 Token | 401 | 401 | |
|||
| 缺 iat | 401 | 401 | |
|||
| 未来 iat | 401 | 401 | |
|||
| 非法 sid | 401 | 401 | |
|||
| 非法 sub | 401 | 401 | |
|||
|
|||
错误响应均未泄漏 JWT 原文、`Bearer`、`token_hash`、sid、jti、手机号、密钥、异常类名或堆栈。 |
|||
|
|||
## 10–11. 正常轮换与串行重放 |
|||
|
|||
**轮换**:sid 不变、jti 变化、旧记录 `consumed_at` 非空、version 只 +1、新记录 `parent_token_id` 指向旧记录、同一时刻仅 1 个活动 Refresh、A2 可访问、R2 可再次刷新 —— 全部 PASS。 |
|||
|
|||
**串行重放**:R1 重放 → **401**;当前 session 活动 Refresh 数 **0**;A1 与 A2 访问 `/auth/me` 均 **401**;R2 刷新 **401**;其他设备 session **200**;写入一次重放安全审计。 |
|||
|
|||
## 12–13. 真实并发(10 轮)与胜出方 Access |
|||
|
|||
每轮两个独立 HTTP 客户端 + `threading.Barrier` 同时发起,**10/10 轮**全部满足: |
|||
|
|||
| 断言 | 实测 | |
|||
|---|---| |
|||
| 无 HTTP 500 | 10/10 | |
|||
| 最多一个刷新成功 | 10/10 | |
|||
| 失败方为 401 | 10/10 | |
|||
| 重放后活动 Refresh **严格为 0** | 10/10 | |
|||
| 原 Access 与**胜出方新 Access** 均失效 | 20 次断言全部满足 | |
|||
| 其他设备 session 始终 200 | 10/10 | |
|||
| 无锁异常 / 堆栈泄漏 | 10/10 | |
|||
| 日志无 `refresh reuse revocation could not take locks` | ✅ | |
|||
| 无同一 session 两个活动后继 | ✅ | |
|||
|
|||
## 14–16. 多设备 / 竞争 / inactive 与注销 |
|||
|
|||
- **logout**:第一次成功,Access 与 Refresh 随即 401;重复 logout 按既有幂等语义;其他设备不受影响。 |
|||
- **多设备隔离**:两设备 sessionId 不同;A 登出后 A 失效、B 的 Access 与 Refresh 均保持有效。 |
|||
- **logout 与 refresh 并发**:无 500;未补 logout 的情况下活动 Refresh 数为 0;竞争可能返回的新 Access 最终 401。 |
|||
- **inactive**:登录不返回 Token、不新增 `auth_tokens`;置为 inactive 后旧 Access/Refresh 均 401。 |
|||
- **注销完成**(走正式懒转换完成路径):状态 COMPLETED、用户 `active=false`、全部会话撤销、两台设备 Access/Refresh 均 401、其他用户完全不受影响。 |
|||
|
|||
## 17. Clock 与真实数据库时间 |
|||
|
|||
| 断言 | 实测 | |
|||
|---|---| |
|||
| `created_at` 与 Access `iat` 同一时刻 | ±1s 内一致 | |
|||
| `expired_at` 与 Refresh `exp` 同一时刻 | ±1s 内一致 | |
|||
| `consumed_at` 与刷新时刻 | ±1s 内一致 | |
|||
| `revoked_at` 与登出时刻 | ±5s 内一致 | |
|||
| AUTH 审计 `created_at` 与请求时刻 | ±120s 内一致 | |
|||
|
|||
## 18–19. 明文扫描 / 审计与日志脱敏 |
|||
|
|||
- `token_hash` 为 64 位小写十六进制,不等于 Refresh 原文;库中搜索两类 Token 原文命中 **0**。 |
|||
- 应用日志与 stdout 扫描:Token / 验证码 / 密钥命中 **0**;扫描器经**注入探针**验证有效。 |
|||
- 审计动作仅限 4 个白名单值;details 不含 Token、摘要、`Bearer`、手机号、jti、`Authorization`、密钥;不含原始 sid(用不可逆 `sessionRef`)。 |
|||
|
|||
## 20–22. 副作用 / RabbitMQ / OpenAPI |
|||
|
|||
- **数据库副作用**:登录+刷新+串行重放前后,`candidate_dimension_scores`、`messages`、`notifications`、`applications`、`talent_invitations` 内容摘要**零变化**(按 id 摘要比对,非仅 COUNT)。 |
|||
- **RabbitMQ**:4 个项目队列全部存在,`ready=0`、`unacked=0`;S02 运行期间无新增消息。 |
|||
- **OpenAPI**:静态与运行时均 **175 / 213**;`/auth/refresh` 请求体仅 `refreshToken`;`/auth/logout` 无请求体;公共 schema 不含 tokenHash/sessionId/phone/secret 等内部字段。 |
|||
|
|||
## 23. PASS / FAIL / SKIP 清单 |
|||
|
|||
| 阶段 | PASS | FAIL | SKIP | |
|||
|---|---|---|---| |
|||
| A 代码审查(10 项) | 10 | 0 | 0 | |
|||
| B 自动化(5 组) | 5 | 0 | 0 | |
|||
| C–E 环境/迁移/启动 | 8 | 0 | 0 | |
|||
| F–M 黑盒(含 10 轮并发) | **117** | 0 | 0 | |
|||
| N–S 时间/脱敏/副作用/外围/OpenAPI | **30** | 0 | 0 | |
|||
| O 配置启动矩阵(9 配置) | **18** | 0 | 0 | |
|||
| **合计** | **165** | **0** | **0** | |
|||
|
|||
## 24. 剩余风险 |
|||
|
|||
1. **重放撤销重试可能重复写审计**:`revokeReplayedSession` 在需要重试时会再写一对 `AUTH_REFRESH_REUSE_DETECTED` / `AUTH_SESSION_REVOKED`。本轮所有场景(含 10 轮并发)**重试次数为 0**,未观测到重复;属潜在项,建议后续把审计移到循环外。 |
|||
2. **`AppJwtProperties` 无专门单元测试**(见第 2 节观察项)。 |
|||
3. **旧 `JWT_SECRET` 仍被 `talentdiscovery.CandidateKeyGenerator` 使用**:本轮已确认空密钥时**安全失败**(抛异常、不产生可预测 key),但该配置项仍存在;建议后续为其单独引入专用密钥。 |
|||
4. 本轮**未执行**:真实短信供应商、Redis 会话缓存、设备指纹、Access 有效期缩短等 S02 明确非目标项。 |
|||
5. 验收数据保留在库(见第 26 节)。 |
|||
|
|||
## 25. 是否允许冻结 S02 |
|||
|
|||
**允许。** 24 项验收判据全部满足,无 FAIL、无核心项 SKIP,24 条"不得冻结"清单全部未触发。 |
|||
|
|||
--- |
|||
|
|||
## 26. 验收结束处理 |
|||
|
|||
- 本轮启动的 JAR(PID 21420,命令行含 `s02-acc-main-app.log`)**已停止**;8080 恢复空闲;未停止任何未知 Java 进程;未停止 Docker。 |
|||
- **验收数据保留**,未执行删除。理由:`audit_logs` 有 **143** 条 `AUTH_*` 行引用本轮验收用户且该表**无外键**,删除用户会产生孤儿审计行;而本任务边界禁止删除 `audit_logs`。精确足迹如下(供后续人工处置): |
|||
|
|||
| 范围 | 行数 | |
|||
|---|---| |
|||
| `users`(本轮 15:25 之后创建,199 前缀) | 35 | |
|||
| `auth_tokens`(这些用户) | 93 | |
|||
| `audit_logs` `AUTH_*`(这些用户) | 143 | |
|||
| `account_deletions`(这些用户) | 1 | |
|||
|
|||
> 注:库中另有 3 个 199 前缀用户属于 **S01 验收**(2026-09-14 09:48 与 2026-08-13),及其 2 条投递记录,**非本轮产生,未触碰**。 |
|||
|
|||
- 最终 Git:暂存区**空**、`git diff --check` 无错误、V1–V41 未修改、V42 为唯一新增迁移、未删除文件、**未** `git add -A` / commit / push、未进入 S03。 |
|||
|
|||
--- |
|||
|
|||
# S02 独立代码复验和真实MySQL/Docker黑盒全部通过,可以进入精确暂存检查点。 |
|||
@ -0,0 +1,167 @@ |
|||
# S03「生产默认账号、简历隐私与 Actuator 隔离」独立代码复验及真实 MySQL/Docker 黑盒验收 |
|||
|
|||
日期:2026-09-15 |
|||
分支:`feat/s03-production-privacy` |
|||
结论:**FAIL —— 已复现具体缺陷(阶段 I-1:业务端口 Actuator 路径非 404),S03 不可冻结;报告包含下一轮定向整改指令。** |
|||
|
|||
## 1. Git 基线与工作区状态 |
|||
|
|||
- 当前分支 `feat/s03-production-privacy`;HEAD `f399060 feat: isolate and revoke authentication token sessions`。 |
|||
- 暂存区为空;全部 S03 修改处于未提交(M/??)状态。 |
|||
- `git diff --check` 通过;`git diff --name-only -- mydreamjob-server/src/main/resources/db/migration` 为空(V1–V42 无改动)。 |
|||
- 唯一新增迁移:`V43__disable_legacy_seed_accounts.sql`。 |
|||
- 未执行 `git add -A`/commit/push/reset/restore/checkout/clean;未删除历史文件。 |
|||
|
|||
## 2. 独立代码审查(阶段 A) |
|||
|
|||
| 项 | 结论 | 关键证据 | |
|||
|---|---|---| |
|||
| A1 V43 迁移安全 | PASS | 3 条 UPDATE(无 DELETE/DROP/TRUNCATE);不按 id 匹配;每条同时匹配原始手机号+邮箱+公开 bcrypt 哈希+主角色+`is_active=1`;只置 `is_active=0`;不碰 `user_roles`;指纹被改的账号不命中;V1–V42 未修改 | |
|||
| A2 管理员初始化装配 | PASS | 默认关闭 + 字面量 `"true"` 双重校验;手机号无默认值、非法即启动失败;已有活动 ADMIN 无副作用跳过;不设密码(`password_hash=NULL`)/不发 Token/不发短信/无公网接口;日志与异常文本固定且不含手机号(`AdminBootstrapRunner.ERROR_INVALID_PHONE/ERROR_PHONE_IN_USE`) | |
|||
| A3 事务与并发 | PASS | `JdbcTemplate + REQUIRES_NEW` 独立事务,users 与 user_roles 同事务回滚;刻意绕过 Hibernate 避免 `SqlExceptionHelper` 记录绑定手机号;异常被捕获只返回 `CONFLICT`,原始 cause 不传播;冲突后仅 `isValidBootstrapAdmin` 全条件匹配才幂等 | |
|||
| A4 简历授权失败关闭 | PASS | `CompanyResumeAccessPolicy`:归属全部服务端推导;无记录/PRIVATE 按 PRIVATE;APPLIED_COMPANIES 严格白名单(SUB/VIEWED/INTERVIEW/HIRED,区分大小写,未知/空白/null/WITHDRAWN/REJECTED 全拒);VERIFIED_COMPANIES 要求 `verified=true` 且岗位归属;授权失败发生在文件读取前 | |
|||
| A5 文件与响应安全 | PASS | 授权后读文件;`Content-Disposition attachment+UTF-8`、`Cache-Control: no-store`、Content-Type 白名单回退 octet-stream;响应不含 resumeUrl/storagePath/studentUserId | |
|||
| A6 Actuator 隔离(设计层面) | PASS* | 独立管理端口默认 127.0.0.1;仅暴露 health/info/metrics/prometheus;`show-details/components=never`;Prometheus 显式开启;不当 JWT 运维认证。*注:阶段 I-1 真实验收发现业务端口行为不达标,见第 12 节 | |
|||
| A7 副作用测试真实性 | PASS | `CompanyResumeDownloadIntegrationTest` 行级字段快照(如 users: id/phone/email/role/is_active/is_verified/updated_at),非 COUNT/SUM;含反证测试(单行 status 变化必须被发现)与 15 表字段覆盖断言 | |
|||
|
|||
## 3. 自动化独立复验(阶段 B) |
|||
|
|||
| 命令 | Tests | Failures | Errors | Skipped | 结果 | |
|||
|---|---:|---:|---:|---:|---| |
|||
| `mvn clean verify`(实测,非沿用历史) | 1834 | 0 | 0 | 0 | BUILD SUCCESS | |
|||
| `python docs\verify_openapi.py` | — | — | — | — | paths=176 operations=214,All checks passed | |
|||
|
|||
## 4. Docker/MySQL 环境(阶段 C) |
|||
|
|||
- Docker Engine 29.6.2;容器由负责人环境启动(本次仅 `docker start` 已存在容器,未重建、未删 volume)。 |
|||
- MySQL 8.4.11(127.0.0.1:3307,库 `mydreamjob`);Redis PONG;RabbitMQ healthy。 |
|||
- Flyway V1–V42 全部 success=1。 |
|||
|
|||
## 5. V43 真实迁移验收(阶段 D) |
|||
|
|||
- 迁移前三个种子账号脱敏快照:role=ADMIN/STUDENT/COMPANY,均 `is_active=1`,指纹与 V1_1 原始值完全匹配,`user_roles` 各 1 条,关联业务数据 4(applications)/21(jobs)/2(profiles)/1(company)。 |
|||
- 最新 JAR + dev(`ddl-auto=validate`)启动后 `flyway_schema_history`:`V43 disable legacy seed accounts success=1`,无失败迁移,V1–V42 校验和未变。 |
|||
- 三个种子账号 `is_active` 1→0;users 行仍存在;`user_roles` 仍存在;关联业务数据未删除。 |
|||
- 指纹偏差隔离夹具(7976/7977,改过手机号与哈希)在 V43 后仍 `is_active=1`,未误伤。 |
|||
- 原种子账号(demo 口令)登录返回 400 且无法取得 Token(已禁用账号不能建立会话),达成"不能取得 Token"目标。 |
|||
|
|||
## 6. 管理员初始化配置矩阵(阶段 E,真实 MySQL) |
|||
|
|||
| 用例 | 结果 | 实测证据 | |
|||
|---|---|---| |
|||
| E1 默认关闭 | PASS | 应用正常启动且未创建任何 ADMIN | |
|||
| E2 enabled=true+非法手机号 | PASS | 启动失败,固定文案 `admin bootstrap rejected: configured phone is invalid` | |
|||
| E3 无活动ADMIN+空闲手机号 | PASS | users 恰 1 行(role=ADMIN、active、verified、`password_hash=NULL`);`user_roles` 仅 ADMIN;无 Token/短信/通知;日志无手机号 | |
|||
| E4 重复启动同配置 | PASS | `skipped`;users 与 ADMIN 角色计数均不增 | |
|||
| E5 手机号被 STUDENT 占用 | PASS | 启动失败固定文案 `phone is already in use`;该 STUDENT 未被提权/复活/覆盖 | |
|||
| E6 真实双 JAR 并发同手机号 | PASS | 两个独立进程 + 屏障同时初始化:最终 users 恰 1 行、ADMIN 角色恰 1 条;一方创建、一方安全幂等跳过;无手机号泄漏;无 `SqlExceptionHelper` 唯一键日志 | |
|||
|
|||
前置与后置:临时禁用 20 个历史验收 ADMIN(id 逐一记录)并以精确 UPDATE 恢复原状;E3/E6 新建的验收 ADMIN 置为 inactive,未删除。 |
|||
|
|||
## 7. 启动最新 JAR 与双端口(阶段 F) |
|||
|
|||
- `Started MyDreamjobApplication`;无 Flyway/schema-validation 错误。 |
|||
- 8080 与 8081 均监听;**8081 仅绑定 127.0.0.1**。 |
|||
- `GET /api/v1/health` → 200/UP(仅最小状态,无 db/redis/rabbit/连接池字段)。 |
|||
- 运行时 OpenAPI:paths=176,operations=214,与静态一致。 |
|||
|
|||
## 8. 企业简历真实 HTTP 权限矩阵(阶段 G / H) |
|||
|
|||
夹具:3 家企业(认证/未认证/其他)、6 名学生(APPLIED_COMPANIES·SUBMITTED、PRIVATE、无隐私记录、VERIFIED_COMPANIES·未认证企业、APPLIED_COMPANIES·WITHDRAWN、VERIFIED_COMPANIES·已认证企业),真实简历文件进存储基目录,投递/隐私由服务端推导。 |
|||
|
|||
| 用例 | HTTP | 期望 | 结果 | |
|||
|---|---|---|---| |
|||
| 匿名 | 401 | 401 | PASS | |
|||
| STUDENT Token | 403/2002 | 403 | PASS | |
|||
| ADMIN Token | 403/2002 | 403 | PASS | |
|||
| 其他企业 | 403/2002 | 403 | PASS | |
|||
| 未认证企业(岗位不属于) | 403/2002 | 403 | PASS | |
|||
| 认证企业+APPLIED+SUBMITTED | 200 | 200 | PASS(下文字节/头断言) | |
|||
| PRIVATE 隐私 | 403/2002 | 403 | PASS | |
|||
| 无隐私记录(默认 PRIVATE) | 403/2002 | 403 | PASS | |
|||
| VERIFIED_COMPANIES+未认证企业 | 403/2002 | 403 | PASS | |
|||
| APPLIED_COMPANIES+WITHDRAWN | 403/2002 | 403 | PASS | |
|||
| VERIFIED_COMPANIES+已认证企业 | 200 | 200 | PASS(字节一致) | |
|||
| 投递不存在 | 404/8002 | 404 | PASS | |
|||
|
|||
成功下载断言:文件字节与夹具逐字节一致(`RESUME-FIXTURE-A/F-20260915`,字节级比较 PASS);`Content-Disposition: attachment; filename="=?UTF-8?Q?...?="`(净化文件名);`Cache-Control: no-store`;Content-Type 走白名单;响应不含存储路径/resumeUrl/studentUserId/手机号/邮箱。 |
|||
日志/审计扫描:未发现简历正文标记、本地路径、S03 测试手机号、密钥等禁止字段。 |
|||
|
|||
## 9. Actuator 双端口真实验收(阶段 I) |
|||
|
|||
**管理端口 127.0.0.1:8081 — 全部 PASS** |
|||
- `/actuator/health` 200,JSON 仅含 `status`(最小化,无 components/db/redis/rabbit/diskSpace/hikari)。 |
|||
- `/actuator/metrics` 200(含 names 数组);`/actuator/prometheus` 200(Prometheus 文本、含 `jvm_memory_used_bytes` 等稳定指标)。 |
|||
- `/actuator/env`、`/beans`、`/configprops`、`/heapdump`、`/threaddump` 均不可读(401)。 |
|||
- 端口仅绑定 127.0.0.1;prometheus 文本未发现密码/Token/Bearer 等敏感值。 |
|||
|
|||
**业务端口 8080 — FAIL(本轮关键缺陷)** |
|||
|
|||
| 路径 | 匿名 | 带 STUDENT Token(有效) | |
|||
|---|---|---| |
|||
| `/actuator` | 401 | 500 | |
|||
| `/actuator/health` | 500 | 500 | |
|||
| `/actuator/metrics` | 500 | 500 | |
|||
| `/actuator/info` | 500 | 500 | |
|||
| `/actuator/prometheus` | 500 | 500 | |
|||
| `/actuator/env` | 401 | 500 | |
|||
| `/actuator/beans` | 401 | 500 | |
|||
|
|||
任务书要求这些路径**无论匿名或带 Token 一律 404**;实测为 401/500。根因:`SecurityConfig` 对 `/actuator/health/**` 等显式 `permitAll`,但业务端口并不注册 Actuator 端点,请求落入 MVC 静态资源解析抛 `NoResourceFoundException`,被 `GlobalExceptionHandler` 通用 handler 映射为 500;未放行的 `/actuator`、`/actuator/env`、`/actuator/beans` 在匿名时被安全层拦为 401,带 Token 后同样 500。 |
|||
同时,`ActuatorIsolationIntegrationTest.businessPortDoesNotExposeActuatorEndpoints` 断言这些路径为 404 且在 H2 测试环境下通过——**测试环境(H2、无独立管理端口随机化 etc.)与真实 MySQL + 完整 Security 配置的生产行为不一致,测试未能捕获 401/500 差异**,属测试真实性缺口。 |
|||
|
|||
## 10. 副作用(阶段 J) |
|||
|
|||
下载成功、下载拒绝、下载 404 三类请求前后,对 15 张表(users、user_roles、student_profiles、resume_versions、applications、jobs、companies、auth_tokens、audit_logs、candidate_dimension_scores、notifications、messages、scoring_risk_events、scoring_risk_actions、talent_invitations)作行级全量快照对比:**完全一致,无任何变化**。PASS。 |
|||
|
|||
## 11. RabbitMQ(阶段 K) |
|||
|
|||
4 个队列真实存在且 0 积压:`queue.sms.verification`、`queue.notification`、`queue.audit`、`queue.notification.dlq`(messages=0、ready=0、unacked=0)。管理员初始化与简历下载均未产生新增消息。PASS。 |
|||
|
|||
## 12. OpenAPI(阶段 L) |
|||
|
|||
静态 `verify_openapi.py`:176 paths / 214 operations,全部通过(含 S03 简历下载契约、S02/S03 校验)。运行时 `/api/v1/v3/api-docs`:176/214 与静态一致。新端点仅 `GET /company/candidates/{applicationId}/resume`,无 requestBody、返回文件流;企业 DTO 不含 resumeUrl/storagePath/studentUserId/phone/email。PASS。 |
|||
|
|||
## 13. PASS / FAIL / SKIP 清单 |
|||
|
|||
- PASS:阶段 A(A1–A7);阶段 B(1834 测试、OpenAPI 静态);阶段 C;阶段 D(V43 真实迁移与种子禁用、不误伤、登录不可用);阶段 E(E1–E6);阶段 F;阶段 G/H(12/12、字节与头断言、日志脱敏);阶段 I-2 管理端口;阶段 J;阶段 K;阶段 L。 |
|||
- **FAIL:阶段 I-1(业务端口 Actuator 路径 401/500 而非 404)+ 关联测试真实性缺口(ActuatorIsolationIntegrationTest 与生产行为不一致)。** |
|||
- SKIP:无(核心项全部执行;仅"更大规模容量压测"不在本轮范围,不构成阻断)。 |
|||
|
|||
## 14. 剩余风险 |
|||
|
|||
- 业务端口对 `/actuator/health` 等返回 500:属于错误响应泄漏(对探测者暴露服务器异常迹象),且不满足契约的 404 语义;修复后需补"业务端口带 Token 亦为 404"的真实 HTTP 覆盖。 |
|||
- 登录接口对已禁用账号返回 400 而非 403/401(无法取得 Token 已满足;状态码语义差异建议与契约对齐)。 |
|||
- 管理端口依赖网络隔离运维边界,生产中需确认部署层网络策略确实限制 8081 仅内网可访问。 |
|||
|
|||
## 15. 是否允许冻结 S03 |
|||
|
|||
**不允许。** 建议整改指令(下一轮定向修复,不修改本报告已执行的验收环境): |
|||
|
|||
1. 业务端口对未注册 Actuator 路径统一返回 404: |
|||
- 在 `GlobalExceptionHandler` 增加 `NoResourceFoundException` 的精确映射(404),避免落入通用 500;或 |
|||
- 移除 `SecurityConfig` 中业务端口对 `/actuator/**` 的 `permitAll` 并显式声明业务端口 Actuator 不可达(任选其一,保持管理端口行为不变)。 |
|||
2. 增加真实 MySQL 环境下的 HTTP 断言:业务端口 `/actuator`、`/actuator/health`、`/actuator/metrics`、`/actuator/prometheus`、`/actuator/env`、`/actuator/beans` 在匿名与有效 STUDENT/COMPANY/ADMIN Token 下均为 404;并说明测试环境中为何出现 H2 404 而真实 MySQL 401/500 的差异,消除测试假绿。 |
|||
3. 修复后在最新 JAR + 真实 MySQL 上重跑阶段 I(业务端口 404 矩阵 + 管理端口只读矩阵)与 1834 项全量回归。 |
|||
|
|||
本报告不包含数据库密码、JWT、验证码、完整手机号、邮箱、验证手机号或简历正文。验收脚本与日志保留在 `backend-infra/s03-*.ps1|.py|.log`,夹具数据按"本轮唯一前缀/精确主键"清理并记录;`audit_logs` 未做删除。 |
|||
|
|||
## 16. Actuator 404 增量复验结果(定向整改后) |
|||
|
|||
上一节记录了初次独立验收的唯一阻断:业务端口 8080 上 `/actuator/**` 匿名/带 Token 行为为 401/500 而非任务书要求的 404,且 H2 测试与生产行为不一致。针对该阻断完成定向整改后,使用最新 `mvn clean verify` JAR 与真实 MySQL 8.4.11 复验: |
|||
|
|||
1. **整改方式 —— SecurityConfig**:原对 `/actuator/health/**`、`/actuator/info`、`/actuator/metrics/**`、`/actuator/prometheus` 的四个 `permitAll` 收敛为单一 `.requestMatchers("/actuator/**").permitAll()`。目的**非暴露 Actuator**(Actuator 仍只注册在独立管理端口),而是让所有 `/actuator/**` 请求进入 MVC 的不存在资源处理,消除匿名请求先被认证层转 401 的分支;业务端口未新增任何 Actuator Controller 或端点映射。 |
|||
2. **整改方式 —— GlobalExceptionHandler**:新增 `@ExceptionHandler(NoResourceFoundException.class)`,固定返回 HTTP 404、`code=404`、`message=not found`、`requestId`;不回显异常 message、请求路径、本地路径或堆栈;日志仅固定文案 + 归一化 requestId;未新增 ErrorCode。 |
|||
3. **业务端口矩阵(真实 HTTP)**:14 条路径(`/actuator`、`/actuator/`、`/actuator/health`、`/actuator/health/liveness`、`/actuator/info`、`/actuator/metrics`、`/actuator/metrics/jvm.memory.used`、`/actuator/prometheus`、`/actuator/env`、`/actuator/beans`、`/actuator/configprops`、`/actuator/heapdump`、`/actuator/threaddump`、`/actuator/unknown`)× 6 种身份(匿名 / STUDENT / COMPANY / ADMIN / 非法 Token / Refresh Token)共 **84 个组合全部返回 404**,且响应递归扫描无任何异常类名、框架包名、路径、Token、手机号等泄漏。 |
|||
4. **管理端口 127.0.0.1:8081**:health(200,最小 JSON,仅 `status`)/ info(200)/ metrics(200,含 names)/ prometheus(200,含稳定 JVM 指标)白名单端点正常;env / beans / configprops / heapdump / threaddump / unknown 全部 404(未暴露端点未被修复带成 200);8081 仅绑定 127.0.0.1。 |
|||
5. **副作用**:复验期间日志无 `NoResourceFoundException` 堆栈、无 ERROR;RabbitMQ 4 个队列(sms.verification / notification / audit / notification.dlq)0 积压;数据库无任何改动。 |
|||
6. **自动化**:定向(Actuator/Security/GlobalExceptionHandler)61/61;全量 `mvn clean verify` **1836/1836**(含新增 2 个 Actuator 复验测试),Failures=0、Errors=0、Skipped=0;OpenAPI 静态与运行时均为 **176 paths / 214 operations**,Actuator 不进入业务 OpenAPI。 |
|||
7. **一致性**:V1–V42 未修改,V43 仍为唯一新增迁移;未新增 ErrorCode、公网业务接口、表或索引;应用进程已停止,当前未提交、未推送。 |
|||
|
|||
## 17. 最终结论 |
|||
|
|||
- 初次独立验收发现业务端口 Actuator 401/500 阻断; |
|||
- 完成定向整改(SecurityConfig 统一 `/actuator/**` 语义 + GlobalExceptionHandler 安全 404 映射 + 失败测试补齐); |
|||
- 最新 JAR + 真实 MySQL/Docker 增量复验全部通过。 |
|||
|
|||
**S03 独立代码复验和真实 MySQL/Docker 黑盒全部通过,可以进入精确暂存检查点。** |
|||
@ -0,0 +1,167 @@ |
|||
# S03 生产配置、简历隐私与 Actuator 隔离设计 |
|||
|
|||
状态:设计已确认,待按独立检查点实施。 |
|||
|
|||
## 1. 目标与边界 |
|||
|
|||
S03 只处理三类高风险问题: |
|||
|
|||
1. 禁用仍保持原始状态的通用 Flyway 演示账号,并提供显式、一次性的管理员初始化能力; |
|||
2. 让 `resumeVisibility` 在企业读取简历文件的正式边界真正生效; |
|||
3. 将 Actuator 健康探针与指标端点隔离,防止普通业务用户读取运行指标。 |
|||
|
|||
S03 不修改 Access/Refresh Token 语义,不修改评分、排名、推荐、人才发现、邀约、Offer、申诉、消息撤回、简历上传与学生本人下载语义,不做性能重构,不进入 Q01/R01。 |
|||
|
|||
## 2. 已核实的现状 |
|||
|
|||
- `V1_1__seed_data.sql` 无环境隔离地插入已知 ADMIN、STUDENT、COMPANY 账号及公开口令哈希。 |
|||
- V1–V42 已经执行,不允许修改校验和;S03 使用新的 V43 迁移。 |
|||
- `student_privacy_settings.resume_visibility` 已支持 `PRIVATE`、`APPLIED_COMPANIES`、`VERIFIED_COMPANIES`,默认 `PRIVATE`。 |
|||
- `PrivacySettingsService.canCompanyViewResume(...)` 没有生产调用方。 |
|||
- 该方法的 `APPLIED_COMPANIES` 分支没有核对投递岗位归属 `companyId`,不能直接作为授权边界使用。 |
|||
- 企业候选人 DTO 当前主动排除了 `resumeUrl`;当前没有企业下载简历文件的正式接口。 |
|||
- `/actuator/health/**` 和 `/actuator/info` 公开;`metrics/prometheus` 被暴露后会落入普通 `authenticated()`,任何登录用户均可能访问。 |
|||
|
|||
## 3. S03-A 历史种子账号治理 |
|||
|
|||
### 3.1 V43 迁移原则 |
|||
|
|||
新增且仅新增: |
|||
|
|||
`V43__disable_legacy_seed_accounts.sql` |
|||
|
|||
V43 只能禁用仍与 V1_1 原始指纹完全一致的三条种子账号。匹配至少同时包含: |
|||
|
|||
- 原始手机号; |
|||
- 原始邮箱; |
|||
- 原始公开 bcrypt 哈希; |
|||
- 原始主角色; |
|||
- `is_active=1`。 |
|||
|
|||
不得依赖固定 `id=1/2/3`。不得删除用户、角色、审计或业务数据,不得重置自增。不得修改已被运营人员改过手机号、邮箱、哈希或角色的账号。 |
|||
|
|||
命中后仅将 `users.is_active` 置为 `0`。保留记录与关联关系,用于历史数据完整性和审计追溯。由于认证签发链已拒绝 inactive 用户,已知账号不能再取得会话。 |
|||
|
|||
迁移必须幂等,并分别覆盖 ADMIN、STUDENT、COMPANY 三条原始种子。迁移报告只记录命中数量,不输出完整手机号、邮箱或哈希。 |
|||
|
|||
### 3.2 管理员一次性初始化 |
|||
|
|||
新增显式开关,默认关闭: |
|||
|
|||
- `app.bootstrap.admin.enabled=${ADMIN_BOOTSTRAP_ENABLED:false}` |
|||
- `app.bootstrap.admin.phone=${ADMIN_BOOTSTRAP_PHONE:}` |
|||
- `app.bootstrap.admin.nickname=${ADMIN_BOOTSTRAP_NICKNAME:平台管理员}` |
|||
|
|||
初始化器仅在开关严格为 `true` 时装配。普通启动不得隐式创建管理员。 |
|||
|
|||
初始化规则: |
|||
|
|||
1. 手机号必须通过现有手机号格式校验;不得写日志、异常详情或审计 details。 |
|||
2. 数据库已有活动 ADMIN 时安全跳过,不创建第二个管理员。 |
|||
3. 数据库没有活动 ADMIN 且手机号不存在时,创建 `active=true`、`verified=true` 的 ADMIN,并同时写入 `users.role` 与 `user_roles`。 |
|||
4. 手机号已属于 STUDENT/COMPANY、inactive 用户或角色信息不一致时启动失败,禁止自动提权或复活。 |
|||
5. 相同配置重复启动必须幂等。 |
|||
6. 初始化成功后应提示固定文本,部署方必须移除开关;日志不得包含手机号。 |
|||
7. 多实例部署时只允许一个实例带初始化开关启动;数据库唯一手机号约束作为最后防线,冲突必须失败而不是吞掉。 |
|||
|
|||
不新增公网初始化接口,不接受请求参数创建 ADMIN,不设置默认密码。管理员仍使用正式短信认证流程;真实短信供应商未配置时不能以 Mock 绕过。 |
|||
|
|||
## 4. S03-B 简历隐私授权 |
|||
|
|||
### 4.1 唯一新增企业接口 |
|||
|
|||
新增: |
|||
|
|||
`GET /company/candidates/{applicationId}/resume` |
|||
|
|||
该接口返回文件流,不返回 `resumeUrl`、存储相对路径、绝对路径、学生用户 ID、手机号或邮箱。文件名必须继续经过 `ResumeFilenameSanitizer`。 |
|||
|
|||
除该接口外不新增按 `studentUserId`、`candidateKey` 或任意 URL 下载简历的入口。人才发现接口本轮不得直接下载简历。 |
|||
|
|||
### 4.2 授权顺序 |
|||
|
|||
必须在读取文件前依次完成: |
|||
|
|||
1. 从 `SecurityContext` 取得当前用户,要求 COMPANY; |
|||
2. 定位当前用户拥有的 Company; |
|||
3. 查询 `applicationId` 及对应 Job; |
|||
4. 校验 Job 属于当前 Company; |
|||
5. 取得投递对应的 `studentUserId`; |
|||
6. 按隐私设置判定可见性; |
|||
7. 通过后才读取 `student_profiles.resume_url` 和文件内容。 |
|||
|
|||
跨企业投递统一返回既有 403 语义,不通过差异响应泄露归属。隐私拒绝使用 `FORBIDDEN(2002)`。不存在的投递保持既有 `APPLICATION_NOT_FOUND`;授权通过但学生没有简历时使用既有 `PROFILE_NOT_FOUND`,不新增 ErrorCode。 |
|||
|
|||
### 4.3 可见性决策表 |
|||
|
|||
| 设置 | 企业条件 | 投递条件 | 结果 | |
|||
|---|---|---|---| |
|||
| 无设置记录/null | 任意 | 任意 | 403,按 PRIVATE 处理 | |
|||
| PRIVATE | 任意 | 包括已投递、面试、录用 | 403 | |
|||
| APPLIED_COMPANIES | 岗位属于当前企业 | SUBMITTED/VIEWED/INTERVIEW/HIRED | 200 | |
|||
| APPLIED_COMPANIES | 岗位属于当前企业 | WITHDRAWN/REJECTED | 403 | |
|||
| APPLIED_COMPANIES | 其他企业 | 任意 | 403 | |
|||
| VERIFIED_COMPANIES | 当前企业 `verified=true` 且拥有该投递岗位 | 任意合法投递状态 | 200 | |
|||
| VERIFIED_COMPANIES | 未认证企业或其他企业 | 任意 | 403 | |
|||
|
|||
`VERIFIED_COMPANIES` 不是“知道学生 ID 即可查看”。它仍要求合法的候选业务上下文,本轮该上下文限定为当前企业拥有的 `applicationId`。 |
|||
|
|||
### 4.4 代码结构 |
|||
|
|||
将现有布尔方法收束为失败关闭的授权组件,例如 `CompanyResumeAccessPolicy`。授权组件必须校验 company ownership,不能依赖 Controller 先验,也不能把客户端传入的 `studentUserId/companyId` 当成可信事实。 |
|||
|
|||
文件读取放在独立 Service 方法中。Controller 只解析身份、路径参数、构建安全响应头。任何拒绝路径都不得调用 `ResumeStorageService.read()`。 |
|||
|
|||
企业候选人列表与详情 DTO 保持不含 `resumeUrl`。学生本人上传、下载、版本、导出接口完全不变。 |
|||
|
|||
## 5. S03-C Actuator 隔离 |
|||
|
|||
### 5.1 运行边界 |
|||
|
|||
推荐配置: |
|||
|
|||
- `management.server.port=${MANAGEMENT_SERVER_PORT:8081}` |
|||
- `management.server.address=${MANAGEMENT_SERVER_ADDRESS:127.0.0.1}` |
|||
- 仅暴露 `health,info,metrics,prometheus`; |
|||
- `health.show-details=never`; |
|||
- `health.show-components=never`。 |
|||
|
|||
生产默认只绑定回环地址。需要采集 Prometheus 时,由部署层通过受控网络或反向代理访问管理端口,不通过公网业务端口开放。 |
|||
|
|||
### 5.2 业务端口行为 |
|||
|
|||
业务端口不得提供 `/actuator/metrics` 或 `/actuator/prometheus`。普通 STUDENT、COMPANY、ADMIN 的业务 JWT 不能把管理指标变成公网资源。健康检查继续提供最小 `UP/DOWN` 信息,不返回数据库、Redis、RabbitMQ、磁盘路径或连接池细节。 |
|||
|
|||
本轮不新增运维账号体系,不把 ADMIN 等同于基础设施运维身份。 |
|||
|
|||
## 6. 数据与副作用 |
|||
|
|||
允许的数据库变化仅为: |
|||
|
|||
- V43 对完全匹配的旧种子账号执行 `is_active: 1 -> 0`; |
|||
- 显式管理员初始化首次创建一条 ADMIN 用户和对应角色; |
|||
- 企业成功下载简历如需审计,只允许白名单动作与 `applicationId/companyId`,不得记录路径、文件名、学生 ID、联系方式或简历内容。 |
|||
|
|||
简历访问不得修改 `student_profiles`、`resume_versions`、投递、评分、排名、推荐、邀约、消息、通知或风险表,不发送 RabbitMQ、短信或邮件。 |
|||
|
|||
## 7. 契约与兼容性 |
|||
|
|||
- S02 基线为 175 paths / 213 operations。 |
|||
- 新增一个企业简历下载接口后,预期为 176 paths / 214 operations;最终以运行时 OpenAPI 导出为准。 |
|||
- 不修改既有响应字段,不把 `resumeUrl` 加回任何企业 DTO。 |
|||
- 不新增 ErrorCode。 |
|||
- V43 是唯一新增迁移,V1–V42 不得修改。 |
|||
|
|||
## 8. 验收门槛 |
|||
|
|||
必须同时通过: |
|||
|
|||
- V43 在 H2 快速层和真实 MySQL 8 执行成功; |
|||
- 仅完全匹配的三条原始种子账号被禁用,改过任一指纹的账号不受影响; |
|||
- 管理员初始化关闭、成功、重复、已有 ADMIN、手机号冲突、并发冲突场景; |
|||
- 三种简历可见性及所有投递状态、跨企业、未认证企业、ADMIN/STUDENT/匿名权限矩阵; |
|||
- 拒绝路径零文件读取,企业 JSON/响应头无路径及个人信息泄漏; |
|||
- Actuator 业务端口与管理端口真实启动验证; |
|||
- 全量测试和 `mvn clean verify` 零失败、零错误、零跳过; |
|||
- 静态与运行时 OpenAPI 一致; |
|||
- 真实 MySQL/Docker 黑盒通过后才能冻结。 |
|||
@ -0,0 +1,270 @@ |
|||
# S03 生产配置、简历隐私与 Actuator 隔离实施指令 |
|||
|
|||
项目根目录:`C:\Users\15642\Desktop\zhilai` |
|||
|
|||
候选基线:S02 已完成独立代码复验、真实 MySQL/Docker 黑盒、精确提交并推送;功能分支提交为 `f399060edb2ff778322a4f021c93213f39a6f6bf`。 |
|||
|
|||
本轮必须先完整阅读 `docs/s03-production-security-and-resume-privacy-design.md`。该文档是 S03 权威口径;发生冲突时停止并报告,不得自行扩大语义。 |
|||
|
|||
## 一、开始前核查 |
|||
|
|||
执行: |
|||
|
|||
```powershell |
|||
cd C:\Users\15642\Desktop\zhilai |
|||
git branch --show-current |
|||
git status --short |
|||
git diff --cached --name-only |
|||
git log -5 --oneline |
|||
git diff --check |
|||
``` |
|||
|
|||
要求: |
|||
|
|||
- 从 S02 已提交基线创建独立分支 `feat/s03-production-privacy`; |
|||
- 切分支前确认暂存区和已跟踪工作区为空; |
|||
- 保留全部历史未跟踪脚本、日志、JSON、SQL、xlsx、`.env` 和验收目录; |
|||
- 不执行 `git reset/restore/checkout -- <path>/clean`;创建新分支仅允许 `git switch -c feat/s03-production-privacy`; |
|||
- 不执行 `git add -A`、commit、push; |
|||
- 实施阶段不操作 Docker,不执行真实 MySQL 黑盒; |
|||
- 搜索并完整阅读适用的 `AGENTS.md`,不存在则记录。 |
|||
|
|||
如已有修改与 S03 目标文件重叠,停止并报告。 |
|||
|
|||
## 二、必须完整阅读 |
|||
|
|||
迁移与模型: |
|||
|
|||
- `V1__base_schema.sql` |
|||
- `V1_1__seed_data.sql` |
|||
- `V2__business_schema.sql` |
|||
- `V27__student_privacy_settings.sql` |
|||
- `V42__auth_token_sessions.sql` |
|||
- `User`、`UserRepository`、`Company`、`CompanyRepository` |
|||
- `Application`、`ApplicationRepository`、`Job`、`StudentProfile` |
|||
|
|||
隐私与简历: |
|||
|
|||
- `PrivacySettingsService`、`StudentPrivacySettings`、`ResumeVisibility` |
|||
- `ApplicationController`、`ApplicationService`、`CandidateDetailView` |
|||
- `ProfileController`、`ProfileService` |
|||
- `ResumeStorageService`、`LocalResumeStorageService` |
|||
- `ResumeFilenameSanitizer`、`ResumeFileValidator` |
|||
- 全部 `profile`、`resume`、`privacy`、`application` 测试 |
|||
|
|||
认证与运维: |
|||
|
|||
- `SecurityConfig`、`CurrentUserAccessor` |
|||
- `AuthService`、`AuthSessionService`、`JwtAuthenticationFilter` |
|||
- `application.yml` 及所有 profile 配置 |
|||
- `ErrorCode`、`GlobalExceptionHandler` |
|||
- `pom.xml` 与 Actuator 测试 |
|||
|
|||
文档: |
|||
|
|||
- S03 权威设计文档 |
|||
- `privacy-settings-api.md` |
|||
- `resume-api.md` |
|||
- `frontend-integration.md` |
|||
- `backend-development-roadmap.md` |
|||
- `phase1-scope-and-acceptance.md` |
|||
- `openapi-rc.json` |
|||
- `verify_openapi.py` |
|||
|
|||
先执行搜索并记录所有企业侧简历暴露点: |
|||
|
|||
```powershell |
|||
rg -n -i "resumeUrl|resume_url|download.*resume|resume.*download|ResumeStorageService\.read|canCompanyViewResume|studentUserId" mydreamjob-server/src/main/java |
|||
``` |
|||
|
|||
## 三、S03-A:禁用历史种子账号 |
|||
|
|||
新增且仅新增 `V43__disable_legacy_seed_accounts.sql`。 |
|||
|
|||
要求: |
|||
|
|||
1. 不修改 V1–V42。 |
|||
2. 不按固定 ID 匹配。 |
|||
3. 每条 UPDATE 必须同时匹配 V1_1 中的原手机号、原邮箱、原 bcrypt 哈希、原角色、`is_active=1`。 |
|||
4. ADMIN、STUDENT、COMPANY 三条分别处理。 |
|||
5. 仅更新 `is_active=0`,不删除 users/user_roles,不修改历史业务表。 |
|||
6. 改过手机号、邮箱、哈希或角色的同源账号不得被禁用。 |
|||
7. 重复执行效果稳定。 |
|||
|
|||
新增迁移集成测试,至少覆盖:完整指纹命中、每个单字段偏差不命中、非固定 ID 也能命中、重复执行、关联数据保留、V1–V42 校验和不变。 |
|||
|
|||
## 四、S03-A:一次性管理员初始化 |
|||
|
|||
新增强类型配置和条件装配,例如: |
|||
|
|||
- `AdminBootstrapProperties` |
|||
- `AdminBootstrapRunner` |
|||
- 必要的内部 Service |
|||
|
|||
严格按设计文档执行:默认关闭;只有严格 `true` 才装配;手机号来自环境变量;无默认手机号;不打印手机号;不提供公网接口。 |
|||
|
|||
核心行为: |
|||
|
|||
- 已有活动 ADMIN:无副作用跳过; |
|||
- 无活动 ADMIN + 手机号不存在:创建活动且已验证 ADMIN,`users.role` 与 `user_roles` 一致; |
|||
- 手机号已属于任何用户或角色状态异常:启动失败,禁止提权、复活或覆盖; |
|||
- 重复启动幂等; |
|||
- 多实例冲突安全失败; |
|||
- 不签发 Token、不发送短信、不写默认密码。 |
|||
|
|||
错误信息必须固定且不包含手机号、用户对象、SQL 或环境变量值。日志只能使用固定事件文本。 |
|||
|
|||
必须先写失败测试,覆盖:disabled、missing phone、invalid phone、existing active admin、new admin、duplicate run、existing student/company/inactive user、users/user_roles 一致性、日志脱敏、并发冲突。 |
|||
|
|||
## 五、S03-B:企业简历下载与隐私 |
|||
|
|||
新增唯一公网接口: |
|||
|
|||
`GET /company/candidates/{applicationId}/resume` |
|||
|
|||
推荐新增: |
|||
|
|||
- `CompanyResumeAccessPolicy` |
|||
- `CompanyResumeService` |
|||
- 必要的文件响应 record/DTO |
|||
|
|||
不得将文件访问逻辑塞入现有候选人 DTO,不得返回 `resumeUrl`。 |
|||
|
|||
授权必须在 Service/Policy 中完成,不可信任 Controller 传入的归属信息。按以下顺序:当前 COMPANY 用户 → 当前 Company → Application+Job → Job.companyId → Student → privacy setting → profile/file。 |
|||
|
|||
必须修复现有 `canCompanyViewResume` 的公司归属缺失:`APPLIED_COMPANIES` 不能只校验学生和状态;必须证明 application 对应 Job 属于当前 company。允许替换该方法或让它委托给新 Policy,但不得保留一个可被误用的宽松生产方法。 |
|||
|
|||
语义严格使用设计文档决策表: |
|||
|
|||
- 默认/null 与 PRIVATE:全部企业 403; |
|||
- APPLIED_COMPANIES:仅本企业有效投递,WITHDRAWN/REJECTED 拒绝; |
|||
- VERIFIED_COMPANIES:企业 verified=true 且拥有 application 的岗位,状态不额外限制; |
|||
- 跨企业、未认证、错误角色均拒绝; |
|||
- 人才发现本轮不能借 candidateKey 下载简历。 |
|||
|
|||
响应要求: |
|||
|
|||
- 使用安全 `Content-Disposition`; |
|||
- content type 只能来自已验证的白名单; |
|||
- 不返回内部路径、studentUserId、手机号、邮箱; |
|||
- 拒绝路径不得调用 storage `read`; |
|||
- 文件不存在安全失败,不回显路径; |
|||
- 不缓存私有文件,至少返回 `Cache-Control: no-store`; |
|||
- 不把简历正文写入日志、审计或异常。 |
|||
|
|||
权限测试必须使用真实 SecurityFilterChain/MockMvc,并覆盖 COMPANY、其他企业、未认证企业、STUDENT、ADMIN、匿名。集成测试必须使用真实 H2 Repository 和真实本地临时存储,不得 Mock 掉被验证的授权链。 |
|||
|
|||
对所有状态 `SUBMITTED/VIEWED/INTERVIEW/HIRED/REJECTED/WITHDRAWN` 分别验证三种 visibility。验证企业候选人列表/详情仍不含 `resumeUrl`。 |
|||
|
|||
## 六、S03-C:Actuator 隔离 |
|||
|
|||
按设计新增: |
|||
|
|||
```yaml |
|||
management: |
|||
server: |
|||
port: ${MANAGEMENT_SERVER_PORT:8081} |
|||
address: ${MANAGEMENT_SERVER_ADDRESS:127.0.0.1} |
|||
``` |
|||
|
|||
并将 health details/components 设为 `never`。不要使用业务 JWT 角色代替运维网络隔离,不新增运维公网接口。 |
|||
|
|||
必须验证: |
|||
|
|||
- 业务端口无法访问 metrics/prometheus; |
|||
- 管理端口只绑定配置地址; |
|||
- health 仅返回最小状态,不含 DB/Redis/RabbitMQ/磁盘/连接池详情; |
|||
- metrics/prometheus 只在管理端口存在; |
|||
- STUDENT/COMPANY/ADMIN Token 不会使业务端口指标可见; |
|||
- dev/test/integration-test 不发生随机端口冲突。 |
|||
|
|||
若 Spring Boot 独立 management context 与当前 SecurityFilterChain 行为不一致,先新增真实上下文失败测试,再做最小配置;不得通过重新公开指标绕过。 |
|||
|
|||
## 七、数据与副作用测试 |
|||
|
|||
前后保存完整摘要,至少覆盖: |
|||
|
|||
- users、user_roles; |
|||
- student_profiles、resume_versions; |
|||
- applications、jobs、companies; |
|||
- auth_tokens、audit_logs; |
|||
- candidate_dimension_scores; |
|||
- notifications、messages; |
|||
- scoring_risk_events/actions; |
|||
- talent_invitations。 |
|||
|
|||
企业下载只允许读取;不得修改上述业务表,不发送 RabbitMQ、短信、邮件或通知。若记录下载审计,字段必须为固定白名单且不得包含路径、文件名、学生 ID 或内容。 |
|||
|
|||
## 八、OpenAPI 与文档 |
|||
|
|||
从运行时契约更新 `docs/openapi-rc.json`,不得手工伪造。 |
|||
|
|||
更新: |
|||
|
|||
- `privacy-settings-api.md` |
|||
- `resume-api.md` |
|||
- `frontend-integration.md` |
|||
- `backend-development-roadmap.md` |
|||
- `phase1-scope-and-acceptance.md` |
|||
- `verify_openapi.py` |
|||
|
|||
校验器必须递归断言: |
|||
|
|||
- 只有一个新增路径/operation; |
|||
- 下载接口无 requestBody; |
|||
- 企业候选人 DTO 不含 `resumeUrl/studentUserId/phone/email/storagePath`; |
|||
- 不存在按 studentUserId/candidateKey 的简历下载路径; |
|||
- Actuator 不进入业务 OpenAPI; |
|||
- 预期基线为 176 paths / 214 operations,最终以运行时生成结果为准。 |
|||
|
|||
## 九、验证顺序 |
|||
|
|||
先运行 S03 新增定向测试,再运行: |
|||
|
|||
```powershell |
|||
cd C:\Users\15642\Desktop\zhilai\mydreamjob-server |
|||
mvn -Dtest="*AdminBootstrap*Test,*LegacySeed*Test,*Privacy*Test,*Resume*Test,*CompanyCandidate*Test,*Actuator*Test" test |
|||
mvn -Dtest="*Auth*Test,*Security*Test,*Application*Test,*Company*Test,*Privacy*Test,*Resume*Test" test |
|||
mvn test |
|||
mvn clean verify |
|||
python ..\docs\verify_openapi.py |
|||
``` |
|||
|
|||
最后执行: |
|||
|
|||
```powershell |
|||
cd C:\Users\15642\Desktop\zhilai |
|||
git diff --check |
|||
git status --short |
|||
git diff --cached --name-only |
|||
git diff --name-only -- mydreamjob-server/src/main/resources/db/migration |
|||
``` |
|||
|
|||
必须满足 Failures=0、Errors=0、Skipped=0、clean verify 成功、静态 OpenAPI 校验通过、暂存区为空、V43 为唯一迁移、未新增 ErrorCode。 |
|||
|
|||
## 十、实施结束状态 |
|||
|
|||
实施结束立即停止,不操作 Docker,不执行真实 MySQL 黑盒,不暂存、不提交、不推送,不进入 Q01。 |
|||
|
|||
最终报告必须列出: |
|||
|
|||
1. V43 精确匹配条件和各账号命中结果; |
|||
2. 被修改过的种子账号不受影响证据; |
|||
3. 管理员初始化配置、幂等、冲突与并发结果; |
|||
4. 是否打印手机号或创建默认密码; |
|||
5. 企业简历权限完整决策矩阵; |
|||
6. `APPLIED_COMPANIES` 公司归属缺陷修复方式; |
|||
7. 拒绝路径文件读取次数; |
|||
8. 完整响应与响应头脱敏结果; |
|||
9. Actuator 业务端口/管理端口结果; |
|||
10. 数据库副作用与 RabbitMQ 结果; |
|||
11. 专项、关联、全量测试数及 Failures/Errors/Skipped; |
|||
12. clean verify 与 OpenAPI 结果; |
|||
13. 精确修改文件和 Git 状态; |
|||
14. 是否新增 Flyway、表、索引、ErrorCode、路径; |
|||
15. 明确未执行 git add -A、commit、push、Docker/MySQL 黑盒; |
|||
16. 明确真实 MySQL/Docker 黑盒仍待独立验收。 |
|||
|
|||
最终状态只能写: |
|||
|
|||
“S03 已实现并完成自动化验证与干净构建,待独立代码复验及真实 MySQL/Docker 黑盒验收。” |
|||
@ -0,0 +1,144 @@ |
|||
#!/usr/bin/env bash |
|||
# S01 第四轮 F6 —— sms_verification_codes 表结构契约门禁 |
|||
# |
|||
# 目的:防止冒烟脚本再次使用**不存在的列**(F6:expires_at), |
|||
# 且不允许测试自己维护一份与生产无关的假字段列表来自证正确。 |
|||
# |
|||
# 契约来源(本脚本同时使用前两个,并交叉校验二者一致): |
|||
# 1. 实体 @Column:mydreamjob-server/.../auth/SmsVerificationCode.java |
|||
# 2. 建表 Flyway:mydreamjob-server/.../db/migration/V1__base_schema.sql |
|||
# 3. 真实 MySQL SHOW COLUMNS —— 属**真实验收阶段**,本脚本只输出对账命令, |
|||
# 不在自动化阶段伪造该来源。 |
|||
# |
|||
# 重要:expires_at 是 personal_data_export_tasks(V30) / ability_attempts(V35) / |
|||
# talent_invitations(V40) 三张表的**真实列**。因此本门禁只对 |
|||
# sms_verification_codes 这一个表断言"不存在 expires_at", |
|||
# 不得写成全库禁令(那会误报,并掩盖真正的字段错误)。 |
|||
# |
|||
# 运行: bash docs/tests/smoke-test-schema-contract-test.sh |
|||
set -u |
|||
|
|||
REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)" |
|||
SMOKE="$REPO_ROOT/docs/smoke-test.sh" |
|||
ENTITY="$REPO_ROOT/mydreamjob-server/src/main/java/com/mydreamjob/auth/SmsVerificationCode.java" |
|||
MIGRATION_DIR="$REPO_ROOT/mydreamjob-server/src/main/resources/db/migration" |
|||
FLYWAY="$MIGRATION_DIR/V1__base_schema.sql" |
|||
TABLE="sms_verification_codes" |
|||
|
|||
PASS=0 |
|||
FAIL=0 |
|||
ok() { echo "[PASS] $1"; PASS=$((PASS + 1)); } |
|||
bad() { echo "[FAIL] $1"; FAIL=$((FAIL + 1)); } |
|||
check() { if [ "$1" = "1" ]; then ok "$2"; else bad "$2"; fi; } |
|||
|
|||
echo "=== S01 F6: sms_verification_codes 表结构契约门禁 ===" |
|||
echo "实体来源 : $ENTITY" |
|||
echo "Flyway来源: $FLYWAY" |
|||
echo "脚本 : $SMOKE" |
|||
echo "" |
|||
|
|||
# ── 契约来源 1:实体 @Column ────────────────────────────────── |
|||
entity_cols="" |
|||
if [ -f "$ENTITY" ]; then |
|||
# 主键 id 在实体中以 @Id 标注(无 @Column(name=...)),单独补入 |
|||
grep -qE '^[[:space:]]*@Id[[:space:]]*$' "$ENTITY" && entity_cols="id" |
|||
entity_cols="$entity_cols $(grep -oE '@Column\(name[[:space:]]*=[[:space:]]*"[A-Za-z0-9_]+"' "$ENTITY" \ |
|||
| sed 's/.*"\(.*\)"/\1/')" |
|||
entity_cols="$(printf '%s' "$entity_cols" | tr ' ' '\n' | grep -v '^$' | sort -u | tr '\n' ' ')" |
|||
fi |
|||
|
|||
# ── 契约来源 2:建表 Flyway ─────────────────────────────────── |
|||
flyway_cols="" |
|||
if [ -f "$FLYWAY" ]; then |
|||
flyway_cols="$(awk -v t="$TABLE" ' |
|||
$0 ~ "^CREATE TABLE (IF NOT EXISTS )?" t "([[:space:]]|\\()" { in_t=1; next } |
|||
in_t && /^\)/ { in_t=0 } |
|||
in_t { |
|||
line=$0 |
|||
sub(/^[[:space:]]+/, "", line) |
|||
if (line ~ /^(INDEX|KEY|PRIMARY|CONSTRAINT|UNIQUE|FOREIGN)/) next |
|||
if (line ~ /^[A-Za-z0-9_]+[[:space:]]/) { split(line, a, /[[:space:]]/); print a[1] } |
|||
} |
|||
' "$FLYWAY" | sort -u | tr '\n' ' ')" |
|||
fi |
|||
|
|||
check "$([ -f "$ENTITY" ] && echo 1 || echo 0)" "C1. 实体文件存在" |
|||
check "$([ -f "$FLYWAY" ] && echo 1 || echo 0)" "C2. 建表 Flyway 存在" |
|||
check "$([ -n "$entity_cols" ] && echo 1 || echo 0)" "C3. 实体 @Column 解析出非空列集合" |
|||
check "$([ -n "$flyway_cols" ] && echo 1 || echo 0)" "C4. Flyway 解析出非空列集合" |
|||
|
|||
# 两个独立来源必须一致(否则说明解析失败或生产定义本身不一致) |
|||
check "$([ "$(printf '%s' "$entity_cols" | tr ' ' '\n' | grep -c .)" -gt 0 ] \ |
|||
&& [ "$entity_cols" = "$flyway_cols" ] && echo 1 || echo 0)" \ |
|||
"C5. 实体 @Column 与建表 Flyway 的列集合一致" |
|||
|
|||
echo " 实体列 : ${entity_cols:-<空>}" |
|||
echo " Flyway列: ${flyway_cols:-<空>}" |
|||
echo "" |
|||
|
|||
# ── 字段存在性(表限定)─────────────────────────────────────── |
|||
check "$(case " $flyway_cols " in *" expired_at "*) echo 1 ;; *) echo 0 ;; esac)" \ |
|||
"C6. sms_verification_codes 存在 expired_at 列" |
|||
check "$(case " $flyway_cols " in *" expires_at "*) echo 0 ;; *) echo 1 ;; esac)" \ |
|||
"C7. sms_verification_codes **不存在** expires_at 列" |
|||
check "$(case " $entity_cols " in *" expired_at "*) echo 1 ;; *) echo 0 ;; esac)" \ |
|||
"C8. 实体 expired_at 映射存在" |
|||
|
|||
# ── 反向护栏:expires_at 确实是别的表的真实列 ────────────────── |
|||
# 若此条失败,说明有人把它当成了"全库不存在的列"——那会让本门禁退化成误报。 |
|||
other_tables_with_expires_at=0 |
|||
for f in "$MIGRATION_DIR"/V30__personal_data_export_tasks.sql \ |
|||
"$MIGRATION_DIR"/V35__ability_assessment.sql \ |
|||
"$MIGRATION_DIR"/V40__enterprise_talent_invitations.sql; do |
|||
[ -f "$f" ] || continue |
|||
grep -qE '^[[:space:]]*expires_at[[:space:]]' "$f" && other_tables_with_expires_at=$((other_tables_with_expires_at + 1)) |
|||
done |
|||
check "$([ "$other_tables_with_expires_at" -gt 0 ] && echo 1 || echo 0)" \ |
|||
"C9. expires_at 确实是其他表的真实列(故门禁必须按表限定,不得全局禁用)" |
|||
|
|||
# ── 脚本侧:表名与列名 ──────────────────────────────────────── |
|||
sql_block="$(awk '/SELECT code FROM sms_verification_codes/,/LIMIT 1;/' "$SMOKE" 2>/dev/null)" |
|||
# 只保留双引号内的 SQL 片段,剥掉 shell 语法(printf / 引号 / 续行反斜杠), |
|||
# 否则 printf、%s 之类会被误当成列名。 |
|||
sql_text="$(printf '%s' "$sql_block" | grep -oE '"[^"]*"' | tr -d '"' | tr '\n' ' ')" |
|||
check "$([ -n "$sql_text" ] && echo 1 || echo 0)" "C10. 能从 smoke-test.sh 提取取码 SQL 文本" |
|||
|
|||
check "$(printf '%s' "$sql_text" | grep -q "FROM $TABLE" && echo 1 || echo 0)" \ |
|||
"C11. 取码 SQL 查询表为 $TABLE" |
|||
check "$(printf '%s' "$sql_text" | grep -q 'expired_at' && echo 1 || echo 0)" \ |
|||
"C12. 取码 SQL 使用 expired_at" |
|||
check "$(printf '%s' "$sql_text" | grep -q 'expires_at' && echo 0 || echo 1)" \ |
|||
"C13. 取码 SQL **不含** expires_at" |
|||
|
|||
# 脚本 SQL 使用的每个列名都必须落在真实列集合内 |
|||
used_cols="$(printf '%s' "$sql_text" \ |
|||
| sed "s/'[^']*'//g" \ |
|||
| grep -oE '\b[A-Za-z_][A-Za-z0-9_]*\b' \ |
|||
| grep -viE '^(SELECT|code|FROM|WHERE|AND|OR|ORDER|BY|DESC|ASC|LIMIT|NOW)$|^'$TABLE'$' \ |
|||
| sort -u | tr '\n' ' ')" |
|||
unknown="" |
|||
for c in $used_cols; do |
|||
case " $flyway_cols " in |
|||
*" $c "*) ;; |
|||
*) unknown="$unknown $c" ;; |
|||
esac |
|||
done |
|||
check "$([ -z "$unknown" ] && echo 1 || echo 0)" \ |
|||
"C14. 取码 SQL 使用的列全部存在于 $TABLE${unknown:+(不存在的列:$unknown)}" |
|||
|
|||
# 取码列 code 必须在真实列集合内(正向确认解析没跑偏) |
|||
check "$(case " $flyway_cols " in *" code "*) echo 1 ;; *) echo 0 ;; esac)" \ |
|||
"C15. 取码 SQL 的列 code 是 $TABLE 的真实列" |
|||
|
|||
echo "" |
|||
echo "── 真实验收阶段必须执行(本脚本不伪造该来源)───────────────" |
|||
echo "docker exec -i <mysql容器> sh -c \\" |
|||
echo " 'MYSQL_PWD=\"\$MYSQL_PASSWORD\" mysql -u\"\$MYSQL_USER\" -N -B \"\${MYSQL_DATABASE:-mydreamjob}\" <<<\"SHOW COLUMNS FROM $TABLE;\"'" |
|||
echo "对账要求:SHOW COLUMNS 的 Field 集合必须等于上方 Flyway列;" |
|||
echo " 必须包含 expired_at;必须不含 expires_at。" |
|||
echo "" |
|||
|
|||
echo "============================================" |
|||
echo "PASS=$PASS FAIL=$FAIL" |
|||
echo "============================================" |
|||
[ "$FAIL" -eq 0 ] |
|||
@ -0,0 +1,548 @@ |
|||
#!/usr/bin/env bash |
|||
# S01 定向整改 F1 / F4 / F5 —— smoke-test.sh 敏感信息泄漏防护测试 |
|||
# |
|||
# 覆盖: |
|||
# F1 取码机制(双重保护 / 不依赖应用日志 / 格式校验 / 清理) |
|||
# F4 xtrace 泄漏:**真实以 `bash -x` 执行被测脚本**,捕获 stdout+stderr 并扫描敏感标记 |
|||
# F4 source 模式不把敏感变量带回调用方 |
|||
# F5 数据库密码不进入 mysql argv(真实执行容器侧脚本文本,检查假 mysql 的实际 argv) |
|||
# §7 文档门禁只针对**活跃文件列表**,不对历史验收报告做全 docs 扫描 |
|||
# |
|||
# 测试使用假的 docker / curl / mysql(PATH 注入),不要求 Docker 或真实 MySQL。 |
|||
# 仓库内不写入真实验证码、JWT 或数据库口令;测试结束扫描输出确认标记未泄露。 |
|||
# |
|||
# 运行: bash docs/tests/smoke-test-sms-code-test.sh |
|||
set -u |
|||
|
|||
REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)" |
|||
SMOKE="$REPO_ROOT/docs/smoke-test.sh" |
|||
|
|||
# ── 固定测试标记(均为假值,非真实凭据)───────────────────────── |
|||
TEST_CODE="739251" |
|||
TEST_PHONE="19900009999" |
|||
TEST_JWT="FAKEJWT_MARKER_QA7X" |
|||
TEST_DBPASS="FAKEDBPASS_MARKER_QA7X" |
|||
MARKER_MSG="test OTP retrieval is disabled" |
|||
|
|||
TMP="$(mktemp -d)" |
|||
FAKEBIN="$TMP/bin" |
|||
mkdir -p "$FAKEBIN" |
|||
export FAKE_DIR="$TMP" |
|||
trap 'rm -rf "$TMP"' EXIT |
|||
|
|||
PASS=0 |
|||
FAIL=0 |
|||
ok() { echo "[PASS] $1"; PASS=$((PASS + 1)); } |
|||
bad() { echo "[FAIL] $1"; FAIL=$((FAIL + 1)); } |
|||
check() { if [ "$1" = "1" ]; then ok "$2"; else bad "$2"; fi; } |
|||
|
|||
# ── 假 docker:真实执行被传入的容器侧 sh -c 脚本(含假 mysql)────── |
|||
cat > "$FAKEBIN/docker" <<'FAKEDOCKER' |
|||
#!/usr/bin/env bash |
|||
# 记录完整 argv(用于 F5 断言:宿主机侧命令行不得含密码) |
|||
printf '%s\n' "$*" >> "${FAKE_DIR:?}/docker-argv.log" |
|||
script="" |
|||
for a in "$@"; do script="$a"; done |
|||
# 记录容器侧脚本文本(变量名可以出现,值不可以) |
|||
printf '%s\n' "$script" > "${FAKE_DIR}/container-script.txt" |
|||
case "${FAKE_DOCKER_MODE:-ok}" in |
|||
fail) exit 1 ;; |
|||
empty) cat >/dev/null 2>&1 || true; exit 0 ;; |
|||
garbage) cat >/dev/null 2>&1 || true; printf '%s\n' "${FAKE_GARBAGE:-not-a-code}"; exit 0 ;; |
|||
short) cat >/dev/null 2>&1 || true; printf '%s\n' "12345"; exit 0 ;; |
|||
esac |
|||
# 真实以本地 sh 执行容器侧脚本,使假 mysql 成为真正被检查 argv 的进程 |
|||
exec sh -c "$script" |
|||
FAKEDOCKER |
|||
chmod +x "$FAKEBIN/docker" |
|||
|
|||
# ── 假 mysql:记录 argv 与 stdin(SQL),并按 sms_verification_codes 真实语义作答 ── |
|||
# 它**不是**无条件返回验证码:先校验 SQL 使用的列。 |
|||
# 真实 MySQL 对未知列返回 ERROR 1054 (42S22),这里如实复现, |
|||
# 使脚本里字段名写错(例如 expires_at)无法被"总能拿到验证码"掩盖。 |
|||
# |
|||
# FAKE_ROWS 未设置 → 保留历史行为(输出 FAKE_CODE),供 F1/F5 既有用例使用。 |
|||
# FAKE_ROWS 设置 → 按行套用 WHERE / ORDER BY 语义作答。行格式: |
|||
# id|phone|purpose|used|expire_offset_s|created_at|code |
|||
# expire_offset_s > 0 表示 expired_at 晚于 NOW();查询用严格大于,故 =0 不命中。 |
|||
cat > "$FAKEBIN/mysql" <<'FAKEMYSQL' |
|||
#!/usr/bin/env bash |
|||
printf '%s\n' "$*" > "${FAKE_DIR:?}/mysql-argv.log" |
|||
sql="$(cat)" |
|||
printf '%s' "$sql" > "${FAKE_DIR}/mysql-stdin.sql" |
|||
|
|||
# ── 表结构契约检查:sms_verification_codes 的真实列是 expired_at ── |
|||
case "$sql" in |
|||
*sms_verification_codes*) |
|||
case "$sql" in |
|||
*expires_at*) |
|||
printf "ERROR 1054 (42S22): Unknown column 'expires_at' in 'where clause'\n" >&2 |
|||
exit 1 |
|||
;; |
|||
esac |
|||
;; |
|||
esac |
|||
|
|||
# 未提供假数据:保持历史行为 |
|||
if [ -z "${FAKE_ROWS:-}" ]; then |
|||
printf '%s\n' "${FAKE_CODE:-}" |
|||
exit 0 |
|||
fi |
|||
|
|||
# 从 SQL 解析查询参数(形态与应用脚本一致) |
|||
phone="$(printf '%s' "$sql" | sed -n "s/.*phone *= *'\([^']*\)'.*/\1/p" | head -n 1)" |
|||
purpose="$(printf '%s' "$sql" | sed -n "s/.*purpose *= *'\([^']*\)'.*/\1/p" | head -n 1)" |
|||
|
|||
# 逐行套用 WHERE 语义,按 created_at DESC, id DESC 取第一条 |
|||
best_key="" best_code="" |
|||
while IFS='|' read -r rid rphone rpurpose rused roffset rcreated rcode; do |
|||
[ -n "${rid:-}" ] || continue |
|||
[ "$rphone" = "$phone" ] || continue |
|||
[ "$rpurpose" = "$purpose" ] || continue |
|||
[ "$rused" = "0" ] || continue |
|||
case "$roffset" in |
|||
''|*[!0-9-]*) continue ;; |
|||
esac |
|||
[ "$roffset" -gt 0 ] || continue |
|||
key="$(printf '%s|%010d' "$rcreated" "$rid")" |
|||
if [ -z "$best_key" ] || [[ "$key" > "$best_key" ]]; then |
|||
best_key="$key" best_code="$rcode" |
|||
fi |
|||
done <<< "${FAKE_ROWS}" |
|||
|
|||
[ -n "$best_code" ] && printf '%s\n' "$best_code" |
|||
exit 0 |
|||
FAKEMYSQL |
|||
chmod +x "$FAKEBIN/mysql" |
|||
|
|||
# ── 假 curl:记录请求;登录响应含固定测试 JWT ──────────────────── |
|||
cat > "$FAKEBIN/curl" <<'FAKECURL' |
|||
#!/usr/bin/env bash |
|||
url=""; want_stdin=0 |
|||
for a in "$@"; do |
|||
case "$a" in |
|||
http*) url="$a" ;; |
|||
--data-binary) want_stdin=1 ;; |
|||
*@-) want_stdin=1 ;; |
|||
esac |
|||
done |
|||
has_code="no" |
|||
if [ "$want_stdin" = "1" ]; then |
|||
body="$(cat)" |
|||
case "$body" in |
|||
*[0-9][0-9][0-9][0-9][0-9][0-9]*) has_code="yes" ;; |
|||
esac |
|||
fi |
|||
case "$url" in |
|||
*"/auth/login"*) |
|||
echo "login-attempted has6digit=$has_code" >> "${FAKE_DIR:?}/flow.log" |
|||
printf '{"code":200,"data":{"accessToken":"%s"}}\n200\n' "${FAKE_JWT:-FAKEJWT}" ;; |
|||
*"/auth/sms/send"*) printf '{}\n200\n' ;; |
|||
*"/health"*) printf '{"code":200}\n200\n' ;; |
|||
*"/notifications/unread-count"*) printf '{"unreadCount":1}\n200\n' ;; |
|||
*"/notifications/"*) printf '{"message":"marked as read"}\n200\n' ;; |
|||
*"/notifications"*) printf '{"items":[{"id":88}]}\n200\n' ;; |
|||
*"/applications/"*) printf '{"status":"VIEWED"}\n200\n' ;; |
|||
*"/applications"*) printf '{"id":77}\n200\n' ;; |
|||
*"/jobs/public/"*) printf '{"title":"x"}\n200\n' ;; |
|||
*"/match"*) printf '{"rulesVersion":"v1"}\n200\n' ;; |
|||
*"/jobs"*) printf '{"items":[{"id":11,"title":"x"}]}\n200\n' ;; |
|||
*) printf '{}\n200\n' ;; |
|||
esac |
|||
exit 0 |
|||
FAKECURL |
|||
chmod +x "$FAKEBIN/curl" |
|||
export PATH="$FAKEBIN:$PATH" |
|||
export FAKE_JWT="$TEST_JWT" |
|||
|
|||
# 数据库凭据标记:让容器侧脚本的凭据前置检查通过, |
|||
# 同时用固定标记值验证它绝不出现在 argv / stdout / stderr / 文件中 |
|||
export MYSQL_USER="smoke_readonly" |
|||
export MYSQL_PASSWORD="$TEST_DBPASS" |
|||
export MYSQL_DATABASE="mydreamjob" |
|||
|
|||
# 扫描给定文本是否含任一敏感标记;输出命中的标记名(不含值本身) |
|||
scan_hits() { |
|||
local text="$1" hits="" |
|||
case "$text" in *"$TEST_CODE"*) hits="$hits CODE" ;; esac |
|||
case "$text" in *"$TEST_PHONE"*) hits="$hits PHONE" ;; esac |
|||
case "$text" in *"$TEST_JWT"*) hits="$hits JWT" ;; esac |
|||
case "$text" in *"$TEST_DBPASS"*) hits="$hits DBPASS" ;; esac |
|||
case "$text" in *"Authorization: Bearer"*) hits="$hits AUTH" ;; esac |
|||
case "$text" in *"LOGIN_BODY="*) hits="$hits LOGIN_BODY" ;; esac |
|||
printf '%s' "$hits" |
|||
} |
|||
|
|||
echo "=== S01 F1/F4/F5: smoke-test.sh 敏感信息泄漏防护测试 ===" |
|||
echo "smoke-test.sh: $SMOKE" |
|||
echo "固定测试标记已加载(值不打印)。" |
|||
echo "" |
|||
|
|||
# ══════════════════════════════════════════════════════════════ |
|||
# Part 1 — F1 取码保护(进程内,可观测 REPLY) |
|||
# ══════════════════════════════════════════════════════════════ |
|||
run_sms_code() { |
|||
local mock="$1" allow="$2" mode="$3" garbage="${4:-}" |
|||
local out |
|||
out="$(cd "$REPO_ROOT" && bash -c ' |
|||
set -u |
|||
SMOKE_TEST_LIB_ONLY=1 |
|||
export SMOKE_TEST_LIB_ONLY |
|||
if [ "$1" != "__UNSET__" ]; then export SMS_MOCK_ENABLED="$1"; else unset SMS_MOCK_ENABLED; fi |
|||
if [ "$2" != "__UNSET__" ]; then export ALLOW_TEST_OTP_DB_READ="$2"; else unset ALLOW_TEST_OTP_DB_READ; fi |
|||
export FAKE_DOCKER_MODE="$3" |
|||
export FAKE_CODE="'"$TEST_CODE"'" |
|||
export FAKE_GARBAGE="$4" |
|||
# shellcheck disable=SC1090 |
|||
. "'"$SMOKE"'" |
|||
if sms_code; then |
|||
if [ -n "${REPLY:-}" ]; then printf "RESULT=ok\n"; else printf "RESULT=ok_but_empty\n"; fi |
|||
unset REPLY |
|||
else |
|||
printf "RESULT=fail\n" |
|||
fi |
|||
' _ "$mock" "$allow" "$mode" "$garbage" 2>&1)" |
|||
printf '%s' "$out" |
|||
} |
|||
|
|||
# ── 以指定假表数据运行 sms_code,并比对取回的验证码 ───────────── |
|||
# 只输出 RESULT=ok/fail 与 MATCH=yes/no,**绝不输出验证码本身**。 |
|||
run_sms_code_with_rows() { |
|||
local rows="$1" expect="$2" |
|||
local out |
|||
out="$(cd "$REPO_ROOT" && bash -c ' |
|||
set -u |
|||
export SMOKE_TEST_LIB_ONLY=1 |
|||
export SMS_MOCK_ENABLED=true ALLOW_TEST_OTP_DB_READ=true FAKE_DOCKER_MODE=ok |
|||
export FAKE_ROWS="$1" |
|||
export STUDENT_PHONE="'"$TEST_PHONE"'" |
|||
export MYSQL_USER="smoke_readonly" MYSQL_PASSWORD="$2" MYSQL_DATABASE="mydreamjob" |
|||
# shellcheck disable=SC1090 |
|||
. "'"$SMOKE"'" |
|||
if sms_code; then |
|||
if [ -n "${REPLY:-}" ]; then |
|||
printf "RESULT=ok\n" |
|||
if [ "${REPLY}" = "$3" ]; then printf "MATCH=yes\n"; else printf "MATCH=no\n"; fi |
|||
else |
|||
printf "RESULT=ok_but_empty\n" |
|||
fi |
|||
unset REPLY |
|||
else |
|||
printf "RESULT=fail\n" |
|||
fi |
|||
' _ "$rows" "$TEST_DBPASS" "$expect" 2>&1)" |
|||
printf '%s' "$out" |
|||
} |
|||
|
|||
# ── 端到端运行指定脚本(用于验证「字段错误时不继续登录」)──────── |
|||
# 脚本置于 $TMP 下镜像 docs/ 的结构,使其相对定位 backend-infra/compose.yaml 生效。 |
|||
run_full_script() { |
|||
local script="$1" |
|||
: > "$TMP/flow.log" |
|||
(cd "$REPO_ROOT" && \ |
|||
SMS_MOCK_ENABLED=true ALLOW_TEST_OTP_DB_READ=true FAKE_DOCKER_MODE=ok \ |
|||
FAKE_CODE="$TEST_CODE" FAKE_JWT="$TEST_JWT" \ |
|||
MYSQL_PASSWORD="$TEST_DBPASS" MYSQL_USER="smoke_readonly" MYSQL_DATABASE="mydreamjob" \ |
|||
STUDENT_PHONE="$TEST_PHONE" BASE_URL="http://127.0.0.1:1/api/v1" \ |
|||
bash "$script" 2>&1) || true |
|||
} |
|||
|
|||
r="$(run_sms_code true true ok)" |
|||
check "$(printf '%s' "$r" | grep -q 'RESULT=ok' && echo 1 || echo 0)" "1. mock=true且ALLOW=true时可取得测试验证码" |
|||
check "$(printf '%s' "$r" | grep -q "$TEST_CODE" && echo 0 || echo 1)" "10a. 取码调用 stdout/stderr 不含验证码" |
|||
|
|||
r="$(run_sms_code false true ok)" |
|||
check "$(printf '%s' "$r" | grep -q 'RESULT=fail' && echo 1 || echo 0)" "2. mock=false 时拒绝查询" |
|||
check "$(printf '%s' "$r" | grep -qF "$MARKER_MSG" && echo 1 || echo 0)" "2b. 拒绝时输出固定提示" |
|||
|
|||
r="$(run_sms_code __UNSET__ true ok)" |
|||
check "$(printf '%s' "$r" | grep -q 'RESULT=fail' && echo 1 || echo 0)" "3. 配置缺失时拒绝查询" |
|||
|
|||
r="$(run_sms_code true __UNSET__ ok)" |
|||
check "$(printf '%s' "$r" | grep -q 'RESULT=fail' && echo 1 || echo 0)" "4. ALLOW_TEST_OTP_DB_READ 缺失时拒绝查询" |
|||
|
|||
r="$(run_sms_code true false ok)" |
|||
check "$(printf '%s' "$r" | grep -q 'RESULT=fail' && echo 1 || echo 0)" "5. ALLOW_TEST_OTP_DB_READ=false 时拒绝查询" |
|||
|
|||
r="$(run_sms_code true true empty)" |
|||
check "$(printf '%s' "$r" | grep -q 'RESULT=fail' && echo 1 || echo 0)" "6. 没有可用验证码时安全失败" |
|||
|
|||
r="$(run_sms_code true true fail)" |
|||
check "$(printf '%s' "$r" | grep -q 'RESULT=fail' && echo 1 || echo 0)" "7. 查询失败时不继续" |
|||
|
|||
r="$(run_sms_code true true garbage 'not-a-code')" |
|||
check "$(printf '%s' "$r" | grep -q 'RESULT=fail' && echo 1 || echo 0)" "8a. 非数字结果被拒绝" |
|||
r="$(run_sms_code true true short)" |
|||
check "$(printf '%s' "$r" | grep -q 'RESULT=fail' && echo 1 || echo 0)" "8b. 长度非法结果被拒绝" |
|||
|
|||
# ══════════════════════════════════════════════════════════════ |
|||
# Part 2 — F4:真实 `bash -x` 执行,扫描 stdout+stderr |
|||
# ══════════════════════════════════════════════════════════════ |
|||
run_smoke_xtrace() { |
|||
local mode="$1" |
|||
: > "$TMP/flow.log"; : > "$TMP/docker-argv.log"; : > "$TMP/mysql-argv.log" |
|||
(cd "$REPO_ROOT" && \ |
|||
SMS_MOCK_ENABLED=true ALLOW_TEST_OTP_DB_READ=true FAKE_DOCKER_MODE="$mode" \ |
|||
FAKE_CODE="$TEST_CODE" FAKE_JWT="$TEST_JWT" \ |
|||
MYSQL_PASSWORD="$TEST_DBPASS" MYSQL_USER="smoke_readonly" MYSQL_DATABASE="mydreamjob" \ |
|||
STUDENT_PHONE="$TEST_PHONE" BASE_URL="http://127.0.0.1:1/api/v1" \ |
|||
bash -x "$SMOKE" 2>&1) || true |
|||
} |
|||
|
|||
out_x="$(run_smoke_xtrace ok)" |
|||
hits_x="$(scan_hits "$out_x")" |
|||
check "$(printf '%s' "$hits_x" | grep -q 'CODE' && echo 0 || echo 1)" "F4-1. bash -x 下不泄露验证码" |
|||
check "$(printf '%s' "$hits_x" | grep -q 'PHONE' && echo 0 || echo 1)" "F4-2. bash -x 下不泄露完整手机号" |
|||
check "$(printf '%s' "$hits_x" | grep -q 'JWT' && echo 0 || echo 1)" "F4-3. bash -x 下不泄露 JWT" |
|||
check "$(printf '%s' "$hits_x" | grep -q 'AUTH' && echo 0 || echo 1)" "F4-4. bash -x 下不泄露 Authorization 头" |
|||
check "$(printf '%s' "$hits_x" | grep -q 'DBPASS' && echo 0 || echo 1)" "F4-5. bash -x 下不泄露数据库密码" |
|||
check "$(printf '%s' "$hits_x" | grep -q 'LOGIN_BODY' && echo 0 || echo 1)" "F4-6. bash -x 下不泄露 LOGIN_BODY" |
|||
|
|||
# 正常流程仍可完成(登录被调用且带 6 位码;登录判定 PASS) |
|||
check "$(grep -q 'login-attempted has6digit=yes' "$TMP/flow.log" 2>/dev/null && echo 1 || echo 0)" \ |
|||
"F4-7. bash -x 下正常流程仍可完成登录" |
|||
check "$(printf '%s' "$out_x" | grep -q '\[PASS\] login' && echo 1 || echo 0)" \ |
|||
"F4-8. bash -x 下登录步骤判定 PASS" |
|||
|
|||
# xtrace 未在脚本内被重新启用:脚本内不存在可执行的 set -x |
|||
setx_code="$(grep -n 'set -x' "$SMOKE" | grep -v '^[0-9]*:[[:space:]]*#' | wc -l)" |
|||
check "$([ "$setx_code" -eq 0 ] && echo 1 || echo 0)" "F4-9. 脚本中不存在可执行的 set -x(不会恢复 xtrace)" |
|||
|
|||
# 失败流程同样不泄露 |
|||
out_f="$(run_smoke_xtrace empty)" |
|||
hits_f="$(scan_hits "$out_f")" |
|||
check "$([ -z "$hits_f" ] && echo 1 || echo 0)" "F4-10. 失败流程(无可用验证码)同样不泄露敏感值" |
|||
|
|||
# ══════════════════════════════════════════════════════════════ |
|||
# Part 3 — F4:source 模式不把敏感值带回调用方 |
|||
# ══════════════════════════════════════════════════════════════ |
|||
src_out="$(cd "$REPO_ROOT" && bash -c ' |
|||
set -u |
|||
export SMOKE_TEST_LIB_ONLY=1 |
|||
export SMS_MOCK_ENABLED=true ALLOW_TEST_OTP_DB_READ=true |
|||
export FAKE_DOCKER_MODE=ok FAKE_CODE="'"$TEST_CODE"'" |
|||
. "'"$SMOKE"'" |
|||
trap -p EXIT > "'"$TMP"'/trap.txt" 2>/dev/null || true |
|||
if sms_code; then |
|||
if [ -n "${REPLY:-}" ]; then echo "reply-nonempty"; else echo "reply-empty"; fi |
|||
unset REPLY |
|||
fi |
|||
# 清理后不得残留任何非空敏感变量 |
|||
leak=0 |
|||
for v in REPLY SMS OTP LOGIN_BODY LOGIN TOKEN AUTH; do |
|||
eval "val=\${$v:-}" |
|||
[ -n "$val" ] && leak=1 |
|||
done |
|||
echo "leak=$leak" |
|||
' 2>&1)" |
|||
check "$(printf '%s' "$src_out" | grep -q 'reply-nonempty' && echo 1 || echo 0)" "F4-11. source 模式下取码契约(REPLY)可用" |
|||
check "$(printf '%s' "$src_out" | grep -q 'leak=0' && echo 1 || echo 0)" "F4-12. source 模式下清理后无敏感变量残留" |
|||
check "$(grep -q 'cleanup_secrets' "$TMP/trap.txt" 2>/dev/null && echo 1 || echo 0)" "F4-13. source 模式下 EXIT trap 已登记清理函数" |
|||
check "$(grep -q 'PHONE' "$SMOKE" && grep -q 'TOKEN' "$SMOKE" && grep -q 'AUTH' "$SMOKE" && grep -q 'COMPANY_TOKEN' "$SMOKE" && echo 1 || echo 0)" \ |
|||
"F4-14. cleanup_secrets 覆盖 PHONE/TOKEN/AUTH/COMPANY_TOKEN" |
|||
|
|||
# ══════════════════════════════════════════════════════════════ |
|||
# Part 4 — F5:数据库密码不进入 mysql argv |
|||
# ══════════════════════════════════════════════════════════════ |
|||
: > "$TMP/mysql-argv.log"; : > "$TMP/docker-argv.log" |
|||
_f5="$(run_sms_code true true ok)" |
|||
mysql_argv="$(cat "$TMP/mysql-argv.log" 2>/dev/null)" |
|||
docker_argv="$(cat "$TMP/docker-argv.log" 2>/dev/null)" |
|||
container_script="$(cat "$TMP/container-script.txt" 2>/dev/null)" |
|||
mysql_stdin="$(cat "$TMP/mysql-stdin.sql" 2>/dev/null)" |
|||
|
|||
check "$(printf '%s' "$mysql_argv" | grep -qF "$TEST_DBPASS" && echo 0 || echo 1)" "F5-1. mysql argv 不含数据库密码值" |
|||
check "$(printf '%s' "$docker_argv" | grep -qF "$TEST_DBPASS" && echo 0 || echo 1)" "F5-2. 宿主机 docker 命令行不含数据库密码值" |
|||
check "$(printf '%s' "$container_script" | grep -qF "$TEST_DBPASS" && echo 0 || echo 1)" "F5-3. 容器侧脚本文本不含密码值" |
|||
check "$(printf '%s' "$container_script" | grep -q 'MYSQL_PASSWORD' && echo 1 || echo 0)" "F5-4. 容器侧脚本以变量名引用 MYSQL_PASSWORD" |
|||
check "$(printf '%s' "$mysql_argv" | grep -qE '(^| )-p' && echo 0 || echo 1)" "F5-5. mysql argv 不含 -p<密码>" |
|||
check "$(printf '%s' "$mysql_argv" | grep -q -- '--password' && echo 0 || echo 1)" "F5-6. mysql argv 不含 --password" |
|||
check "$(printf '%s' "$container_script" | grep -q 'MYSQL_ROOT_PASSWORD' && echo 0 || echo 1)" "F5-7. 不使用 root 凭据(无 MYSQL_ROOT_PASSWORD)" |
|||
check "$(printf '%s' "$container_script" | grep -q 'uroot' && echo 0 || echo 1)" "F5-8. 不使用 -uroot" |
|||
check "$(printf '%s' "$mysql_argv" | grep -q 'smoke_readonly' && echo 1 || echo 0)" "F5-9. 使用应用账号 MYSQL_USER" |
|||
check "$(printf '%s' "$mysql_argv" | grep -q 'mydreamjob' && echo 1 || echo 0)" "F5-10. 使用 MYSQL_DATABASE" |
|||
check "$(printf '%s' "$mysql_stdin" | grep -qi '^SELECT' && echo 1 || echo 0)" "F5-11. SQL 经 stdin 传入" |
|||
check "$(printf '%s' "$mysql_stdin" | grep -qiE 'INSERT|UPDATE|DELETE|DROP|ALTER' && echo 0 || echo 1)" "F5-12. 查询仅包含 SELECT(无写操作)" |
|||
check "$(printf '%s' "$mysql_argv" | grep -q 'MYSQL_PWD\|MYSQL_PASSWORD' && echo 0 || echo 1)" "F5-13. 密码不经 argv 传递" |
|||
check "$(printf '%s' "$container_script" | grep -q 'MYSQL_PWD' && echo 1 || echo 0)" "F5-14. 密码经 MYSQL_PWD 环境变量传递" |
|||
check "$(printf '%s' "$container_script" | grep -q 'MYSQL_PASSWORD' && grep -q 'exit 3' "$TMP/container-script.txt" && echo 1 || echo 0)" \ |
|||
"F5-15. 凭据缺失时安全失败(不回退 root)" |
|||
check "$(printf '%s' "$_f5" | grep -q 'RESULT=ok' && echo 1 || echo 0)" "F5-16. argv 安全前提下取码仍可用" |
|||
|
|||
# docker 不可用(容器不存在)时不泄露且安全失败 |
|||
: > "$TMP/mysql-argv.log" |
|||
r="$(run_sms_code true true fail)" |
|||
check "$(printf '%s' "$r" | grep -q 'RESULT=fail' && echo 1 || echo 0)" "F5-17. 查询失败时安全失败" |
|||
check "$(printf '%s' "$r" | grep -q "$TEST_DBPASS" && echo 0 || echo 1)" "F5-18. 失败路径 stderr 不含数据库密码" |
|||
|
|||
# ══════════════════════════════════════════════════════════════ |
|||
# Part 5 — F1 静态保证 + §7 活跃文件文档门禁 |
|||
# ══════════════════════════════════════════════════════════════ |
|||
check "$(grep -qE 'grep[^|]*\.log|grep[^|]*stdout' "$SMOKE" && echo 0 || echo 1)" "13. smoke-test.sh 不 grep 应用日志" |
|||
check "$(grep -q 'MOCK SMS CODE' "$SMOKE" && echo 0 || echo 1)" "14. smoke-test.sh 中不存在 MOCK SMS CODE" |
|||
mech=1 |
|||
grep -q 'unset REPLY' "$SMOKE" || mech=0 |
|||
grep -q 'unset SMS' "$SMOKE" || mech=0 |
|||
grep -q 'trap cleanup_secrets EXIT' "$SMOKE" || mech=0 |
|||
grep -q 'unset LOGIN_BODY' "$SMOKE" || mech=0 |
|||
check "$mech" "15. 存在 REPLY/SMS/LOGIN_BODY 清理与 EXIT trap" |
|||
check "$(grep -qE 'curl.*(-v|--trace|--trace-ascii)' "$SMOKE" && echo 0 || echo 1)" "20. 未使用 curl -v/--trace/--trace-ascii" |
|||
check "$(grep -q 'report_fields' "$SMOKE" && echo 1 || echo 0)" "21. 登录响应经 report_fields(不把响应全文交给打印函数)" |
|||
|
|||
# §7:仅扫描**活跃操作文档与正式代码**,不把历史验收报告计入 |
|||
ACTIVE_FILES="docs/acceptance-week1.md docs/api-v0.1.md docs/frontend-integration.md docs/offer-api.md docs/resume-api.md docs/smoke-test.sh README.md" |
|||
active_viol=0 |
|||
for f in $ACTIVE_FILES; do |
|||
[ -f "$REPO_ROOT/$f" ] || continue |
|||
c=$(grep -c "验证码.*打印到日志" "$REPO_ROOT/$f" 2>/dev/null); c=${c:-0} |
|||
c2=$(grep -c "从日志获取验证码" "$REPO_ROOT/$f" 2>/dev/null); c2=${c2:-0} |
|||
c3=$(grep -c "MOCK SMS CODE" "$REPO_ROOT/$f" 2>/dev/null); c3=${c3:-0} |
|||
[ $((c + c2 + c3)) -ne 0 ] && active_viol=$((active_viol + 1)) |
|||
done |
|||
check "$([ "$active_viol" -eq 0 ] && echo 1 || echo 0)" "22. 活跃操作文档不存在『从日志取码』有效指导" |
|||
src_viol=$(grep -rn "MOCK SMS CODE" "$REPO_ROOT/mydreamjob-server/src/main/java" 2>/dev/null | wc -l) |
|||
check "$([ "$src_viol" -eq 0 ] && echo 1 || echo 0)" "23. 正式 Java 代码中不存在 MOCK SMS CODE" |
|||
hist_ok=1 |
|||
[ -f "$REPO_ROOT/docs/s01-independent-mysql-acceptance-20260911.md" ] || hist_ok=0 |
|||
[ -f "$REPO_ROOT/docs/s01-remediation-f1f2f3-20260911.md" ] || hist_ok=0 |
|||
check "$hist_ok" "24. 历史验收报告仍保留(作为历史失败证据,不参与活跃文档扫描)" |
|||
|
|||
# ══════════════════════════════════════════════════════════════ |
|||
# Part 6 — F6:SQL 字段契约(expired_at)与查询语义 |
|||
# ══════════════════════════════════════════════════════════════ |
|||
# 背景:expires_at 在本仓库中是 personal_data_export_tasks(V30)、 |
|||
# ability_attempts(V35)、talent_invitations(V40) 三张表的**真实列**; |
|||
# 只有 sms_verification_codes 使用 expired_at。因此断言必须限定在该表的查询上, |
|||
# **不能**写成"全库不存在 expires_at"——那会误报。 |
|||
# |
|||
# 真实列清单从**创建该表的 Flyway** 派生,不在测试里手工维护一份与生产无关的 |
|||
# 假字段列表来自证正确。 |
|||
FLYWAY="$REPO_ROOT/mydreamjob-server/src/main/resources/db/migration/V1__base_schema.sql" |
|||
real_cols="$(awk ' |
|||
/^CREATE TABLE (IF NOT EXISTS )?sms_verification_codes/ { in_t=1; next } |
|||
in_t && /^\)/ { in_t=0 } |
|||
in_t { |
|||
line=$0 |
|||
sub(/^[[:space:]]+/, "", line) |
|||
if (line ~ /^(INDEX|KEY|PRIMARY|CONSTRAINT|UNIQUE|FOREIGN)/) next |
|||
if (line ~ /^[a-z_]+[[:space:]]/) { split(line, a, /[[:space:]]/); print a[1] } |
|||
} |
|||
' "$FLYWAY" | sort -u | tr '\n' ' ')" |
|||
|
|||
# 取一次真实查询文本(此调用未设置 FAKE_ROWS,走历史路径) |
|||
: > "$TMP/mysql-stdin.sql" |
|||
_f6="$(run_sms_code true true ok)" |
|||
sql_text="$(cat "$TMP/mysql-stdin.sql" 2>/dev/null)" |
|||
|
|||
check "$(printf '%s' "$sql_text" | grep -q 'expired_at > NOW()' && echo 1 || echo 0)" \ |
|||
"F6-1. otp_db_query 使用 expired_at > NOW()" |
|||
check "$(printf '%s' "$sql_text" | grep -q 'expires_at' && echo 0 || echo 1)" \ |
|||
"F6-2. otp_db_query 不再出现错误字段 expires_at" |
|||
|
|||
# 解析查询里使用的列名(去掉字符串字面量与 SQL 关键字) |
|||
used_cols="$(printf '%s' "$sql_text" \ |
|||
| sed "s/'[^']*'//g" \ |
|||
| grep -oE '\b[a-z_][a-z0-9_]*\b' \ |
|||
| grep -vEx 'select|from|where|and|or|order|by|desc|asc|limit|now|sms_verification_codes' \ |
|||
| sort -u)" |
|||
unknown_cols="" |
|||
for c in $used_cols; do |
|||
case " $real_cols " in |
|||
*" $c "*) ;; |
|||
*) unknown_cols="$unknown_cols $c" ;; |
|||
esac |
|||
done |
|||
check "$([ -z "$unknown_cols" ] && echo 1 || echo 0)" \ |
|||
"F6-3. 查询只使用 sms_verification_codes 的真实列${unknown_cols:+(命中不存在的列:$unknown_cols)}" |
|||
# 防止"派生失败得到空清单"导致上一条恒真 |
|||
check "$(printf '%s' "$real_cols" | grep -q 'expired_at' && echo 1 || echo 0)" \ |
|||
"F6-4. 真实列清单由 Flyway 派生且包含 expired_at(非空、非自证)" |
|||
|
|||
check "$(printf '%s' "$sql_text" | grep -qiE '^SELECT code FROM sms_verification_codes' && echo 1 || echo 0)" \ |
|||
"F6-5. 假 mysql 捕获到完整 stdin SQL(不是无条件返回验证码)" |
|||
|
|||
# 字段错误 → 假 mysql 必须失败(复现真实 MySQL 的 ERROR 1054) |
|||
printf '%s\n' "SELECT code FROM sms_verification_codes WHERE phone='x' AND expires_at > NOW();" \ |
|||
| "$FAKEBIN/mysql" >/dev/null 2>"$TMP/err1054.txt" || rc1054=$? |
|||
rc1054="${rc1054:-0}" |
|||
check "$([ "$rc1054" -ne 0 ] && echo 1 || echo 0)" \ |
|||
"F6-6. SQL 字段错误时假 mysql 返回失败(非 0)" |
|||
check "$(grep -q '1054' "$TMP/err1054.txt" 2>/dev/null && echo 1 || echo 0)" \ |
|||
"F6-6b. 字段错误时报 ERROR 1054 (42S22)" |
|||
|
|||
# 端到端:把字段改错的副本跑完整流程,验证不会继续登录 |
|||
BUGDIR="$TMP/bugrepo" |
|||
mkdir -p "$BUGDIR/docs" "$BUGDIR/backend-infra" |
|||
cp "$REPO_ROOT/backend-infra/compose.yaml" "$BUGDIR/backend-infra/compose.yaml" 2>/dev/null || true |
|||
sed 's/expired_at > NOW()/expires_at > NOW()/' "$SMOKE" > "$BUGDIR/docs/smoke-test.sh" |
|||
out_bug="$(run_full_script "$BUGDIR/docs/smoke-test.sh")" |
|||
check "$(printf '%s' "$out_bug" | grep -q '\[FAIL\] login skipped' && echo 1 || echo 0)" \ |
|||
"F6-7. 字段错误时 sms_code() 不继续登录" |
|||
check "$(grep -q 'login-attempted' "$TMP/flow.log" 2>/dev/null && echo 0 || echo 1)" \ |
|||
"F6-7b. 字段错误时未发出任何 login 请求" |
|||
|
|||
# 正确字段 → 取码成功并可完成登录 |
|||
out_ok="$(run_full_script "$SMOKE")" |
|||
check "$(grep -q 'login-attempted has6digit=yes' "$TMP/flow.log" 2>/dev/null && echo 1 || echo 0)" \ |
|||
"F6-8. 正确字段时取码成功并完成登录" |
|||
|
|||
# ── 过期 / used / purpose / phone / 最新一条 语义 ────────────── |
|||
# 行格式 id|phone|purpose|used|expire_offset_s|created_at|code |
|||
P="$TEST_PHONE" |
|||
R_FUTURE="1|$P|login|0|300|2026-09-11 10:00:00|$TEST_CODE" |
|||
R_EXACT="1|$P|login|0|0|2026-09-11 10:00:00|$TEST_CODE" |
|||
R_PAST="1|$P|login|0|-300|2026-09-11 10:00:00|$TEST_CODE" |
|||
R_USED="1|$P|login|1|300|2026-09-11 10:00:00|$TEST_CODE" |
|||
R_PURPOSE="1|$P|register|0|300|2026-09-11 10:00:00|$TEST_CODE" |
|||
R_PHONE="1|19900001111|login|0|300|2026-09-11 10:00:00|$TEST_CODE" |
|||
R_OLD="1|$P|login|0|300|2026-09-11 10:00:00|111111" |
|||
R_NEW="2|$P|login|0|300|2026-09-11 11:00:00|222222" |
|||
|
|||
r="$(run_sms_code_with_rows "$R_EXACT" "$TEST_CODE")" |
|||
check "$(printf '%s' "$r" | grep -q 'RESULT=fail' && echo 1 || echo 0)" \ |
|||
"F6-9. expired_at 恰好等于 NOW() 时不得取出" |
|||
|
|||
r="$(run_sms_code_with_rows "$R_FUTURE" "$TEST_CODE")" |
|||
check "$(printf '%s' "$r" | grep -q 'MATCH=yes' && echo 1 || echo 0)" \ |
|||
"F6-10. expired_at 晚于 NOW() 时可以取出" |
|||
|
|||
r="$(run_sms_code_with_rows "$R_PAST" "$TEST_CODE")" |
|||
check "$(printf '%s' "$r" | grep -q 'RESULT=fail' && echo 1 || echo 0)" \ |
|||
"F6-11. expired_at 早于 NOW() 时不得取出" |
|||
|
|||
r="$(run_sms_code_with_rows "$R_USED" "$TEST_CODE")" |
|||
check "$(printf '%s' "$r" | grep -q 'RESULT=fail' && echo 1 || echo 0)" \ |
|||
"F6-12. used=1 不得取出" |
|||
|
|||
r="$(run_sms_code_with_rows "$R_PURPOSE" "$TEST_CODE")" |
|||
check "$(printf '%s' "$r" | grep -q 'RESULT=fail' && echo 1 || echo 0)" \ |
|||
"F6-13. purpose 不匹配不得取出" |
|||
|
|||
r="$(run_sms_code_with_rows "$R_PHONE" "$TEST_CODE")" |
|||
check "$(printf '%s' "$r" | grep -q 'RESULT=fail' && echo 1 || echo 0)" \ |
|||
"F6-14. phone 不匹配不得取出" |
|||
|
|||
r="$(run_sms_code_with_rows "$(printf '%s\n%s' "$R_OLD" "$R_NEW")" "222222")" |
|||
check "$(printf '%s' "$r" | grep -q 'MATCH=yes' && echo 1 || echo 0)" \ |
|||
"F6-15. 多条有效记录只取 created_at/id 最新一条" |
|||
# 逆序输入仍取同一条,证明按 ORDER BY 取值而非"最后一行胜出" |
|||
r="$(run_sms_code_with_rows "$(printf '%s\n%s' "$R_NEW" "$R_OLD")" "222222")" |
|||
check "$(printf '%s' "$r" | grep -q 'MATCH=yes' && echo 1 || echo 0)" \ |
|||
"F6-15b. 与输入顺序无关,按 created_at/id 排序取值" |
|||
|
|||
echo "" |
|||
echo "============================================" |
|||
echo "PASS=$PASS FAIL=$FAIL" |
|||
echo "============================================" |
|||
|
|||
# 最终泄露扫描(测试自身输出也不得含标记) |
|||
if printf '%s' "$out_x$out_f$r$src_out" | grep -qF "$TEST_CODE"; then |
|||
echo "LEAK DETECTED: code"; exit 1 |
|||
fi |
|||
if printf '%s' "$out_x$out_f$r$src_out" | grep -qF "$TEST_JWT"; then |
|||
echo "LEAK DETECTED: jwt"; exit 1 |
|||
fi |
|||
if printf '%s' "$out_x$out_f$r$src_out" | grep -qF "$TEST_DBPASS"; then |
|||
echo "LEAK DETECTED: dbpass"; exit 1 |
|||
fi |
|||
[ "$FAIL" -eq 0 ] |
|||
@ -0,0 +1,132 @@ |
|||
package com.mydreamjob.application; |
|||
|
|||
import com.mydreamjob.common.exception.BusinessException; |
|||
import com.mydreamjob.common.exception.ErrorCode; |
|||
import com.mydreamjob.company.Company; |
|||
import com.mydreamjob.company.CompanyRepository; |
|||
import com.mydreamjob.job.Job; |
|||
import com.mydreamjob.job.JobRepository; |
|||
import com.mydreamjob.privacy.ResumeVisibility; |
|||
import com.mydreamjob.privacy.StudentPrivacySettings; |
|||
import com.mydreamjob.privacy.StudentPrivacySettingsRepository; |
|||
import lombok.RequiredArgsConstructor; |
|||
import org.springframework.stereotype.Component; |
|||
|
|||
import java.util.Set; |
|||
|
|||
/** |
|||
* S03-B 企业简历访问授权(失败关闭)。 |
|||
* |
|||
* <p>授权顺序严格按设计 §4.2:当前 COMPANY 用户 → 当前 Company → Application+Job → |
|||
* Job.companyId → Student → privacy setting。归属信息全部由服务端推导, |
|||
* <strong>不信任任何客户端传入的 companyId / studentUserId</strong>。</p> |
|||
* |
|||
* <p>决策表(设计 §4.3):</p> |
|||
* <ul> |
|||
* <li>无设置记录 / null / PRIVATE:拒绝(按 PRIVATE 处理)</li> |
|||
* <li>APPLIED_COMPANIES:岗位必须属于当前企业,且投递状态为 |
|||
* SUBMITTED/VIEWED/INTERVIEW/HIRED;WITHDRAWN/REJECTED 拒绝</li> |
|||
* <li>VERIFIED_COMPANIES:当前企业 {@code verified=true} <strong>且</strong>拥有该投递岗位; |
|||
* 投递状态不额外限制</li> |
|||
* </ul> |
|||
* |
|||
* <p>本类修复了此前 {@code PrivacySettingsService.canCompanyViewResume} 只校验学生与状态、 |
|||
* 不校验企业归属的缺陷。</p> |
|||
*/ |
|||
@Component |
|||
@RequiredArgsConstructor |
|||
public class CompanyResumeAccessPolicy { |
|||
|
|||
/** |
|||
* {@code APPLIED_COMPANIES} 允许的投递状态:**严格白名单**。 |
|||
* |
|||
* <p>不得改为「不是拒绝状态就允许」的黑名单逻辑 —— 那会让 {@code null}、空白、 |
|||
* 未知状态或大小写变体(如 {@code "submitted"})失败开放。状态比较区分大小写。</p> |
|||
*/ |
|||
static final Set<String> APPLIED_COMPANY_VISIBLE_STATUSES = |
|||
Set.of("SUBMITTED", "VIEWED", "INTERVIEW", "HIRED"); |
|||
|
|||
private final CompanyRepository companyRepository; |
|||
private final ApplicationRepository applicationRepository; |
|||
private final JobRepository jobRepository; |
|||
private final StudentPrivacySettingsRepository privacyRepository; |
|||
|
|||
/** 授权通过后返回候选人用户 ID;这是后续读取简历的唯一依据。 */ |
|||
public record Decision(Long studentUserId) { |
|||
} |
|||
|
|||
/** |
|||
* 企业下载接口使用的授权入口:仅凭当前登录公司用户与路径中的 applicationId 完成全部判定。 |
|||
* |
|||
* @throws BusinessException 未拥有企业资料 / 跨企业 / 隐私拒绝 → {@code FORBIDDEN(2002)}; |
|||
* 投递不存在 → {@code APPLICATION_NOT_FOUND(8002)} |
|||
*/ |
|||
public Decision authorizeForDownload(Long companyUserId, Long applicationId) { |
|||
if (companyUserId == null || applicationId == null) { |
|||
throw new BusinessException(ErrorCode.FORBIDDEN); |
|||
} |
|||
Company company = companyRepository.findByOwnerUserId(companyUserId).orElse(null); |
|||
if (company == null) { |
|||
throw new BusinessException(ErrorCode.FORBIDDEN); |
|||
} |
|||
Application application = applicationRepository.findById(applicationId) |
|||
.orElseThrow(() -> new BusinessException(ErrorCode.APPLICATION_NOT_FOUND)); |
|||
|
|||
if (!isVisibleToCompanyInternal(application, company.getId())) { |
|||
throw new BusinessException(ErrorCode.FORBIDDEN); |
|||
} |
|||
return new Decision(application.getStudentUserId()); |
|||
} |
|||
|
|||
/** |
|||
* 既有布尔语义的授权判定(供 {@code PrivacySettingsService} 委托)。 |
|||
* |
|||
* <p>调用方提供的 {@code studentUserId} 只用于一致性校验:必须与投递记录一致, |
|||
* 否则视为不可信并拒绝。</p> |
|||
*/ |
|||
public boolean isVisible(Long studentUserId, Long companyId, Long applicationId) { |
|||
if (studentUserId == null || companyId == null || applicationId == null) { |
|||
return false; |
|||
} |
|||
Application application = applicationRepository.findById(applicationId).orElse(null); |
|||
if (application == null || !studentUserId.equals(application.getStudentUserId())) { |
|||
return false; |
|||
} |
|||
return isVisibleToCompanyInternal(application, companyId); |
|||
} |
|||
|
|||
/** |
|||
* 核心判定:投递所属岗位必须属于该企业,再按学生隐私设置决定可见性。 |
|||
*/ |
|||
private boolean isVisibleToCompanyInternal(Application application, Long companyId) { |
|||
Job job = jobRepository.findById(application.getJobId()).orElse(null); |
|||
if (job == null || !companyId.equals(job.getCompanyId())) { |
|||
// 岗位不存在或不属于当前企业:一律拒绝(含跨企业投递)。
|
|||
return false; |
|||
} |
|||
|
|||
ResumeVisibility visibility = visibilityOf(application.getStudentUserId()); |
|||
switch (visibility) { |
|||
case APPLIED_COMPANIES: |
|||
// 白名单判定(失败关闭):null / 空白 / 未知 / 大小写变体一律拒绝。
|
|||
// 先做 null 判断,避免 Set.of(...).contains(null) 抛 NPE。
|
|||
return application.getStatus() != null |
|||
&& APPLIED_COMPANY_VISIBLE_STATUSES.contains(application.getStatus()); |
|||
case VERIFIED_COMPANIES: |
|||
Company company = companyRepository.findById(companyId).orElse(null); |
|||
return company != null && Boolean.TRUE.equals(company.getVerified()); |
|||
case PRIVATE: |
|||
default: |
|||
// 无设置记录、null 或 PRIVATE:按 PRIVATE 处理。
|
|||
return false; |
|||
} |
|||
} |
|||
|
|||
private ResumeVisibility visibilityOf(Long studentUserId) { |
|||
StudentPrivacySettings settings = privacyRepository.findByStudentUserId(studentUserId).orElse(null); |
|||
if (settings == null || settings.getResumeVisibility() == null) { |
|||
return ResumeVisibility.PRIVATE; |
|||
} |
|||
return settings.getResumeVisibility(); |
|||
} |
|||
} |
|||
@ -0,0 +1,72 @@ |
|||
package com.mydreamjob.application; |
|||
|
|||
import com.mydreamjob.application.dto.CompanyResumeFile; |
|||
import com.mydreamjob.common.exception.BusinessException; |
|||
import com.mydreamjob.common.exception.ErrorCode; |
|||
import com.mydreamjob.profile.StudentProfile; |
|||
import com.mydreamjob.profile.StudentProfileRepository; |
|||
import com.mydreamjob.profile.storage.ResumeFilenameSanitizer; |
|||
import com.mydreamjob.profile.storage.ResumeStorageService; |
|||
import lombok.RequiredArgsConstructor; |
|||
import lombok.extern.slf4j.Slf4j; |
|||
import org.springframework.stereotype.Service; |
|||
import org.springframework.transaction.annotation.Transactional; |
|||
|
|||
import java.util.Set; |
|||
|
|||
/** |
|||
* S03-B 企业读取候选人简历文件。 |
|||
* |
|||
* <p>本类只负责「授权已通过之后」的文件读取与响应构建: |
|||
* 授权全部由 {@link CompanyResumeAccessPolicy} 完成,**任何拒绝路径都不会走到 |
|||
* {@link ResumeStorageService#read}</strong>。</p> |
|||
* |
|||
* <p>简历正文、存储路径、原始文件名与学生标识都不写入日志、审计或异常。</p> |
|||
*/ |
|||
@Slf4j |
|||
@Service |
|||
@RequiredArgsConstructor |
|||
public class CompanyResumeService { |
|||
|
|||
/** Content-Type 白名单:只能来自上传时已校验的三种简历类型。 */ |
|||
static final Set<String> ALLOWED_CONTENT_TYPES = Set.of( |
|||
"application/pdf", |
|||
"application/msword", |
|||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document"); |
|||
|
|||
static final String FALLBACK_CONTENT_TYPE = "application/octet-stream"; |
|||
|
|||
private final CompanyResumeAccessPolicy accessPolicy; |
|||
private final StudentProfileRepository profileRepository; |
|||
private final ResumeStorageService resumeStorageService; |
|||
|
|||
/** |
|||
* 读取该投递对应候选人的简历。 |
|||
* |
|||
* @throws BusinessException 授权失败({@code FORBIDDEN} / {@code APPLICATION_NOT_FOUND}), |
|||
* 或授权通过但学生没有简历({@code PROFILE_NOT_FOUND}) |
|||
*/ |
|||
@Transactional(readOnly = true) |
|||
public CompanyResumeFile load(Long companyUserId, Long applicationId) { |
|||
// 先授权,后读文件:拒绝路径绝不触碰存储。
|
|||
CompanyResumeAccessPolicy.Decision decision = |
|||
accessPolicy.authorizeForDownload(companyUserId, applicationId); |
|||
|
|||
StudentProfile profile = profileRepository.findByUserId(decision.studentUserId()) |
|||
.orElseThrow(() -> new BusinessException(ErrorCode.PROFILE_NOT_FOUND)); |
|||
|
|||
String storagePath = profile.getResumeUrl(); |
|||
if (storagePath == null || storagePath.isBlank()) { |
|||
throw new BusinessException(ErrorCode.PROFILE_NOT_FOUND); |
|||
} |
|||
|
|||
byte[] content = resumeStorageService.read(storagePath); |
|||
String filename = ResumeFilenameSanitizer.forDownload(profile.getResumeOriginalName(), "resume"); |
|||
String contentType = resolveContentType(profile.getResumeContentType()); |
|||
return new CompanyResumeFile(content, filename, contentType); |
|||
} |
|||
|
|||
private String resolveContentType(String stored) { |
|||
return (stored != null && ALLOWED_CONTENT_TYPES.contains(stored)) ? stored : FALLBACK_CONTENT_TYPE; |
|||
} |
|||
} |
|||
@ -0,0 +1,10 @@ |
|||
package com.mydreamjob.application.dto; |
|||
|
|||
/** |
|||
* S03-B 企业简历下载的内部响应载体。 |
|||
* |
|||
* <p>只携带渲染文件响应所必需的三个字段:文件内容、已净化的下载文件名、白名单内的 |
|||
* Content-Type。**不包含**存储路径、学生用户 ID、手机号、邮箱或简历正文文本。</p> |
|||
*/ |
|||
public record CompanyResumeFile(byte[] content, String filename, String contentType) { |
|||
} |
|||
@ -0,0 +1,17 @@ |
|||
package com.mydreamjob.auth; |
|||
|
|||
import java.time.Instant; |
|||
|
|||
/** |
|||
* 已通过完整校验的 Access Token 声明(S02)。 |
|||
* |
|||
* <p>强类型只读视图:调用方不再接触 JJWT 的 Claims 对象,也不依赖 claim 名。</p> |
|||
*/ |
|||
public record AccessTokenClaims( |
|||
Long userId, |
|||
String sessionId, |
|||
String tokenId, |
|||
String role, |
|||
Instant issuedAt, |
|||
Instant expiresAt) { |
|||
} |
|||
@ -0,0 +1,105 @@ |
|||
package com.mydreamjob.auth; |
|||
|
|||
import org.springframework.dao.DataAccessException; |
|||
import org.springframework.jdbc.core.JdbcTemplate; |
|||
import org.springframework.stereotype.Component; |
|||
import org.springframework.transaction.support.TransactionTemplate; |
|||
|
|||
import java.util.List; |
|||
import java.util.Optional; |
|||
|
|||
/** |
|||
* S03 定向整改:管理员账号创建的**独立事务组件**。 |
|||
* |
|||
* <p>为什么不用 JPA/Hibernate:并发初始化时数据库唯一约束会成为正常并发控制手段, |
|||
* 而 Hibernate 的 {@code SqlExceptionHelper} 会在业务代码捕获异常**之前**以 ERROR 级别 |
|||
* 记录整条语句与绑定值 —— 管理员手机号因此进入生产可见日志。</p> |
|||
* |
|||
* <p>本组件改用 {@link JdbcTemplate} 的参数化语句:</p> |
|||
* <ul> |
|||
* <li>冲突在 JDBC 层被捕获,**不经过** Hibernate 日志体系;</li> |
|||
* <li>原始数据库异常与 message/cause **一律丢弃**,对外只返回结果码;</li> |
|||
* <li>{@code users} 与 {@code user_roles} 在**同一事务**内提交 —— |
|||
* 角色写入失败时用户创建整体回滚;</li> |
|||
* <li>不打印 SQL、手机号或绑定参数。</li> |
|||
* </ul> |
|||
*/ |
|||
@Component |
|||
public class AdminBootstrapCreator { |
|||
|
|||
/** 创建结果:成功创建 / 数据库唯一约束冲突。 */ |
|||
public enum CreateResult { |
|||
CREATED, |
|||
CONFLICT |
|||
} |
|||
|
|||
/** 引导账号的最小状态视图(不含任何敏感字段输出)。 */ |
|||
public record BootstrapUser(Long id, String role, boolean active, boolean verified, |
|||
boolean passwordAbsent, List<String> roles) { |
|||
} |
|||
|
|||
private final JdbcTemplate jdbcTemplate; |
|||
private final TransactionTemplate transactionTemplate; |
|||
|
|||
/** |
|||
* 使用**独立 REQUIRES_NEW 事务**:冲突必须只回滚本次创建,不得把调用方的外层事务 |
|||
* 标记为 rollback-only(否则调用方提交时会抛 {@code UnexpectedRollbackException})。 |
|||
*/ |
|||
public AdminBootstrapCreator(JdbcTemplate jdbcTemplate, |
|||
org.springframework.transaction.PlatformTransactionManager transactionManager) { |
|||
this.jdbcTemplate = jdbcTemplate; |
|||
this.transactionTemplate = new TransactionTemplate(transactionManager); |
|||
this.transactionTemplate.setPropagationBehavior( |
|||
org.springframework.transaction.TransactionDefinition.PROPAGATION_REQUIRES_NEW); |
|||
} |
|||
|
|||
/** |
|||
* 在同一事务内写入 {@code users} 与 {@code user_roles}。 |
|||
* |
|||
* @return {@link CreateResult#CREATED} 或 {@link CreateResult#CONFLICT}; |
|||
* 冲突时不抛出任何携带原始数据库信息的异常 |
|||
*/ |
|||
public CreateResult createAdmin(String phone, String nickname) { |
|||
try { |
|||
transactionTemplate.executeWithoutResult(status -> { |
|||
jdbcTemplate.update( |
|||
"INSERT INTO users (phone, nickname, password_hash, role, is_active, is_verified, " |
|||
+ "created_at, updated_at) VALUES (?, ?, NULL, 'ADMIN', 1, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)", |
|||
phone, nickname); |
|||
Long userId = jdbcTemplate.queryForObject( |
|||
"SELECT id FROM users WHERE phone = ?", Long.class, phone); |
|||
jdbcTemplate.update("INSERT INTO user_roles (user_id, role) VALUES (?, 'ADMIN')", userId); |
|||
}); |
|||
return CreateResult.CREATED; |
|||
} catch (DataAccessException e) { |
|||
// 唯一约束冲突(并发初始化)、角色写入失败或任何 JDBC 层失败:事务已整体回滚。
|
|||
// 刻意**丢弃**原始异常:它的 message 可能包含 SQL 与绑定值(例如手机号)。
|
|||
// 调用方据此重新查询,决定是「幂等成功」还是抛出不带 cause 的固定安全异常。
|
|||
return CreateResult.CONFLICT; |
|||
} |
|||
} |
|||
|
|||
/** 按手机号读取引导所需的最小状态;只读、参数化,不经过 Hibernate。 */ |
|||
public Optional<BootstrapUser> findByPhone(String phone) { |
|||
List<BootstrapUser> rows = jdbcTemplate.query( |
|||
"SELECT id, role, is_active, is_verified, password_hash FROM users WHERE phone = ?", |
|||
(rs, i) -> new BootstrapUser( |
|||
rs.getLong("id"), |
|||
rs.getString("role"), |
|||
rs.getInt("is_active") == 1, |
|||
rs.getInt("is_verified") == 1, |
|||
rs.getString("password_hash") == null, |
|||
List.of()), |
|||
phone); |
|||
if (rows.isEmpty()) { |
|||
return Optional.empty(); |
|||
} |
|||
BootstrapUser base = rows.get(0); |
|||
List<String> roles = jdbcTemplate.queryForList( |
|||
"SELECT role FROM user_roles WHERE user_id = ?", String.class, base.id()); |
|||
return Optional.of(new BootstrapUser(base.id(), base.role(), base.active(), |
|||
base.verified(), base.passwordAbsent(), roles)); |
|||
} |
|||
|
|||
/** 是否存在活动 ADMIN(不涉及手机号,可用 JPA 仓储)。 */ |
|||
} |
|||
@ -0,0 +1,119 @@ |
|||
package com.mydreamjob.auth; |
|||
|
|||
import com.mydreamjob.config.AdminBootstrapProperties; |
|||
import lombok.RequiredArgsConstructor; |
|||
import lombok.extern.slf4j.Slf4j; |
|||
import org.springframework.boot.ApplicationArguments; |
|||
import org.springframework.boot.ApplicationRunner; |
|||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; |
|||
import org.springframework.stereotype.Component; |
|||
|
|||
import java.util.Optional; |
|||
import java.util.Set; |
|||
import java.util.regex.Pattern; |
|||
|
|||
/** |
|||
* S03-A 一次性管理员初始化(显式开关,默认关闭)。 |
|||
* |
|||
* <p>仅在 {@code app.bootstrap.admin.enabled} 为字面量 {@code "true"} 时装配; |
|||
* 本类还会再校验一次字面量,确保大小写变体不会误启用。</p> |
|||
* |
|||
* <p>规则(设计 §3.2):</p> |
|||
* <ol> |
|||
* <li>手机号必须通过既有手机号格式校验;不写日志、异常详情或审计 details;</li> |
|||
* <li>已有活动 ADMIN → 无副作用跳过;</li> |
|||
* <li>无活动 ADMIN 且手机号未被占用 → 创建 active=true / verified=true 的 ADMIN, |
|||
* 同时写入 {@code users.role} 与 {@code user_roles};</li> |
|||
* <li>手机号已属于任何用户(任意角色 / inactive / 角色不一致)→ 启动失败, |
|||
* 禁止提权、复活或覆盖;</li> |
|||
* <li>相同配置重复启动幂等;</li> |
|||
* <li>多实例并发:数据库唯一手机号约束是最后防线,冲突直接失败,不吞掉。</li> |
|||
* </ol> |
|||
* |
|||
* <p>不签发 Token、不发送短信、不设置默认密码、不提供公网接口。</p> |
|||
*/ |
|||
@Slf4j |
|||
@Component |
|||
@RequiredArgsConstructor |
|||
@ConditionalOnProperty(name = "app.bootstrap.admin.enabled", havingValue = "true") |
|||
public class AdminBootstrapRunner implements ApplicationRunner { |
|||
|
|||
/** 与登录链路一致的手机号格式:11 位数字。 */ |
|||
private static final Pattern PHONE_PATTERN = Pattern.compile("[0-9]{11}"); |
|||
|
|||
/** 严格字面量:只有 "true" 才算启用。 */ |
|||
private static final String STRICTLY_ENABLED = "true"; |
|||
|
|||
/** 固定安全文案:不包含手机号、用户对象、SQL 或环境变量值。 */ |
|||
static final String ERROR_INVALID_PHONE = "admin bootstrap rejected: configured phone is invalid"; |
|||
static final String ERROR_PHONE_IN_USE = "admin bootstrap rejected: configured phone is already in use"; |
|||
|
|||
private final AdminBootstrapProperties properties; |
|||
private final UserRepository userRepository; |
|||
/** S03 整改:创建走独立的 JdbcTemplate 事务组件,避免 Hibernate 记录绑定手机号。 */ |
|||
private final AdminBootstrapCreator creator; |
|||
|
|||
@Override |
|||
public void run(ApplicationArguments args) { |
|||
// 兜底的字面量校验:即使 @ConditionalOnProperty 的匹配更宽松,也不允许非 "true" 生效。
|
|||
if (!STRICTLY_ENABLED.equals(properties.getEnabled())) { |
|||
return; |
|||
} |
|||
|
|||
String phone = properties.getPhone(); |
|||
if (phone == null || !PHONE_PATTERN.matcher(phone.trim()).matches()) { |
|||
throw new IllegalStateException(ERROR_INVALID_PHONE); |
|||
} |
|||
phone = phone.trim(); |
|||
|
|||
// 配置手机号已被任何用户占用(含 inactive、STUDENT、COMPANY、角色不一致)?
|
|||
// 必须先验证该账号,不能被“已有活动 ADMIN”短路:否则一个主角色为 ADMIN、
|
|||
// 但角色表缺失/含附加角色/仍有密码的账号会绕过严格幂等判据。
|
|||
Optional<AdminBootstrapCreator.BootstrapUser> existing = creator.findByPhone(phone); |
|||
if (existing.isPresent()) { |
|||
// 方案 A:仅当现存账号正是本次初始化配置要求的合法 ADMIN 时幂等成功;
|
|||
// 普通用户 / inactive / 角色不一致一律启动失败,绝不提权、复活或覆盖。
|
|||
if (isValidBootstrapAdmin(existing.get())) { |
|||
log.info("admin bootstrap skipped: a matching administrator already exists"); |
|||
return; |
|||
} |
|||
throw new IllegalStateException(ERROR_PHONE_IN_USE); |
|||
} |
|||
|
|||
// 配置手机号不存在且已有其他活动 ADMIN:无副作用跳过,绝不创建第二个管理员。
|
|||
if (userRepository.countByRoleAndActiveTrue(User.Role.ADMIN) > 0) { |
|||
log.info("admin bootstrap skipped: an active administrator already exists"); |
|||
return; |
|||
} |
|||
|
|||
AdminBootstrapCreator.CreateResult result = creator.createAdmin(phone, properties.getNickname()); |
|||
if (result == AdminBootstrapCreator.CreateResult.CONFLICT) { |
|||
// 并发初始化:另一个实例刚创建了同一手机号。按方案 A 重新判定:
|
|||
// 只有现存账号完全合法才幂等成功,否则一律失败。
|
|||
if (creator.findByPhone(phone).filter(this::isValidBootstrapAdmin).isPresent()) { |
|||
log.info("admin bootstrap skipped: a matching administrator already exists"); |
|||
return; |
|||
} |
|||
throw new IllegalStateException(ERROR_PHONE_IN_USE); |
|||
} |
|||
|
|||
// 固定事件文本,不含手机号;部署方初始化后必须移除开关。
|
|||
log.info("admin bootstrap created an administrator; remove the bootstrap switch now"); |
|||
} |
|||
|
|||
/** |
|||
* 方案 A 的幂等判据:现存账号必须**恰好**是本次初始化所要求的合法管理员。 |
|||
* |
|||
* <p>要求角色为 ADMIN、active、verified、无默认口令、{@code user_roles} 含 ADMIN, |
|||
* 且不含 STUDENT/COMPANY 等附加角色。任一不满足即视为「手机号被占用」,启动失败。</p> |
|||
*/ |
|||
private boolean isValidBootstrapAdmin(AdminBootstrapCreator.BootstrapUser user) { |
|||
return User.Role.ADMIN.name().equals(user.role()) |
|||
&& user.active() |
|||
&& user.verified() |
|||
&& user.passwordAbsent() |
|||
&& user.roles().contains(User.Role.ADMIN.name()) |
|||
&& !user.roles().contains(User.Role.STUDENT.name()) |
|||
&& !user.roles().contains(User.Role.COMPANY.name()); |
|||
} |
|||
} |
|||
@ -0,0 +1,332 @@ |
|||
package com.mydreamjob.auth; |
|||
|
|||
import com.mydreamjob.audit.AuditContext; |
|||
import com.mydreamjob.audit.AuditService; |
|||
import lombok.extern.slf4j.Slf4j; |
|||
import org.springframework.dao.DataAccessException; |
|||
import org.springframework.stereotype.Service; |
|||
import org.springframework.transaction.PlatformTransactionManager; |
|||
import org.springframework.transaction.TransactionDefinition; |
|||
import org.springframework.transaction.annotation.Transactional; |
|||
import org.springframework.transaction.support.TransactionTemplate; |
|||
|
|||
import java.time.Instant; |
|||
import java.time.LocalDateTime; |
|||
import java.time.ZoneId; |
|||
import java.util.Objects; |
|||
import java.util.Optional; |
|||
import java.util.UUID; |
|||
|
|||
/** |
|||
* S02 登录会话服务:创建会话、校验会话、轮换与消费 Refresh Token、撤销会话。 |
|||
* |
|||
* <p>并发正确性完全由数据库条件更新保证({@link AuthTokenRepository#consumeIfCurrent}), |
|||
* 不使用任何 JVM 本地锁。</p> |
|||
* |
|||
* <p>时间一律由调用方(认证入口)传入的 {@code capturedNow} 决定,本类不自行取时。</p> |
|||
*/ |
|||
@Slf4j |
|||
@Service |
|||
public class AuthSessionService { |
|||
|
|||
/** 固定业务时区(设计 D13)。 */ |
|||
public static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai"); |
|||
|
|||
/** 撤销原因白名单(设计 D12 / 第十二节)。 */ |
|||
public static final String REASON_LOGOUT = "USER_LOGOUT"; |
|||
public static final String REASON_REPLAY = "REFRESH_REUSE_DETECTED"; |
|||
public static final String REASON_ACCOUNT_DELETED = "ACCOUNT_DELETED"; |
|||
public static final String REASON_USER_DISABLED = "USER_DISABLED"; |
|||
|
|||
private final AuthTokenRepository authTokenRepository; |
|||
private final AuthTokenHasher authTokenHasher; |
|||
private final JwtTokenProvider jwtTokenProvider; |
|||
private final UserRepository userRepository; |
|||
private final AuditService auditService; |
|||
/** 刷新尝试:消费旧 Token + 写入后继 Token 必须在同一事务内完成。 */ |
|||
private final TransactionTemplate refreshTransaction; |
|||
/** 重放撤销:必须在刷新尝试事务**结束之后**独立提交,避免与并发胜出方互相持锁。 */ |
|||
private final TransactionTemplate replayTransaction; |
|||
|
|||
public AuthSessionService(AuthTokenRepository authTokenRepository, |
|||
AuthTokenHasher authTokenHasher, |
|||
JwtTokenProvider jwtTokenProvider, |
|||
UserRepository userRepository, |
|||
AuditService auditService, |
|||
PlatformTransactionManager transactionManager) { |
|||
this.authTokenRepository = authTokenRepository; |
|||
this.authTokenHasher = authTokenHasher; |
|||
this.jwtTokenProvider = jwtTokenProvider; |
|||
this.userRepository = userRepository; |
|||
this.auditService = auditService; |
|||
this.refreshTransaction = new TransactionTemplate(transactionManager); |
|||
this.refreshTransaction.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRED); |
|||
this.replayTransaction = new TransactionTemplate(transactionManager); |
|||
this.replayTransaction.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); |
|||
} |
|||
|
|||
/** 刷新尝试的结果:成功(携带新 Token 对)或识别为重放。 */ |
|||
private sealed interface RefreshAttempt { |
|||
record Succeeded(SessionTokens tokens) implements RefreshAttempt { |
|||
} |
|||
|
|||
record Replayed(Long userId, String sessionId) implements RefreshAttempt { |
|||
} |
|||
} |
|||
|
|||
/** 一次会话签发的结果。Token 原文只在内存中短暂存在。 */ |
|||
public record SessionTokens(String accessToken, String refreshToken, String sessionId) { |
|||
} |
|||
|
|||
// ── 登录:创建会话 ───────────────────────────────────────────
|
|||
|
|||
/** |
|||
* 创建一次登录会话并签发 Access/Refresh Token。 |
|||
* |
|||
* <p>必须运行在调用方事务内:会话记录持久化失败时,整个登录事务失败,不得返回 Token。</p> |
|||
*/ |
|||
@Transactional |
|||
public SessionTokens createSession(User user, Instant capturedNow, AuditContext ctx) { |
|||
// 失败关闭:不给不可用用户创建活动会话,更不返回 Token。
|
|||
// 不能先发 Token 再由过滤器拒绝 —— 那样 Token 已经流出去了。
|
|||
if (user == null || !isUsable(user)) { |
|||
throw new InvalidTokenException(); |
|||
} |
|||
String sessionId = UUID.randomUUID().toString(); |
|||
String accessJti = UUID.randomUUID().toString(); |
|||
String refreshJti = UUID.randomUUID().toString(); |
|||
|
|||
IssuedToken access = jwtTokenProvider.generateAccessToken( |
|||
user.getId(), user.getRole().name(), sessionId, accessJti, capturedNow); |
|||
IssuedToken refresh = jwtTokenProvider.generateRefreshToken( |
|||
user.getId(), sessionId, refreshJti, capturedNow); |
|||
|
|||
authTokenRepository.save(AuthToken.builder() |
|||
.userId(user.getId()) |
|||
.tokenHash(authTokenHasher.hash(refresh.token())) |
|||
.tokenType(AuthToken.TYPE_REFRESH) |
|||
.expiredAt(toLocal(refresh.expiresAt())) |
|||
.revoked(false) |
|||
.sessionId(sessionId) |
|||
.tokenJti(refreshJti) |
|||
// created_at 必须与入口捕获的 capturedNow 同源,不能由审计读取系统时间。
|
|||
.createdAt(toLocal(capturedNow)) |
|||
.build()); |
|||
|
|||
auditService.logSessionCreated(user.getId(), sessionRef(sessionId), ctx, toLocal(capturedNow)); |
|||
return new SessionTokens(access.token(), refresh.token(), sessionId); |
|||
} |
|||
|
|||
// ── 校验:Access 所属会话是否可用(失败关闭)──────────────────
|
|||
|
|||
/** |
|||
* Access Token 的服务端会话校验(设计 D10)。任一条不满足即返回 {@code false}: |
|||
* 会话属于该用户、存在未消费未撤销未过期的 Refresh 记录、用户存在且 active。 |
|||
* |
|||
* <p><strong>关于账号注销</strong>:这里刻意只检查 {@code active},不单独判定注销记录的 |
|||
* COMPLETED 状态。账号注销完成时 {@code AccountDeletionCompletionWorker} 会 |
|||
* 1) 脱敏并将用户置为 inactive,2) 撤销该用户全部会话 —— 两道处理叠加后, |
|||
* 真实注销用户的旧 Token 无论走哪条路径都会失败。把 COMPLETED 的 409/9303 语义 |
|||
* 保留给 {@code DeletionGuardInterceptor},可避免改变既有注销端点契约。</p> |
|||
* |
|||
* <p>任何数据库/运行时异常一律**失败关闭**,不建立认证。</p> |
|||
*/ |
|||
public boolean isSessionActive(Long userId, String sessionId, Instant capturedNow) { |
|||
if (userId == null || sessionId == null || sessionId.isBlank()) { |
|||
return false; |
|||
} |
|||
try { |
|||
if (!isUserUsable(userId)) { |
|||
// 固定安全文本:不包含 userId、sessionId、Token 或 claim。
|
|||
log.warn("session validation rejected: user not usable"); |
|||
return false; |
|||
} |
|||
if (authTokenRepository.countActiveSessionTokens(userId, sessionId, toLocal(capturedNow)) <= 0) { |
|||
log.warn("session validation rejected: no active session"); |
|||
return false; |
|||
} |
|||
return true; |
|||
} catch (Exception e) { |
|||
// 固定安全文本:不回显异常消息、userId、sessionId 或 Token。
|
|||
log.warn("session validation failed"); |
|||
return false; |
|||
} |
|||
} |
|||
|
|||
// ── 刷新:消费并轮换 ─────────────────────────────────────────
|
|||
|
|||
/** |
|||
* 刷新编排入口。**本方法不运行在事务中** —— 这一点是并发重放处置正确性的前提: |
|||
* 刷新尝试事务必须先结束(释放其持有的行锁),重放撤销才能在无竞争的事务里生效, |
|||
* 从而覆盖并发胜出方刚写入的后继 Token。 |
|||
* |
|||
* @throws InvalidTokenException 任何失败,包括重放 |
|||
*/ |
|||
public SessionTokens refresh(String rawRefreshToken, Instant capturedNow, AuditContext ctx) { |
|||
// 校验与摘要不访问数据库,放在事务外,异常时仍能拿到 sessionId 用于重放撤销。
|
|||
RefreshTokenClaims claims = jwtTokenProvider.parseAndValidateRefreshToken(rawRefreshToken, capturedNow); |
|||
String hash = authTokenHasher.hash(rawRefreshToken); |
|||
|
|||
RefreshAttempt attempt; |
|||
try { |
|||
attempt = refreshTransaction.execute( |
|||
status -> executeRefreshAttempt(claims, hash, capturedNow, ctx)); |
|||
} catch (DataAccessException e) { |
|||
// 并发的另一次刷新正持有该记录的行锁。本次刷新无法完成,
|
|||
// 按重放处置并撤销整个会话(失败关闭),对调用方仍是统一的 401。
|
|||
revokeReplayedSession(claims.userId(), claims.sessionId(), ctx, capturedNow); |
|||
throw new InvalidTokenException(); |
|||
} |
|||
|
|||
if (attempt instanceof RefreshAttempt.Replayed replayed) { |
|||
// 此时刷新尝试事务已经结束:胜出方要么已提交、要么已被本事务回滚释放,
|
|||
// 撤销事务不会再与之互相持锁。
|
|||
revokeReplayedSession(replayed.userId(), replayed.sessionId(), ctx, capturedNow); |
|||
throw new InvalidTokenException(); |
|||
} |
|||
|
|||
return ((RefreshAttempt.Succeeded) attempt).tokens(); |
|||
} |
|||
|
|||
/** |
|||
* 一次刷新尝试:校验记录、原子消费旧 Token、写入后继 Token,全部在同一事务内。 |
|||
* |
|||
* <p>识别为重放时**不抛异常**,而是返回 {@link RefreshAttempt.Replayed} —— |
|||
* 否则事务回滚会连带丢失撤销所需的会话信息。</p> |
|||
*/ |
|||
private RefreshAttempt executeRefreshAttempt(RefreshTokenClaims claims, String hash, |
|||
Instant capturedNow, AuditContext ctx) { |
|||
AuthToken record = authTokenRepository.findTopByTokenHashOrderByIdDesc(hash) |
|||
.orElseThrow(InvalidTokenException::new); |
|||
|
|||
// 声明必须与数据库记录一致,否则视为不可信。
|
|||
if (!Objects.equals(record.getUserId(), claims.userId()) |
|||
|| !Objects.equals(record.getSessionId(), claims.sessionId()) |
|||
|| !Objects.equals(record.getTokenJti(), claims.tokenId())) { |
|||
throw new InvalidTokenException(); |
|||
} |
|||
|
|||
User user = requireUsableUser(claims.userId()); |
|||
|
|||
int consumed = authTokenRepository.consumeIfCurrent( |
|||
record.getId(), record.getVersion(), toLocal(capturedNow), toLocal(capturedNow)); |
|||
|
|||
if (consumed != 1) { |
|||
// 已消费 / 已撤销 / 已过期:交回编排入口做重放处置。
|
|||
return new RefreshAttempt.Replayed(record.getUserId(), record.getSessionId()); |
|||
} |
|||
|
|||
String newAccessJti = UUID.randomUUID().toString(); |
|||
String newRefreshJti = UUID.randomUUID().toString(); |
|||
|
|||
IssuedToken access = jwtTokenProvider.generateAccessToken( |
|||
user.getId(), user.getRole().name(), claims.sessionId(), newAccessJti, capturedNow); |
|||
IssuedToken refresh = jwtTokenProvider.generateRefreshToken( |
|||
user.getId(), claims.sessionId(), newRefreshJti, capturedNow); |
|||
|
|||
authTokenRepository.save(AuthToken.builder() |
|||
.userId(user.getId()) |
|||
.tokenHash(authTokenHasher.hash(refresh.token())) |
|||
.tokenType(AuthToken.TYPE_REFRESH) |
|||
.expiredAt(toLocal(refresh.expiresAt())) |
|||
.revoked(false) |
|||
.sessionId(claims.sessionId()) |
|||
.tokenJti(newRefreshJti) |
|||
.parentTokenId(record.getId()) |
|||
// created_at 与入口捕获的 capturedNow 同源。
|
|||
.createdAt(toLocal(capturedNow)) |
|||
.build()); |
|||
|
|||
auditService.logTokenRefreshed(user.getId(), sessionRef(claims.sessionId()), ctx, toLocal(capturedNow)); |
|||
return new RefreshAttempt.Succeeded( |
|||
new SessionTokens(access.token(), refresh.token(), claims.sessionId())); |
|||
} |
|||
|
|||
/** |
|||
* 重放处置:在独立 REQUIRES_NEW 事务中撤销该会话全部记录并写脱敏审计, |
|||
* 随后**校验该会话已无活动 Token**(含并发胜出方的后继 Token)。 |
|||
* |
|||
* <p>撤销失败不做吞掉处理:先做有限次重试(无 sleep),仍失败则让异常向外传播 —— |
|||
* 宁可暴露基础设施故障,也不能留下一个可继续使用的会话。</p> |
|||
*/ |
|||
private void revokeReplayedSession(Long userId, String sessionId, AuditContext ctx, Instant capturedNow) { |
|||
if (sessionId == null) { |
|||
return; |
|||
} |
|||
for (int attempt = 0; attempt < MAX_REPLAY_REVOKE_ATTEMPTS; attempt++) { |
|||
replayTransaction.execute(status -> { |
|||
authTokenRepository.revokeSession(userId, sessionId, toLocal(capturedNow), REASON_REPLAY); |
|||
auditService.logRefreshReuseDetected(userId, sessionRef(sessionId), ctx, toLocal(capturedNow)); |
|||
auditService.logSessionRevoked(userId, sessionRef(sessionId), REASON_REPLAY, ctx, toLocal(capturedNow)); |
|||
return null; |
|||
}); |
|||
if (authTokenRepository.countActiveSessionTokens(userId, sessionId, toLocal(capturedNow)) <= 0) { |
|||
return; |
|||
} |
|||
// 胜出方的后继 Token 可能在本轮撤销之后才提交,重试一轮覆盖它。
|
|||
// 固定安全文本:不记录 userId、sessionId 或 Token。
|
|||
log.warn("replayed session still had active tokens; retrying revocation"); |
|||
} |
|||
throw new IllegalStateException("replayed session could not be revoked"); |
|||
} |
|||
|
|||
/** 重放撤销的重试上限(无 sleep;正常情况下第一轮即成功)。 */ |
|||
private static final int MAX_REPLAY_REVOKE_ATTEMPTS = 3; |
|||
|
|||
// ── 登出:撤销当前会话 ───────────────────────────────────────
|
|||
|
|||
/** |
|||
* 撤销当前会话(幂等)。重复登出不报错,也不影响其他设备会话。 |
|||
*/ |
|||
@Transactional |
|||
public void logout(Long userId, String sessionId, Instant capturedNow, AuditContext ctx) { |
|||
if (userId == null || sessionId == null || sessionId.isBlank()) { |
|||
throw new InvalidTokenException(); |
|||
} |
|||
authTokenRepository.revokeSession(userId, sessionId, toLocal(capturedNow), REASON_LOGOUT); |
|||
auditService.logSessionRevoked(userId, sessionRef(sessionId), REASON_LOGOUT, ctx, toLocal(capturedNow)); |
|||
} |
|||
|
|||
// ── 全局吊销 ─────────────────────────────────────────────────
|
|||
|
|||
/** |
|||
* 撤销该用户全部会话(账号注销完成、用户禁用等)。 |
|||
* |
|||
* @return 受影响行数 |
|||
*/ |
|||
@Transactional |
|||
public int revokeAllUserSessions(Long userId, String reason, Instant capturedNow) { |
|||
if (userId == null) { |
|||
return 0; |
|||
} |
|||
return authTokenRepository.revokeAllForUser(userId, toLocal(capturedNow), reason); |
|||
} |
|||
|
|||
// ── 内部工具 ─────────────────────────────────────────────────
|
|||
|
|||
private User requireUsableUser(Long userId) { |
|||
return userRepository.findById(userId) |
|||
.filter(this::isUsable) |
|||
.orElseThrow(InvalidTokenException::new); |
|||
} |
|||
|
|||
private boolean isUserUsable(Long userId) { |
|||
Optional<User> user = userRepository.findById(userId); |
|||
return user.isPresent() && isUsable(user.get()); |
|||
} |
|||
|
|||
private boolean isUsable(User user) { |
|||
return user.getActive() != null && user.getActive(); |
|||
} |
|||
|
|||
/** |
|||
* 审计用的会话标识:sessionId 的 HMAC 截断,不可逆且不泄露原值。 |
|||
*/ |
|||
String sessionRef(String sessionId) { |
|||
return authTokenHasher.hash("sid:" + sessionId).substring(0, 16); |
|||
} |
|||
|
|||
static LocalDateTime toLocal(Instant instant) { |
|||
return LocalDateTime.ofInstant(instant, BUSINESS_ZONE); |
|||
} |
|||
} |
|||
@ -0,0 +1,71 @@ |
|||
package com.mydreamjob.auth; |
|||
|
|||
import com.mydreamjob.config.AppJwtProperties; |
|||
import org.springframework.stereotype.Component; |
|||
|
|||
import javax.crypto.Mac; |
|||
import javax.crypto.spec.SecretKeySpec; |
|||
import java.nio.charset.StandardCharsets; |
|||
import java.security.InvalidKeyException; |
|||
import java.security.MessageDigest; |
|||
import java.security.NoSuchAlgorithmException; |
|||
|
|||
/** |
|||
* Refresh Token 摘要器(S02)。 |
|||
* |
|||
* <p>数据库只保存 {@code HMAC-SHA256(token-hash-secret, rawToken)} 的十六进制表示, |
|||
* 不保存 Token 原文,也不使用可逆加密或无密钥的裸摘要。</p> |
|||
* |
|||
* <p>本类**不记录**输入、输出或异常原值。比较使用常量时间比较。</p> |
|||
*/ |
|||
@Component |
|||
public class AuthTokenHasher { |
|||
|
|||
private static final String ALGORITHM = "HmacSHA256"; |
|||
private static final char[] HEX = "0123456789abcdef".toCharArray(); |
|||
|
|||
private final byte[] key; |
|||
|
|||
public AuthTokenHasher(AppJwtProperties properties) { |
|||
this.key = properties.getTokenHashSecret().getBytes(StandardCharsets.UTF_8); |
|||
} |
|||
|
|||
/** |
|||
* 计算摘要,返回固定 64 字符的小写十六进制字符串。 |
|||
* |
|||
* @param rawToken Token 原文;{@code null} 视为空串以保证稳定输出 |
|||
*/ |
|||
public String hash(String rawToken) { |
|||
byte[] input = (rawToken == null ? "" : rawToken).getBytes(StandardCharsets.UTF_8); |
|||
try { |
|||
Mac mac = Mac.getInstance(ALGORITHM); |
|||
mac.init(new SecretKeySpec(key, ALGORITHM)); |
|||
return toHex(mac.doFinal(input)); |
|||
} catch (NoSuchAlgorithmException | InvalidKeyException e) { |
|||
// 固定安全文本:不回显输入、输出或异常原值。
|
|||
throw new IllegalStateException("token hash unavailable"); |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 常量时间比较两个摘要。任一为 {@code null} 时返回 {@code false}。 |
|||
*/ |
|||
public boolean matches(String expectedHash, String actualHash) { |
|||
if (expectedHash == null || actualHash == null) { |
|||
return false; |
|||
} |
|||
return MessageDigest.isEqual( |
|||
expectedHash.getBytes(StandardCharsets.UTF_8), |
|||
actualHash.getBytes(StandardCharsets.UTF_8)); |
|||
} |
|||
|
|||
private static String toHex(byte[] bytes) { |
|||
char[] out = new char[bytes.length * 2]; |
|||
for (int i = 0; i < bytes.length; i++) { |
|||
int v = bytes[i] & 0xFF; |
|||
out[i * 2] = HEX[v >>> 4]; |
|||
out[i * 2 + 1] = HEX[v & 0x0F]; |
|||
} |
|||
return new String(out); |
|||
} |
|||
} |
|||
@ -0,0 +1,16 @@ |
|||
package com.mydreamjob.auth; |
|||
|
|||
/** |
|||
* Token 校验失败(S02)。 |
|||
* |
|||
* <p>异常消息固定为安全文本,**绝不包含 Token 原文、claim 原值或摘要**。 |
|||
* 对外一律表现为统一的 401,不区分“不存在”“已消费”“已撤销”或“重放”。</p> |
|||
*/ |
|||
public class InvalidTokenException extends RuntimeException { |
|||
|
|||
private static final long serialVersionUID = 1L; |
|||
|
|||
public InvalidTokenException() { |
|||
super("invalid token"); |
|||
} |
|||
} |
|||
@ -0,0 +1,17 @@ |
|||
package com.mydreamjob.auth; |
|||
|
|||
import java.time.Instant; |
|||
|
|||
/** |
|||
* 一次 Token 签发的结果(S02)。 |
|||
* |
|||
* <p>{@code token} 为原始 JWT 原文,只在内存中短暂存在;调用方不得将其写入 |
|||
* 数据库、日志、审计或错误响应。</p> |
|||
*/ |
|||
public record IssuedToken( |
|||
String token, |
|||
String sessionId, |
|||
String tokenId, |
|||
Instant issuedAt, |
|||
Instant expiresAt) { |
|||
} |
|||
@ -1,84 +1,207 @@ |
|||
package com.mydreamjob.auth; |
|||
|
|||
import io.jsonwebtoken.*; |
|||
import com.mydreamjob.config.AppJwtProperties; |
|||
import io.jsonwebtoken.Claims; |
|||
import io.jsonwebtoken.JwtException; |
|||
import io.jsonwebtoken.Jwts; |
|||
import io.jsonwebtoken.security.Keys; |
|||
import lombok.extern.slf4j.Slf4j; |
|||
import org.springframework.beans.factory.annotation.Value; |
|||
import org.springframework.stereotype.Component; |
|||
|
|||
import javax.crypto.SecretKey; |
|||
import java.nio.charset.StandardCharsets; |
|||
import java.time.Instant; |
|||
import java.util.Date; |
|||
import java.util.Map; |
|||
import java.util.UUID; |
|||
|
|||
/** |
|||
* JWT 工具类 |
|||
* S02 Access/Refresh Token 签发与校验。 |
|||
* |
|||
* <p>核心变更:</p> |
|||
* <ul> |
|||
* <li>Access 与 Refresh 使用**不同签名密钥**,交叉验签必然失败;</li> |
|||
* <li>强制校验 {@code type}、{@code iss}、{@code aud},不再用“验签通过即有效”判断用途;</li> |
|||
* <li>新 Token 不再包含手机号;</li> |
|||
* <li>{@code iat}/{@code exp} 一律来自调用方传入的 {@code capturedNow},本类不自行取时;</li> |
|||
* <li>过期边界固定为 {@code exp <= capturedNow} 视为过期;</li> |
|||
* <li>对外只返回强类型 record,不暴露 JJWT {@code Claims};</li> |
|||
* <li>异常消息固定为安全文本,不含 Token 或 claim 原值。</li> |
|||
* </ul> |
|||
* |
|||
* <p>本类**不访问数据库**。</p> |
|||
*/ |
|||
@Slf4j |
|||
@Component |
|||
public class JwtTokenProvider { |
|||
|
|||
private final SecretKey secretKey; |
|||
private static final String CLAIM_TYPE = "type"; |
|||
private static final String CLAIM_SID = "sid"; |
|||
private static final String CLAIM_ROLE = "role"; |
|||
private static final String TYPE_ACCESS = "access"; |
|||
private static final String TYPE_REFRESH = "refresh"; |
|||
|
|||
private final SecretKey accessKey; |
|||
private final SecretKey refreshKey; |
|||
private final long expirationMs; |
|||
private final long refreshExpirationMs; |
|||
private final String issuer; |
|||
private final String accessAudience; |
|||
private final String refreshAudience; |
|||
|
|||
public JwtTokenProvider( |
|||
@Value("${app.jwt.secret}") String secret, |
|||
@Value("${app.jwt.expiration-ms:86400000}") long expirationMs, |
|||
@Value("${app.jwt.refresh-expiration-ms:604800000}") long refreshExpirationMs) { |
|||
this.secretKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); |
|||
this.expirationMs = expirationMs; |
|||
this.refreshExpirationMs = refreshExpirationMs; |
|||
public JwtTokenProvider(AppJwtProperties properties) { |
|||
this.accessKey = Keys.hmacShaKeyFor(properties.getAccessSecret().getBytes(StandardCharsets.UTF_8)); |
|||
this.refreshKey = Keys.hmacShaKeyFor(properties.getRefreshSecret().getBytes(StandardCharsets.UTF_8)); |
|||
this.expirationMs = properties.getExpirationMs(); |
|||
this.refreshExpirationMs = properties.getRefreshExpirationMs(); |
|||
this.issuer = properties.getIssuer(); |
|||
this.accessAudience = properties.getAccessAudience(); |
|||
this.refreshAudience = properties.getRefreshAudience(); |
|||
} |
|||
|
|||
public String generateAccessToken(Long userId, String phone, Map<String, Object> claims) { |
|||
return generateToken(userId, phone, claims, expirationMs); |
|||
} |
|||
// ── 签发 ────────────────────────────────────────────────────
|
|||
|
|||
public String generateRefreshToken(Long userId, String phone) { |
|||
return generateToken(userId, phone, Map.of("type", "refresh"), refreshExpirationMs); |
|||
public IssuedToken generateAccessToken(Long userId, String role, String sessionId, |
|||
String tokenId, Instant capturedNow) { |
|||
Instant expiresAt = capturedNow.plusMillis(expirationMs); |
|||
String token = Jwts.builder() |
|||
.subject(String.valueOf(userId)) |
|||
.id(tokenId) |
|||
.claim(CLAIM_TYPE, TYPE_ACCESS) |
|||
.claim(CLAIM_SID, sessionId) |
|||
.claim(CLAIM_ROLE, role) |
|||
.issuer(issuer) |
|||
.audience().add(accessAudience).and() |
|||
.issuedAt(Date.from(capturedNow)) |
|||
.expiration(Date.from(expiresAt)) |
|||
.signWith(accessKey) |
|||
.compact(); |
|||
return new IssuedToken(token, sessionId, tokenId, capturedNow, expiresAt); |
|||
} |
|||
|
|||
private String generateToken(Long userId, String phone, Map<String, Object> claims, long expirationMs) { |
|||
Date now = new Date(); |
|||
Date expiry = new Date(now.getTime() + expirationMs); |
|||
|
|||
return Jwts.builder() |
|||
public IssuedToken generateRefreshToken(Long userId, String sessionId, |
|||
String tokenId, Instant capturedNow) { |
|||
Instant expiresAt = capturedNow.plusMillis(refreshExpirationMs); |
|||
String token = Jwts.builder() |
|||
.subject(String.valueOf(userId)) |
|||
.claim("phone", phone) |
|||
.claims(claims) |
|||
.issuedAt(now) |
|||
.expiration(expiry) |
|||
.signWith(secretKey) |
|||
.id(tokenId) |
|||
.claim(CLAIM_TYPE, TYPE_REFRESH) |
|||
.claim(CLAIM_SID, sessionId) |
|||
.issuer(issuer) |
|||
.audience().add(refreshAudience).and() |
|||
.issuedAt(Date.from(capturedNow)) |
|||
.expiration(Date.from(expiresAt)) |
|||
.signWith(refreshKey) |
|||
.compact(); |
|||
return new IssuedToken(token, sessionId, tokenId, capturedNow, expiresAt); |
|||
} |
|||
|
|||
public Long parseUserId(String token) { |
|||
String subject = Jwts.parser() |
|||
.verifyWith(secretKey) |
|||
.build() |
|||
.parseSignedClaims(token) |
|||
.getPayload() |
|||
.getSubject(); |
|||
return Long.parseLong(subject); |
|||
// ── 校验 ────────────────────────────────────────────────────
|
|||
|
|||
/** |
|||
* 校验并解析 Access Token。任何失败(签名、类型、issuer、audience、subject、 |
|||
* sid、jti、role、过期)统一抛出 {@link InvalidTokenException}。 |
|||
*/ |
|||
public AccessTokenClaims parseAndValidateAccessToken(String rawToken, Instant capturedNow) { |
|||
Claims claims = parse(rawToken, accessKey, TYPE_ACCESS, accessAudience, capturedNow); |
|||
Long userId = requirePositiveSubject(claims); |
|||
String sid = requireCanonicalUuid(claims.get(CLAIM_SID, String.class)); |
|||
String jti = requireCanonicalUuid(claims.getId()); |
|||
String role = claims.get(CLAIM_ROLE, String.class); |
|||
if (role == null || role.isBlank()) { |
|||
throw new InvalidTokenException(); |
|||
} |
|||
return new AccessTokenClaims(userId, sid, jti, role, |
|||
toInstant(claims.getIssuedAt()), toInstant(claims.getExpiration())); |
|||
} |
|||
|
|||
public boolean validateToken(String token) { |
|||
/** |
|||
* 校验并解析 Refresh Token。Refresh **不含** role,也不接受 role 参与判断。 |
|||
*/ |
|||
public RefreshTokenClaims parseAndValidateRefreshToken(String rawToken, Instant capturedNow) { |
|||
Claims claims = parse(rawToken, refreshKey, TYPE_REFRESH, refreshAudience, capturedNow); |
|||
Long userId = requirePositiveSubject(claims); |
|||
String sid = requireCanonicalUuid(claims.get(CLAIM_SID, String.class)); |
|||
String jti = requireCanonicalUuid(claims.getId()); |
|||
return new RefreshTokenClaims(userId, sid, jti, |
|||
toInstant(claims.getIssuedAt()), toInstant(claims.getExpiration())); |
|||
} |
|||
|
|||
private Claims parse(String rawToken, SecretKey key, String expectedType, |
|||
String expectedAudience, Instant capturedNow) { |
|||
if (rawToken == null || rawToken.isBlank()) { |
|||
throw new InvalidTokenException(); |
|||
} |
|||
Claims claims; |
|||
try { |
|||
Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token); |
|||
return true; |
|||
} catch (JwtException e) { |
|||
log.debug("JWT validation failed: {}", e.getMessage()); |
|||
return false; |
|||
claims = Jwts.parser() |
|||
.verifyWith(key) |
|||
// 过期判定由下面显式完成:这里把解析时钟固定在很早的时间,
|
|||
// 避免依赖 jjwt 内部边界语义,从而严格执行本文档的契约。
|
|||
.clock(() -> Date.from(Instant.EPOCH)) |
|||
.require(CLAIM_TYPE, expectedType) |
|||
.requireIssuer(issuer) |
|||
.requireAudience(expectedAudience) |
|||
.build() |
|||
.parseSignedClaims(rawToken) |
|||
.getPayload(); |
|||
} catch (JwtException | IllegalArgumentException e) { |
|||
// 固定安全文本:不回显 Token、claim 原值或异常消息。
|
|||
throw new InvalidTokenException(); |
|||
} |
|||
|
|||
// 时间契约(设计第七节):iat <= capturedNow < exp。
|
|||
//
|
|||
// 过期判定:exp 缺失或 exp <= capturedNow 一律拒绝。
|
|||
Date expiration = claims.getExpiration(); |
|||
if (expiration == null || !expiration.toInstant().isAfter(capturedNow)) { |
|||
throw new InvalidTokenException(); |
|||
} |
|||
|
|||
// 签发时间判定:iat 缺失或 iat > capturedNow 一律拒绝。
|
|||
// 缺 iat 的 Token 无法界定有效窗口;iat 在未来说明 Token 被伪造或时钟被绕过。
|
|||
Date issuedAt = claims.getIssuedAt(); |
|||
if (issuedAt == null || issuedAt.toInstant().isAfter(capturedNow)) { |
|||
throw new InvalidTokenException(); |
|||
} |
|||
|
|||
return claims; |
|||
} |
|||
|
|||
private Long requirePositiveSubject(Claims claims) { |
|||
String subject = claims.getSubject(); |
|||
if (subject == null || subject.isBlank()) { |
|||
throw new InvalidTokenException(); |
|||
} |
|||
long userId; |
|||
try { |
|||
userId = Long.parseLong(subject); |
|||
} catch (NumberFormatException e) { |
|||
throw new InvalidTokenException(); |
|||
} |
|||
if (userId <= 0) { |
|||
throw new InvalidTokenException(); |
|||
} |
|||
return userId; |
|||
} |
|||
|
|||
public boolean isTokenExpired(String token) { |
|||
private String requireCanonicalUuid(String value) { |
|||
if (value == null || value.isBlank()) { |
|||
throw new InvalidTokenException(); |
|||
} |
|||
UUID parsed; |
|||
try { |
|||
return Jwts.parser().verifyWith(secretKey).build() |
|||
.parseSignedClaims(token).getPayload().getExpiration().before(new Date()); |
|||
} catch (JwtException e) { |
|||
return true; |
|||
parsed = UUID.fromString(value); |
|||
} catch (IllegalArgumentException e) { |
|||
throw new InvalidTokenException(); |
|||
} |
|||
// 只接受规范小写连字符形式,避免宽松解析放行异常写法。
|
|||
if (!parsed.toString().equals(value)) { |
|||
throw new InvalidTokenException(); |
|||
} |
|||
return value; |
|||
} |
|||
|
|||
private static Instant toInstant(Date date) { |
|||
return date == null ? null : date.toInstant(); |
|||
} |
|||
} |
|||
|
|||
@ -0,0 +1,16 @@ |
|||
package com.mydreamjob.auth; |
|||
|
|||
import java.time.Instant; |
|||
|
|||
/** |
|||
* 已通过完整校验的 Refresh Token 声明(S02)。 |
|||
* |
|||
* <p>Refresh Token 不含 role,也不含手机号。</p> |
|||
*/ |
|||
public record RefreshTokenClaims( |
|||
Long userId, |
|||
String sessionId, |
|||
String tokenId, |
|||
Instant issuedAt, |
|||
Instant expiresAt) { |
|||
} |
|||
@ -0,0 +1,25 @@ |
|||
package com.mydreamjob.auth; |
|||
|
|||
import jakarta.servlet.http.HttpServletRequest; |
|||
import org.springframework.security.web.authentication.WebAuthenticationDetails; |
|||
|
|||
/** |
|||
* 携带已验证会话标识的认证详情(S02)。 |
|||
* |
|||
* <p>{@code sessionId} 只可能由 {@link JwtAuthenticationFilter} 在 Access Token |
|||
* 通过完整校验(签名、类型、issuer、audience、claim 与服务端会话状态)之后写入, |
|||
* 因此可作为 {@code /auth/logout} 的可信会话来源,不接受任何客户端参数。</p> |
|||
*/ |
|||
public class SessionAuthenticationDetails extends WebAuthenticationDetails { |
|||
|
|||
private final String sessionId; |
|||
|
|||
public SessionAuthenticationDetails(HttpServletRequest request, String sessionId) { |
|||
super(request); |
|||
this.sessionId = sessionId; |
|||
} |
|||
|
|||
public String getSessionId() { |
|||
return sessionId; |
|||
} |
|||
} |
|||
@ -0,0 +1,21 @@ |
|||
package com.mydreamjob.auth.dto; |
|||
|
|||
import io.swagger.v3.oas.annotations.media.Schema; |
|||
import jakarta.validation.constraints.NotBlank; |
|||
import jakarta.validation.constraints.Size; |
|||
import lombok.Data; |
|||
|
|||
/** |
|||
* S02 刷新请求(白名单 DTO)。 |
|||
* |
|||
* <p>只允许 {@code refreshToken} 一个字段;Token 不接受 query/path 参数传递。</p> |
|||
*/ |
|||
@Data |
|||
@Schema(name = "RefreshTokenRequest", description = "刷新请求") |
|||
public class RefreshTokenRequest { |
|||
|
|||
@NotBlank(message = "refreshToken is required") |
|||
@Size(max = 4096, message = "refreshToken is too long") |
|||
@Schema(description = "登录或上一次刷新返回的 Refresh Token", requiredMode = Schema.RequiredMode.REQUIRED) |
|||
private String refreshToken; |
|||
} |
|||
@ -0,0 +1,15 @@ |
|||
package com.mydreamjob.auth.dto; |
|||
|
|||
import io.swagger.v3.oas.annotations.media.Schema; |
|||
|
|||
/** |
|||
* S02 刷新响应(白名单 DTO)。 |
|||
* |
|||
* <p>只包含新 Token 对与有效期;不包含 userId、手机号、sessionId 或任何数据库字段。</p> |
|||
*/ |
|||
@Schema(name = "TokenPairResponse", description = "刷新后的 Token 对") |
|||
public record TokenPairResponse( |
|||
@Schema(description = "新的 Access Token") String accessToken, |
|||
@Schema(description = "新的 Refresh Token") String refreshToken, |
|||
@Schema(description = "Access Token 有效期(秒)") long expiresIn) { |
|||
} |
|||
@ -0,0 +1,23 @@ |
|||
package com.mydreamjob.auth.sms; |
|||
|
|||
import lombok.extern.slf4j.Slf4j; |
|||
|
|||
/** |
|||
* Mock 短信发送实现。 |
|||
* |
|||
* <p>由 {@link SmsSenderConfiguration} 在 {@code app.sms.mock.enabled} 显式解析为 |
|||
* {@code true} 时装配(不再使用条件注解,以保证任何取值下恰好一个 Bean)。</p> |
|||
* |
|||
* <p>不接入真实短信网关,仅记录一条固定文本的发送事件。 |
|||
* <strong>不得</strong>把验证码或完整手机号写入日志。</p> |
|||
*/ |
|||
@Slf4j |
|||
public class MockSmsCodeSender implements SmsCodeSender { |
|||
|
|||
@Override |
|||
public void sendVerificationCode(String phone, String purpose, String code) { |
|||
// 固定安全文本:不包含验证码、手机号、Token 或任何配置细节。
|
|||
// 本地开发如需验证码,请从 sms_verification_codes 表读取(见 docs 说明),不要重新写回日志。
|
|||
log.info("SMS verification code generated (mock sender)"); |
|||
} |
|||
} |
|||
@ -0,0 +1,24 @@ |
|||
package com.mydreamjob.auth.sms; |
|||
|
|||
/** |
|||
* 短信验证码发送抽象。 |
|||
* |
|||
* <p>由 {@code app.sms.mock.enabled} 决定加载哪一个实现: |
|||
* <ul> |
|||
* <li>{@code true} → {@link MockSmsCodeSender}(本地/测试环境,不调用外部网关)</li> |
|||
* <li>{@code false} 或未配置 → {@link UnavailableSmsCodeSender}(安全失败,不伪造成功)</li> |
|||
* </ul> |
|||
* |
|||
* <p>实现方<strong>禁止</strong>把验证码或完整手机号写入任何级别的日志。 |
|||
*/ |
|||
public interface SmsCodeSender { |
|||
|
|||
/** |
|||
* 发送验证码。 |
|||
* |
|||
* @param phone 接收手机号(实现方不得写入日志) |
|||
* @param purpose 验证码用途 |
|||
* @param code 验证码(实现方不得写入日志) |
|||
*/ |
|||
void sendVerificationCode(String phone, String purpose, String code); |
|||
} |
|||
@ -0,0 +1,37 @@ |
|||
package com.mydreamjob.auth.sms; |
|||
|
|||
import org.springframework.beans.factory.annotation.Value; |
|||
import org.springframework.context.annotation.Bean; |
|||
import org.springframework.context.annotation.Configuration; |
|||
|
|||
/** |
|||
* 短信发送实现装配(单 Bean 方案)。 |
|||
* |
|||
* <p>由本配置类<strong>始终且仅</strong>产生一个 {@link SmsCodeSender},从而彻底避免 |
|||
* “两个互斥 {@code @ConditionalOnProperty} 在空值/空白值下都不匹配 → 0 个 Bean → |
|||
* 模糊 Spring 装配错误导致启动失败”的问题。</p> |
|||
* |
|||
* <p>取值语义(大小写不敏感,两侧空白忽略):</p> |
|||
* <ul> |
|||
* <li>{@code true} → {@link MockSmsCodeSender}</li> |
|||
* <li>{@code false} / 缺失 / 空字符串 / 仅空白 / 其他非法值 → {@link UnavailableSmsCodeSender}</li> |
|||
* </ul> |
|||
* |
|||
* <p>安全约束:只有显式 {@code true} 才启用 Mock;非法值一律安全回退到不可用实现, |
|||
* 既不启用 Mock,也不进入发送流程。本类不输出配置原值到日志。</p> |
|||
*/ |
|||
@Configuration |
|||
public class SmsSenderConfiguration { |
|||
|
|||
/** 显式启用 Mock 的唯一取值。 */ |
|||
private static final String MOCK_ENABLED_VALUE = "true"; |
|||
|
|||
@Bean |
|||
public SmsCodeSender smsCodeSender( |
|||
@Value("${app.sms.mock.enabled:false}") String rawEnabled) { |
|||
if (rawEnabled != null && MOCK_ENABLED_VALUE.equalsIgnoreCase(rawEnabled.trim())) { |
|||
return new MockSmsCodeSender(); |
|||
} |
|||
return new UnavailableSmsCodeSender(); |
|||
} |
|||
} |
|||
@ -0,0 +1,25 @@ |
|||
package com.mydreamjob.auth.sms; |
|||
|
|||
import com.mydreamjob.common.exception.BusinessException; |
|||
import com.mydreamjob.common.exception.ErrorCode; |
|||
|
|||
/** |
|||
* 不可用短信发送实现。 |
|||
* |
|||
* <p>由 {@link SmsSenderConfiguration} 在 {@code app.sms.mock.enabled} 为 false、缺失、 |
|||
* 空值、仅空白或其他非法值时装配(不再使用条件注解,以保证任何取值下恰好一个 Bean)。</p> |
|||
* |
|||
* <p>当前未接入真实短信网关,因此本实现<strong>安全失败</strong>: |
|||
* 抛出既有的“服务不可用”业务异常,绝不返回虚假的“发送成功”。 |
|||
* 异常消息为固定安全文本,不含手机号、验证码或任何配置细节。</p> |
|||
*/ |
|||
public class UnavailableSmsCodeSender implements SmsCodeSender { |
|||
|
|||
/** 固定安全文本:不含手机号、验证码、Token、内网地址或配置项。 */ |
|||
static final String SAFE_MESSAGE = "sms sender unavailable"; |
|||
|
|||
@Override |
|||
public void sendVerificationCode(String phone, String purpose, String code) { |
|||
throw new BusinessException(ErrorCode.SERVICE_UNAVAILABLE, SAFE_MESSAGE); |
|||
} |
|||
} |
|||
Some files were not shown because too many files changed in this diff
Loading…
Reference in new issue