fix: harden authentication, privacy, search, and scoring infrastructure #1

Merged
zhangshun merged 28 commits from refactor/r01-structure-performance into master 1 month ago
  1. 6
      docs/acceptance-week1.md
  2. 161
      docs/account-deletion-test-isolation-fix-20260917.md
  3. 69
      docs/api-v0.1.md
  4. 5
      docs/backend-development-roadmap.md
  5. 224
      docs/def-01-job-keyword-normalization-remediation-20260920.md
  6. 2250
      docs/final-independent-acceptance-20260918.md
  7. 83
      docs/frontend-integration.md
  8. 2
      docs/offer-api.md
  9. 237
      docs/openapi-rc.json
  10. 26
      docs/phase1-scope-and-acceptance.md
  11. 29
      docs/privacy-settings-api.md
  12. 303
      docs/q01-job-search-mysql-explain-evidence.md
  13. 190
      docs/q01-job-search-real-query-design.md
  14. 265
      docs/q01-job-search-real-query-implementation-instruction.md
  15. 265
      docs/q01-job-search-real-query-implementation-report.md
  16. 110
      docs/r01-a-request-context-implementation-instruction.md
  17. 284
      docs/r01-a-request-context-implementation-report.md
  18. 197
      docs/r01-a2-independent-verification-20260917.md
  19. 218
      docs/r01-a2-request-context-migration-report.md
  20. 221
      docs/r01-a3-independent-verification-20260917.md
  21. 276
      docs/r01-a3-request-context-migration-report.md
  22. 72
      docs/r01-a4-independent-verification-20260917.md
  23. 278
      docs/r01-a4-request-context-migration-report.md
  24. 279
      docs/r01-a5-independent-verification-20260917.md
  25. 79
      docs/r01-a5-request-context-migration-report.md
  26. 128
      docs/r01-a6-fairness-fixture-isolation-fix-20260917.md
  27. 359
      docs/r01-a6-final-request-context-migration-report.md
  28. 492
      docs/r01-a6-independent-verification-20260917.md
  29. 497
      docs/r01-b-authorization-exception-mapping-report.md
  30. 19
      docs/r01-b-error-status-mapping-instruction.md
  31. 46
      docs/r01-b-error-status-mapping-report.md
  32. 617
      docs/r01-b-independent-verification-20260918.md
  33. 183
      docs/r01-c-fairness-fixture-isolation-fix-20260918.md
  34. 347
      docs/r01-c-independent-verification-20260918.md
  35. 17
      docs/r01-c-pure-calculation-instruction.md
  36. 24
      docs/r01-c-pure-calculation-report.md
  37. 437
      docs/r01-c-scoring-recommendation-rules-refactor-report.md
  38. 25
      docs/r01-d1-percentile-performance-report.md
  39. 29
      docs/r01-d2-personal-data-query-report.md
  40. 48
      docs/r01-d3-conversation-unread-query-report.md
  41. 87
      docs/r01-d4-1-scoring-materialization-implementation-report.md
  42. 277
      docs/r01-d4-2-blocker-remediation-20260916.md
  43. 234
      docs/r01-d4-2-claim-fence-remediation-20260916.md
  44. 188
      docs/r01-d4-2-claim-token-final-verification-20260916.md
  45. 275
      docs/r01-d4-2-final-independent-incremental-verification-20260916.md
  46. 57
      docs/r01-d4-2-get-scoring-readonly-implementation-report.md
  47. 656
      docs/r01-d4-2-independent-mysql-acceptance-20260916.md
  48. 246
      docs/r01-d4-get-scoring-snapshot-migration-design.md
  49. 352
      docs/r01-e-batch-query-unread-count-performance-report.md
  50. 412
      docs/r01-e-independent-verification-20260918.md
  51. 81
      docs/r01-structure-performance-design.md
  52. 47
      docs/resume-api.md
  53. 483
      docs/s01-independent-mysql-acceptance-20260911.md
  54. 288
      docs/s01-independent-mysql-acceptance-20260914.md
  55. 786
      docs/s01-remediation-f1f2f3-20260911.md
  56. 674
      docs/s02-auth-token-isolation-design.md
  57. 724
      docs/s02-auth-token-isolation-implementation-instruction.md
  58. 202
      docs/s02-independent-mysql-acceptance-20260914.md
  59. 167
      docs/s03-independent-mysql-acceptance-20260915.md
  60. 167
      docs/s03-production-security-and-resume-privacy-design.md
  61. 270
      docs/s03-production-security-and-resume-privacy-implementation-instruction.md
  62. 225
      docs/smoke-test.sh
  63. 144
      docs/tests/smoke-test-schema-contract-test.sh
  64. 548
      docs/tests/smoke-test-sms-code-test.sh
  65. 75
      docs/verify_openapi.py
  66. 5
      mydreamjob-server/pom.xml
  67. 28
      mydreamjob-server/src/main/java/com/mydreamjob/ability/AbilityAttemptService.java
  68. 42
      mydreamjob-server/src/main/java/com/mydreamjob/ability/AbilityController.java
  69. 93
      mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationController.java
  70. 9
      mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationService.java
  71. 3
      mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationStatusHistoryRepository.java
  72. 132
      mydreamjob-server/src/main/java/com/mydreamjob/application/CompanyResumeAccessPolicy.java
  73. 72
      mydreamjob-server/src/main/java/com/mydreamjob/application/CompanyResumeService.java
  74. 10
      mydreamjob-server/src/main/java/com/mydreamjob/application/dto/CompanyResumeFile.java
  75. 21
      mydreamjob-server/src/main/java/com/mydreamjob/assessment/AssessmentAttemptService.java
  76. 44
      mydreamjob-server/src/main/java/com/mydreamjob/assessment/AssessmentController.java
  77. 5
      mydreamjob-server/src/main/java/com/mydreamjob/audit/AuditLog.java
  78. 46
      mydreamjob-server/src/main/java/com/mydreamjob/audit/AuditService.java
  79. 17
      mydreamjob-server/src/main/java/com/mydreamjob/auth/AccessTokenClaims.java
  80. 105
      mydreamjob-server/src/main/java/com/mydreamjob/auth/AdminBootstrapCreator.java
  81. 119
      mydreamjob-server/src/main/java/com/mydreamjob/auth/AdminBootstrapRunner.java
  82. 74
      mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthService.java
  83. 332
      mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthSessionService.java
  84. 48
      mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthToken.java
  85. 71
      mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthTokenHasher.java
  86. 99
      mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthTokenRepository.java
  87. 16
      mydreamjob-server/src/main/java/com/mydreamjob/auth/InvalidTokenException.java
  88. 17
      mydreamjob-server/src/main/java/com/mydreamjob/auth/IssuedToken.java
  89. 59
      mydreamjob-server/src/main/java/com/mydreamjob/auth/JwtAuthenticationFilter.java
  90. 215
      mydreamjob-server/src/main/java/com/mydreamjob/auth/JwtTokenProvider.java
  91. 16
      mydreamjob-server/src/main/java/com/mydreamjob/auth/RefreshTokenClaims.java
  92. 25
      mydreamjob-server/src/main/java/com/mydreamjob/auth/SessionAuthenticationDetails.java
  93. 35
      mydreamjob-server/src/main/java/com/mydreamjob/auth/SmsVerificationService.java
  94. 21
      mydreamjob-server/src/main/java/com/mydreamjob/auth/dto/RefreshTokenRequest.java
  95. 15
      mydreamjob-server/src/main/java/com/mydreamjob/auth/dto/TokenPairResponse.java
  96. 23
      mydreamjob-server/src/main/java/com/mydreamjob/auth/sms/MockSmsCodeSender.java
  97. 24
      mydreamjob-server/src/main/java/com/mydreamjob/auth/sms/SmsCodeSender.java
  98. 37
      mydreamjob-server/src/main/java/com/mydreamjob/auth/sms/SmsSenderConfiguration.java
  99. 25
      mydreamjob-server/src/main/java/com/mydreamjob/auth/sms/UnavailableSmsCodeSender.java
  100. 30
      mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/ApplicationMatchController.java

6
docs/acceptance-week1.md

@ -91,7 +91,7 @@ curl http://localhost:8080/api/v1/info
# 发送验证码
curl -X POST "http://localhost:8080/api/v1/auth/sms/send?phone=13800000001&purpose=login"
# Mock 登录(从日志获取验证码)
# Mock 登录(验证码不输出到日志;本地按 docs/frontend-integration.md §3 受控取得)
curl -X POST http://localhost:8080/api/v1/auth/login \
-H "Content-Type: application/json" \
-d '{"phone":"13800000001","code":"<验证码>"}'
@ -145,11 +145,11 @@ mvn spring-boot:run -Dspring-boot.run.profiles=dev \
| STUDENT | 13800000002 | 学生测试 |
| COMPANY | 13800000003 | 企业测试 |
> 注: 使用 Mock 短信验证码登录,验证码从应用日志中获取
> 注: 使用 Mock 短信验证码登录。应用日志**不再输出验证码**,本地按 `docs/frontend-integration.md` §3 的受控方式取得(需 `SMS_MOCK_ENABLED=true` 且 `ALLOW_TEST_OTP_DB_READ=true`);生产环境禁止查询验证码表。
## 已知限制
1. **短信验证码 Mock**: 验证码打印到日志,不发送真实短信
1. **短信验证码 Mock**: 应用日志**不输出验证码**,不发送真实短信;本地取码见 `docs/frontend-integration.md` §3(验证码当前仍为数据库明文存储)
2. **业务模块**: 岗位、投递、聊天、评分等模块仅定义接口骨架
3. **RabbitMQ 监听**: 队列已定义但未实现消费者(Week 2+)
4. **Actuator 健康**: RabbitMQ 健康检查未配置(仅 ping)

161
docs/account-deletion-test-isolation-fix-20260917.md

@ -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 功能分支)
---
> 账号注销测试夹具隔离修复完成;测试交叉污染已消除,生产代码零改动。

69
docs/api-v0.1.md

@ -24,7 +24,7 @@ Query: phone=13800000001, purpose=login
**限制:**
- 60秒内同一手机号只能发送1次
- 5次错误后锁定5分钟
- Mock环境验证码打印到日志
- Mock 环境验证码**不输出到日志**;本地按 `docs/frontend-integration.md` §3 的受控方式取得(生产禁止查表)
### 2. Mock 登录
```
@ -169,12 +169,65 @@ http://localhost:8080/swagger-ui.html
http://localhost:8080/v3/api-docs
```
## 已知限制 (Week 1)
### 4. 刷新 Token(S02)
1. 短信验证码为 Mock 实现,验证码打印到日志,不发送真实短信
```
POST /api/v1/auth/refresh
Content-Type: application/json
{ "refreshToken": "<refreshToken>" }
```
- 匿名可调用;是否成功取决于 Refresh Token 校验结果。
- **只接受 Refresh Token**;把 Access Token 传进来一律 401。
- 请求体只允许 `refreshToken` 一个字段;Token 不接受 query/path 参数。
- 成功后返回新的 Token 对,**旧 Refresh Token 立即失效**(强制轮换)。
- 已消费/已撤销的 Refresh Token 再次提交 → 401,并撤销该登录会话(重放处置)。
- 失败响应不说明 Token 是"不存在""已消费"还是"已撤销"。
**成功响应:**
```json
{
"code": 200,
"data": {
"accessToken": "eyJhbGciOi...",
"refreshToken": "eyJhbGciOi...",
"expiresIn": 86400
},
"requestId": "<request-id>"
}
```
### 5. 登出(S02)
```
POST /api/v1/auth/logout
Authorization: Bearer <accessToken>
```
- 必须已认证;**无请求体**。
- 身份与会话来自 Access Token,不接受客户端传入 `userId`/`sessionId`/`refreshToken`。
- 只撤销**当前登录会话**,其他设备会话不受影响。
- 重复登出按幂等处理;成功响应不包含任何 Token。
## Token 与会话模型(S02)
| Token | 用途 | 说明 |
|---|---|---|
| Access Token | 普通业务接口的 `Authorization` 头 | 带 `type=access`,有效期 24h;每次请求都会校验服务端会话状态 |
| Refresh Token | 仅用于 `POST /auth/refresh` | 带 `type=refresh`,有效期 7 天;不落库,数据库只保存其 HMAC-SHA256 摘要 |
- Access 与 Refresh **使用不同签名密钥**,交叉验签必然失败。
- 每次登录创建独立的**登录会话**(`sessionId`);同一用户可同时存在多个会话。
- 登出只撤销当前会话;账号注销完成、用户禁用会撤销该用户**全部会话**。
- 本版**不再兼容**无 `type` 的旧 Token(所有旧 Token 失效,用户需重新登录一次)。
- Token 原文不进入数据库、日志、审计、URL 或错误响应。
## 已知限制
1. 短信验证码为 Mock 实现,应用日志**不输出验证码**,不发送真实短信;未接入真实供应商时发码返回“服务不可用”
2. 无企业注册/认证流程
3. 无岗位、投递、聊天、评分等业务功能
4. 无 Token 刷新接口(Week 2+)
5. 无密码修改/重置功能
6. RabbitMQ 未实现消息监听器(仅队列定义)
7. Actuator 健康检查部分依赖(Redis/RabbitMQ)在未启动时返回 DOWN
3. 无密码修改/重置功能
4. 未提供查看全部登录设备、远程踢出单个设备、Token introspection 等接口
5. RabbitMQ 未实现消息监听器(仅队列定义)
6. Actuator 健康检查部分依赖(Redis/RabbitMQ)在未启动时返回 DOWN

5
docs/backend-development-roadmap.md

@ -11,6 +11,11 @@
| V43 | 独立代码复验、真实 MySQL/Docker 验收及 Git 检查点已完成(cf39737) | N01 已关闭版本内小样本补集泄露;30/300/3000 查询预算通过,EXPLAIN 扩容风险保留观察 |
| 评分规则解释与合理性 | N02 独立代码复验、真实 MySQL/Docker 验收及 Git 检查点已完成(dbd27e6) | 保持分数、权重、排名和企业评分契约不变 |
| 消息撤回与会话一致性 | N07 定向整改完成并通过自动化与干净构建,待独立代码复验与真实 MySQL/Docker 验收 | 两分钟窗口、发送者权限、正文覆盖、幂等并发、DIRECT/邀约隐私均已锁定 |
| S01 认证安全整改 | 独立代码复验与真实 MySQL/Docker 黑盒全部通过(`b5084fd`) | 保持验证码受控取码、双开关与 `/jobs` 认证边界不变 |
| S02 Access/Refresh Token 隔离 | 已实现并完成自动化验证与干净构建,待独立代码复验及真实 MySQL/Docker 黑盒验收 | 类型隔离、独立密钥、轮换、重放处置、会话吊销已锁定;OpenAPI 175/213 |
| S03 生产配置与简历隐私 | 已实现并完成自动化验证与干净构建,待独立代码复验及真实 MySQL/Docker 黑盒验收 | V43 禁用原始指纹种子账号;管理员初始化默认关闭且幂等;企业简历下载按隐私决策表授权;Actuator 隔离到管理端口;OpenAPI 176/214 |
| Q01 岗位搜索真实查询覆盖(C02 证据补充) | 已完成实现、跨库一致性整改、独立复验及 Git 检查点(`77dc624`)。新增 54 条真实库测试:H2 Repository 21、H2 Service 9、H2 副作用 4、MySQL 8.4 Specification 15、MySQL 副作用 4、MySQL EXPLAIN 1;全量 1890/1890 | OpenAPI 保持 176/214;前导通配符、filesort 与真实规模执行计划移交 R01 |
| R01 结构与性能收束 | R01-A `0b5b849`、R01-B `15bd98b`、R01-C `b68519e`、R01-D1 `cf0c71e`、R01-D2 `8c40fd4` 已建本地检查点;**R01-D3 已完成**会话列表未读数 N+1 收束;**R01-D4 已冻结并推送**(提交 `52c8026`,远端 `origin/refactor/r01-structure-performance`);**R01-A2 请求身份与 requestId 收束(第二批 8 个 Controller)已完成独立复验、提交并推送**(提交 `72b82a3`);**R01-A3(第三批 8 个 Controller)已完成独立复验、提交并推送**(提交 `d5c3712`);**账号注销测试夹具隔离修复已提交并推送**(提交 `35baa84`);**R01-A4(第四批 8 个 Controller)已完成实施、独立复验、提交并推送**(提交 `0226977`);**R01-A5(第五批 8 个 Controller)已完成独立复验、提交并推送**(提交 `534afe68`);**R01-A6(最终批 5 个 Controller)已完成实施与独立复验**(实施报告 `docs/r01-a6-final-request-context-migration-report.md`;独立复验报告 `docs/r01-a6-independent-verification-20260917.md`)。**请求上下文收束 A 系列到此结束,不再拆分 A7/A8**;下一阶段为 R01-B。历史 R01-D4 迁移方案见 `docs/r01-d4-get-scoring-snapshot-migration-design.md` | R01-D4:三 GET 评分接口已停写快照,改为写侧登记 + 后台 Worker + 补偿扫描物化,V44/V45 已并入真实 MySQL;claim fence 采用不可复用 claim token(V45)。**R01-A2 至 A6 共迁移 37 个 Controller**(A2–A5 各 8 个 + A6 5 个),身份与 requestId 统一到 CurrentUserAccessor / RequestIdAccessor,非法主体由「400/500」统一为 401;角色、归属与 403/404 契约未变。A2–A5 各删除 **16 个**私有解析方法,A6 删除 **6 个**。**请求上下文收束终态:业务 Controller 身份解析 0、requestId 重复解析 0**;剩余命中仅为基础设施(身份 3 文件:CurrentUserAccessor / JwtAuthenticationFilter / DeletionGuardInterceptor;requestId 5 文件:AuditContext / GlobalExceptionHandler / RequestIdAccessor / RequestLoggingFilter / DeletionGuardInterceptor),**A7/A8 不再设置**。**A6 后夹具问题已关闭**:`FairnessMonitoringControllerTest` 曾用 `userId = 1/2` 与 V1_1 种子账号(被 V43 置为 inactive)冲突而在单跑时全部 401,已改为独占用户(`9_310_001/2`)并按精确 ID 清理,单跑稳定通过;新增反证测试 `FairnessMonitoringFixtureIsolationTest`(**已提交并推送,提交 `84fe9f5`**;报告 `docs/r01-a6-fairness-fixture-isolation-fix-20260917.md`)。**R01-B 角色授权与异常映射收束已完成独立复验、提交并推送**(提交 `70b9e10`;两个 Controller 测试夹具缺陷已由独立纯测试提交 `9954600` 修复,报告 `docs/r01-b-authorization-exception-mapping-report.md`、`docs/r01-b-independent-verification-20260918.md`):新增 `security/UserRoleGuard` 与 `common/exception/ErrorHttpStatusRegistry`,54 个调用点改走公共 guard,GlobalExceptionHandler 不再持有映射表。**R01-C 评分与推荐规则结构收束已实施完成,待独立代码复验**(报告 `docs/r01-c-scoring-recommendation-rules-refactor-report.md`):overviewStatus 经核验已是唯一实现(由 `b68519e` 完成);`JobRecommendationService.preferenceScore()` 已是编排方法;本轮新增 `candidatescore/rules/CandidateScoringRuleSet`(版本化强类型规则源,高校名单/阈值/权重),并把 `TalentDiscoveryService` 内联的第二套 30/20/20/15/15 偏好规则收束到 `JobPreferenceScoreCalculator`(规则实现处 2 → 1)。**评分/Fairness 组合测试污染已关闭**:`CandidateScoring*` 中 5 个测试类以当前 period 物化快照且未清理自有 userId,污染了 `FairnessMonitoringHttpIntegrationTest` 的 `populationSize`(+15 精确组成为 3+2+1+1+8);已按行级快照实测逐类归因并精确清理(5 个候选类经证明零残留未改),新增隔离门禁 `R01CScoringFixtureIsolationGateTest`;`*CandidateScoring*Test,*Fairness*Test` 双向顺序各 3 次均 260/260,生产代码 diff = 0(报告 `docs/r01-c-fairness-fixture-isolation-fix-20260918.md`);下一阶段为 R01-E |
| 其余五个功能组 | 技能词云、入职清单/公司知识、薪资计算器/参考、诚信积分/激励、简历查重 | N03–N06、N08仍属一期,细节设计后编码 |
| 上线保障 | 现有资产待逐项核验 | G01–G04,一期交付门槛 |
| 后续候选池 | AI简历、AI模拟面试/预测、SHAP高级解释等 | 以清单第6节为准,移出一期 |

224
docs/def-01-job-keyword-normalization-remediation-20260920.md

@ -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 追加章节于提交前保持**待提交**状态。

2250
docs/final-independent-acceptance-20260918.md

File diff suppressed because it is too large

83
docs/frontend-integration.md

@ -34,17 +34,22 @@ mvn spring-boot:run -Dspring-boot.run.profiles=dev
健康检查:`http://127.0.0.1:8080/api/v1/health` 返回 `status: UP`。
## 3. Mock 验证码日志位置
## 3. 本地获取测试验证码(不通过日志)
开发环境短信为 Mock,验证码打印在**服务控制台/stdout 日志**中:
**应用日志不会输出验证码**,也不得从控制台 / stdout / 日志文件中读取验证码。
```
MOCK SMS CODE: phone=13800000002, purpose=login, code=483920
```
本地 dev/test 联调请使用受控方式取得验证码:
1. 启动应用时显式开启 Mock 短信:`SMS_MOCK_ENABLED=true`(即 `app.sms.mock.enabled=true`)
2. 显式授权本次执行读取验证码表:`ALLOW_TEST_OTP_DB_READ=true`
3. 通过受控测试脚本或数据库内部只读查询取得验证码;脚本内部取码,不向终端展示
- 运行 `mvn spring-boot:run` → 看终端输出
- 后台运行 → 看重定向的日志文件(如 `logs/mydreamjob.log`)
- 每次发码后取**最新一条** `code=<6位数字>`
`docs/smoke-test.sh` 已按上述双重保护实现;两项条件任一不满足即输出 `test OTP retrieval is disabled` 并安全失败。
**约束:**
- 生产环境**禁止**查询验证码表辅助登录
- 当前尚未接入真实短信供应商;`app.sms.mock.enabled` 为 `false`、缺失、空值或非法值时,发码返回“服务不可用”,绝不返回虚假成功
- 验证码当前仍以**明文**存储于 `sms_verification_codes`,属已知遗留风险(未加密、未哈希)
## 4. Apifox 导入 OpenAPI
@ -54,9 +59,9 @@ MOCK SMS CODE: phone=13800000002, purpose=login, code=483920
4. 点击「确认导入」,Apifox 自动生成全部接口、参数、响应模型
5. 在 Apifox 中可直接调试:登录接口获取 token 后,设置「环境变量 `token`」,在接口「Authorization」填 `Bearer {{token}}`
## 5. Token 使用方式
## 5. Token 使用方式(S02)
登录成功返回 `accessToken`(有效期 24h)与 `refreshToken`。
登录成功返回 `accessToken`(有效期 24h)与 `refreshToken`(有效期 7 天)。
```json
{
@ -76,6 +81,36 @@ MOCK SMS CODE: phone=13800000002, purpose=login, code=483920
Authorization: Bearer <accessToken>
```
### 5.1 两个 Token 的用途必须分开
| Token | 用在哪 |
|---|---|
| `accessToken` | 所有普通业务接口的 `Authorization` 头 |
| `refreshToken` | **只能**用于 `POST /api/v1/auth/refresh` |
把 `refreshToken` 放进普通接口的 `Authorization` 头会被拒绝(401);
把 `accessToken` 传给刷新接口同样会被拒绝(401)。两者由不同密钥签发,不可互换。
### 5.2 刷新与登出
```
POST /api/v1/auth/refresh body: { "refreshToken": "<refreshToken>" }
POST /api/v1/auth/logout header: Authorization: Bearer <accessToken> (无 body)
```
### 5.3 前端必须遵守的规则
1. **上线即强制重新登录**:S02 之后所有旧 Token(不含类型与会话信息)全部失效,
前端需要接受一次强制登录,不要尝试兼容旧 Token。
2. 普通请求**只携带 Access Token**。
3. 收到普通接口 401 时,**最多刷新一次**;刷新成功后原子替换两个 Token 并重放原请求。
4. **刷新请求必须串行**:多个并发 401 不能同时用同一个 Refresh Token 去刷新
(并发刷新会被服务端判定为重放并撤销整个会话)。
5. **刷新失败一律清除本地两个 Token 并跳转登录页**,不要循环刷新。
6. **登出时先调用 `/auth/logout`,随后无条件清除本地 Token**;
不要依赖第二次登出请求(Access Token 失效后它本来就会 401)。
7. **不要记录或上报 Token**:不写入 URL、本地日志、埋点或错误上报正文。
## 3. 请求头
| Header | 必需 | 说明 |
@ -141,7 +176,7 @@ Authorization: Bearer <accessToken>
**登录方式**(开发环境 Mock):
1. `POST /auth/sms/send?phone=<手机号>` 发送验证码
2. 验证码打印在服务日志:`MOCK SMS CODE: ... code=xxxxxx`
2. 验证码**不再输出到日志**;本地按 §3 的受控方式取得(生产禁止查表)
3. `POST /auth/login` 携带 `{phone, code, purpose:"login"}` 获取 token
## 7. 典型联调流程
@ -442,6 +477,8 @@ Authorization: Bearer <accessToken>
- 值可能是**相对存储路径**或文件存储地址,**不保证是完整的 `http/https` URL**。
- 前端**下载简历应调用 `GET /api/v1/profile/me/resume`**,不要直接拼接 `resumeUrl` 做请求地址。
- 上传仍仅接受 PDF/DOC/DOCX 且不超过 10MB;服务端会同时校验扩展名、Content-Type 和实际文件结构。伪装扩展名、损坏文件、危险 DOCX 压缩包或含路径/控制字符的文件名返回 400,前端不要仅依据浏览器 MIME 判断文件有效。
- 上传替换失败时原简历保持可用;成功提交后旧文件才会清理。该流程不改变现有请求字段或响应结构。
### 12.12 graduationYear 特别说明
@ -953,3 +990,27 @@ V43 仅供管理员后台使用,前端不得向学生或企业展示。当前
可用接口、参数和小样本规则见 [candidate-scoring-fairness-api.md](candidate-scoring-fairness-api.md)。前端不得根据城市、学校、姓名等字段创建敏感人群筛选,也不得尝试通过多次小样本查询反推出候选人。
总览、维度和申诉响应都显式返回 `availability/reason`。状态/版本/回滚原因及分布桶均按独立候选人数计算;`appealCount` 仅表示事件数。`SUPPRESSED` 时不得用总体计数、补集或其它接口字段反推出被隐藏比例,`NO_DATA`、`INSUFFICIENT_SAMPLE`、`SUPPRESSED` 均不得渲染为 `NORMAL`。
## 13. 企业下载候选人简历(S03)
```
GET /api/v1/company/candidates/{applicationId}/resume
Authorization: Bearer <COMPANY 用户 accessToken>
```
响应是**文件流**(不是 JSON 包装),前端应按二进制处理:
- 成功:`200` + `Content-Type`(PDF/DOC/DOCX)+ `Content-Disposition: attachment; filename*=UTF-8''…`
+ `Cache-Control: no-store`
- `401` 未登录或会话失效
- `403` 非 COMPANY 角色、无企业资料、跨企业投递,或候选人隐私设置不允许
- `404` 投递不存在(`APPLICATION_NOT_FOUND`)或学生没有简历(`PROFILE_NOT_FOUND`)
前端注意事项:
1. **不要**从候选人列表/详情里找 `resumeUrl` —— 这些 DTO 有意不含该字段;
下载必须走上面这个接口。
2. 403 与 404 都按「不可下载」处理,**不要**在 UI 上区分「隐私拒绝」与「跨企业」,
避免泄露归属信息。
3. 下载链接不要放进 URL 或日志;如需预览,请用同一 Authorization 头请求。
4. 响应已声明 `no-store`,前端不要再把简历文件写入本地持久缓存。

2
docs/offer-api.md

@ -259,7 +259,7 @@ POST /offers/{id}/remind
> 现网 OpenAPI 亦可访问 `GET /api/v1/v3/api-docs` 导出后导入 Apifox。
测试账号(Mock 短信验证码,验证码见应用日志):
测试账号(Mock 短信验证码:应用日志不再输出,本地按 `docs/frontend-integration.md` §3 的受控方式取得;生产禁止查表):
| 角色 | 手机号 | 用途 |
|---|---|---|

237
docs/openapi-rc.json

@ -1445,8 +1445,8 @@
"tags": [
"认证"
],
"summary": "发送短信验证码(Mock)",
"description": "开发环境 Mock:验证码会打印到日志",
"summary": "发送短信验证码",
"description": "开发/测试使用 Mock 发送实现;生产环境未接入短信网关时返回服务不可用。验证码不会写入日志。",
"operationId": "sendSmsCode",
"parameters": [
{
@ -9213,6 +9213,94 @@
}
}
}
},
"/auth/refresh": {
"post": {
"tags": [
"认证"
],
"summary": "刷新 Token",
"description": "使用 Refresh Token 换取新的 Access/Refresh Token 对;旧 Refresh Token 立即失效",
"operationId": "refresh",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RefreshTokenRequest"
}
}
}
},
"responses": {
"200": {
"description": "成功",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiResponseTokenPairResponse"
}
}
}
}
}
}
},
"/auth/logout": {
"post": {
"tags": [
"认证"
],
"summary": "登出",
"description": "撤销当前登录会话;需要 Access Token,不接受任何请求体",
"operationId": "logout",
"responses": {
"200": {
"description": "成功",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiResponseMapStringString"
}
}
}
}
}
}
},
"/company/candidates/{applicationId}/resume": {
"get": {
"tags": [
"投递"
],
"summary": "企业下载候选人简历",
"description": "按候选人隐私设置授权后返回简历文件流;不返回存储路径、学生 ID 或联系方式",
"operationId": "downloadCandidateResume",
"parameters": [
{
"name": "applicationId",
"in": "path",
"required": true,
"schema": {
"type": "integer",
"format": "int64"
}
}
],
"responses": {
"200": {
"description": "OK",
"content": {
"*/*": {
"schema": {
"type": "string",
"format": "byte"
}
}
}
}
}
}
}
},
"components": {
@ -11881,19 +11969,30 @@
},
"limitations": {
"type": "array",
"items": { "type": "string" }
"items": {
"type": "string"
}
},
"evidenceLevel": {
"type": "string",
"enum": ["DIRECT_RULE", "SELF_REPORTED", "BEHAVIOR_PROXY", "UNVALIDATED_PROXY"]
"enum": [
"DIRECT_RULE",
"SELF_REPORTED",
"BEHAVIOR_PROXY",
"UNVALIDATED_PROXY"
]
},
"ruleContributions": {
"type": "array",
"items": { "$ref": "#/components/schemas/CandidateScoringRuleContribution" }
"items": {
"$ref": "#/components/schemas/CandidateScoringRuleContribution"
}
},
"improvementActions": {
"type": "array",
"items": { "type": "string" }
"items": {
"type": "string"
}
},
"explanationVersion": {
"type": "string"
@ -15301,41 +15400,91 @@
"CandidateScoringCoverage": {
"type": "object",
"properties": {
"computableDimensions": { "type": "integer", "format": "int32" },
"totalDimensions": { "type": "integer", "format": "int32" },
"includedDimensions": { "type": "array", "items": { "type": "string" } },
"computableDimensions": {
"type": "integer",
"format": "int32"
},
"totalDimensions": {
"type": "integer",
"format": "int32"
},
"includedDimensions": {
"type": "array",
"items": {
"type": "string"
}
},
"excludedDimensions": {
"type": "array",
"items": { "$ref": "#/components/schemas/CandidateScoringExcludedDimension" }
"items": {
"$ref": "#/components/schemas/CandidateScoringExcludedDimension"
}
}
}
},
"CandidateScoringExcludedDimension": {
"type": "object",
"properties": {
"dimension": { "type": "string" },
"status": { "type": "string" },
"reason": { "type": "string" }
"dimension": {
"type": "string"
},
"status": {
"type": "string"
},
"reason": {
"type": "string"
}
}
},
"CandidateScoringRuleContribution": {
"type": "object",
"properties": {
"ruleCode": { "type": "string" },
"outcome": { "type": "string", "enum": ["APPLIED", "NOT_APPLIED", "MISSING", "EXPIRED", "UNAVAILABLE"] },
"contribution": { "type": "number", "nullable": true },
"maxContribution": { "type": "number", "nullable": true },
"label": { "type": "string" },
"observation": { "type": "string" },
"explanation": { "type": "string" }
"ruleCode": {
"type": "string"
},
"outcome": {
"type": "string",
"enum": [
"APPLIED",
"NOT_APPLIED",
"MISSING",
"EXPIRED",
"UNAVAILABLE"
]
},
"contribution": {
"type": "number",
"nullable": true
},
"maxContribution": {
"type": "number",
"nullable": true
},
"label": {
"type": "string"
},
"observation": {
"type": "string"
},
"explanation": {
"type": "string"
}
}
},
"CandidateScoringValidity": {
"type": "object",
"properties": {
"completedAt": { "type": "string", "format": "date-time" },
"validUntil": { "type": "string", "format": "date-time" },
"expired": { "type": "boolean" }
"completedAt": {
"type": "string",
"format": "date-time"
},
"validUntil": {
"type": "string",
"format": "date-time"
},
"expired": {
"type": "boolean"
}
}
},
"StudentProfileResponse": {
@ -15632,6 +15781,48 @@
"format": "date-time"
}
}
},
"RefreshTokenRequest": {
"type": "object",
"required": [
"refreshToken"
],
"properties": {
"refreshToken": {
"type": "string",
"minLength": 1,
"maxLength": 4096
}
}
},
"TokenPairResponse": {
"type": "object",
"properties": {
"accessToken": {
"type": "string"
},
"refreshToken": {
"type": "string"
},
"expiresIn": {
"type": "integer",
"format": "int64"
}
}
},
"ApiResponseTokenPairResponse": {
"type": "object",
"properties": {
"code": {
"type": "integer"
},
"message": {
"type": "string"
},
"data": {
"$ref": "#/components/schemas/TokenPairResponse"
}
}
}
},
"securitySchemes": {

26
docs/phase1-scope-and-acceptance.md

@ -29,6 +29,32 @@
| C06 评价与企业回复 | 评价、预审、管理员决策、企业官方回复 | review/、feedback/ | 公开载荷无学生身份;仅通过回复公开;幂等及审核并发;企业归属 | 小刘 / 张顺 |
| C07 测评与评分治理 | RIASEC、四维能力、评分卡、排名、申诉回滚、防刷 | assessment/、ability/、candidatescore/、competition/、risk/ | 抽题/计时/有效期/规则开关;申诉回滚;评分读取与写入边界;结果解释 | 张顺 / 小刘 |
| C08 推荐与人才邀约 | 岗位推荐、学生授权发现、企业邀约与接受后沟通 | recommendation/、talentdiscovery/、talentinvitation/ | 默认不发现;同企业历史投递排除;限额/冷却/幂等;接受后会话隐私 | 张顺 / 小刘 |
| S01 认证安全整改 | 验证码不落日志、受控取码、`/jobs` 认证边界 | auth/、docs/smoke-test.sh、job/JobController.java | 双开关受控取码;mock=false 完整回滚;`/jobs` 权限矩阵(独立代码复验与真实 MySQL/Docker 黑盒已通过) | 张顺 / 独立复验 |
| S02 Token 隔离与会话吊销 | Access/Refresh 类型与密钥隔离、轮换、重放处置、会话吊销 | auth/(JwtTokenProvider、AuthSessionService、AuthTokenHasher、JwtAuthenticationFilter、AuthController)、V42__auth_token_sessions.sql | Refresh 不能访问普通接口;Access 不能刷新;二次使用撤销当前会话;并发刷新最多成功一次;登出/注销完成使会话失效 | 张顺 / 独立复验 |
| S03 生产配置与简历隐私 | 历史种子账号治理、一次性管理员初始化、企业简历下载授权、Actuator 隔离 | auth/(AdminBootstrapRunner)、application/(CompanyResumeAccessPolicy、CompanyResumeService)、V43__disable_legacy_seed_accounts.sql、application.yml | V43 只禁用完全匹配原始指纹的三条种子账号;管理员初始化默认关闭且幂等;三种可见性 × 全部投递状态 × 跨企业/未认证企业矩阵;拒绝路径零文件读取;业务端口不提供指标 | 张顺 / 独立复验 |
### 2.1 Q01 岗位搜索真实查询覆盖(C02 证据补充)
`JobSearchSpecification` 与 `JobService.searchJobs` 原先只被纯 Mockito 测试覆盖(`JobSearchServiceTest` 把 `findAll(Specification, Pageable)` 整体 Mock 掉),无法证明 Criteria 谓词、SQL 过滤、排序与分页在真实数据库上的行为。Q01 补齐真实查询证据,**只新增测试与文档,未改动生产代码、接口、DTO、状态机、权限、搜索语义与索引**。
测试分层与结果(详见 [Q01 实施报告](q01-job-search-real-query-implementation-report.md)):
| 层次 | 测试类 | 数据库 | Tests run | Failures | Errors | Skipped |
|---|---|---|---|---|---|---|
| H2 真实 Repository | `job/JobSearchSpecificationIntegrationTest` | H2 + 真实 Flyway | 19 | 0 | 0 | 0 |
| H2 真实 Service / EntityManager | `job/JobSearchServiceIntegrationTest` | H2 + 真实 Flyway | 9 | 0 | 0 | 0 |
| H2 查询副作用行级快照 | `job/JobSearchQuerySideEffectIntegrationTest` | H2 + 真实 Flyway | 4 | 0 | 0 | 0 |
| 真实 MySQL 8 规格对账 | `job/JobSearchSpecificationMySqlIntegrationTest` | MySQL 8.4(Testcontainers) | 13 | 0 | 0 | 0 |
| 真实 MySQL 查询副作用行级快照 | `job/JobSearchQuerySideEffectMySqlIntegrationTest` | MySQL 8.4(Testcontainers) | 4 | 0 | 0 | 0 |
| 真实 MySQL 执行计划与查询次数 | `job/JobSearchQueryExplainMySqlIntegrationTest` | MySQL 8.4(Testcontainers) | 1 | 0 | 0 | 0 |
- MySQL 层用正式 Flyway 迁移 + `ddl-auto=validate` 启动,连接参数由 `@DynamicPropertySource` 运行期注入,不落盘、不打印;核心用例**真实执行、无 SKIP**。
- H2 与 MySQL 在 keyword(三字段/大小写/trim/空白与 `%`、`_` 特殊字符)、city、employmentType、companyId、薪资边界与区间重叠、PUBLISHED 强制过滤、latest/relevance/salary 排序、分页与 total 上**逐项一致,未发现语义差异**。
- 副作用证据为 jobs、companies、users、applications、candidate_dimension_scores、notifications、conversations、messages、audit_logs 九张表的**行级稳定字段快照**(非 COUNT/SUM),并配「改字段 / 插行 / 删行」三条**反证测试**证明比较本身有效。
- 真实 MySQL 上对组合过滤、latest 分页、salary 排序与 count 执行 `EXPLAIN FORMAT=JSON`;小样本夹具下为全表扫描 + filesort,**不构成生产规模结论**,前导通配符与排序成本移交 R01。
- OpenAPI 保持 176 paths / 214 operations,`docs/openapi-rc.json` 未改动。
状态:**Q01 已完成真实查询测试实现与自动化验证,待独立代码复验及独立真实 MySQL/Docker 验收。**
## 3. 一期必须补齐的八个功能组

29
docs/privacy-settings-api.md

@ -99,4 +99,31 @@
## 6. 审计
更新隐私设置会写入审计日志 `UPDATE_PRIVACY_SETTINGS`。审计中**不得**写入 `phone / email / password / jwt / resumeUrl` 等敏感值。
更新隐私设置会写入审计日志 `UPDATE_PRIVACY_SETTINGS`。审计中**不得**写入 `phone / email / password / jwt / resumeUrl` 等敏感值。
## S03-B:`resumeVisibility` 的企业侧执行边界
此前 `resumeVisibility` 只有存储与读写接口,**没有任何企业侧代码路径读取它**,等同于空转开关。
S03 起该设置在企业读取简历文件的唯一入口 `GET /company/candidates/{applicationId}/resume`
上真正生效,判定全部由失败关闭的 `CompanyResumeAccessPolicy` 完成。
### 可见性决策表
| 设置 | 企业条件 | 投递条件 | 结果 |
|---|---|---|---|
| 无设置记录 / `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 |
### 重要变更
- **`APPLIED_COMPANIES` 与 `VERIFIED_COMPANIES` 现在都强制校验「投递所属岗位属于当前企业」。**
此前 `canCompanyViewResume` 不校验公司归属:`APPLIED_COMPANIES` 完全不使用 `companyId`,
`VERIFIED_COMPANIES` 只校验企业 `verified` —— 任何已认证企业都可凭任意 `applicationId` 查看简历。
- `VERIFIED_COMPANIES` **不是**「知道学生 ID 即可查看」:仍需合法的候选业务上下文。
- 隐私拒绝统一返回 `FORBIDDEN(2002)`;跨企业同样 403,不通过差异响应泄露归属。
- `PrivacySettingsService.canCompanyViewResume` 保留为委托方法,判定逻辑已收束到策略类,
不再保留可被误用的宽松实现。

303
docs/q01-job-search-mysql-explain-evidence.md

@ -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 |

190
docs/q01-job-search-real-query-design.md

@ -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 验收。

265
docs/q01-job-search-real-query-implementation-instruction.md

@ -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 验收。

265
docs/q01-job-search-real-query-implementation-report.md

@ -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 评估替代方案。

110
docs/r01-a-request-context-implementation-instruction.md

@ -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 第一批请求上下文收束完成,待独立代码复验。`

284
docs/r01-a-request-context-implementation-report.md

@ -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、表、索引或依赖。

197
docs/r01-a2-independent-verification-20260917.md

@ -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独立代码复验通过,可以进入精确暂存、本地提交与推送检查点。**
**未修改生产代码、未修改测试(除新增独立测试)、未暂存、未提交、未推送。**

218
docs/r01-a2-request-context-migration-report.md

@ -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收束完成,待独立代码复验。**

221
docs/r01-a3-independent-verification-20260917.md

@ -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。**

276
docs/r01-a3-request-context-migration-report.md

@ -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收束完成,待独立代码复验。**

72
docs/r01-a4-independent-verification-20260917.md

@ -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 相同,本轮未触碰。
- 历史未跟踪文件全部保留,未删除、移动或清理。

278
docs/r01-a4-request-context-migration-report.md

@ -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收束完成,待独立代码复验。**

279
docs/r01-a5-independent-verification-20260917.md

@ -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独立代码复验通过,可以进入精确暂存、本地提交与推送检查点。**

79
docs/r01-a5-request-context-migration-report.md

@ -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。

128
docs/r01-a6-fairness-fixture-isolation-fix-20260917.md

@ -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 单跑夹具缺陷已通过独立纯测试提交修复;
> 该测试不再依赖种子数据状态与测试执行顺序。**

359
docs/r01-a6-final-request-context-migration-report.md

@ -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。**

492
docs/r01-a6-independent-verification-20260917.md

@ -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提交后以独立纯测试提交修复。**

497
docs/r01-b-authorization-exception-mapping-report.md

@ -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角色授权与异常映射收束完成,待独立代码复验。**

19
docs/r01-b-error-status-mapping-instruction.md

@ -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。

46
docs/r01-b-error-status-mapping-report.md

@ -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;
- 未暂存、未提交、未推送;
- 未删除历史未跟踪文件。

617
docs/r01-b-independent-verification-20260918.md

@ -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提交后以独立纯测试提交修复。**

183
docs/r01-c-fairness-fixture-isolation-fix-20260918.md

@ -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 组合测试污染已完成精确归因,并通过独立纯测试提交修复。**

347
docs/r01-c-independent-verification-20260918.md

@ -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独立代码复验通过,可以进入精确暂存与独立提交检查点。**

17
docs/r01-c-pure-calculation-instruction.md

@ -0,0 +1,17 @@
# R01-C 纯计算与重复规则收束实施指令
## 目标
在不改变评分、排名和推荐结果的前提下,收束候选人综合状态规则,并将岗位偏好评分拆成可独立验证的纯计算器。
## 行为边界
- 评分引擎继续按固定 8 维判断完整性,且 OK/ESTIMATED 必须有非空分数才可计算;
- 竞争排名继续按当前快照集合大小判断完整性,并保持历史“按状态可计算”的口径;
- 推荐偏好权重保持城市30、用工类型20、最低薪资20、行业15、岗位族15;
- 薪资边界继续使用 `salaryMax >= expectedSalaryMin`;
- 百分比继续保留两位小数并使用 HALF_UP;
- 不改数据库、接口、DTO、排序、分页、权限、审计或开关语义;
- 不进入 R01-D/E,不暂存、不提交、不推送。
必须先用特征测试锁定两套 overview 输入适配差异和偏好评分边界,再替换生产调用点。

24
docs/r01-c-pure-calculation-report.md

@ -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、依赖、表或索引;
- 暂存区为空,未提交、未推送,历史未跟踪产物保留。

437
docs/r01-c-scoring-recommendation-rules-refactor-report.md

@ -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评分与推荐规则结构收束完成,待独立代码复验。**

25
docs/r01-d1-percentile-performance-report.md

@ -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、公网接口、表、索引或依赖变化;
- 未暂存、未提交、未推送。

29
docs/r01-d2-personal-data-query-report.md

@ -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、公网接口或依赖变化;未暂存、未提交、未推送。

48
docs/r01-d3-conversation-unread-query-report.md

@ -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 验收中复核执行计划与结果。

87
docs/r01-d4-1-scoring-materialization-implementation-report.md

@ -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。

277
docs/r01-d4-2-blocker-remediation-20260916.md

@ -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 评分快照写入迁移尚未冻结。

234
docs/r01-d4-2-claim-fence-remediation-20260916.md

@ -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 评分快照写入迁移尚未冻结。

188
docs/r01-d4-2-claim-token-final-verification-20260916.md

@ -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评分快照写入迁移可以冻结并进入精确暂存检查点。**

275
docs/r01-d4-2-final-independent-incremental-verification-20260916.md

@ -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 不可冻结;报告已包含下一轮定向整改指令。**

57
docs/r01-d4-2-get-scoring-readonly-implementation-report.md

@ -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 黑盒验收。

656
docs/r01-d4-2-independent-mysql-acceptance-20260916.md

@ -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` 首版缺陷),已当场修正脚本,后续输出与报告均不再包含完整号码。

246
docs/r01-d4-get-scoring-snapshot-migration-design.md

@ -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)。

352
docs/r01-e-batch-query-unread-count-performance-report.md

@ -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既有批量查询与会话未读数性能实现已完成验收和测试加固,待独立代码复验。**

412
docs/r01-e-independent-verification-20260918.md

@ -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性能复验通过,可以进入精确暂存与独立提交检查点。**

81
docs/r01-structure-performance-design.md

@ -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。

47
docs/resume-api.md

@ -48,7 +48,7 @@ POST /profile/me/resume-versions (multipart/form-data)
| 字段 | 必填 | 约束 |
|---|---|---|
| `file` | 是 | PDF/DOC/DOCX,≤10MB,扩展名与 Content-Type 一致 |
| `file` | 是 | PDF/DOC/DOCX,≤10MB;扩展名、Content-Type 与实际文件结构必须一致 |
| `name` | 否 | ≤120 字符,可空 |
默认状态 `ACTIVE`、**非默认**;版本号自动分配(`max+1`)。并发创建由 DB 唯一约束兜底,冲突自动重试(独立事务)。
@ -157,6 +157,10 @@ GET /resume-export-tasks/{taskId}/download
- 身份一律来自 JWT(`auth.getName()` = userId),**不信任请求体/路径中的 userId**;
- 不能读取其他用户的简历(owner 校验);
- 不返回内部堆栈;错误响应不泄露数据库结构。
- 上传文件名拒绝路径分隔符、控制字符和响应头换行;下载历史文件时再次净化文件名。
- PDF 校验文件头和结尾标记;DOC 校验 OLE/Word 文档结构标记;DOCX 校验 OOXML 必需条目,并限制 ZIP 条目数、解压总量和压缩比,拒绝路径穿越及压缩炸弹。
- 文件替换采用“新文件落盘 → 数据库更新 → 事务提交后删除旧文件”;回滚时清理新文件。底层本地存储先写 `.uploading` 临时文件,再原子移动到最终随机存储名,失败不暴露半文件。
- 上述措施属于格式与资源安全校验,**不等同于病毒查杀或沙箱检测**;生产部署仍应在存储接入层配置恶意文件扫描。
## 5. 异步导出设计
@ -182,7 +186,7 @@ GET /resume-export-tasks/{taskId}/download
前置:启动基础设施 → 启动应用 → 导入 `docs/openapi-rc.json`(或 `GET /api/v1/v3/api-docs`)。
1. 登录学生账号(Mock 验证码,见日志)。
1. 登录学生账号(Mock 验证码:日志不再输出,本地按 `docs/frontend-integration.md` §3 的受控方式取得;生产禁止查表)。
2. `POST /profile/me/resume-versions`(multipart:`file` + `name=秋招版`)→ 200,`versionNo=1`。
3. 再次上传 → `versionNo=2`;设置 `name=社招版`。
4. `GET /profile/me/resume-versions` → 列表含两个版本。
@ -199,6 +203,7 @@ GET /resume-export-tasks/{taskId}/download
4. **默认简历仅作用于版本体系**:不联动旧 `student_profiles.resume_url`(按确认保持独立)。
5. **被导出任务引用的旧文件可能延迟清理**:替换版本文件时,若旧文件正被 PENDING/RUNNING 导出任务引用则保留(保导出一致性);任务结束后这些文件不会自动回收,需后续清理策略。
6. **OpenAPI 快照**:`docs/openapi-rc.json` 由 H2 集成测试上下文导出(**49 路径**,server URL 为测试默认值),接入现网后可用 `GET /api/v1/v3/api-docs` 重新导出。
7. **不包含恶意软件扫描**:当前只执行文件格式、OOXML 结构、路径和资源消耗防护;不对宏、嵌入对象或恶意载荷作杀毒结论。
## 10. 错误码(新增 82xx)
@ -211,3 +216,41 @@ GET /resume-export-tasks/{taskId}/download
| 8205 | `DUPLICATE_DEFAULT_RESUME` | 409 | 并发设置默认冲突 |
| 8206 | `RESUME_EXPORT_NOT_READY` | 409 | 导出任务未完成,不可下载 |
## S03-B:企业下载候选人简历
### `GET /company/candidates/{applicationId}/resume`
新增的**唯一**企业侧简历读取入口。返回文件流,**不返回** `resumeUrl`、存储相对/绝对路径、
学生用户 ID、手机号或邮箱。
- 认证:`Authorization: Bearer <COMPANY 用户 Access Token>`
- 路径参数:`applicationId`(本企业岗位下的投递)
- 响应:二进制文件流;`Content-Type` 只取自上传时已校验的白名单
(`application/pdf`、`application/msword`、DOCX),否则回退 `application/octet-stream`
- 响应头:`Content-Disposition: attachment; filename*=UTF-8''<已净化文件名>`,
以及 `Cache-Control: no-store`(私有简历不进任何中间缓存)
- 文件名继续经过 `ResumeFilenameSanitizer`,拒绝路径分隔符、控制字符与响应头换行
### 错误语义
| 场景 | 结果 |
|---|---|
| 未登录 / 无有效会话 | 401 |
| 非 COMPANY 角色(STUDENT / ADMIN) | 403 |
| 当前用户没有企业资料 | 403 |
| 投递不存在 | 404 `APPLICATION_NOT_FOUND` |
| 跨企业投递 | 403 |
| 隐私设置不允许(含 PRIVATE / 无设置) | 403 `FORBIDDEN` |
| 授权通过但学生没有简历 | 404 `PROFILE_NOT_FOUND` |
### 授权与副作用
- 授权顺序:当前 COMPANY 用户 → 当前 Company → Application+Job → `Job.companyId` →
Student → privacy setting → profile/file。归属信息全部由服务端推导,
不接受客户端传入的 `companyId`/`studentUserId`。
- **拒绝路径不会调用存储读取**(测试以计数包装器验证为零次)。
- 该接口只读:不修改 `student_profiles`、`resume_versions`、投递、评分、排名、推荐、邀约、
消息、通知或风险表;不发送 RabbitMQ、短信或邮件。
- 学生本人的上传、下载、版本与导出接口语义完全不变。
- 企业候选人列表/详情 DTO **仍不含** `resumeUrl`。
- 不新增按 `studentUserId` 或 `candidateKey` 下载简历的入口;人才发现本轮不得直接下载简历。

483
docs/s01-independent-mysql-acceptance-20260911.md

@ -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不可冻结;验收报告已包含下一轮定向整改指令。**

288
docs/s01-independent-mysql-acceptance-20260914.md

@ -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),是否停止由负责人决定。

786
docs/s01-remediation-f1f2f3-20260911.md

@ -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黑盒复验。**

674
docs/s02-auth-token-isolation-design.md

@ -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 验收通过的替代条件。

724
docs/s02-auth-token-isolation-implementation-instruction.md

@ -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。

202
docs/s02-independent-mysql-acceptance-20260914.md

@ -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黑盒全部通过,可以进入精确暂存检查点。

167
docs/s03-independent-mysql-acceptance-20260915.md

@ -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 黑盒全部通过,可以进入精确暂存检查点。**

167
docs/s03-production-security-and-resume-privacy-design.md

@ -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 黑盒通过后才能冻结。

270
docs/s03-production-security-and-resume-privacy-implementation-instruction.md

@ -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 黑盒验收。”

225
docs/smoke-test.sh

@ -1,20 +1,56 @@
#!/usr/bin/env bash
# ── xtrace 防护(S01 第三轮 F4)───────────────────────────────
# 必须在读取任何环境变量、组装任何请求之前关闭 xtrace。
# 脚本是独立可执行程序,安全优先:全程不重新 set -x,
# 避免验证码 / 完整手机号 / LOGIN_BODY / 登录响应 / TOKEN / AUTH 进入 stderr。
case "$-" in
*x*) set +x ;;
esac
set -u
# MyDreamJob backend RC smoke test.
# Required tools: curl, grep, sed, head, tail.
# Required tools: curl, grep, sed, head, tail, docker(仅取测试验证码时)。
# Environment:
# BASE_URL API base URL (default: http://127.0.0.1:8080/api/v1)
# STUDENT_PHONE Fresh 11-digit phone; generated when omitted
# COMPANY_TOKEN Company JWT; company status step is SKIP when omitted
# SERVER_LOG Server log path used to read Mock SMS codes
# BASE_URL API base URL (default: http://127.0.0.1:8080/api/v1)
# STUDENT_PHONE Fresh 11-digit phone; generated when omitted
# COMPANY_TOKEN Company JWT; company status step is SKIP when omitted
# SMS_MOCK_ENABLED=true **必需**:声明被测应用以 app.sms.mock.enabled=true 运行
# ALLOW_TEST_OTP_DB_READ=true **必需**:显式授权本次本地测试从数据库读取验证码
#
# 安全说明(S01 定向整改 F1 / 第三轮 F4+F5):
# * 应用日志**不再**输出验证码,本脚本也**不得**从日志/控制台/stdout 读取验证码。
# * 仅在 [SMS_MOCK_ENABLED=true] **且** [ALLOW_TEST_OTP_DB_READ=true] 时,
# 才通过容器内部只读 SQL 取得验证码;任一条件不满足立即安全失败。
# * 验证码只保存在 shell 局部变量 / REPLY 约定中,不写日志、不写文件、不输出到终端。
# * **xtrace 永远关闭**:即使以 `bash -x` 调用,脚本开头即 set +x 且全程不恢复。
# * 数据库凭据通过容器进程环境 MYSQL_PWD 传递,**不进入 mysql 命令行参数**;
# 使用应用账号 MYSQL_USER(不使用 root,缺失即安全失败)。
# * 不接入真实短信供应商;生产环境禁止使用该取码方式。
BASE_URL="${BASE_URL:-http://127.0.0.1:8080/api/v1}"
SERVER_LOG="${SERVER_LOG:-}"
PASS=0
FAIL=0
SKIP=0
# ── 凭据变量登记(退出时统一清理)─────────────────────────────
# 覆盖全部敏感变量:手机号、验证码、登录请求体、登录响应、JWT、Authorization 头。
# 注意:不得在此处给 PHONE / STUDENT_PHONE / COMPANY_TOKEN 赋空值,
# 它们携带调用方传入值或由后续逻辑生成。
SMS=""
OTP=""
REPLY=""
LOGIN_BODY=""
LOGIN=""
TOKEN=""
AUTH=""
cleanup_secrets() {
unset PHONE SMS OTP REPLY LOGIN_BODY LOGIN TOKEN AUTH 2>/dev/null || true
unset STUDENT_PHONE COMPANY_TOKEN 2>/dev/null || true
}
trap cleanup_secrets EXIT
if [ -n "${STUDENT_PHONE:-}" ]; then
PHONE="$STUDENT_PHONE"
else
@ -27,20 +63,113 @@ ok() { echo "[PASS] $1"; PASS=$((PASS + 1)); }
fail() { echo "[FAIL] $1"; FAIL=$((FAIL + 1)); }
skip() { echo "[SKIP] $1"; SKIP=$((SKIP + 1)); }
resolve_log() {
case "$SERVER_LOG" in
[A-Za-z]:/*)
local drive rest
drive="$(printf '%s' "${SERVER_LOG:0:1}" | tr '[:upper:]' '[:lower:]')"
rest="${SERVER_LOG:2}"
if [ -d /mnt/c ]; then
echo "/mnt/$drive$rest"
else
echo "/$drive$rest"
fi
;;
*) echo "$SERVER_LOG" ;;
# 手机号脱敏(控制台输出不展示完整号码)
mask_phone() {
local p="${1:-}"
if [ "${#p}" -lt 7 ]; then
printf '%s' '***'
return
fi
printf '%s****%s' "${p:0:3}" "${p: -4}"
}
# ── 布尔解析(大小写/空白不敏感,仅 true 为真)────────────────
is_true() {
local v
v="$(printf '%s' "${1:-}" | tr -d '[:space:]' | tr '[:upper:]' '[:lower:]')"
[ "$v" = "true" ]
}
# ── 取码双重保护 ─────────────────────────────────────────────
# 保护 1:被测应用必须显式处于 mock 短信模式
require_mock_sms_mode() {
is_true "${SMS_MOCK_ENABLED:-}"
}
# 保护 2:本次执行必须显式授权读取验证码表
require_test_otp_db_read_permission() {
is_true "${ALLOW_TEST_OTP_DB_READ:-}"
}
# 从项目实际 compose 文件解析 mysql 容器名(不硬编码、不假设)
# 使用 BASH_SOURCE 以便脚本被 `source` 时仍能正确定位自身目录
compose_mysql_container() {
local self base compose
self="${BASH_SOURCE[0]:-$0}"
base="$(cd "$(dirname "$self")" && pwd)"
for compose in "$base/../backend-infra/compose.yaml" "$base/../backend-infra/docker-compose.yml"; do
[ -f "$compose" ] || continue
awk '
/^[[:space:]]+mysql:[[:space:]]*$/ { in_mysql=1; next }
/^[[:space:]]+[A-Za-z0-9_-]+:[[:space:]]*$/ { in_mysql=0 }
in_mysql && /^[[:space:]]+container_name:[[:space:]]*/ {
sub(/^[[:space:]]*container_name:[[:space:]]*/, ""); print; exit
}
' "$compose"
done
}
# 容器内只读查询(F5:凭据经进程环境 MYSQL_PWD 传递,不进入 mysql argv;SQL 经 stdin)
# - 使用应用账号 MYSQL_USER(不使用 root;缺失即安全失败,不回退 root)
# - 仅允许 SELECT;查询失败返回非 0,由 sms_code() 输出固定安全错误
otp_db_query() {
local phone="$1" purpose="$2" container
container="$(compose_mysql_container | head -n 1)"
[ -n "$container" ] || return 1
printf '%s\n' "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;" \
| docker exec -i "$container" sh -c '
# 应用账号凭据必需,缺失则安全失败(绝不回退 root)
if [ -z "${MYSQL_USER:-}" ] || [ -z "${MYSQL_PASSWORD:-}" ]; then
echo "db credentials unavailable" >&2
exit 3
fi
# 密码只经环境变量 MYSQL_PWD 传递,argv 中不出现 -p/--password
MYSQL_PWD="$MYSQL_PASSWORD" exec mysql \
-u"$MYSQL_USER" \
-N -B \
"${MYSQL_DATABASE:-mydreamjob}"' 2>/dev/null \
| head -n 1
}
# 取得验证码:成功时写入 REPLY;绝不打印到 stdout/stderr
sms_code() {
local disabled_msg="test OTP retrieval is disabled"
if ! require_mock_sms_mode || ! require_test_otp_db_read_permission; then
echo "$disabled_msg" >&2
return 1
fi
case "${PHONE:-}" in
*[!0-9]*|'') echo "$disabled_msg" >&2; return 1 ;;
esac
[ "${#PHONE}" -eq 11 ] || { echo "$disabled_msg" >&2; return 1; }
# xtrace 已在脚本开头全局关闭,这里无需(也不得)保存/恢复其状态
local code="" qstatus=0
IFS= read -r code < <(otp_db_query "$PHONE" "login") || qstatus=$?
# 校验格式:当前验证码为 6 位数字(app.sms.mock.code-length / CODE_PATTERN)
local valid=1
[ "$qstatus" -eq 0 ] || valid=0
case "$code" in
[0-9][0-9][0-9][0-9][0-9][0-9]) : ;;
*) valid=0 ;;
esac
if [ "$valid" -ne 1 ]; then
unset code
echo "test OTP retrieval failed" >&2
return 1
fi
REPLY="$code"
unset code
return 0
}
report() {
@ -53,6 +182,23 @@ report() {
echo " http=$http_code ${biz_code:-code=N/A} ${request_id:-requestId=N/A}"
}
# 仅接收可安全打印的三个字段,不接收任何响应体全文(用于登录等敏感响应)
report_fields() {
local http_code="$1" biz_code="$2" request_id="$3"
echo " http=$http_code ${biz_code:-code=N/A} ${request_id:-requestId=N/A}"
}
# 从响应中只提取可安全打印字段(http / business code / requestId),不保留响应体
extract_status_fields() {
local response="$1"
local http_code body biz_code request_id
http_code="$(printf '%s\n' "$response" | tail -n 1)"
body="$(printf '%s\n' "$response" | sed '$d')"
biz_code="$(printf '%s' "$body" | grep -oE '"code"[[:space:]]*:[[:space:]]*[0-9]+' | head -n 1 | grep -oE '[0-9]+' || true)"
request_id="$(printf '%s' "$body" | grep -oE '"requestId"[[:space:]]*:[[:space:]]*"[^"]*"' | head -n 1 || true)"
printf '%s\n%s\n%s' "$http_code" "$biz_code" "$request_id"
}
extract_token() {
printf '%s' "$1" | sed -n 's/.*"accessToken"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n 1
}
@ -61,17 +207,16 @@ extract_id() {
printf '%s' "$1" | sed -n 's/.*"id"[[:space:]]*:[[:space:]]*\([0-9][0-9]*\).*/\1/p' | head -n 1
}
sms_code() {
local log_path
log_path="$(resolve_log)"
[ -n "$log_path" ] || return 1
[ -f "$log_path" ] || return 1
grep -a "MOCK SMS CODE.*phone=$PHONE" "$log_path" | tail -n 1 | grep -a -oE 'code=[0-9]{6}' | cut -d= -f2
}
# ── 可测试性钩子 ───────────────────────────────────────────
# 供 shell 单元测试加载函数(验证取码保护与泄漏防护)而不执行完整流程。
# 正常运行(不设该变量)时完全不生效,不影响任何行为。
if [ "${SMOKE_TEST_LIB_ONLY:-}" = "1" ]; then
return 0 2>/dev/null || exit 0
fi
echo "=== MyDreamJob backend smoke test ==="
echo "BASE_URL=$BASE_URL"
echo "PHONE=$PHONE"
echo "PHONE=$(mask_phone "$PHONE")"
say "GET /health"
HEALTH="$(curl -sS -w '\n%{http_code}' "$BASE_URL/health" || true)"
@ -84,19 +229,33 @@ for attempt in 1 2 3; do
curl -sS -X POST "$BASE_URL/auth/sms/send?phone=$PHONE&purpose=login" \
-H "X-Request-Id: smoke-sms-$attempt" >/dev/null 2>&1 || true
sleep 2
SMS="$(sms_code || true)"
[ -n "$SMS" ] && break
if sms_code; then
SMS="$REPLY"
unset REPLY
[ -n "$SMS" ] && break
fi
done
if [ -z "$SMS" ]; then
fail "cannot read Mock SMS code; set SERVER_LOG to the server log path"
fail "login skipped"
unset SMS
TOKEN=""
else
LOGIN="$(curl -sS -w '\n%{http_code}' -X POST "$BASE_URL/auth/login" \
# 请求体经 stdin 传入(--data-binary @-),验证码不出现在进程参数中
LOGIN_BODY="{\"phone\":\"$PHONE\",\"code\":\"$SMS\",\"purpose\":\"login\"}"
LOGIN="$(printf '%s' "$LOGIN_BODY" | curl -sS -w '\n%{http_code}' -X POST "$BASE_URL/auth/login" \
-H 'Content-Type: application/json' \
-H 'X-Request-Id: smoke-login-001' \
-d "{\"phone\":\"$PHONE\",\"code\":\"$SMS\",\"purpose\":\"login\"}" || true)"
report "$LOGIN"
--data-binary @- || true)"
unset LOGIN_BODY
unset SMS
# 只保留可安全打印的三个字段;登录响应全文绝不交给打印函数,也绝不落盘
LOGIN_FIELDS="$(extract_status_fields "$LOGIN")"
TOKEN="$(extract_token "$LOGIN")"
unset LOGIN
report_fields "$(printf '%s' "$LOGIN_FIELDS" | sed -n '1p')" \
"$(printf '%s' "$LOGIN_FIELDS" | sed -n '2p')" \
"$(printf '%s' "$LOGIN_FIELDS" | sed -n '3p')"
unset LOGIN_FIELDS
if [ -n "$TOKEN" ]; then ok "login"; else fail "login"; fi
fi

144
docs/tests/smoke-test-schema-contract-test.sh

@ -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 ]

548
docs/tests/smoke-test-sms-code-test.sh

@ -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 ]

75
docs/verify_openapi.py

@ -6,7 +6,7 @@ p = PROJECT_ROOT / 'docs' / 'openapi-rc.json'
d = json.load(open(p, encoding='utf-8'))
print('paths:', len(d['paths']))
assert len(d['paths']) == 173, f"Expected 173 paths, got {len(d['paths'])}"
assert len(d['paths']) == 176, f"Expected 176 paths, got {len(d['paths'])}"
paths = d['paths']
@ -591,10 +591,79 @@ assert '/conversations' in paths and '/conversations/messages' in paths
assert '/conversations/{id}/messages' in paths and '/conversations/{id}/read' in paths
print(' N07 recall path/schema validated; existing messaging operations preserved')
# 统计 operation 总数并校验 V43 + N07 计数 211
# ── S02:Access/Refresh Token 隔离的两个新增 operation ─────────────
# 只允许新增 /auth/refresh 与 /auth/logout;登录原字段不得删除;
# 新 schema 不得暴露数据库字段或凭据。
assert '/auth/refresh' in paths, 'Missing /auth/refresh'
assert '/auth/logout' in paths, 'Missing /auth/logout'
assert 'post' in paths['/auth/refresh'], '/auth/refresh must be POST'
assert 'post' in paths['/auth/logout'], '/auth/logout must be POST'
_refresh_body = paths['/auth/refresh']['post']['requestBody']['content']['application/json']['schema']
_refresh_req = resolve_schema(_refresh_body)
assert sorted(_refresh_req['properties']) == ['refreshToken'], \
f"Refresh request body must only contain refreshToken, got {sorted(_refresh_req['properties'])}"
assert 'requestBody' not in paths['/auth/logout']['post'], '/auth/logout must not declare requestBody'
# 登录接口的静态契约不得被 S02 改动(请求/响应引用保持原样)
_login_post = paths['/auth/login']['post']
assert _login_post['requestBody']['content']['application/json']['schema']['$ref'] == \
'#/components/schemas/LoginRequest', 'login request schema changed'
assert _login_post['responses']['200']['content']['*/*']['schema']['$ref'] == \
'#/components/schemas/ApiResponseMapStringObject', 'login response schema changed'
assert _login_post.get('operationId') == 'mockLogin', 'login operationId changed'
_s02_text = json.dumps({
'refresh': paths['/auth/refresh'],
'logout': paths['/auth/logout'],
'schemas': {k: v for k, v in d['components']['schemas'].items()
if k in ('RefreshTokenRequest', 'TokenPairResponse', 'ApiResponseTokenPairResponse')},
}, ensure_ascii=False)
for forbidden in ('tokenHash', 'refreshTokenHash', 'sessionId', 'revocationReason',
'phone', 'secret', 'password', 'jwt'):
assert forbidden not in _s02_text, f'S02 contract exposes forbidden field {forbidden}'
# 未新增其他认证或管理接口(认证相关路径只能是既有集合 + 这两个)
_auth_paths = {p for p in paths if p.startswith('/auth')}
assert _auth_paths == {
'/auth/sms/send', '/auth/login', '/auth/me',
'/auth/refresh', '/auth/logout',
}, f'Unexpected /auth paths: {sorted(_auth_paths)}'
print(' S02 refresh/logout operations and isolation schemas validated')
# ── S03:唯一新增的企业简历下载接口 ────────────────────────────────
_download_path = '/company/candidates/{applicationId}/resume'
assert _download_path in paths, 'Missing company resume download endpoint'
_download_get = paths[_download_path]['get']
assert 'requestBody' not in _download_get, 'Resume download must not declare requestBody'
assert set(paths[_download_path].keys()) == {'get'}, 'Resume download must be GET only'
# 不得存在按 studentUserId / candidateKey 下载简历的入口
for _p in paths:
_lower = _p.lower()
if 'resume' in _lower or 'download' in _lower:
assert 'studentuserid' not in _lower, f'Resume path must not key on studentUserId: {_p}'
assert 'candidatekey' not in _lower, f'Resume path must not key on candidateKey: {_p}'
# 企业候选人 DTO 不得回带 resumeUrl / studentUserId / phone / email / storagePath
for _dto in ('CandidateDetailView', 'CandidateItemView'):
_node = d['components']['schemas'].get(_dto)
if _node is None:
continue
_text = json.dumps(resolve_schema(_node), ensure_ascii=False)
for _forbidden in ('resumeUrl', 'studentUserId', 'phone', 'email', 'storagePath'):
assert _forbidden not in _text, f'{_dto} exposes forbidden field {_forbidden}'
# Actuator 不得进入业务 OpenAPI
for _p in paths:
assert not _p.startswith('/actuator'), f'Actuator must not appear in the business contract: {_p}'
print(' S03 company resume download contract validated; candidate DTOs remain redacted')
# 统计 operation 总数并校验 V43 + N07 + S02 + S03 计数 214
operations = sum(len(methods) for methods in d['paths'].values())
print('operations:', operations)
assert operations == 211, f"Expected 211 operations, got {operations}"
assert operations == 214, f"Expected 214 operations, got {operations}"
print(" Offer advance endpoints all present")
print(" 26 operations all present")
print(" Account deletion endpoints present (3 operations)")

5
mydreamjob-server/pom.xml

@ -54,6 +54,11 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<!-- S03-C:Actuator 暴露 prometheus 端点所需;缺少该依赖时 /actuator/prometheus 不存在。 -->
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-registry-prometheus</artifactId>
</dependency>
<!-- Flyway -->
<dependency>

28
mydreamjob-server/src/main/java/com/mydreamjob/ability/AbilityAttemptService.java

@ -10,6 +10,7 @@ import com.mydreamjob.audit.AuditContext;
import com.mydreamjob.audit.AuditService;
import com.mydreamjob.auth.User;
import com.mydreamjob.auth.UserRepository;
import com.mydreamjob.candidatescore.refresh.CandidateScoringRefreshRequestService;
import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode;
import lombok.RequiredArgsConstructor;
@ -43,6 +44,7 @@ public class AbilityAttemptService {
private static final int MAX_PAGE_SIZE = 50;
private static final String[] DIMENSIONS = {"LOGIC", "COMMUNICATION", "EXECUTION", "LEARNING"};
private final com.mydreamjob.security.UserRoleGuard userRoleGuard;
private final AbilityDefinitionRepository definitionRepository;
private final AbilityQuestionRepository questionRepository;
private final AbilityAttemptRepository attemptRepository;
@ -56,12 +58,14 @@ public class AbilityAttemptService {
private final UserRepository userRepository;
private final AuditService auditService;
private final Clock clock;
/** R01-D4-1:能力测评提交成功后登记评分快照刷新(ABILITY 维度)。 */
private final CandidateScoringRefreshRequestService scoringRefreshRequestService;
// ─────────────────────────── 创建答卷 ───────────────────────────
@Transactional(isolation = Isolation.READ_COMMITTED)
public AbilityAttemptResponse create(Long userId, CreateAbilityAttemptRequest req, AuditContext ctx) {
requireStudent(userId);
userRoleGuard.requireStudent(userId);
AbilityDefinition def = definitionRepository.findByTypeCode(req.getTypeCode())
.orElseThrow(() -> new BusinessException(ErrorCode.ABILITY_TYPE_NOT_FOUND));
if (!AbilityDefinition.STATUS_ACTIVE.equals(def.getStatus())) {
@ -148,7 +152,7 @@ public class AbilityAttemptService {
@Transactional
public AbilityAttemptResponse get(Long userId, Long attemptId) {
requireStudent(userId);
userRoleGuard.requireStudent(userId);
AbilityAttempt attempt = loadOwned(userId, attemptId);
expireIfTimedOut(attempt);
return toAttemptResponse(attempt);
@ -158,7 +162,7 @@ public class AbilityAttemptService {
@Transactional(readOnly = true)
public AbilityResultResponse loadResult(Long userId, Long attemptId) {
requireStudent(userId);
userRoleGuard.requireStudent(userId);
AbilityAttempt attempt = loadOwned(userId, attemptId);
AbilityResult result = resultRepository.findByAttemptId(attemptId)
.orElseThrow(() -> new BusinessException(ErrorCode.ABILITY_RESULT_NOT_FOUND));
@ -171,7 +175,7 @@ public class AbilityAttemptService {
@Transactional
public AbilityAttemptResponse saveAnswers(Long userId, Long attemptId,
SaveAbilityAnswersRequest req, AuditContext ctx) {
requireStudent(userId);
userRoleGuard.requireStudent(userId);
AbilityAttempt attempt = loadOwned(userId, attemptId);
if (expireIfTimedOut(attempt)) {
throw new BusinessException(ErrorCode.ABILITY_ATTEMPT_INVALID_STATE);
@ -248,7 +252,7 @@ public class AbilityAttemptService {
@Transactional
public AbilityResultResponse submit(Long userId, Long attemptId, AuditContext ctx) {
requireStudent(userId);
userRoleGuard.requireStudent(userId);
AbilityAttempt attempt = loadOwned(userId, attemptId);
if (AbilityAttempt.STATUS_COMPLETED.equals(attempt.getStatus())) {
@ -287,6 +291,8 @@ public class AbilityAttemptService {
+ ", questionSetVersion=" + attempt.getQuestionSetVersion()
+ ", rulesVersion=" + (def == null ? null : def.getRulesVersion()),
ctx);
// R01-D4-1:能力测评提交成功(新有效结果)影响 ABILITY 维度 → 登记当前周期刷新。
scoringRefreshRequestService.requestRefresh(userId, now());
return resultService.toResponse(attempt, result, def, now());
}
@ -294,7 +300,7 @@ public class AbilityAttemptService {
@Transactional(readOnly = true)
public Page<AbilityHistoryItem> listMine(Long userId, int page, int size) {
requireStudent(userId);
userRoleGuard.requireStudent(userId);
if (page < 0 || size < 1 || size > MAX_PAGE_SIZE) {
throw new BusinessException(ErrorCode.BAD_REQUEST);
}
@ -540,16 +546,6 @@ public class AbilityAttemptService {
}
return a;
}
private void requireStudent(Long userId) {
User u = userRepository.findById(userId)
.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND));
boolean isStudent = u.getRoles() != null && u.getRoles().contains(User.Role.STUDENT);
if (!isStudent) {
throw new BusinessException(ErrorCode.FORBIDDEN);
}
}
private AbilityAttempt findByIdem(Long userId, String idem) {
if (idem == null) {
return null;

42
mydreamjob-server/src/main/java/com/mydreamjob/ability/AbilityController.java

@ -9,8 +9,8 @@ import com.mydreamjob.ability.dto.SaveAbilityAnswersRequest;
import com.mydreamjob.audit.AuditContext;
import com.mydreamjob.common.api.ApiResponse;
import com.mydreamjob.common.api.PageResponse;
import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode;
import com.mydreamjob.common.logging.RequestIdAccessor;
import com.mydreamjob.security.CurrentUserAccessor;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
@ -18,8 +18,6 @@ import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@ -37,6 +35,8 @@ public class AbilityController {
private final AbilityCatalogService catalogService;
private final AbilityAttemptService attemptService;
private final CurrentUserAccessor currentUserAccessor;
private final RequestIdAccessor requestIdAccessor;
@Operation(summary = "能力测评类型列表",
description = "返回全部 ACTIVE 能力测评定义,不返回题库/答案/评分规则。任意已登录用户。")
@ -46,7 +46,7 @@ public class AbilityController {
})
@GetMapping("/abilities/types")
public ApiResponse<List<AbilityTypeResponse>> listTypes(HttpServletRequest request) {
return ApiResponse.ok(catalogService.listTypes(), requestId(request));
return ApiResponse.ok(catalogService.listTypes(), requestIdAccessor.get(request));
}
@Operation(summary = "创建能力答卷",
@ -63,8 +63,8 @@ public class AbilityController {
@PostMapping("/abilities/attempts")
public ApiResponse<AbilityAttemptResponse> createAttempt(
@Valid @RequestBody CreateAbilityAttemptRequest req, HttpServletRequest request) {
return ApiResponse.ok(attemptService.create(resolveUserId(request), req, AuditContext.from(request)),
requestId(request));
return ApiResponse.ok(attemptService.create(currentUserAccessor.requireUserId(), req, AuditContext.from(request)),
requestIdAccessor.get(request));
}
@Operation(summary = "能力答卷详情",
@ -78,7 +78,7 @@ public class AbilityController {
@GetMapping("/abilities/attempts/{attemptId}")
public ApiResponse<AbilityAttemptResponse> getAttempt(
@PathVariable Long attemptId, HttpServletRequest request) {
return ApiResponse.ok(attemptService.get(resolveUserId(request), attemptId), requestId(request));
return ApiResponse.ok(attemptService.get(currentUserAccessor.requireUserId(), attemptId), requestIdAccessor.get(request));
}
@Operation(summary = "保存能力测评答案",
@ -96,8 +96,8 @@ public class AbilityController {
public ApiResponse<AbilityAttemptResponse> saveAnswers(
@PathVariable Long attemptId,
@Valid @RequestBody SaveAbilityAnswersRequest req, HttpServletRequest request) {
return ApiResponse.ok(attemptService.saveAnswers(resolveUserId(request), attemptId, req,
AuditContext.from(request)), requestId(request));
return ApiResponse.ok(attemptService.saveAnswers(currentUserAccessor.requireUserId(), attemptId, req,
AuditContext.from(request)), requestIdAccessor.get(request));
}
@Operation(summary = "提交能力测评答卷",
@ -114,8 +114,8 @@ public class AbilityController {
@PostMapping("/abilities/attempts/{attemptId}/submit")
public ApiResponse<AbilityResultResponse> submitAttempt(
@PathVariable Long attemptId, HttpServletRequest request) {
return ApiResponse.ok(attemptService.submit(resolveUserId(request), attemptId,
AuditContext.from(request)), requestId(request));
return ApiResponse.ok(attemptService.submit(currentUserAccessor.requireUserId(), attemptId,
AuditContext.from(request)), requestIdAccessor.get(request));
}
@Operation(summary = "查看能力测评结果",
@ -129,7 +129,7 @@ public class AbilityController {
@GetMapping("/abilities/attempts/{attemptId}/result")
public ApiResponse<AbilityResultResponse> getResult(
@PathVariable Long attemptId, HttpServletRequest request) {
return ApiResponse.ok(attemptService.loadResult(resolveUserId(request), attemptId), requestId(request));
return ApiResponse.ok(attemptService.loadResult(currentUserAccessor.requireUserId(), attemptId), requestIdAccessor.get(request));
}
@Operation(summary = "我的能力测评历史",
@ -144,21 +144,9 @@ public class AbilityController {
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
HttpServletRequest request) {
Page<AbilityHistoryItem> result = attemptService.listMine(resolveUserId(request), page, size);
Page<AbilityHistoryItem> result = attemptService.listMine(currentUserAccessor.requireUserId(), page, size);
return ApiResponse.ok(PageResponse.of(
result.getContent(), result.getTotalElements(), result.getNumber(), result.getSize()),
requestId(request));
}
private Long resolveUserId(HttpServletRequest request) {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || !auth.isAuthenticated()) {
throw new BusinessException(ErrorCode.UNAUTHORIZED);
}
return Long.valueOf(auth.getName());
}
private String requestId(HttpServletRequest request) {
return request.getHeader("X-Request-Id");
requestIdAccessor.get(request));
}
}

93
mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationController.java

@ -4,6 +4,7 @@ import com.mydreamjob.common.api.ApiResponse;
import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode;
import com.mydreamjob.application.dto.CandidateDetailView;
import com.mydreamjob.application.dto.CompanyResumeFile;
import com.mydreamjob.application.dto.CandidateListResponse;
import com.mydreamjob.application.dto.ApplicationCreateRequest;
import com.mydreamjob.application.dto.ApplicationStatusRequest;
@ -15,11 +16,13 @@ import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.*;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@ -31,9 +34,16 @@ import java.util.stream.Collectors;
@RequiredArgsConstructor
public class ApplicationController {
/** R01-A2:身份只来自安全上下文,不接受请求参数传入的 userId。 */
private final com.mydreamjob.security.UserRoleGuard userRoleGuard;
private final com.mydreamjob.security.CurrentUserAccessor currentUserAccessor;
/** R01-A2:requestId 只来自过滤器规范化后的唯一产出点。 */
private final com.mydreamjob.common.logging.RequestIdAccessor requestIdAccessor;
private final ApplicationService applicationService;
private final CompanyService companyService;
private final UserRepository userRepository;
private final CompanyResumeService companyResumeService;
private static final Set<String> ALLOWED_STATUSES = Set.of(
"SUBMITTED", "VIEWED", "INTERVIEW", "HIRED", "REJECTED", "WITHDRAWN");
@ -47,9 +57,9 @@ public class ApplicationController {
public ResponseEntity<ApiResponse<Map<String, Object>>> apply(
@Valid @RequestBody ApplicationCreateRequest req,
HttpServletRequest request) {
Long userId = resolveUserId(request);
Long userId = currentUserAccessor.requireUserId();
Application app = applicationService.apply(userId, req, com.mydreamjob.audit.AuditContext.from(request));
return ResponseEntity.ok(ApiResponse.ok(applicationService.toView(app), getRequestId(request)));
return ResponseEntity.ok(ApiResponse.ok(applicationService.toView(app), requestIdAccessor.get(request)));
}
// ── 学生撤回投递 ──────────────────────────────────────────
@ -59,9 +69,9 @@ public class ApplicationController {
public ResponseEntity<ApiResponse<Map<String, Object>>> withdraw(
@PathVariable Long id,
HttpServletRequest request) {
Long userId = resolveUserId(request);
Long userId = currentUserAccessor.requireUserId();
Application app = applicationService.withdraw(userId, id, com.mydreamjob.audit.AuditContext.from(request));
return ResponseEntity.ok(ApiResponse.ok(applicationService.toView(app), getRequestId(request)));
return ResponseEntity.ok(ApiResponse.ok(applicationService.toView(app), requestIdAccessor.get(request)));
}
// ── 投递时间线 ────────────────────────────────────────────
@ -71,9 +81,9 @@ public class ApplicationController {
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> timeline(
@PathVariable Long id,
HttpServletRequest request) {
Long userId = resolveUserId(request);
Long userId = currentUserAccessor.requireUserId();
List<Map<String, Object>> data = applicationService.getTimeline(id, userId);
return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request)));
return ResponseEntity.ok(ApiResponse.ok(data, requestIdAccessor.get(request)));
}
// ── 学生查看自己的投递 ────────────────────────────────────
@ -85,7 +95,7 @@ public class ApplicationController {
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
HttpServletRequest request) {
Long userId = resolveUserId(request);
Long userId = currentUserAccessor.requireUserId();
if (size > 100) size = 100;
Page<Application> result = applicationService.listStudentApplications(userId, status, page, size);
Map<String, Object> data = new HashMap<>();
@ -94,7 +104,7 @@ public class ApplicationController {
data.put("page", result.getNumber());
data.put("size", result.getSize());
data.put("totalPages", result.getTotalPages());
return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request)));
return ResponseEntity.ok(ApiResponse.ok(data, requestIdAccessor.get(request)));
}
@Operation(summary = "投递详情", description = "查看自己的投递记录详情")
@ -102,9 +112,9 @@ public class ApplicationController {
public ResponseEntity<ApiResponse<Map<String, Object>>> getApplication(
@PathVariable Long id,
HttpServletRequest request) {
Long userId = resolveUserId(request);
Long userId = currentUserAccessor.requireUserId();
Application app = applicationService.getApplication(id, userId);
return ResponseEntity.ok(ApiResponse.ok(applicationService.toView(app), getRequestId(request)));
return ResponseEntity.ok(ApiResponse.ok(applicationService.toView(app), requestIdAccessor.get(request)));
}
// ── 企业查看岗位收到的投递 ────────────────────────────────
@ -117,7 +127,7 @@ public class ApplicationController {
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
HttpServletRequest request) {
Long userId = resolveUserId(request);
Long userId = currentUserAccessor.requireUserId();
var company = companyService.findCompanyByOwnerUserId(userId);
if (company == null) throw new BusinessException(ErrorCode.COMPANY_NOT_FOUND);
if (size > 100) size = 100;
@ -129,7 +139,7 @@ public class ApplicationController {
data.put("page", result.getNumber());
data.put("size", result.getSize());
data.put("totalPages", result.getTotalPages());
return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request)));
return ResponseEntity.ok(ApiResponse.ok(data, requestIdAccessor.get(request)));
}
// ── 企业更新投递状态 ──────────────────────────────────────
@ -140,11 +150,11 @@ public class ApplicationController {
@PathVariable Long id,
@Valid @RequestBody ApplicationStatusRequest req,
HttpServletRequest request) {
Long userId = resolveUserId(request);
Long userId = currentUserAccessor.requireUserId();
var company = companyService.findCompanyByOwnerUserId(userId);
if (company == null) throw new BusinessException(ErrorCode.COMPANY_NOT_FOUND);
Application app = applicationService.updateStatus(company.getId(), id, req, com.mydreamjob.audit.AuditContext.from(request));
return ResponseEntity.ok(ApiResponse.ok(applicationService.toView(app), getRequestId(request)));
return ResponseEntity.ok(ApiResponse.ok(applicationService.toView(app), requestIdAccessor.get(request)));
}
// ── 企业候选人看板 ────────────────────────────────────────
@ -160,8 +170,8 @@ public class ApplicationController {
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
HttpServletRequest request) {
Long userId = resolveUserId(request);
assertCompanyRole(userId);
Long userId = currentUserAccessor.requireUserId();
userRoleGuard.requireCompany(userId);
// 参数校验
if (page < 0 || size < 1 || size > 100)
@ -186,7 +196,7 @@ public class ApplicationController {
CandidateListResponse data = applicationService.listCompanyCandidates(
company.getId(), jobId, status, keyword, sort, order, page, size);
return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request)));
return ResponseEntity.ok(ApiResponse.ok(data, requestIdAccessor.get(request)));
}
@Operation(summary = "企业候选人详情", description = "查看投递详情及候选人公开资料,只能查看本企业岗位下的投递")
@ -194,33 +204,38 @@ public class ApplicationController {
public ResponseEntity<ApiResponse<CandidateDetailView>> getCompanyCandidateDetail(
@PathVariable Long applicationId,
HttpServletRequest request) {
Long userId = resolveUserId(request);
assertCompanyRole(userId);
Long userId = currentUserAccessor.requireUserId();
userRoleGuard.requireCompany(userId);
var company = companyService.findCompanyByOwnerUserId(userId);
if (company == null) throw new BusinessException(ErrorCode.COMPANY_NOT_FOUND);
CandidateDetailView data = applicationService.getCompanyCandidateDetail(
company.getId(), applicationId);
return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request)));
}
// ── 辅助 ──────────────────────────────────────────────────
private void assertCompanyRole(Long userId) {
var user = userRepository.findById(userId)
.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND));
if (user.getRoles().stream().noneMatch(r -> r.name().equals("COMPANY")))
throw new BusinessException(ErrorCode.FORBIDDEN);
return ResponseEntity.ok(ApiResponse.ok(data, requestIdAccessor.get(request)));
}
private Long resolveUserId(HttpServletRequest request) {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || !auth.isAuthenticated())
throw new BusinessException(ErrorCode.UNAUTHORIZED);
return Long.valueOf(auth.getName());
@Operation(summary = "企业下载候选人简历",
description = "按候选人隐私设置授权后返回简历文件流;不返回存储路径、学生 ID 或联系方式")
@GetMapping("/company/candidates/{applicationId}/resume")
public ResponseEntity<byte[]> downloadCandidateResume(
@PathVariable Long applicationId,
HttpServletRequest request) {
Long userId = currentUserAccessor.requireUserId();
userRoleGuard.requireCompany(userId);
// 授权全部在 Service/Policy 内完成:这里不传递任何归属信息,只传 applicationId。
CompanyResumeFile file = companyResumeService.load(userId, applicationId);
ContentDisposition disposition = ContentDisposition.attachment()
.filename(file.filename(), StandardCharsets.UTF_8)
.build();
return ResponseEntity.ok()
.contentType(MediaType.parseMediaType(file.contentType()))
.header(HttpHeaders.CONTENT_DISPOSITION, disposition.toString())
// 私有简历不进任何中间缓存。
.header(HttpHeaders.CACHE_CONTROL, "no-store")
.body(file.content());
}
private String getRequestId(HttpServletRequest request) {
return request.getHeader("X-Request-Id");
}
// ── 辅助 ──────────────────────────────────────────────────
}

9
mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationService.java

@ -15,6 +15,7 @@ import com.mydreamjob.job.JobService;
import com.mydreamjob.auth.User;
import com.mydreamjob.auth.UserRepository;
import com.mydreamjob.notification.NotificationService;
import com.mydreamjob.candidatescore.refresh.CandidateScoringRefreshRequestService;
import com.mydreamjob.profile.StudentProfile;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
@ -46,6 +47,8 @@ public class ApplicationService {
private final AuditService auditService;
private final ApplicationStatusHistoryRepository historyRepository;
private final NotificationService notificationService;
/** R01-D4-1:投递新增/撤回/状态变更后登记评分快照刷新。 */
private final CandidateScoringRefreshRequestService scoringRefreshRequestService;
@PersistenceContext
private EntityManager em;
@ -83,6 +86,8 @@ public class ApplicationService {
"你已成功投递岗位 #" + req.getJobId(),
"APPLICATION", app.getId(),
"apply-" + studentUserId + "-" + req.getJobId());
// R01-D4-1:投递数量影响 STABILITY/ACTIVITY 聚合 → 登记当前周期刷新。
scoringRefreshRequestService.requestRefresh(studentUserId, java.time.LocalDateTime.now());
return app;
}
@ -120,6 +125,8 @@ public class ApplicationService {
"你的投递 #" + app.getId() + " 已撤回",
"APPLICATION", app.getId(),
"withdraw-" + studentUserId + "-" + app.getId());
// R01-D4-1:撤回参与 WITHDRAWN/总投递比率计算 → 登记当前周期刷新。
scoringRefreshRequestService.requestRefresh(studentUserId, java.time.LocalDateTime.now());
return app;
}
@ -213,6 +220,8 @@ public class ApplicationService {
"投递状态更新", "你的投递 #" + app.getId() + " 状态变更为 " + target.name(),
"APPLICATION", app.getId(),
"status-" + companyId + "-" + app.getId() + "-" + target.name());
// R01-D4-1:仅状态实际变化(含 WITHDRAWN/HIRED 等聚合口径)时登记刷新。
scoringRefreshRequestService.requestRefresh(app.getStudentUserId(), java.time.LocalDateTime.now());
return app;
}

3
mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationStatusHistoryRepository.java

@ -9,4 +9,7 @@ import java.util.List;
public interface ApplicationStatusHistoryRepository extends JpaRepository<ApplicationStatusHistory, Long> {
List<ApplicationStatusHistory> findByApplicationIdOrderByCreatedAtAsc(Long applicationId);
List<ApplicationStatusHistory> findByApplicationIdInOrderByApplicationIdAscCreatedAtAsc(
List<Long> applicationIds);
}

132
mydreamjob-server/src/main/java/com/mydreamjob/application/CompanyResumeAccessPolicy.java

@ -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();
}
}

72
mydreamjob-server/src/main/java/com/mydreamjob/application/CompanyResumeService.java

@ -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;
}
}

10
mydreamjob-server/src/main/java/com/mydreamjob/application/dto/CompanyResumeFile.java

@ -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) {
}

21
mydreamjob-server/src/main/java/com/mydreamjob/assessment/AssessmentAttemptService.java

@ -47,6 +47,7 @@ public class AssessmentAttemptService {
private static final int MAX_PAGE_SIZE = 50;
private final com.mydreamjob.security.UserRoleGuard userRoleGuard;
private final AssessmentDefinitionRepository definitionRepository;
private final AssessmentQuestionRepository questionRepository;
private final AssessmentAttemptRepository attemptRepository;
@ -71,7 +72,7 @@ public class AssessmentAttemptService {
*/
@Transactional(isolation = Isolation.READ_COMMITTED)
public AssessmentAttemptResponse create(Long userId, CreateAssessmentAttemptRequest req, AuditContext ctx) {
requireStudent(userId);
userRoleGuard.requireStudent(userId);
AssessmentDefinition def = definitionRepository.findByTypeCode(req.getTypeCode())
.orElseThrow(() -> new BusinessException(ErrorCode.ASSESSMENT_TYPE_NOT_FOUND));
// 仅 ACTIVE 类型可创建;INACTIVE 统一 404/9701
@ -142,7 +143,7 @@ public class AssessmentAttemptService {
@Transactional(readOnly = true)
public AssessmentAttemptResponse get(Long userId, Long attemptId) {
requireStudent(userId);
userRoleGuard.requireStudent(userId);
AssessmentAttempt attempt = loadOwned(userId, attemptId);
return toAttemptResponse(attempt);
}
@ -152,7 +153,7 @@ public class AssessmentAttemptService {
@Transactional
public AssessmentAttemptResponse saveAnswers(Long userId, Long attemptId,
SaveAssessmentAnswersRequest req, AuditContext ctx) {
requireStudent(userId);
userRoleGuard.requireStudent(userId);
AssessmentAttempt attempt = loadOwned(userId, attemptId);
if (!isMutable(attempt.getStatus())) {
throw new BusinessException(ErrorCode.ASSESSMENT_ATTEMPT_INVALID_STATE);
@ -204,7 +205,7 @@ public class AssessmentAttemptService {
@Transactional
public AssessmentResultResponse submit(Long userId, Long attemptId, AuditContext ctx) {
requireStudent(userId);
userRoleGuard.requireStudent(userId);
AssessmentAttempt attempt = loadOwned(userId, attemptId);
// 重复提交幂等:返回原结果,不重新生成、不重复审计
@ -261,7 +262,7 @@ public class AssessmentAttemptService {
@Transactional(readOnly = true)
public Page<AssessmentHistoryItem> listMine(Long userId, int page, int size) {
requireStudent(userId);
userRoleGuard.requireStudent(userId);
if (page < 0 || size < 1 || size > MAX_PAGE_SIZE) {
throw new BusinessException(ErrorCode.BAD_REQUEST);
}
@ -315,16 +316,6 @@ public class AssessmentAttemptService {
}
return a;
}
private void requireStudent(Long userId) {
User u = userRepository.findById(userId)
.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND));
boolean isStudent = u.getRoles() != null && u.getRoles().contains(User.Role.STUDENT);
if (!isStudent) {
throw new BusinessException(ErrorCode.FORBIDDEN);
}
}
private AssessmentAttempt findByIdem(Long userId, String idem) {
if (idem == null) {
return null;

44
mydreamjob-server/src/main/java/com/mydreamjob/assessment/AssessmentController.java

@ -10,8 +10,8 @@ import com.mydreamjob.assessment.dto.SaveAssessmentAnswersRequest;
import com.mydreamjob.audit.AuditContext;
import com.mydreamjob.common.api.ApiResponse;
import com.mydreamjob.common.api.PageResponse;
import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode;
import com.mydreamjob.common.logging.RequestIdAccessor;
import com.mydreamjob.security.CurrentUserAccessor;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
@ -19,8 +19,6 @@ import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@ -39,6 +37,8 @@ public class AssessmentController {
private final AssessmentCatalogService catalogService;
private final AssessmentAttemptService attemptService;
private final AssessmentResultService resultService;
private final CurrentUserAccessor currentUserAccessor;
private final RequestIdAccessor requestIdAccessor;
@Operation(summary = "测评类型列表",
description = "返回全部 ACTIVE 测评定义。任意已登录用户可访问;未登录 401。")
@ -48,7 +48,7 @@ public class AssessmentController {
})
@GetMapping("/assessments/types")
public ApiResponse<List<AssessmentTypeResponse>> listTypes(HttpServletRequest request) {
return ApiResponse.ok(catalogService.listTypes(), requestId(request));
return ApiResponse.ok(catalogService.listTypes(), requestIdAccessor.get(request));
}
@Operation(summary = "题目列表",
@ -61,7 +61,7 @@ public class AssessmentController {
@GetMapping("/assessments/{type}/questions")
public ApiResponse<List<AssessmentQuestionResponse>> listQuestions(
@PathVariable String type, HttpServletRequest request) {
return ApiResponse.ok(catalogService.listQuestions(type), requestId(request));
return ApiResponse.ok(catalogService.listQuestions(type), requestIdAccessor.get(request));
}
@Operation(summary = "创建答卷",
@ -77,8 +77,8 @@ public class AssessmentController {
@PostMapping("/assessments/attempts")
public ApiResponse<AssessmentAttemptResponse> createAttempt(
@Valid @RequestBody CreateAssessmentAttemptRequest req, HttpServletRequest request) {
return ApiResponse.ok(attemptService.create(resolveUserId(request), req, AuditContext.from(request)),
requestId(request));
return ApiResponse.ok(attemptService.create(currentUserAccessor.requireUserId(), req, AuditContext.from(request)),
requestIdAccessor.get(request));
}
@Operation(summary = "答卷详情",
@ -92,7 +92,7 @@ public class AssessmentController {
@GetMapping("/assessments/attempts/{attemptId}")
public ApiResponse<AssessmentAttemptResponse> getAttempt(
@PathVariable Long attemptId, HttpServletRequest request) {
return ApiResponse.ok(attemptService.get(resolveUserId(request), attemptId), requestId(request));
return ApiResponse.ok(attemptService.get(currentUserAccessor.requireUserId(), attemptId), requestIdAccessor.get(request));
}
@Operation(summary = "保存答案",
@ -109,8 +109,8 @@ public class AssessmentController {
public ApiResponse<AssessmentAttemptResponse> saveAnswers(
@PathVariable Long attemptId,
@Valid @RequestBody SaveAssessmentAnswersRequest req, HttpServletRequest request) {
return ApiResponse.ok(attemptService.saveAnswers(resolveUserId(request), attemptId, req,
AuditContext.from(request)), requestId(request));
return ApiResponse.ok(attemptService.saveAnswers(currentUserAccessor.requireUserId(), attemptId, req,
AuditContext.from(request)), requestIdAccessor.get(request));
}
@Operation(summary = "提交答卷",
@ -127,8 +127,8 @@ public class AssessmentController {
@PostMapping("/assessments/attempts/{attemptId}/submit")
public ApiResponse<AssessmentResultResponse> submitAttempt(
@PathVariable Long attemptId, HttpServletRequest request) {
return ApiResponse.ok(attemptService.submit(resolveUserId(request), attemptId,
AuditContext.from(request)), requestId(request));
return ApiResponse.ok(attemptService.submit(currentUserAccessor.requireUserId(), attemptId,
AuditContext.from(request)), requestIdAccessor.get(request));
}
@Operation(summary = "查看结果",
@ -143,7 +143,7 @@ public class AssessmentController {
@GetMapping("/assessments/attempts/{attemptId}/result")
public ApiResponse<AssessmentResultResponse> getResult(
@PathVariable Long attemptId, HttpServletRequest request) {
return ApiResponse.ok(resultService.get(resolveUserId(request), attemptId), requestId(request));
return ApiResponse.ok(resultService.get(currentUserAccessor.requireUserId(), attemptId), requestIdAccessor.get(request));
}
@Operation(summary = "我的测评历史",
@ -158,21 +158,9 @@ public class AssessmentController {
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
HttpServletRequest request) {
Page<AssessmentHistoryItem> result = attemptService.listMine(resolveUserId(request), page, size);
Page<AssessmentHistoryItem> result = attemptService.listMine(currentUserAccessor.requireUserId(), page, size);
return ApiResponse.ok(PageResponse.of(
result.getContent(),
result.getTotalElements(), result.getNumber(), result.getSize()), requestId(request));
}
private Long resolveUserId(HttpServletRequest request) {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || !auth.isAuthenticated()) {
throw new BusinessException(ErrorCode.UNAUTHORIZED);
}
return Long.valueOf(auth.getName());
}
private String requestId(HttpServletRequest request) {
return request.getHeader("X-Request-Id");
result.getTotalElements(), result.getNumber(), result.getSize()), requestIdAccessor.get(request));
}
}

5
mydreamjob-server/src/main/java/com/mydreamjob/audit/AuditLog.java

@ -50,7 +50,10 @@ public class AuditLog {
@Column(name = "request_id", length = 32)
private String requestId;
@CreatedDate
// S02:不再使用 @CreatedDate。
// Spring Data 审计会在持久化时用“当前时间”覆盖显式设置的值,而 S02 认证入口
// (登录/刷新/登出/重放处置/全局吊销)要求审计时间与入口单次捕获的 capturedNow 同源。
// 所有写入方(AuditService、SecurityAuditService)本就显式设置 createdAt,移除后行为不变。
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
}

46
mydreamjob-server/src/main/java/com/mydreamjob/audit/AuditService.java

@ -22,7 +22,8 @@ public class AuditService {
public void log(Long userId, String action, String entityType, Long entityId,
String details, String ipAddress, String userAgent, String requestId) {
logWithContext(userId, action, entityType, entityId, details,
AuditContext.builder().requestId(requestId).ipAddress(ipAddress).userAgent(userAgent).build());
AuditContext.builder().requestId(requestId).ipAddress(ipAddress).userAgent(userAgent).build(),
LocalDateTime.now());
}
/**
@ -30,11 +31,22 @@ public class AuditService {
*/
public void log(Long userId, String action, String entityType, Long entityId,
String details, AuditContext context) {
logWithContext(userId, action, entityType, entityId, details, context);
logWithContext(userId, action, entityType, entityId, details, context, LocalDateTime.now());
}
/**
* 记录一个审计事件(显式时间戳版本,S02)。
*
* <p>供 S02 认证入口使用:登录、刷新、登出、重放处置与全局吊销在各自入口
* 只捕获一次 {@code capturedNow},审计时间必须与之同源,不得再次读时。</p>
*/
public void log(Long userId, String action, String entityType, Long entityId,
String details, AuditContext context, LocalDateTime capturedAt) {
logWithContext(userId, action, entityType, entityId, details, context, capturedAt);
}
private void logWithContext(Long userId, String action, String entityType, Long entityId,
String details, AuditContext ctx) {
String details, AuditContext ctx, LocalDateTime createdAt) {
try {
// 脱敏:details 中不得包含 code/password/token/secret 等关键词的值
String safeDetails = sanitize(details);
@ -47,7 +59,7 @@ public class AuditService {
.ipAddress(ctx != null ? ctx.getIpAddress() : null)
.userAgent(ctx != null ? ctx.getUserAgent() : null)
.requestId(ctx != null ? ctx.getRequestId() : null)
.createdAt(LocalDateTime.now())
.createdAt(createdAt)
.build();
auditLogRepository.save(auditLog);
} catch (Exception e) {
@ -73,6 +85,32 @@ public class AuditService {
log(userId, "LOGOUT", "USER", userId, "User logged out", ctx);
}
/** S02:登录会话创建,使用显式 capturedNow。 */
public void logSessionCreated(Long userId, String sessionRef, AuditContext ctx, LocalDateTime capturedAt) {
log(userId, "AUTH_SESSION_CREATED", "AUTH_SESSION", null,
"{\"sessionRef\":\"" + sessionRef + "\"}", ctx, capturedAt);
}
/** S02:Refresh Token 轮换成功,使用显式 capturedNow。 */
public void logTokenRefreshed(Long userId, String sessionRef, AuditContext ctx, LocalDateTime capturedAt) {
log(userId, "AUTH_TOKEN_REFRESHED", "AUTH_SESSION", null,
"{\"sessionRef\":\"" + sessionRef + "\"}", ctx, capturedAt);
}
/** S02:会话被撤销(登出、重放处置或全局吊销)。 */
public void logSessionRevoked(Long userId, String sessionRef, String reason,
AuditContext ctx, LocalDateTime capturedAt) {
log(userId, "AUTH_SESSION_REVOKED", "AUTH_SESSION", null,
"{\"sessionRef\":\"" + sessionRef + "\",\"reason\":\"" + reason + "\"}", ctx, capturedAt);
}
/** S02:检测到 Refresh Token 重放。 */
public void logRefreshReuseDetected(Long userId, String sessionRef,
AuditContext ctx, LocalDateTime capturedAt) {
log(userId, "AUTH_REFRESH_REUSE_DETECTED", "AUTH_SESSION", null,
"{\"sessionRef\":\"" + sessionRef + "\"}", ctx, capturedAt);
}
/**
* 脱敏:不记录可能的敏感字段值
*/

17
mydreamjob-server/src/main/java/com/mydreamjob/auth/AccessTokenClaims.java

@ -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) {
}

105
mydreamjob-server/src/main/java/com/mydreamjob/auth/AdminBootstrapCreator.java

@ -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 仓储)。 */
}

119
mydreamjob-server/src/main/java/com/mydreamjob/auth/AdminBootstrapRunner.java

@ -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());
}
}

74
mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthService.java

@ -1,5 +1,6 @@
package com.mydreamjob.auth;
import com.mydreamjob.audit.AuditContext;
import com.mydreamjob.audit.AuditService;
import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode;
@ -12,25 +13,34 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Clock;
import java.time.Instant;
import java.util.*;
/**
* Mock 认证服务 - 仅用于开发/测试环境登录原型
* 认证服务(S02):手机号验证码登录,并编排刷新与登出。
*
* <p>登录成功后创建服务端登录会话;Access/Refresh 由各自的密钥签发,
* 数据库只保存 Refresh Token 的 HMAC 摘要。</p>
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class AuthService {
/** 登录与刷新响应中的 expiresIn 秒数,保持既有契约不变(设计 D04)。 */
public static final long EXPIRES_IN_SECONDS = 86400L;
private final UserRepository userRepository;
private final JwtTokenProvider jwtTokenProvider;
private final SmsVerificationService smsVerificationService;
private final AuditService auditService;
private final AccountDeletionRepository accountDeletionRepository;
private final AccountDeletionPhoneHasher accountDeletionPhoneHasher;
private final AuthSessionService authSessionService;
private final Clock clock;
/**
* Mock 登录:验证短信验证码后自动创建/登录用户
* Mock 登录:验证短信验证码后自动创建/登录用户,并建立一次登录会话。
*/
@Transactional
public Map<String, Object> mockLogin(String phone, String code, String purpose,
@ -57,34 +67,62 @@ public class AuthService {
.verified(true)
.build();
user = userRepository.save(user);
log.info("Auto-created user: id={}, phone={}", user.getId(), phone);
// 日志不得记录完整手机号
log.info("Auto-created user: id={}", user.getId());
} else {
user = userOpt.get();
}
// 已注销(COMPLETED)用户禁止登录
java.util.Optional<AccountDeletion> deletion = accountDeletionRepository.findByUserId(user.getId());
Optional<AccountDeletion> deletion = accountDeletionRepository.findByUserId(user.getId());
if (deletion.isPresent() && deletion.get().getStatus() == DeletionStatus.COMPLETED) {
throw new BusinessException(ErrorCode.ACCOUNT_DELETED);
}
// 生成 token
String accessToken = jwtTokenProvider.generateAccessToken(user.getId(), user.getPhone(), Map.of("role", user.getRole().name()));
String refreshToken = jwtTokenProvider.generateRefreshToken(user.getId(), user.getPhone());
// 认证入口只取一次时间,后续 JWT iat/exp、数据库时间与审计全部同源。
Instant capturedNow = clock.instant();
AuditContext ctx = AuditContext.builder()
.requestId(requestId)
.ipAddress(ipAddress)
.userAgent(userAgent)
.build();
// 会话记录必须与登录在同一事务内持久化;失败则整个登录失败,不返回 Token。
AuthSessionService.SessionTokens tokens = authSessionService.createSession(user, capturedNow, ctx);
auditService.logLogin(user.getId(), ipAddress, userAgent, requestId);
auditService.log(user.getId(), "LOGIN", "USER", user.getId(), "User logged in", ctx, toLocal(capturedNow));
Map<String, Object> result = new HashMap<>();
result.put("userId", user.getId());
result.put("phone", user.getPhone());
result.put("nickname", user.getNickname());
result.put("role", user.getRole());
result.put("accessToken", accessToken);
result.put("refreshToken", refreshToken);
result.put("expiresIn", 86400);
result.put("accessToken", tokens.accessToken());
result.put("refreshToken", tokens.refreshToken());
result.put("expiresIn", EXPIRES_IN_SECONDS);
return result;
}
/**
* 刷新:消费并轮换 Refresh Token。
*
* @throws InvalidTokenException 任何失败(统一对外 401)
*/
public AuthSessionService.SessionTokens refresh(String refreshToken,
String requestId, String ipAddress, String userAgent) {
Instant capturedNow = clock.instant();
return authSessionService.refresh(refreshToken, capturedNow, context(requestId, ipAddress, userAgent));
}
/**
* 登出:撤销当前会话(幂等)。
*/
public void logout(Long userId, String sessionId,
String requestId, String ipAddress, String userAgent) {
Instant capturedNow = clock.instant();
authSessionService.logout(userId, sessionId, capturedNow, context(requestId, ipAddress, userAgent));
}
/**
* 获取当前用户信息
*/
@ -104,4 +142,16 @@ public class AuthService {
result.put("createdAt", user.getCreatedAt());
return result;
}
private AuditContext context(String requestId, String ipAddress, String userAgent) {
return AuditContext.builder()
.requestId(requestId)
.ipAddress(ipAddress)
.userAgent(userAgent)
.build();
}
private static java.time.LocalDateTime toLocal(Instant instant) {
return AuthSessionService.toLocal(instant);
}
}

332
mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthSessionService.java

@ -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);
}
}

48
mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthToken.java

@ -5,13 +5,22 @@ import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import java.time.LocalDateTime;
/**
* JWT Token 实体(用于 token 吊销和刷新)
* 登录会话中的 Refresh Token 记录(S02)。
*
* <p>字段含义(S02 固定):</p>
* <ul>
* <li>{@code tokenHash} —— **Refresh Token 原文的 HMAC-SHA256 摘要**,绝不保存原文;</li>
* <li>{@code tokenType} —— 新记录固定 {@code REFRESH};</li>
* <li>{@code expiredAt} —— Refresh Token 到期时间;</li>
* <li>{@code revoked} —— 兼容既有布尔语义,与 {@code revokedAt} 同步维护;</li>
* <li>{@code refreshTokenHash} —— 旧字段保留,S02 新逻辑不依赖。</li>
* </ul>
*
* <p>旧数据({@code sessionId IS NULL})永远不被新鉴权逻辑视为活动会话。</p>
*/
@Data
@Builder
@ -19,9 +28,11 @@ import java.time.LocalDateTime;
@AllArgsConstructor
@Entity
@Table(name = "auth_tokens")
@EntityListeners(AuditingEntityListener.class)
public class AuthToken {
/** Token 类型:S02 起 auth_tokens 只保存 Refresh 记录。 */
public static final String TYPE_REFRESH = "REFRESH";
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ -34,7 +45,7 @@ public class AuthToken {
@Column(name = "token_type", nullable = false, length = 20)
@Builder.Default
private String tokenType = "ACCESS";
private String tokenType = TYPE_REFRESH;
@Column(name = "expired_at", nullable = false)
private LocalDateTime expiredAt;
@ -46,7 +57,32 @@ public class AuthToken {
@Column(name = "refresh_token_hash", length = 255)
private String refreshTokenHash;
@CreatedDate
@Column(name = "session_id", length = 36)
private String sessionId;
@Column(name = "token_jti", length = 36)
private String tokenJti;
@Column(name = "parent_token_id")
private Long parentTokenId;
@Column(name = "consumed_at")
private LocalDateTime consumedAt;
@Column(name = "revoked_at")
private LocalDateTime revokedAt;
@Column(name = "revocation_reason", length = 40)
private String revocationReason;
@Version
@Column(name = "version", nullable = false)
@Builder.Default
private Long version = 0L;
// S02:不使用 @CreatedDate —— Spring Data 审计会在持久化时用“当前时间”覆盖显式值,
// 导致 created_at 与认证入口捕获的 capturedNow 不同源。生产写入路径只有
// AuthSessionService(创建会话、轮换后继)两处,均显式设置 createdAt。
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
}

71
mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthTokenHasher.java

@ -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);
}
}

99
mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthTokenRepository.java

@ -1,12 +1,21 @@
package com.mydreamjob.auth;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
/**
* auth_tokens 数据访问(S02)。
*
* <p>并发消费与撤销**一律通过带条件的原子 UPDATE** 完成,并严格检查受影响行数;
* 禁止以 {@code findById -> set -> save} 作为并发保证。</p>
*/
@Repository
public interface AuthTokenRepository extends JpaRepository<AuthToken, Long> {
@ -17,4 +26,94 @@ public interface AuthTokenRepository extends JpaRepository<AuthToken, Long> {
Optional<AuthToken> findByRefreshTokenHash(String refreshTokenHash);
List<AuthToken> findByExpiredAtBefore(LocalDateTime expiredAt);
// ── S02 会话查询 ────────────────────────────────────────────
/**
* 按摘要取候选 Refresh 记录(同摘要理论上唯一,取最新一条以防历史重复)。
*/
Optional<AuthToken> findTopByTokenHashOrderByIdDesc(String tokenHash);
/**
* 会话是否活动:同一 user + session 下存在未消费、未撤销且未过期的 Refresh 记录。
*
* <p>旧数据 {@code sessionId IS NULL} 永远不会命中。</p>
*/
@Query("""
SELECT COUNT(t) FROM AuthToken t
WHERE t.userId = :userId
AND t.sessionId = :sessionId
AND t.revoked = false
AND t.consumedAt IS NULL
AND t.expiredAt > :capturedNow
""")
long countActiveSessionTokens(@Param("userId") Long userId,
@Param("sessionId") String sessionId,
@Param("capturedNow") LocalDateTime capturedNow);
List<AuthToken> findByUserIdAndSessionIdOrderByIdAsc(Long userId, String sessionId);
// ── S02 原子条件更新(返回值必须被严格检查)────────────────────
/**
* 原子消费一条 Refresh 记录。只有满足以下全部条件才会成功:
* id、expectedVersion、未撤销、未消费、未过期。
*
* @return 受影响行数;0 表示并发冲突、已被消费、已被撤销或已过期
*/
@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query("""
UPDATE AuthToken t
SET t.consumedAt = :consumedAt,
t.version = t.version + 1
WHERE t.id = :id
AND t.version = :expectedVersion
AND t.revoked = false
AND t.consumedAt IS NULL
AND t.expiredAt > :capturedNow
""")
int consumeIfCurrent(@Param("id") Long id,
@Param("expectedVersion") Long expectedVersion,
@Param("consumedAt") LocalDateTime consumedAt,
@Param("capturedNow") LocalDateTime capturedNow);
/**
* 撤销某用户某个会话下所有未撤销记录。
*
* @return 受影响行数
*/
@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query("""
UPDATE AuthToken t
SET t.revoked = true,
t.revokedAt = :revokedAt,
t.revocationReason = :reason,
t.version = t.version + 1
WHERE t.userId = :userId
AND t.sessionId = :sessionId
AND t.revoked = false
""")
int revokeSession(@Param("userId") Long userId,
@Param("sessionId") String sessionId,
@Param("revokedAt") LocalDateTime revokedAt,
@Param("reason") String reason);
/**
* 撤销某用户全部未撤销记录(账号注销完成、用户禁用等全局安全事件)。
*
* @return 受影响行数
*/
@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query("""
UPDATE AuthToken t
SET t.revoked = true,
t.revokedAt = :revokedAt,
t.revocationReason = :reason,
t.version = t.version + 1
WHERE t.userId = :userId
AND t.revoked = false
""")
int revokeAllForUser(@Param("userId") Long userId,
@Param("revokedAt") LocalDateTime revokedAt,
@Param("reason") String reason);
}

16
mydreamjob-server/src/main/java/com/mydreamjob/auth/InvalidTokenException.java

@ -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");
}
}

17
mydreamjob-server/src/main/java/com/mydreamjob/auth/IssuedToken.java

@ -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) {
}

59
mydreamjob-server/src/main/java/com/mydreamjob/auth/JwtAuthenticationFilter.java

@ -9,16 +9,28 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.lang.Nullable;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.time.Clock;
import java.time.Instant;
import java.util.Collections;
/**
* JWT 认证过滤器
* S02 认证过滤器:只接受 Access Token。
*
* <p>与修复前的关键差异:</p>
* <ul>
* <li>只调用 {@link JwtTokenProvider#parseAndValidateAccessToken},
* Refresh Token、无 {@code type}、未知 {@code type}、错误 issuer/audience 一律拒绝;</li>
* <li>验签之后还必须通过服务端会话校验(会话存在、未消费、未撤销、未过期,
* 且用户 active、注销未完成);</li>
* <li>任何失败都不建立部分认证,并清理 SecurityContext;</li>
* <li>数据库或会话校验异常时**失败关闭**,绝不放行;</li>
* <li>日志只用固定安全文本,不使用异常 {@code getMessage()},不回显 Token、subject、sid、jti。</li>
* </ul>
*/
@Slf4j
@Component
@ -26,26 +38,47 @@ import java.util.Collections;
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtTokenProvider tokenProvider;
private final AuthSessionService authSessionService;
private final Clock clock;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String token = extractToken(request);
if (token != null && tokenProvider.validateToken(token)) {
try {
Long userId = tokenProvider.parseUserId(token);
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(userId, null, Collections.emptyList());
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authentication);
log.debug("Authenticated user {} from JWT", userId);
} catch (Exception e) {
log.warn("JWT authentication failed for token: {}", e.getMessage());
}
if (token != null) {
authenticate(request, token);
}
filterChain.doFilter(request, response);
}
private void authenticate(HttpServletRequest request, String token) {
try {
Instant capturedNow = clock.instant();
AccessTokenClaims claims = tokenProvider.parseAndValidateAccessToken(token, capturedNow);
if (!authSessionService.isSessionActive(claims.userId(), claims.sessionId(), capturedNow)) {
SecurityContextHolder.clearContext();
log.warn("JWT authentication rejected: session not active");
return;
}
// 资源权限仍以后续服务层校验为准,这里只建立“已认证用户”身份。
// principal 保持为 userId(既有约 40 处 Long.valueOf(auth.getName()) 依赖该形态),
// 已校验的 sessionId 通过 details 传递,供 /auth/logout 使用。
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(claims.userId(), null, Collections.emptyList());
authentication.setDetails(new SessionAuthenticationDetails(request, claims.sessionId()));
SecurityContextHolder.getContext().setAuthentication(authentication);
} catch (InvalidTokenException e) {
// 固定安全文本:不区分过期/类型错误/签名错误,也不回显任何 claim 原值。
SecurityContextHolder.clearContext();
} catch (Exception e) {
// 会话校验或数据库异常:失败关闭。
SecurityContextHolder.clearContext();
log.warn("JWT authentication rejected: internal check failed");
}
}
@Nullable
private String extractToken(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");

215
mydreamjob-server/src/main/java/com/mydreamjob/auth/JwtTokenProvider.java

@ -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();
}
}

16
mydreamjob-server/src/main/java/com/mydreamjob/auth/RefreshTokenClaims.java

@ -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) {
}

25
mydreamjob-server/src/main/java/com/mydreamjob/auth/SessionAuthenticationDetails.java

@ -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;
}
}

35
mydreamjob-server/src/main/java/com/mydreamjob/auth/SmsVerificationService.java

@ -1,6 +1,7 @@
package com.mydreamjob.auth;
import com.mydreamjob.audit.AuditService;
import com.mydreamjob.auth.sms.SmsCodeSender;
import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode;
import lombok.RequiredArgsConstructor;
@ -18,8 +19,10 @@ import java.util.regex.Pattern;
import java.util.concurrent.TimeUnit;
/**
* Mock 短信验证码服务
* 不接入真实短信网关,验证码直接生成并存入 Redis
* 短信验证码服务。
*
* <p>验证码只写入短信验证码表,并通过 {@link SmsCodeSender} 投递;
* <strong>任何级别的日志都不得包含验证码或完整手机号</strong>。</p>
*/
@Slf4j
@Service
@ -29,6 +32,7 @@ public class SmsVerificationService {
private final SmsVerificationCodeRepository codeRepository;
private final RedisTemplate<String, Object> redisTemplate;
private final AuditService auditService;
private final SmsCodeSender smsCodeSender;
@Value("${app.sms.mock.code-length:6}")
private int codeLength;
@ -49,7 +53,10 @@ public class SmsVerificationService {
private static final Pattern CODE_PATTERN = Pattern.compile("[0-9]{6}");
/**
* 发送验证码(Mock)
* 发送验证码。
*
* <p>发送动作委派给 {@link SmsCodeSender}:
* {@code app.sms.mock.enabled=false} 或未配置时使用不可用实现,安全失败并回滚本次验证码落库。</p>
*/
@Transactional
public void sendCode(String phone, String purpose,
@ -59,14 +66,14 @@ public class SmsVerificationService {
// 检查锁定
String lockoutKey = LOCKOUT_KEY_PREFIX + phone;
if (redisTemplate.hasKey(lockoutKey)) {
log.warn("Phone {} is locked out", phone);
log.warn("SMS send rejected: phone locked out, phone={}", maskPhone(phone));
throw new BusinessException(ErrorCode.SMS_RATE_LIMITED);
}
// 检查频率限制(60秒内最多1次)
String rateLimitKey = RATE_LIMIT_KEY_PREFIX + phone;
if (redisTemplate.hasKey(rateLimitKey)) {
log.warn("Phone {} rate limited", phone);
log.warn("SMS send rejected: rate limited, phone={}", maskPhone(phone));
throw new BusinessException(ErrorCode.SMS_RATE_LIMITED);
}
@ -89,14 +96,26 @@ public class SmsVerificationService {
// 设置频率限制
redisTemplate.opsForValue().set(rateLimitKey, "1", 60, TimeUnit.SECONDS);
// Mock: 打印验证码到日志(不发送真实短信)
log.info("MOCK SMS CODE: phone={}, purpose={}, code={}", phone, purpose, code);
// 投递验证码:实现方保证不把验证码/完整手机号写入日志。
// 不可用实现会在此抛出 SERVICE_UNAVAILABLE,令本次事务回滚(不产生虚假的“发送成功”)。
smsCodeSender.sendVerificationCode(phone, purpose, code);
// 审计明细同样不得记录完整手机号
auditService.log(null, "SMS_SEND", "PHONE", null,
"Mock SMS sent to " + phone + " for " + purpose,
"SMS send accepted; phone=" + maskPhone(phone) + "; purpose=" + purpose,
ipAddress, userAgent, requestId);
}
/**
* 手机号脱敏:仅保留前 3 位与后 4 位,用于日志与审计明细。
*/
private String maskPhone(String phone) {
if (phone == null || phone.length() < 7) {
return "***";
}
return phone.substring(0, 3) + "****" + phone.substring(phone.length() - 4);
}
/**
* 验证验证码
* 按手机号+用途查询最新一条未使用记录,原子性标记已使用。

21
mydreamjob-server/src/main/java/com/mydreamjob/auth/dto/RefreshTokenRequest.java

@ -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;
}

15
mydreamjob-server/src/main/java/com/mydreamjob/auth/dto/TokenPairResponse.java

@ -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) {
}

23
mydreamjob-server/src/main/java/com/mydreamjob/auth/sms/MockSmsCodeSender.java

@ -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)");
}
}

24
mydreamjob-server/src/main/java/com/mydreamjob/auth/sms/SmsCodeSender.java

@ -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);
}

37
mydreamjob-server/src/main/java/com/mydreamjob/auth/sms/SmsSenderConfiguration.java

@ -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();
}
}

25
mydreamjob-server/src/main/java/com/mydreamjob/auth/sms/UnavailableSmsCodeSender.java

@ -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);
}
}

30
mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/ApplicationMatchController.java

@ -8,8 +8,6 @@ import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@ -22,6 +20,11 @@ import java.util.Map;
@RequiredArgsConstructor
public class ApplicationMatchController {
/** R01-A4:身份只来自安全上下文,不接受请求参数传入的 userId。 */
private final com.mydreamjob.security.CurrentUserAccessor currentUserAccessor;
/** R01-A4:requestId 只来自过滤器规范化后的唯一产出点。 */
private final com.mydreamjob.common.logging.RequestIdAccessor requestIdAccessor;
private final BidirectionalMatchService matchService;
@Operation(summary = "提交求职者岗位意愿分", description = "仅投递本人(STUDENT);0-100;MATCHED/CLOSED 后不可修改")
@ -30,10 +33,10 @@ public class ApplicationMatchController {
@PathVariable Long id,
@Valid @RequestBody ScoreRequest req,
HttpServletRequest request) {
Long userId = resolveUserId(request);
Long userId = currentUserAccessor.requireUserId();
ApplicationMatchRecord record = matchService.submitCandidateInterestScore(
userId, id, req.getScore(), com.mydreamjob.audit.AuditContext.from(request));
return ResponseEntity.ok(ApiResponse.ok(toView(record), getRequestId(request)));
return ResponseEntity.ok(ApiResponse.ok(toView(record), requestIdAccessor.get(request)));
}
@Operation(summary = "提交企业 HR 适配分", description = "仅岗位所属企业;0-100;MATCHED/CLOSED 后不可修改")
@ -42,10 +45,10 @@ public class ApplicationMatchController {
@PathVariable Long id,
@Valid @RequestBody ScoreRequest req,
HttpServletRequest request) {
Long userId = resolveUserId(request);
Long userId = currentUserAccessor.requireUserId();
ApplicationMatchRecord record = matchService.submitHrSuitabilityScore(
userId, id, req.getScore(), com.mydreamjob.audit.AuditContext.from(request));
return ResponseEntity.ok(ApiResponse.ok(toView(record), getRequestId(request)));
return ResponseEntity.ok(ApiResponse.ok(toView(record), requestIdAccessor.get(request)));
}
@Operation(summary = "查询投递匹配状态", description = "仅投递求职者或岗位所属企业;不暴露内部路径/事件 ID")
@ -53,8 +56,8 @@ public class ApplicationMatchController {
public ResponseEntity<ApiResponse<Map<String, Object>>> getMatch(
@PathVariable Long id,
HttpServletRequest request) {
Long userId = resolveUserId(request);
return ResponseEntity.ok(ApiResponse.ok(matchService.getMatchView(userId, id), getRequestId(request)));
Long userId = currentUserAccessor.requireUserId();
return ResponseEntity.ok(ApiResponse.ok(matchService.getMatchView(userId, id), requestIdAccessor.get(request)));
}
/** 评分接口返回记录视图(脱敏,不含内部字段) */
@ -74,15 +77,4 @@ public class ApplicationMatchController {
return v;
}
private Long resolveUserId(HttpServletRequest request) {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || !auth.isAuthenticated())
throw new com.mydreamjob.common.exception.BusinessException(
com.mydreamjob.common.exception.ErrorCode.UNAUTHORIZED);
return Long.valueOf(auth.getName());
}
private String getRequestId(HttpServletRequest request) {
return request.getHeader("X-Request-Id");
}
}

Some files were not shown because too many files changed in this diff

Loading…
Cancel
Save