Browse Source

Merge pull request 'fix: harden authentication, privacy, search, and scoring infrastructure' (#1) from refactor/r01-structure-performance into master

master
zhangshun 1 month ago
parent
commit
dedd47e3db
  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. 27
      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. 215
      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. 53
      mydreamjob-server/src/main/java/com/mydreamjob/auth/JwtAuthenticationFilter.java
  90. 213
      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" 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 \ curl -X POST http://localhost:8080/api/v1/auth/login \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{"phone":"13800000001","code":"<验证码>"}' -d '{"phone":"13800000001","code":"<验证码>"}'
@ -145,11 +145,11 @@ mvn spring-boot:run -Dspring-boot.run.profiles=dev \
| STUDENT | 13800000002 | 学生测试 | | STUDENT | 13800000002 | 学生测试 |
| COMPANY | 13800000003 | 企业测试 | | 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. **业务模块**: 岗位、投递、聊天、评分等模块仅定义接口骨架 2. **业务模块**: 岗位、投递、聊天、评分等模块仅定义接口骨架
3. **RabbitMQ 监听**: 队列已定义但未实现消费者(Week 2+) 3. **RabbitMQ 监听**: 队列已定义但未实现消费者(Week 2+)
4. **Actuator 健康**: RabbitMQ 健康检查未配置(仅 ping) 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次 - 60秒内同一手机号只能发送1次
- 5次错误后锁定5分钟 - 5次错误后锁定5分钟
- Mock环境验证码打印到日志 - Mock 环境验证码**不输出到日志**;本地按 `docs/frontend-integration.md` §3 的受控方式取得(生产禁止查表)
### 2. Mock 登录 ### 2. Mock 登录
``` ```
@ -169,12 +169,65 @@ http://localhost:8080/swagger-ui.html
http://localhost:8080/v3/api-docs 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. 无企业注册/认证流程 2. 无企业注册/认证流程
3. 无岗位、投递、聊天、评分等业务功能 3. 无密码修改/重置功能
4. 无 Token 刷新接口(Week 2+) 4. 未提供查看全部登录设备、远程踢出单个设备、Token introspection 等接口
5. 无密码修改/重置功能 5. RabbitMQ 未实现消息监听器(仅队列定义)
6. RabbitMQ 未实现消息监听器(仅队列定义) 6. Actuator 健康检查部分依赖(Redis/RabbitMQ)在未启动时返回 DOWN
7. Actuator 健康检查部分依赖(Redis/RabbitMQ)在未启动时返回 DOWN

5
docs/backend-development-roadmap.md

@ -11,6 +11,11 @@
| V43 | 独立代码复验、真实 MySQL/Docker 验收及 Git 检查点已完成(cf39737) | N01 已关闭版本内小样本补集泄露;30/300/3000 查询预算通过,EXPLAIN 扩容风险保留观察 | | V43 | 独立代码复验、真实 MySQL/Docker 验收及 Git 检查点已完成(cf39737) | N01 已关闭版本内小样本补集泄露;30/300/3000 查询预算通过,EXPLAIN 扩容风险保留观察 |
| 评分规则解释与合理性 | N02 独立代码复验、真实 MySQL/Docker 验收及 Git 检查点已完成(dbd27e6) | 保持分数、权重、排名和企业评分契约不变 | | 评分规则解释与合理性 | N02 独立代码复验、真实 MySQL/Docker 验收及 Git 检查点已完成(dbd27e6) | 保持分数、权重、排名和企业评分契约不变 |
| 消息撤回与会话一致性 | N07 定向整改完成并通过自动化与干净构建,待独立代码复验与真实 MySQL/Docker 验收 | 两分钟窗口、发送者权限、正文覆盖、幂等并发、DIRECT/邀约隐私均已锁定 | | 消息撤回与会话一致性 | 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仍属一期,细节设计后编码 | | 其余五个功能组 | 技能词云、入职清单/公司知识、薪资计算器/参考、诚信积分/激励、简历查重 | N03–N06、N08仍属一期,细节设计后编码 |
| 上线保障 | 现有资产待逐项核验 | G01–G04,一期交付门槛 | | 上线保障 | 现有资产待逐项核验 | G01–G04,一期交付门槛 |
| 后续候选池 | AI简历、AI模拟面试/预测、SHAP高级解释等 | 以清单第6节为准,移出一期 | | 后续候选池 | 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`。 健康检查:`http://127.0.0.1:8080/api/v1/health` 返回 `status: UP`。
## 3. Mock 验证码日志位置 ## 3. 本地获取测试验证码(不通过日志)
开发环境短信为 Mock,验证码打印在**服务控制台/stdout 日志**中: **应用日志不会输出验证码**,也不得从控制台 / stdout / 日志文件中读取验证码。
``` 本地 dev/test 联调请使用受控方式取得验证码:
MOCK SMS CODE: phone=13800000002, purpose=login, code=483920
``` 1. 启动应用时显式开启 Mock 短信:`SMS_MOCK_ENABLED=true`(即 `app.sms.mock.enabled=true`)
2. 显式授权本次执行读取验证码表:`ALLOW_TEST_OTP_DB_READ=true`
3. 通过受控测试脚本或数据库内部只读查询取得验证码;脚本内部取码,不向终端展示
- 运行 `mvn spring-boot:run` → 看终端输出 `docs/smoke-test.sh` 已按上述双重保护实现;两项条件任一不满足即输出 `test OTP retrieval is disabled` 并安全失败。
- 后台运行 → 看重定向的日志文件(如 `logs/mydreamjob.log`)
- 每次发码后取**最新一条** `code=<6位数字>` **约束:**
- 生产环境**禁止**查询验证码表辅助登录
- 当前尚未接入真实短信供应商;`app.sms.mock.enabled` 为 `false`、缺失、空值或非法值时,发码返回“服务不可用”,绝不返回虚假成功
- 验证码当前仍以**明文**存储于 `sms_verification_codes`,属已知遗留风险(未加密、未哈希)
## 4. Apifox 导入 OpenAPI ## 4. Apifox 导入 OpenAPI
@ -54,9 +59,9 @@ MOCK SMS CODE: phone=13800000002, purpose=login, code=483920
4. 点击「确认导入」,Apifox 自动生成全部接口、参数、响应模型 4. 点击「确认导入」,Apifox 自动生成全部接口、参数、响应模型
5. 在 Apifox 中可直接调试:登录接口获取 token 后,设置「环境变量 `token`」,在接口「Authorization」填 `Bearer {{token}}` 5. 在 Apifox 中可直接调试:登录接口获取 token 后,设置「环境变量 `token`」,在接口「Authorization」填 `Bearer {{token}}`
## 5. Token 使用方式 ## 5. Token 使用方式(S02)
登录成功返回 `accessToken`(有效期 24h)与 `refreshToken`。 登录成功返回 `accessToken`(有效期 24h)与 `refreshToken`(有效期 7 天)。
```json ```json
{ {
@ -76,6 +81,36 @@ MOCK SMS CODE: phone=13800000002, purpose=login, code=483920
Authorization: Bearer <accessToken> 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. 请求头 ## 3. 请求头
| Header | 必需 | 说明 | | Header | 必需 | 说明 |
@ -141,7 +176,7 @@ Authorization: Bearer <accessToken>
**登录方式**(开发环境 Mock): **登录方式**(开发环境 Mock):
1. `POST /auth/sms/send?phone=<手机号>` 发送验证码 1. `POST /auth/sms/send?phone=<手机号>` 发送验证码
2. 验证码打印在服务日志:`MOCK SMS CODE: ... code=xxxxxx` 2. 验证码**不再输出到日志**;本地按 §3 的受控方式取得(生产禁止查表)
3. `POST /auth/login` 携带 `{phone, code, purpose:"login"}` 获取 token 3. `POST /auth/login` 携带 `{phone, code, purpose:"login"}` 获取 token
## 7. 典型联调流程 ## 7. 典型联调流程
@ -442,6 +477,8 @@ Authorization: Bearer <accessToken>
- 值可能是**相对存储路径**或文件存储地址,**不保证是完整的 `http/https` URL**。 - 值可能是**相对存储路径**或文件存储地址,**不保证是完整的 `http/https` URL**。
- 前端**下载简历应调用 `GET /api/v1/profile/me/resume`**,不要直接拼接 `resumeUrl` 做请求地址。 - 前端**下载简历应调用 `GET /api/v1/profile/me/resume`**,不要直接拼接 `resumeUrl` 做请求地址。
- 上传仍仅接受 PDF/DOC/DOCX 且不超过 10MB;服务端会同时校验扩展名、Content-Type 和实际文件结构。伪装扩展名、损坏文件、危险 DOCX 压缩包或含路径/控制字符的文件名返回 400,前端不要仅依据浏览器 MIME 判断文件有效。
- 上传替换失败时原简历保持可用;成功提交后旧文件才会清理。该流程不改变现有请求字段或响应结构。
### 12.12 graduationYear 特别说明 ### 12.12 graduationYear 特别说明
@ -953,3 +990,27 @@ V43 仅供管理员后台使用,前端不得向学生或企业展示。当前
可用接口、参数和小样本规则见 [candidate-scoring-fairness-api.md](candidate-scoring-fairness-api.md)。前端不得根据城市、学校、姓名等字段创建敏感人群筛选,也不得尝试通过多次小样本查询反推出候选人。 可用接口、参数和小样本规则见 [candidate-scoring-fairness-api.md](candidate-scoring-fairness-api.md)。前端不得根据城市、学校、姓名等字段创建敏感人群筛选,也不得尝试通过多次小样本查询反推出候选人。
总览、维度和申诉响应都显式返回 `availability/reason`。状态/版本/回滚原因及分布桶均按独立候选人数计算;`appealCount` 仅表示事件数。`SUPPRESSED` 时不得用总体计数、补集或其它接口字段反推出被隐藏比例,`NO_DATA`、`INSUFFICIENT_SAMPLE`、`SUPPRESSED` 均不得渲染为 `NORMAL`。 总览、维度和申诉响应都显式返回 `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。 > 现网 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": [ "tags": [
"认证" "认证"
], ],
"summary": "发送短信验证码(Mock)", "summary": "发送短信验证码",
"description": "开发环境 Mock:验证码会打印到日志", "description": "开发/测试使用 Mock 发送实现;生产环境未接入短信网关时返回服务不可用。验证码不会写入日志。",
"operationId": "sendSmsCode", "operationId": "sendSmsCode",
"parameters": [ "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": { "components": {
@ -11881,19 +11969,30 @@
}, },
"limitations": { "limitations": {
"type": "array", "type": "array",
"items": { "type": "string" } "items": {
"type": "string"
}
}, },
"evidenceLevel": { "evidenceLevel": {
"type": "string", "type": "string",
"enum": ["DIRECT_RULE", "SELF_REPORTED", "BEHAVIOR_PROXY", "UNVALIDATED_PROXY"] "enum": [
"DIRECT_RULE",
"SELF_REPORTED",
"BEHAVIOR_PROXY",
"UNVALIDATED_PROXY"
]
}, },
"ruleContributions": { "ruleContributions": {
"type": "array", "type": "array",
"items": { "$ref": "#/components/schemas/CandidateScoringRuleContribution" } "items": {
"$ref": "#/components/schemas/CandidateScoringRuleContribution"
}
}, },
"improvementActions": { "improvementActions": {
"type": "array", "type": "array",
"items": { "type": "string" } "items": {
"type": "string"
}
}, },
"explanationVersion": { "explanationVersion": {
"type": "string" "type": "string"
@ -15301,41 +15400,91 @@
"CandidateScoringCoverage": { "CandidateScoringCoverage": {
"type": "object", "type": "object",
"properties": { "properties": {
"computableDimensions": { "type": "integer", "format": "int32" }, "computableDimensions": {
"totalDimensions": { "type": "integer", "format": "int32" }, "type": "integer",
"includedDimensions": { "type": "array", "items": { "type": "string" } }, "format": "int32"
},
"totalDimensions": {
"type": "integer",
"format": "int32"
},
"includedDimensions": {
"type": "array",
"items": {
"type": "string"
}
},
"excludedDimensions": { "excludedDimensions": {
"type": "array", "type": "array",
"items": { "$ref": "#/components/schemas/CandidateScoringExcludedDimension" } "items": {
"$ref": "#/components/schemas/CandidateScoringExcludedDimension"
}
} }
} }
}, },
"CandidateScoringExcludedDimension": { "CandidateScoringExcludedDimension": {
"type": "object", "type": "object",
"properties": { "properties": {
"dimension": { "type": "string" }, "dimension": {
"status": { "type": "string" }, "type": "string"
"reason": { "type": "string" } },
"status": {
"type": "string"
},
"reason": {
"type": "string"
}
} }
}, },
"CandidateScoringRuleContribution": { "CandidateScoringRuleContribution": {
"type": "object", "type": "object",
"properties": { "properties": {
"ruleCode": { "type": "string" }, "ruleCode": {
"outcome": { "type": "string", "enum": ["APPLIED", "NOT_APPLIED", "MISSING", "EXPIRED", "UNAVAILABLE"] }, "type": "string"
"contribution": { "type": "number", "nullable": true }, },
"maxContribution": { "type": "number", "nullable": true }, "outcome": {
"label": { "type": "string" }, "type": "string",
"observation": { "type": "string" }, "enum": [
"explanation": { "type": "string" } "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": { "CandidateScoringValidity": {
"type": "object", "type": "object",
"properties": { "properties": {
"completedAt": { "type": "string", "format": "date-time" }, "completedAt": {
"validUntil": { "type": "string", "format": "date-time" }, "type": "string",
"expired": { "type": "boolean" } "format": "date-time"
},
"validUntil": {
"type": "string",
"format": "date-time"
},
"expired": {
"type": "boolean"
}
} }
}, },
"StudentProfileResponse": { "StudentProfileResponse": {
@ -15632,6 +15781,48 @@
"format": "date-time" "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": { "securitySchemes": {

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

@ -29,6 +29,32 @@
| C06 评价与企业回复 | 评价、预审、管理员决策、企业官方回复 | review/、feedback/ | 公开载荷无学生身份;仅通过回复公开;幂等及审核并发;企业归属 | 小刘 / 张顺 | | C06 评价与企业回复 | 评价、预审、管理员决策、企业官方回复 | review/、feedback/ | 公开载荷无学生身份;仅通过回复公开;幂等及审核并发;企业归属 | 小刘 / 张顺 |
| C07 测评与评分治理 | RIASEC、四维能力、评分卡、排名、申诉回滚、防刷 | assessment/、ability/、candidatescore/、competition/、risk/ | 抽题/计时/有效期/规则开关;申诉回滚;评分读取与写入边界;结果解释 | 张顺 / 小刘 | | C07 测评与评分治理 | RIASEC、四维能力、评分卡、排名、申诉回滚、防刷 | assessment/、ability/、candidatescore/、competition/、risk/ | 抽题/计时/有效期/规则开关;申诉回滚;评分读取与写入边界;结果解释 | 张顺 / 小刘 |
| C08 推荐与人才邀约 | 岗位推荐、学生授权发现、企业邀约与接受后沟通 | recommendation/、talentdiscovery/、talentinvitation/ | 默认不发现;同企业历史投递排除;限额/冷却/幂等;接受后会话隐私 | 张顺 / 小刘 | | 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. 一期必须补齐的八个功能组 ## 3. 一期必须补齐的八个功能组

27
docs/privacy-settings-api.md

@ -100,3 +100,30 @@
## 6. 审计 ## 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 字符,可空 | | `name` | 否 | ≤120 字符,可空 |
默认状态 `ACTIVE`、**非默认**;版本号自动分配(`max+1`)。并发创建由 DB 唯一约束兜底,冲突自动重试(独立事务)。 默认状态 `ACTIVE`、**非默认**;版本号自动分配(`max+1`)。并发创建由 DB 唯一约束兜底,冲突自动重试(独立事务)。
@ -157,6 +157,10 @@ GET /resume-export-tasks/{taskId}/download
- 身份一律来自 JWT(`auth.getName()` = userId),**不信任请求体/路径中的 userId**; - 身份一律来自 JWT(`auth.getName()` = userId),**不信任请求体/路径中的 userId**;
- 不能读取其他用户的简历(owner 校验); - 不能读取其他用户的简历(owner 校验);
- 不返回内部堆栈;错误响应不泄露数据库结构。 - 不返回内部堆栈;错误响应不泄露数据库结构。
- 上传文件名拒绝路径分隔符、控制字符和响应头换行;下载历史文件时再次净化文件名。
- PDF 校验文件头和结尾标记;DOC 校验 OLE/Word 文档结构标记;DOCX 校验 OOXML 必需条目,并限制 ZIP 条目数、解压总量和压缩比,拒绝路径穿越及压缩炸弹。
- 文件替换采用“新文件落盘 → 数据库更新 → 事务提交后删除旧文件”;回滚时清理新文件。底层本地存储先写 `.uploading` 临时文件,再原子移动到最终随机存储名,失败不暴露半文件。
- 上述措施属于格式与资源安全校验,**不等同于病毒查杀或沙箱检测**;生产部署仍应在存储接入层配置恶意文件扫描。
## 5. 异步导出设计 ## 5. 异步导出设计
@ -182,7 +186,7 @@ GET /resume-export-tasks/{taskId}/download
前置:启动基础设施 → 启动应用 → 导入 `docs/openapi-rc.json`(或 `GET /api/v1/v3/api-docs`)。 前置:启动基础设施 → 启动应用 → 导入 `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`。 2. `POST /profile/me/resume-versions`(multipart:`file` + `name=秋招版`)→ 200,`versionNo=1`。
3. 再次上传 → `versionNo=2`;设置 `name=社招版`。 3. 再次上传 → `versionNo=2`;设置 `name=社招版`。
4. `GET /profile/me/resume-versions` → 列表含两个版本。 4. `GET /profile/me/resume-versions` → 列表含两个版本。
@ -199,6 +203,7 @@ GET /resume-export-tasks/{taskId}/download
4. **默认简历仅作用于版本体系**:不联动旧 `student_profiles.resume_url`(按确认保持独立)。 4. **默认简历仅作用于版本体系**:不联动旧 `student_profiles.resume_url`(按确认保持独立)。
5. **被导出任务引用的旧文件可能延迟清理**:替换版本文件时,若旧文件正被 PENDING/RUNNING 导出任务引用则保留(保导出一致性);任务结束后这些文件不会自动回收,需后续清理策略。 5. **被导出任务引用的旧文件可能延迟清理**:替换版本文件时,若旧文件正被 PENDING/RUNNING 导出任务引用则保留(保导出一致性);任务结束后这些文件不会自动回收,需后续清理策略。
6. **OpenAPI 快照**:`docs/openapi-rc.json` 由 H2 集成测试上下文导出(**49 路径**,server URL 为测试默认值),接入现网后可用 `GET /api/v1/v3/api-docs` 重新导出。 6. **OpenAPI 快照**:`docs/openapi-rc.json` 由 H2 集成测试上下文导出(**49 路径**,server URL 为测试默认值),接入现网后可用 `GET /api/v1/v3/api-docs` 重新导出。
7. **不包含恶意软件扫描**:当前只执行文件格式、OOXML 结构、路径和资源消耗防护;不对宏、嵌入对象或恶意载荷作杀毒结论。
## 10. 错误码(新增 82xx) ## 10. 错误码(新增 82xx)
@ -211,3 +216,41 @@ GET /resume-export-tasks/{taskId}/download
| 8205 | `DUPLICATE_DEFAULT_RESUME` | 409 | 并发设置默认冲突 | | 8205 | `DUPLICATE_DEFAULT_RESUME` | 409 | 并发设置默认冲突 |
| 8206 | `RESUME_EXPORT_NOT_READY` | 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 黑盒验收。”

215
docs/smoke-test.sh

@ -1,20 +1,56 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# ── xtrace 防护(S01 第三轮 F4)───────────────────────────────
# 必须在读取任何环境变量、组装任何请求之前关闭 xtrace。
# 脚本是独立可执行程序,安全优先:全程不重新 set -x,
# 避免验证码 / 完整手机号 / LOGIN_BODY / 登录响应 / TOKEN / AUTH 进入 stderr。
case "$-" in
*x*) set +x ;;
esac
set -u set -u
# MyDreamJob backend RC smoke test. # MyDreamJob backend RC smoke test.
# Required tools: curl, grep, sed, head, tail. # Required tools: curl, grep, sed, head, tail, docker(仅取测试验证码时)。
# Environment: # Environment:
# BASE_URL API base URL (default: http://127.0.0.1:8080/api/v1) # BASE_URL API base URL (default: http://127.0.0.1:8080/api/v1)
# STUDENT_PHONE Fresh 11-digit phone; generated when omitted # STUDENT_PHONE Fresh 11-digit phone; generated when omitted
# COMPANY_TOKEN Company JWT; company status step is SKIP when omitted # COMPANY_TOKEN Company JWT; company status step is SKIP when omitted
# SERVER_LOG Server log path used to read Mock SMS codes # 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}" BASE_URL="${BASE_URL:-http://127.0.0.1:8080/api/v1}"
SERVER_LOG="${SERVER_LOG:-}"
PASS=0 PASS=0
FAIL=0 FAIL=0
SKIP=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 if [ -n "${STUDENT_PHONE:-}" ]; then
PHONE="$STUDENT_PHONE" PHONE="$STUDENT_PHONE"
else else
@ -27,20 +63,113 @@ ok() { echo "[PASS] $1"; PASS=$((PASS + 1)); }
fail() { echo "[FAIL] $1"; FAIL=$((FAIL + 1)); } fail() { echo "[FAIL] $1"; FAIL=$((FAIL + 1)); }
skip() { echo "[SKIP] $1"; SKIP=$((SKIP + 1)); } skip() { echo "[SKIP] $1"; SKIP=$((SKIP + 1)); }
resolve_log() { # 手机号脱敏(控制台输出不展示完整号码)
case "$SERVER_LOG" in mask_phone() {
[A-Za-z]:/*) local p="${1:-}"
local drive rest if [ "${#p}" -lt 7 ]; then
drive="$(printf '%s' "${SERVER_LOG:0:1}" | tr '[:upper:]' '[:lower:]')" printf '%s' '***'
rest="${SERVER_LOG:2}" return
if [ -d /mnt/c ]; then fi
echo "/mnt/$drive$rest" printf '%s****%s' "${p:0:3}" "${p: -4}"
else }
echo "/$drive$rest"
# ── 布尔解析(大小写/空白不敏感,仅 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 fi
;;
*) echo "$SERVER_LOG" ;; 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 esac
if [ "$valid" -ne 1 ]; then
unset code
echo "test OTP retrieval failed" >&2
return 1
fi
REPLY="$code"
unset code
return 0
} }
report() { report() {
@ -53,6 +182,23 @@ report() {
echo " http=$http_code ${biz_code:-code=N/A} ${request_id:-requestId=N/A}" 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() { extract_token() {
printf '%s' "$1" | sed -n 's/.*"accessToken"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n 1 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 printf '%s' "$1" | sed -n 's/.*"id"[[:space:]]*:[[:space:]]*\([0-9][0-9]*\).*/\1/p' | head -n 1
} }
sms_code() { # ── 可测试性钩子 ───────────────────────────────────────────
local log_path # 供 shell 单元测试加载函数(验证取码保护与泄漏防护)而不执行完整流程。
log_path="$(resolve_log)" # 正常运行(不设该变量)时完全不生效,不影响任何行为。
[ -n "$log_path" ] || return 1 if [ "${SMOKE_TEST_LIB_ONLY:-}" = "1" ]; then
[ -f "$log_path" ] || return 1 return 0 2>/dev/null || exit 0
grep -a "MOCK SMS CODE.*phone=$PHONE" "$log_path" | tail -n 1 | grep -a -oE 'code=[0-9]{6}' | cut -d= -f2 fi
}
echo "=== MyDreamJob backend smoke test ===" echo "=== MyDreamJob backend smoke test ==="
echo "BASE_URL=$BASE_URL" echo "BASE_URL=$BASE_URL"
echo "PHONE=$PHONE" echo "PHONE=$(mask_phone "$PHONE")"
say "GET /health" say "GET /health"
HEALTH="$(curl -sS -w '\n%{http_code}' "$BASE_URL/health" || true)" 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" \ 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 -H "X-Request-Id: smoke-sms-$attempt" >/dev/null 2>&1 || true
sleep 2 sleep 2
SMS="$(sms_code || true)" if sms_code; then
SMS="$REPLY"
unset REPLY
[ -n "$SMS" ] && break [ -n "$SMS" ] && break
fi
done done
if [ -z "$SMS" ]; then if [ -z "$SMS" ]; then
fail "cannot read Mock SMS code; set SERVER_LOG to the server log path" fail "login skipped"
unset SMS
TOKEN="" TOKEN=""
else 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 'Content-Type: application/json' \
-H 'X-Request-Id: smoke-login-001' \ -H 'X-Request-Id: smoke-login-001' \
-d "{\"phone\":\"$PHONE\",\"code\":\"$SMS\",\"purpose\":\"login\"}" || true)" --data-binary @- || true)"
report "$LOGIN" unset LOGIN_BODY
unset SMS
# 只保留可安全打印的三个字段;登录响应全文绝不交给打印函数,也绝不落盘
LOGIN_FIELDS="$(extract_status_fields "$LOGIN")"
TOKEN="$(extract_token "$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 if [ -n "$TOKEN" ]; then ok "login"; else fail "login"; fi
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')) d = json.load(open(p, encoding='utf-8'))
print('paths:', len(d['paths'])) 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'] 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 assert '/conversations/{id}/messages' in paths and '/conversations/{id}/read' in paths
print(' N07 recall path/schema validated; existing messaging operations preserved') 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()) operations = sum(len(methods) for methods in d['paths'].values())
print('operations:', operations) 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(" Offer advance endpoints all present")
print(" 26 operations all present") print(" 26 operations all present")
print(" Account deletion endpoints present (3 operations)") print(" Account deletion endpoints present (3 operations)")

5
mydreamjob-server/pom.xml

@ -54,6 +54,11 @@
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId> <artifactId>spring-boot-starter-actuator</artifactId>
</dependency> </dependency>
<!-- S03-C:Actuator 暴露 prometheus 端点所需;缺少该依赖时 /actuator/prometheus 不存在。 -->
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-registry-prometheus</artifactId>
</dependency>
<!-- Flyway --> <!-- Flyway -->
<dependency> <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.audit.AuditService;
import com.mydreamjob.auth.User; import com.mydreamjob.auth.User;
import com.mydreamjob.auth.UserRepository; import com.mydreamjob.auth.UserRepository;
import com.mydreamjob.candidatescore.refresh.CandidateScoringRefreshRequestService;
import com.mydreamjob.common.exception.BusinessException; import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode; import com.mydreamjob.common.exception.ErrorCode;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@ -43,6 +44,7 @@ public class AbilityAttemptService {
private static final int MAX_PAGE_SIZE = 50; private static final int MAX_PAGE_SIZE = 50;
private static final String[] DIMENSIONS = {"LOGIC", "COMMUNICATION", "EXECUTION", "LEARNING"}; private static final String[] DIMENSIONS = {"LOGIC", "COMMUNICATION", "EXECUTION", "LEARNING"};
private final com.mydreamjob.security.UserRoleGuard userRoleGuard;
private final AbilityDefinitionRepository definitionRepository; private final AbilityDefinitionRepository definitionRepository;
private final AbilityQuestionRepository questionRepository; private final AbilityQuestionRepository questionRepository;
private final AbilityAttemptRepository attemptRepository; private final AbilityAttemptRepository attemptRepository;
@ -56,12 +58,14 @@ public class AbilityAttemptService {
private final UserRepository userRepository; private final UserRepository userRepository;
private final AuditService auditService; private final AuditService auditService;
private final Clock clock; private final Clock clock;
/** R01-D4-1:能力测评提交成功后登记评分快照刷新(ABILITY 维度)。 */
private final CandidateScoringRefreshRequestService scoringRefreshRequestService;
// ─────────────────────────── 创建答卷 ─────────────────────────── // ─────────────────────────── 创建答卷 ───────────────────────────
@Transactional(isolation = Isolation.READ_COMMITTED) @Transactional(isolation = Isolation.READ_COMMITTED)
public AbilityAttemptResponse create(Long userId, CreateAbilityAttemptRequest req, AuditContext ctx) { public AbilityAttemptResponse create(Long userId, CreateAbilityAttemptRequest req, AuditContext ctx) {
requireStudent(userId); userRoleGuard.requireStudent(userId);
AbilityDefinition def = definitionRepository.findByTypeCode(req.getTypeCode()) AbilityDefinition def = definitionRepository.findByTypeCode(req.getTypeCode())
.orElseThrow(() -> new BusinessException(ErrorCode.ABILITY_TYPE_NOT_FOUND)); .orElseThrow(() -> new BusinessException(ErrorCode.ABILITY_TYPE_NOT_FOUND));
if (!AbilityDefinition.STATUS_ACTIVE.equals(def.getStatus())) { if (!AbilityDefinition.STATUS_ACTIVE.equals(def.getStatus())) {
@ -148,7 +152,7 @@ public class AbilityAttemptService {
@Transactional @Transactional
public AbilityAttemptResponse get(Long userId, Long attemptId) { public AbilityAttemptResponse get(Long userId, Long attemptId) {
requireStudent(userId); userRoleGuard.requireStudent(userId);
AbilityAttempt attempt = loadOwned(userId, attemptId); AbilityAttempt attempt = loadOwned(userId, attemptId);
expireIfTimedOut(attempt); expireIfTimedOut(attempt);
return toAttemptResponse(attempt); return toAttemptResponse(attempt);
@ -158,7 +162,7 @@ public class AbilityAttemptService {
@Transactional(readOnly = true) @Transactional(readOnly = true)
public AbilityResultResponse loadResult(Long userId, Long attemptId) { public AbilityResultResponse loadResult(Long userId, Long attemptId) {
requireStudent(userId); userRoleGuard.requireStudent(userId);
AbilityAttempt attempt = loadOwned(userId, attemptId); AbilityAttempt attempt = loadOwned(userId, attemptId);
AbilityResult result = resultRepository.findByAttemptId(attemptId) AbilityResult result = resultRepository.findByAttemptId(attemptId)
.orElseThrow(() -> new BusinessException(ErrorCode.ABILITY_RESULT_NOT_FOUND)); .orElseThrow(() -> new BusinessException(ErrorCode.ABILITY_RESULT_NOT_FOUND));
@ -171,7 +175,7 @@ public class AbilityAttemptService {
@Transactional @Transactional
public AbilityAttemptResponse saveAnswers(Long userId, Long attemptId, public AbilityAttemptResponse saveAnswers(Long userId, Long attemptId,
SaveAbilityAnswersRequest req, AuditContext ctx) { SaveAbilityAnswersRequest req, AuditContext ctx) {
requireStudent(userId); userRoleGuard.requireStudent(userId);
AbilityAttempt attempt = loadOwned(userId, attemptId); AbilityAttempt attempt = loadOwned(userId, attemptId);
if (expireIfTimedOut(attempt)) { if (expireIfTimedOut(attempt)) {
throw new BusinessException(ErrorCode.ABILITY_ATTEMPT_INVALID_STATE); throw new BusinessException(ErrorCode.ABILITY_ATTEMPT_INVALID_STATE);
@ -248,7 +252,7 @@ public class AbilityAttemptService {
@Transactional @Transactional
public AbilityResultResponse submit(Long userId, Long attemptId, AuditContext ctx) { public AbilityResultResponse submit(Long userId, Long attemptId, AuditContext ctx) {
requireStudent(userId); userRoleGuard.requireStudent(userId);
AbilityAttempt attempt = loadOwned(userId, attemptId); AbilityAttempt attempt = loadOwned(userId, attemptId);
if (AbilityAttempt.STATUS_COMPLETED.equals(attempt.getStatus())) { if (AbilityAttempt.STATUS_COMPLETED.equals(attempt.getStatus())) {
@ -287,6 +291,8 @@ public class AbilityAttemptService {
+ ", questionSetVersion=" + attempt.getQuestionSetVersion() + ", questionSetVersion=" + attempt.getQuestionSetVersion()
+ ", rulesVersion=" + (def == null ? null : def.getRulesVersion()), + ", rulesVersion=" + (def == null ? null : def.getRulesVersion()),
ctx); ctx);
// R01-D4-1:能力测评提交成功(新有效结果)影响 ABILITY 维度 → 登记当前周期刷新。
scoringRefreshRequestService.requestRefresh(userId, now());
return resultService.toResponse(attempt, result, def, now()); return resultService.toResponse(attempt, result, def, now());
} }
@ -294,7 +300,7 @@ public class AbilityAttemptService {
@Transactional(readOnly = true) @Transactional(readOnly = true)
public Page<AbilityHistoryItem> listMine(Long userId, int page, int size) { public Page<AbilityHistoryItem> listMine(Long userId, int page, int size) {
requireStudent(userId); userRoleGuard.requireStudent(userId);
if (page < 0 || size < 1 || size > MAX_PAGE_SIZE) { if (page < 0 || size < 1 || size > MAX_PAGE_SIZE) {
throw new BusinessException(ErrorCode.BAD_REQUEST); throw new BusinessException(ErrorCode.BAD_REQUEST);
} }
@ -540,16 +546,6 @@ public class AbilityAttemptService {
} }
return a; 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) { private AbilityAttempt findByIdem(Long userId, String idem) {
if (idem == null) { if (idem == null) {
return 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.audit.AuditContext;
import com.mydreamjob.common.api.ApiResponse; import com.mydreamjob.common.api.ApiResponse;
import com.mydreamjob.common.api.PageResponse; import com.mydreamjob.common.api.PageResponse;
import com.mydreamjob.common.exception.BusinessException; import com.mydreamjob.common.logging.RequestIdAccessor;
import com.mydreamjob.common.exception.ErrorCode; import com.mydreamjob.security.CurrentUserAccessor;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
@ -18,8 +18,6 @@ import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page; 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 org.springframework.web.bind.annotation.*;
import java.util.List; import java.util.List;
@ -37,6 +35,8 @@ public class AbilityController {
private final AbilityCatalogService catalogService; private final AbilityCatalogService catalogService;
private final AbilityAttemptService attemptService; private final AbilityAttemptService attemptService;
private final CurrentUserAccessor currentUserAccessor;
private final RequestIdAccessor requestIdAccessor;
@Operation(summary = "能力测评类型列表", @Operation(summary = "能力测评类型列表",
description = "返回全部 ACTIVE 能力测评定义,不返回题库/答案/评分规则。任意已登录用户。") description = "返回全部 ACTIVE 能力测评定义,不返回题库/答案/评分规则。任意已登录用户。")
@ -46,7 +46,7 @@ public class AbilityController {
}) })
@GetMapping("/abilities/types") @GetMapping("/abilities/types")
public ApiResponse<List<AbilityTypeResponse>> listTypes(HttpServletRequest request) { public ApiResponse<List<AbilityTypeResponse>> listTypes(HttpServletRequest request) {
return ApiResponse.ok(catalogService.listTypes(), requestId(request)); return ApiResponse.ok(catalogService.listTypes(), requestIdAccessor.get(request));
} }
@Operation(summary = "创建能力答卷", @Operation(summary = "创建能力答卷",
@ -63,8 +63,8 @@ public class AbilityController {
@PostMapping("/abilities/attempts") @PostMapping("/abilities/attempts")
public ApiResponse<AbilityAttemptResponse> createAttempt( public ApiResponse<AbilityAttemptResponse> createAttempt(
@Valid @RequestBody CreateAbilityAttemptRequest req, HttpServletRequest request) { @Valid @RequestBody CreateAbilityAttemptRequest req, HttpServletRequest request) {
return ApiResponse.ok(attemptService.create(resolveUserId(request), req, AuditContext.from(request)), return ApiResponse.ok(attemptService.create(currentUserAccessor.requireUserId(), req, AuditContext.from(request)),
requestId(request)); requestIdAccessor.get(request));
} }
@Operation(summary = "能力答卷详情", @Operation(summary = "能力答卷详情",
@ -78,7 +78,7 @@ public class AbilityController {
@GetMapping("/abilities/attempts/{attemptId}") @GetMapping("/abilities/attempts/{attemptId}")
public ApiResponse<AbilityAttemptResponse> getAttempt( public ApiResponse<AbilityAttemptResponse> getAttempt(
@PathVariable Long attemptId, HttpServletRequest request) { @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 = "保存能力测评答案", @Operation(summary = "保存能力测评答案",
@ -96,8 +96,8 @@ public class AbilityController {
public ApiResponse<AbilityAttemptResponse> saveAnswers( public ApiResponse<AbilityAttemptResponse> saveAnswers(
@PathVariable Long attemptId, @PathVariable Long attemptId,
@Valid @RequestBody SaveAbilityAnswersRequest req, HttpServletRequest request) { @Valid @RequestBody SaveAbilityAnswersRequest req, HttpServletRequest request) {
return ApiResponse.ok(attemptService.saveAnswers(resolveUserId(request), attemptId, req, return ApiResponse.ok(attemptService.saveAnswers(currentUserAccessor.requireUserId(), attemptId, req,
AuditContext.from(request)), requestId(request)); AuditContext.from(request)), requestIdAccessor.get(request));
} }
@Operation(summary = "提交能力测评答卷", @Operation(summary = "提交能力测评答卷",
@ -114,8 +114,8 @@ public class AbilityController {
@PostMapping("/abilities/attempts/{attemptId}/submit") @PostMapping("/abilities/attempts/{attemptId}/submit")
public ApiResponse<AbilityResultResponse> submitAttempt( public ApiResponse<AbilityResultResponse> submitAttempt(
@PathVariable Long attemptId, HttpServletRequest request) { @PathVariable Long attemptId, HttpServletRequest request) {
return ApiResponse.ok(attemptService.submit(resolveUserId(request), attemptId, return ApiResponse.ok(attemptService.submit(currentUserAccessor.requireUserId(), attemptId,
AuditContext.from(request)), requestId(request)); AuditContext.from(request)), requestIdAccessor.get(request));
} }
@Operation(summary = "查看能力测评结果", @Operation(summary = "查看能力测评结果",
@ -129,7 +129,7 @@ public class AbilityController {
@GetMapping("/abilities/attempts/{attemptId}/result") @GetMapping("/abilities/attempts/{attemptId}/result")
public ApiResponse<AbilityResultResponse> getResult( public ApiResponse<AbilityResultResponse> getResult(
@PathVariable Long attemptId, HttpServletRequest request) { @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 = "我的能力测评历史", @Operation(summary = "我的能力测评历史",
@ -144,21 +144,9 @@ public class AbilityController {
@RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size, @RequestParam(defaultValue = "20") int size,
HttpServletRequest request) { 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( return ApiResponse.ok(PageResponse.of(
result.getContent(), result.getTotalElements(), result.getNumber(), result.getSize()), result.getContent(), result.getTotalElements(), result.getNumber(), result.getSize()),
requestId(request)); 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());
}
private String requestId(HttpServletRequest request) {
return request.getHeader("X-Request-Id");
} }
} }

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.BusinessException;
import com.mydreamjob.common.exception.ErrorCode; import com.mydreamjob.common.exception.ErrorCode;
import com.mydreamjob.application.dto.CandidateDetailView; import com.mydreamjob.application.dto.CandidateDetailView;
import com.mydreamjob.application.dto.CompanyResumeFile;
import com.mydreamjob.application.dto.CandidateListResponse; import com.mydreamjob.application.dto.CandidateListResponse;
import com.mydreamjob.application.dto.ApplicationCreateRequest; import com.mydreamjob.application.dto.ApplicationCreateRequest;
import com.mydreamjob.application.dto.ApplicationStatusRequest; import com.mydreamjob.application.dto.ApplicationStatusRequest;
@ -15,11 +16,13 @@ import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page; 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.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.nio.charset.StandardCharsets;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@ -31,9 +34,16 @@ import java.util.stream.Collectors;
@RequiredArgsConstructor @RequiredArgsConstructor
public class ApplicationController { 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 ApplicationService applicationService;
private final CompanyService companyService; private final CompanyService companyService;
private final UserRepository userRepository; private final UserRepository userRepository;
private final CompanyResumeService companyResumeService;
private static final Set<String> ALLOWED_STATUSES = Set.of( private static final Set<String> ALLOWED_STATUSES = Set.of(
"SUBMITTED", "VIEWED", "INTERVIEW", "HIRED", "REJECTED", "WITHDRAWN"); "SUBMITTED", "VIEWED", "INTERVIEW", "HIRED", "REJECTED", "WITHDRAWN");
@ -47,9 +57,9 @@ public class ApplicationController {
public ResponseEntity<ApiResponse<Map<String, Object>>> apply( public ResponseEntity<ApiResponse<Map<String, Object>>> apply(
@Valid @RequestBody ApplicationCreateRequest req, @Valid @RequestBody ApplicationCreateRequest req,
HttpServletRequest request) { HttpServletRequest request) {
Long userId = resolveUserId(request); Long userId = currentUserAccessor.requireUserId();
Application app = applicationService.apply(userId, req, com.mydreamjob.audit.AuditContext.from(request)); 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( public ResponseEntity<ApiResponse<Map<String, Object>>> withdraw(
@PathVariable Long id, @PathVariable Long id,
HttpServletRequest request) { HttpServletRequest request) {
Long userId = resolveUserId(request); Long userId = currentUserAccessor.requireUserId();
Application app = applicationService.withdraw(userId, id, com.mydreamjob.audit.AuditContext.from(request)); 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( public ResponseEntity<ApiResponse<List<Map<String, Object>>>> timeline(
@PathVariable Long id, @PathVariable Long id,
HttpServletRequest request) { HttpServletRequest request) {
Long userId = resolveUserId(request); Long userId = currentUserAccessor.requireUserId();
List<Map<String, Object>> data = applicationService.getTimeline(id, userId); 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 = "0") int page,
@RequestParam(defaultValue = "20") int size, @RequestParam(defaultValue = "20") int size,
HttpServletRequest request) { HttpServletRequest request) {
Long userId = resolveUserId(request); Long userId = currentUserAccessor.requireUserId();
if (size > 100) size = 100; if (size > 100) size = 100;
Page<Application> result = applicationService.listStudentApplications(userId, status, page, size); Page<Application> result = applicationService.listStudentApplications(userId, status, page, size);
Map<String, Object> data = new HashMap<>(); Map<String, Object> data = new HashMap<>();
@ -94,7 +104,7 @@ public class ApplicationController {
data.put("page", result.getNumber()); data.put("page", result.getNumber());
data.put("size", result.getSize()); data.put("size", result.getSize());
data.put("totalPages", result.getTotalPages()); 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 = "查看自己的投递记录详情") @Operation(summary = "投递详情", description = "查看自己的投递记录详情")
@ -102,9 +112,9 @@ public class ApplicationController {
public ResponseEntity<ApiResponse<Map<String, Object>>> getApplication( public ResponseEntity<ApiResponse<Map<String, Object>>> getApplication(
@PathVariable Long id, @PathVariable Long id,
HttpServletRequest request) { HttpServletRequest request) {
Long userId = resolveUserId(request); Long userId = currentUserAccessor.requireUserId();
Application app = applicationService.getApplication(id, userId); 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 = "0") int page,
@RequestParam(defaultValue = "20") int size, @RequestParam(defaultValue = "20") int size,
HttpServletRequest request) { HttpServletRequest request) {
Long userId = resolveUserId(request); Long userId = currentUserAccessor.requireUserId();
var company = companyService.findCompanyByOwnerUserId(userId); var company = companyService.findCompanyByOwnerUserId(userId);
if (company == null) throw new BusinessException(ErrorCode.COMPANY_NOT_FOUND); if (company == null) throw new BusinessException(ErrorCode.COMPANY_NOT_FOUND);
if (size > 100) size = 100; if (size > 100) size = 100;
@ -129,7 +139,7 @@ public class ApplicationController {
data.put("page", result.getNumber()); data.put("page", result.getNumber());
data.put("size", result.getSize()); data.put("size", result.getSize());
data.put("totalPages", result.getTotalPages()); 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, @PathVariable Long id,
@Valid @RequestBody ApplicationStatusRequest req, @Valid @RequestBody ApplicationStatusRequest req,
HttpServletRequest request) { HttpServletRequest request) {
Long userId = resolveUserId(request); Long userId = currentUserAccessor.requireUserId();
var company = companyService.findCompanyByOwnerUserId(userId); var company = companyService.findCompanyByOwnerUserId(userId);
if (company == null) throw new BusinessException(ErrorCode.COMPANY_NOT_FOUND); if (company == null) throw new BusinessException(ErrorCode.COMPANY_NOT_FOUND);
Application app = applicationService.updateStatus(company.getId(), id, req, com.mydreamjob.audit.AuditContext.from(request)); 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 = "0") int page,
@RequestParam(defaultValue = "20") int size, @RequestParam(defaultValue = "20") int size,
HttpServletRequest request) { HttpServletRequest request) {
Long userId = resolveUserId(request); Long userId = currentUserAccessor.requireUserId();
assertCompanyRole(userId); userRoleGuard.requireCompany(userId);
// 参数校验 // 参数校验
if (page < 0 || size < 1 || size > 100) if (page < 0 || size < 1 || size > 100)
@ -186,7 +196,7 @@ public class ApplicationController {
CandidateListResponse data = applicationService.listCompanyCandidates( CandidateListResponse data = applicationService.listCompanyCandidates(
company.getId(), jobId, status, keyword, sort, order, page, size); 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 = "查看投递详情及候选人公开资料,只能查看本企业岗位下的投递") @Operation(summary = "企业候选人详情", description = "查看投递详情及候选人公开资料,只能查看本企业岗位下的投递")
@ -194,33 +204,38 @@ public class ApplicationController {
public ResponseEntity<ApiResponse<CandidateDetailView>> getCompanyCandidateDetail( public ResponseEntity<ApiResponse<CandidateDetailView>> getCompanyCandidateDetail(
@PathVariable Long applicationId, @PathVariable Long applicationId,
HttpServletRequest request) { HttpServletRequest request) {
Long userId = resolveUserId(request); Long userId = currentUserAccessor.requireUserId();
assertCompanyRole(userId); userRoleGuard.requireCompany(userId);
var company = companyService.findCompanyByOwnerUserId(userId); var company = companyService.findCompanyByOwnerUserId(userId);
if (company == null) throw new BusinessException(ErrorCode.COMPANY_NOT_FOUND); if (company == null) throw new BusinessException(ErrorCode.COMPANY_NOT_FOUND);
CandidateDetailView data = applicationService.getCompanyCandidateDetail( CandidateDetailView data = applicationService.getCompanyCandidateDetail(
company.getId(), applicationId); company.getId(), applicationId);
return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request))); return ResponseEntity.ok(ApiResponse.ok(data, requestIdAccessor.get(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);
} }
private Long resolveUserId(HttpServletRequest request) { @Operation(summary = "企业下载候选人简历",
Authentication auth = SecurityContextHolder.getContext().getAuthentication(); description = "按候选人隐私设置授权后返回简历文件流;不返回存储路径、学生 ID 或联系方式")
if (auth == null || !auth.isAuthenticated()) @GetMapping("/company/candidates/{applicationId}/resume")
throw new BusinessException(ErrorCode.UNAUTHORIZED); public ResponseEntity<byte[]> downloadCandidateResume(
return Long.valueOf(auth.getName()); @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.User;
import com.mydreamjob.auth.UserRepository; import com.mydreamjob.auth.UserRepository;
import com.mydreamjob.notification.NotificationService; import com.mydreamjob.notification.NotificationService;
import com.mydreamjob.candidatescore.refresh.CandidateScoringRefreshRequestService;
import com.mydreamjob.profile.StudentProfile; import com.mydreamjob.profile.StudentProfile;
import jakarta.persistence.EntityManager; import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext; import jakarta.persistence.PersistenceContext;
@ -46,6 +47,8 @@ public class ApplicationService {
private final AuditService auditService; private final AuditService auditService;
private final ApplicationStatusHistoryRepository historyRepository; private final ApplicationStatusHistoryRepository historyRepository;
private final NotificationService notificationService; private final NotificationService notificationService;
/** R01-D4-1:投递新增/撤回/状态变更后登记评分快照刷新。 */
private final CandidateScoringRefreshRequestService scoringRefreshRequestService;
@PersistenceContext @PersistenceContext
private EntityManager em; private EntityManager em;
@ -83,6 +86,8 @@ public class ApplicationService {
"你已成功投递岗位 #" + req.getJobId(), "你已成功投递岗位 #" + req.getJobId(),
"APPLICATION", app.getId(), "APPLICATION", app.getId(),
"apply-" + studentUserId + "-" + req.getJobId()); "apply-" + studentUserId + "-" + req.getJobId());
// R01-D4-1:投递数量影响 STABILITY/ACTIVITY 聚合 → 登记当前周期刷新。
scoringRefreshRequestService.requestRefresh(studentUserId, java.time.LocalDateTime.now());
return app; return app;
} }
@ -120,6 +125,8 @@ public class ApplicationService {
"你的投递 #" + app.getId() + " 已撤回", "你的投递 #" + app.getId() + " 已撤回",
"APPLICATION", app.getId(), "APPLICATION", app.getId(),
"withdraw-" + studentUserId + "-" + app.getId()); "withdraw-" + studentUserId + "-" + app.getId());
// R01-D4-1:撤回参与 WITHDRAWN/总投递比率计算 → 登记当前周期刷新。
scoringRefreshRequestService.requestRefresh(studentUserId, java.time.LocalDateTime.now());
return app; return app;
} }
@ -213,6 +220,8 @@ public class ApplicationService {
"投递状态更新", "你的投递 #" + app.getId() + " 状态变更为 " + target.name(), "投递状态更新", "你的投递 #" + app.getId() + " 状态变更为 " + target.name(),
"APPLICATION", app.getId(), "APPLICATION", app.getId(),
"status-" + companyId + "-" + app.getId() + "-" + target.name()); "status-" + companyId + "-" + app.getId() + "-" + target.name());
// R01-D4-1:仅状态实际变化(含 WITHDRAWN/HIRED 等聚合口径)时登记刷新。
scoringRefreshRequestService.requestRefresh(app.getStudentUserId(), java.time.LocalDateTime.now());
return app; 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> { public interface ApplicationStatusHistoryRepository extends JpaRepository<ApplicationStatusHistory, Long> {
List<ApplicationStatusHistory> findByApplicationIdOrderByCreatedAtAsc(Long applicationId); 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 static final int MAX_PAGE_SIZE = 50;
private final com.mydreamjob.security.UserRoleGuard userRoleGuard;
private final AssessmentDefinitionRepository definitionRepository; private final AssessmentDefinitionRepository definitionRepository;
private final AssessmentQuestionRepository questionRepository; private final AssessmentQuestionRepository questionRepository;
private final AssessmentAttemptRepository attemptRepository; private final AssessmentAttemptRepository attemptRepository;
@ -71,7 +72,7 @@ public class AssessmentAttemptService {
*/ */
@Transactional(isolation = Isolation.READ_COMMITTED) @Transactional(isolation = Isolation.READ_COMMITTED)
public AssessmentAttemptResponse create(Long userId, CreateAssessmentAttemptRequest req, AuditContext ctx) { public AssessmentAttemptResponse create(Long userId, CreateAssessmentAttemptRequest req, AuditContext ctx) {
requireStudent(userId); userRoleGuard.requireStudent(userId);
AssessmentDefinition def = definitionRepository.findByTypeCode(req.getTypeCode()) AssessmentDefinition def = definitionRepository.findByTypeCode(req.getTypeCode())
.orElseThrow(() -> new BusinessException(ErrorCode.ASSESSMENT_TYPE_NOT_FOUND)); .orElseThrow(() -> new BusinessException(ErrorCode.ASSESSMENT_TYPE_NOT_FOUND));
// 仅 ACTIVE 类型可创建;INACTIVE 统一 404/9701 // 仅 ACTIVE 类型可创建;INACTIVE 统一 404/9701
@ -142,7 +143,7 @@ public class AssessmentAttemptService {
@Transactional(readOnly = true) @Transactional(readOnly = true)
public AssessmentAttemptResponse get(Long userId, Long attemptId) { public AssessmentAttemptResponse get(Long userId, Long attemptId) {
requireStudent(userId); userRoleGuard.requireStudent(userId);
AssessmentAttempt attempt = loadOwned(userId, attemptId); AssessmentAttempt attempt = loadOwned(userId, attemptId);
return toAttemptResponse(attempt); return toAttemptResponse(attempt);
} }
@ -152,7 +153,7 @@ public class AssessmentAttemptService {
@Transactional @Transactional
public AssessmentAttemptResponse saveAnswers(Long userId, Long attemptId, public AssessmentAttemptResponse saveAnswers(Long userId, Long attemptId,
SaveAssessmentAnswersRequest req, AuditContext ctx) { SaveAssessmentAnswersRequest req, AuditContext ctx) {
requireStudent(userId); userRoleGuard.requireStudent(userId);
AssessmentAttempt attempt = loadOwned(userId, attemptId); AssessmentAttempt attempt = loadOwned(userId, attemptId);
if (!isMutable(attempt.getStatus())) { if (!isMutable(attempt.getStatus())) {
throw new BusinessException(ErrorCode.ASSESSMENT_ATTEMPT_INVALID_STATE); throw new BusinessException(ErrorCode.ASSESSMENT_ATTEMPT_INVALID_STATE);
@ -204,7 +205,7 @@ public class AssessmentAttemptService {
@Transactional @Transactional
public AssessmentResultResponse submit(Long userId, Long attemptId, AuditContext ctx) { public AssessmentResultResponse submit(Long userId, Long attemptId, AuditContext ctx) {
requireStudent(userId); userRoleGuard.requireStudent(userId);
AssessmentAttempt attempt = loadOwned(userId, attemptId); AssessmentAttempt attempt = loadOwned(userId, attemptId);
// 重复提交幂等:返回原结果,不重新生成、不重复审计 // 重复提交幂等:返回原结果,不重新生成、不重复审计
@ -261,7 +262,7 @@ public class AssessmentAttemptService {
@Transactional(readOnly = true) @Transactional(readOnly = true)
public Page<AssessmentHistoryItem> listMine(Long userId, int page, int size) { public Page<AssessmentHistoryItem> listMine(Long userId, int page, int size) {
requireStudent(userId); userRoleGuard.requireStudent(userId);
if (page < 0 || size < 1 || size > MAX_PAGE_SIZE) { if (page < 0 || size < 1 || size > MAX_PAGE_SIZE) {
throw new BusinessException(ErrorCode.BAD_REQUEST); throw new BusinessException(ErrorCode.BAD_REQUEST);
} }
@ -315,16 +316,6 @@ public class AssessmentAttemptService {
} }
return a; 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) { private AssessmentAttempt findByIdem(Long userId, String idem) {
if (idem == null) { if (idem == null) {
return 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.audit.AuditContext;
import com.mydreamjob.common.api.ApiResponse; import com.mydreamjob.common.api.ApiResponse;
import com.mydreamjob.common.api.PageResponse; import com.mydreamjob.common.api.PageResponse;
import com.mydreamjob.common.exception.BusinessException; import com.mydreamjob.common.logging.RequestIdAccessor;
import com.mydreamjob.common.exception.ErrorCode; import com.mydreamjob.security.CurrentUserAccessor;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
@ -19,8 +19,6 @@ import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page; 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 org.springframework.web.bind.annotation.*;
import java.util.List; import java.util.List;
@ -39,6 +37,8 @@ public class AssessmentController {
private final AssessmentCatalogService catalogService; private final AssessmentCatalogService catalogService;
private final AssessmentAttemptService attemptService; private final AssessmentAttemptService attemptService;
private final AssessmentResultService resultService; private final AssessmentResultService resultService;
private final CurrentUserAccessor currentUserAccessor;
private final RequestIdAccessor requestIdAccessor;
@Operation(summary = "测评类型列表", @Operation(summary = "测评类型列表",
description = "返回全部 ACTIVE 测评定义。任意已登录用户可访问;未登录 401。") description = "返回全部 ACTIVE 测评定义。任意已登录用户可访问;未登录 401。")
@ -48,7 +48,7 @@ public class AssessmentController {
}) })
@GetMapping("/assessments/types") @GetMapping("/assessments/types")
public ApiResponse<List<AssessmentTypeResponse>> listTypes(HttpServletRequest request) { public ApiResponse<List<AssessmentTypeResponse>> listTypes(HttpServletRequest request) {
return ApiResponse.ok(catalogService.listTypes(), requestId(request)); return ApiResponse.ok(catalogService.listTypes(), requestIdAccessor.get(request));
} }
@Operation(summary = "题目列表", @Operation(summary = "题目列表",
@ -61,7 +61,7 @@ public class AssessmentController {
@GetMapping("/assessments/{type}/questions") @GetMapping("/assessments/{type}/questions")
public ApiResponse<List<AssessmentQuestionResponse>> listQuestions( public ApiResponse<List<AssessmentQuestionResponse>> listQuestions(
@PathVariable String type, HttpServletRequest request) { @PathVariable String type, HttpServletRequest request) {
return ApiResponse.ok(catalogService.listQuestions(type), requestId(request)); return ApiResponse.ok(catalogService.listQuestions(type), requestIdAccessor.get(request));
} }
@Operation(summary = "创建答卷", @Operation(summary = "创建答卷",
@ -77,8 +77,8 @@ public class AssessmentController {
@PostMapping("/assessments/attempts") @PostMapping("/assessments/attempts")
public ApiResponse<AssessmentAttemptResponse> createAttempt( public ApiResponse<AssessmentAttemptResponse> createAttempt(
@Valid @RequestBody CreateAssessmentAttemptRequest req, HttpServletRequest request) { @Valid @RequestBody CreateAssessmentAttemptRequest req, HttpServletRequest request) {
return ApiResponse.ok(attemptService.create(resolveUserId(request), req, AuditContext.from(request)), return ApiResponse.ok(attemptService.create(currentUserAccessor.requireUserId(), req, AuditContext.from(request)),
requestId(request)); requestIdAccessor.get(request));
} }
@Operation(summary = "答卷详情", @Operation(summary = "答卷详情",
@ -92,7 +92,7 @@ public class AssessmentController {
@GetMapping("/assessments/attempts/{attemptId}") @GetMapping("/assessments/attempts/{attemptId}")
public ApiResponse<AssessmentAttemptResponse> getAttempt( public ApiResponse<AssessmentAttemptResponse> getAttempt(
@PathVariable Long attemptId, HttpServletRequest request) { @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 = "保存答案", @Operation(summary = "保存答案",
@ -109,8 +109,8 @@ public class AssessmentController {
public ApiResponse<AssessmentAttemptResponse> saveAnswers( public ApiResponse<AssessmentAttemptResponse> saveAnswers(
@PathVariable Long attemptId, @PathVariable Long attemptId,
@Valid @RequestBody SaveAssessmentAnswersRequest req, HttpServletRequest request) { @Valid @RequestBody SaveAssessmentAnswersRequest req, HttpServletRequest request) {
return ApiResponse.ok(attemptService.saveAnswers(resolveUserId(request), attemptId, req, return ApiResponse.ok(attemptService.saveAnswers(currentUserAccessor.requireUserId(), attemptId, req,
AuditContext.from(request)), requestId(request)); AuditContext.from(request)), requestIdAccessor.get(request));
} }
@Operation(summary = "提交答卷", @Operation(summary = "提交答卷",
@ -127,8 +127,8 @@ public class AssessmentController {
@PostMapping("/assessments/attempts/{attemptId}/submit") @PostMapping("/assessments/attempts/{attemptId}/submit")
public ApiResponse<AssessmentResultResponse> submitAttempt( public ApiResponse<AssessmentResultResponse> submitAttempt(
@PathVariable Long attemptId, HttpServletRequest request) { @PathVariable Long attemptId, HttpServletRequest request) {
return ApiResponse.ok(attemptService.submit(resolveUserId(request), attemptId, return ApiResponse.ok(attemptService.submit(currentUserAccessor.requireUserId(), attemptId,
AuditContext.from(request)), requestId(request)); AuditContext.from(request)), requestIdAccessor.get(request));
} }
@Operation(summary = "查看结果", @Operation(summary = "查看结果",
@ -143,7 +143,7 @@ public class AssessmentController {
@GetMapping("/assessments/attempts/{attemptId}/result") @GetMapping("/assessments/attempts/{attemptId}/result")
public ApiResponse<AssessmentResultResponse> getResult( public ApiResponse<AssessmentResultResponse> getResult(
@PathVariable Long attemptId, HttpServletRequest request) { @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 = "我的测评历史", @Operation(summary = "我的测评历史",
@ -158,21 +158,9 @@ public class AssessmentController {
@RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size, @RequestParam(defaultValue = "20") int size,
HttpServletRequest request) { 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( return ApiResponse.ok(PageResponse.of(
result.getContent(), result.getContent(),
result.getTotalElements(), result.getNumber(), result.getSize()), requestId(request)); result.getTotalElements(), result.getNumber(), result.getSize()), 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());
}
private String requestId(HttpServletRequest request) {
return request.getHeader("X-Request-Id");
} }
} }

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

@ -50,7 +50,10 @@ public class AuditLog {
@Column(name = "request_id", length = 32) @Column(name = "request_id", length = 32)
private String requestId; private String requestId;
@CreatedDate // S02:不再使用 @CreatedDate。
// Spring Data 审计会在持久化时用“当前时间”覆盖显式设置的值,而 S02 认证入口
// (登录/刷新/登出/重放处置/全局吊销)要求审计时间与入口单次捕获的 capturedNow 同源。
// 所有写入方(AuditService、SecurityAuditService)本就显式设置 createdAt,移除后行为不变。
@Column(name = "created_at", nullable = false, updatable = false) @Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt; 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, public void log(Long userId, String action, String entityType, Long entityId,
String details, String ipAddress, String userAgent, String requestId) { String details, String ipAddress, String userAgent, String requestId) {
logWithContext(userId, action, entityType, entityId, details, 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, public void log(Long userId, String action, String entityType, Long entityId,
String details, AuditContext context) { 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, private void logWithContext(Long userId, String action, String entityType, Long entityId,
String details, AuditContext ctx) { String details, AuditContext ctx, LocalDateTime createdAt) {
try { try {
// 脱敏:details 中不得包含 code/password/token/secret 等关键词的值 // 脱敏:details 中不得包含 code/password/token/secret 等关键词的值
String safeDetails = sanitize(details); String safeDetails = sanitize(details);
@ -47,7 +59,7 @@ public class AuditService {
.ipAddress(ctx != null ? ctx.getIpAddress() : null) .ipAddress(ctx != null ? ctx.getIpAddress() : null)
.userAgent(ctx != null ? ctx.getUserAgent() : null) .userAgent(ctx != null ? ctx.getUserAgent() : null)
.requestId(ctx != null ? ctx.getRequestId() : null) .requestId(ctx != null ? ctx.getRequestId() : null)
.createdAt(LocalDateTime.now()) .createdAt(createdAt)
.build(); .build();
auditLogRepository.save(auditLog); auditLogRepository.save(auditLog);
} catch (Exception e) { } catch (Exception e) {
@ -73,6 +85,32 @@ public class AuditService {
log(userId, "LOGOUT", "USER", userId, "User logged out", ctx); 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; package com.mydreamjob.auth;
import com.mydreamjob.audit.AuditContext;
import com.mydreamjob.audit.AuditService; import com.mydreamjob.audit.AuditService;
import com.mydreamjob.common.exception.BusinessException; import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode; import com.mydreamjob.common.exception.ErrorCode;
@ -12,25 +13,34 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import java.time.Clock;
import java.time.Instant;
import java.util.*; import java.util.*;
/** /**
* Mock 认证服务 - 仅用于开发/测试环境登录原型 * 认证服务(S02):手机号验证码登录,并编排刷新与登出。
*
* <p>登录成功后创建服务端登录会话;Access/Refresh 由各自的密钥签发,
* 数据库只保存 Refresh Token 的 HMAC 摘要。</p>
*/ */
@Slf4j @Slf4j
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
public class AuthService { public class AuthService {
/** 登录与刷新响应中的 expiresIn 秒数,保持既有契约不变(设计 D04)。 */
public static final long EXPIRES_IN_SECONDS = 86400L;
private final UserRepository userRepository; private final UserRepository userRepository;
private final JwtTokenProvider jwtTokenProvider;
private final SmsVerificationService smsVerificationService; private final SmsVerificationService smsVerificationService;
private final AuditService auditService; private final AuditService auditService;
private final AccountDeletionRepository accountDeletionRepository; private final AccountDeletionRepository accountDeletionRepository;
private final AccountDeletionPhoneHasher accountDeletionPhoneHasher; private final AccountDeletionPhoneHasher accountDeletionPhoneHasher;
private final AuthSessionService authSessionService;
private final Clock clock;
/** /**
* Mock 登录:验证短信验证码后自动创建/登录用户 * Mock 登录:验证短信验证码后自动创建/登录用户,并建立一次登录会话。
*/ */
@Transactional @Transactional
public Map<String, Object> mockLogin(String phone, String code, String purpose, public Map<String, Object> mockLogin(String phone, String code, String purpose,
@ -57,34 +67,62 @@ public class AuthService {
.verified(true) .verified(true)
.build(); .build();
user = userRepository.save(user); user = userRepository.save(user);
log.info("Auto-created user: id={}, phone={}", user.getId(), phone); // 日志不得记录完整手机号
log.info("Auto-created user: id={}", user.getId());
} else { } else {
user = userOpt.get(); user = userOpt.get();
} }
// 已注销(COMPLETED)用户禁止登录 // 已注销(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) { if (deletion.isPresent() && deletion.get().getStatus() == DeletionStatus.COMPLETED) {
throw new BusinessException(ErrorCode.ACCOUNT_DELETED); throw new BusinessException(ErrorCode.ACCOUNT_DELETED);
} }
// 生成 token // 认证入口只取一次时间,后续 JWT iat/exp、数据库时间与审计全部同源。
String accessToken = jwtTokenProvider.generateAccessToken(user.getId(), user.getPhone(), Map.of("role", user.getRole().name())); Instant capturedNow = clock.instant();
String refreshToken = jwtTokenProvider.generateRefreshToken(user.getId(), user.getPhone()); 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<>(); Map<String, Object> result = new HashMap<>();
result.put("userId", user.getId()); result.put("userId", user.getId());
result.put("phone", user.getPhone()); result.put("phone", user.getPhone());
result.put("nickname", user.getNickname()); result.put("nickname", user.getNickname());
result.put("role", user.getRole()); result.put("role", user.getRole());
result.put("accessToken", accessToken); result.put("accessToken", tokens.accessToken());
result.put("refreshToken", refreshToken); result.put("refreshToken", tokens.refreshToken());
result.put("expiresIn", 86400); result.put("expiresIn", EXPIRES_IN_SECONDS);
return result; 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()); result.put("createdAt", user.getCreatedAt());
return result; 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.Builder;
import lombok.Data; import lombok.Data;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import java.time.LocalDateTime; 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 @Data
@Builder @Builder
@ -19,9 +28,11 @@ import java.time.LocalDateTime;
@AllArgsConstructor @AllArgsConstructor
@Entity @Entity
@Table(name = "auth_tokens") @Table(name = "auth_tokens")
@EntityListeners(AuditingEntityListener.class)
public class AuthToken { public class AuthToken {
/** Token 类型:S02 起 auth_tokens 只保存 Refresh 记录。 */
public static final String TYPE_REFRESH = "REFRESH";
@Id @Id
@GeneratedValue(strategy = GenerationType.IDENTITY) @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id; private Long id;
@ -34,7 +45,7 @@ public class AuthToken {
@Column(name = "token_type", nullable = false, length = 20) @Column(name = "token_type", nullable = false, length = 20)
@Builder.Default @Builder.Default
private String tokenType = "ACCESS"; private String tokenType = TYPE_REFRESH;
@Column(name = "expired_at", nullable = false) @Column(name = "expired_at", nullable = false)
private LocalDateTime expiredAt; private LocalDateTime expiredAt;
@ -46,7 +57,32 @@ public class AuthToken {
@Column(name = "refresh_token_hash", length = 255) @Column(name = "refresh_token_hash", length = 255)
private String refreshTokenHash; 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) @Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt; 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; package com.mydreamjob.auth;
import org.springframework.data.jpa.repository.JpaRepository; 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 org.springframework.stereotype.Repository;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
/**
* auth_tokens 数据访问(S02)。
*
* <p>并发消费与撤销**一律通过带条件的原子 UPDATE** 完成,并严格检查受影响行数;
* 禁止以 {@code findById -> set -> save} 作为并发保证。</p>
*/
@Repository @Repository
public interface AuthTokenRepository extends JpaRepository<AuthToken, Long> { public interface AuthTokenRepository extends JpaRepository<AuthToken, Long> {
@ -17,4 +26,94 @@ public interface AuthTokenRepository extends JpaRepository<AuthToken, Long> {
Optional<AuthToken> findByRefreshTokenHash(String refreshTokenHash); Optional<AuthToken> findByRefreshTokenHash(String refreshTokenHash);
List<AuthToken> findByExpiredAtBefore(LocalDateTime expiredAt); 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) {
}

53
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.lang.Nullable;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils; import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter; import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException; import java.io.IOException;
import java.time.Clock;
import java.time.Instant;
import java.util.Collections; 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 @Slf4j
@Component @Component
@ -26,25 +38,46 @@ import java.util.Collections;
public class JwtAuthenticationFilter extends OncePerRequestFilter { public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtTokenProvider tokenProvider; private final JwtTokenProvider tokenProvider;
private final AuthSessionService authSessionService;
private final Clock clock;
@Override @Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException { FilterChain filterChain) throws ServletException, IOException {
String token = extractToken(request); String token = extractToken(request);
if (token != null && tokenProvider.validateToken(token)) { if (token != null) {
authenticate(request, token);
}
filterChain.doFilter(request, response);
}
private void authenticate(HttpServletRequest request, String token) {
try { try {
Long userId = tokenProvider.parseUserId(token); 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 = UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(userId, null, Collections.emptyList()); new UsernamePasswordAuthenticationToken(claims.userId(), null, Collections.emptyList());
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); authentication.setDetails(new SessionAuthenticationDetails(request, claims.sessionId()));
SecurityContextHolder.getContext().setAuthentication(authentication); SecurityContextHolder.getContext().setAuthentication(authentication);
log.debug("Authenticated user {} from JWT", userId); } catch (InvalidTokenException e) {
// 固定安全文本:不区分过期/类型错误/签名错误,也不回显任何 claim 原值。
SecurityContextHolder.clearContext();
} catch (Exception e) { } catch (Exception e) {
log.warn("JWT authentication failed for token: {}", e.getMessage()); // 会话校验或数据库异常:失败关闭。
SecurityContextHolder.clearContext();
log.warn("JWT authentication rejected: internal check failed");
} }
} }
filterChain.doFilter(request, response);
}
@Nullable @Nullable
private String extractToken(HttpServletRequest request) { private String extractToken(HttpServletRequest request) {

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

@ -1,84 +1,207 @@
package com.mydreamjob.auth; 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 io.jsonwebtoken.security.Keys;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import javax.crypto.SecretKey; import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.Date; 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 @Slf4j
@Component @Component
public class JwtTokenProvider { 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 expirationMs;
private final long refreshExpirationMs; private final long refreshExpirationMs;
private final String issuer;
private final String accessAudience;
private final String refreshAudience;
public JwtTokenProvider( public JwtTokenProvider(AppJwtProperties properties) {
@Value("${app.jwt.secret}") String secret, this.accessKey = Keys.hmacShaKeyFor(properties.getAccessSecret().getBytes(StandardCharsets.UTF_8));
@Value("${app.jwt.expiration-ms:86400000}") long expirationMs, this.refreshKey = Keys.hmacShaKeyFor(properties.getRefreshSecret().getBytes(StandardCharsets.UTF_8));
@Value("${app.jwt.refresh-expiration-ms:604800000}") long refreshExpirationMs) { this.expirationMs = properties.getExpirationMs();
this.secretKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); this.refreshExpirationMs = properties.getRefreshExpirationMs();
this.expirationMs = expirationMs; this.issuer = properties.getIssuer();
this.refreshExpirationMs = refreshExpirationMs; 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) { public IssuedToken generateAccessToken(Long userId, String role, String sessionId,
return generateToken(userId, phone, Map.of("type", "refresh"), refreshExpirationMs); 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) { public IssuedToken generateRefreshToken(Long userId, String sessionId,
Date now = new Date(); String tokenId, Instant capturedNow) {
Date expiry = new Date(now.getTime() + expirationMs); Instant expiresAt = capturedNow.plusMillis(refreshExpirationMs);
String token = Jwts.builder()
return Jwts.builder()
.subject(String.valueOf(userId)) .subject(String.valueOf(userId))
.claim("phone", phone) .id(tokenId)
.claims(claims) .claim(CLAIM_TYPE, TYPE_REFRESH)
.issuedAt(now) .claim(CLAIM_SID, sessionId)
.expiration(expiry) .issuer(issuer)
.signWith(secretKey) .audience().add(refreshAudience).and()
.issuedAt(Date.from(capturedNow))
.expiration(Date.from(expiresAt))
.signWith(refreshKey)
.compact(); .compact();
return new IssuedToken(token, sessionId, tokenId, capturedNow, expiresAt);
}
// ── 校验 ────────────────────────────────────────────────────
/**
* 校验并解析 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()));
}
/**
* 校验并解析 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()));
} }
public Long parseUserId(String token) { private Claims parse(String rawToken, SecretKey key, String expectedType,
String subject = Jwts.parser() String expectedAudience, Instant capturedNow) {
.verifyWith(secretKey) if (rawToken == null || rawToken.isBlank()) {
throw new InvalidTokenException();
}
Claims claims;
try {
claims = Jwts.parser()
.verifyWith(key)
// 过期判定由下面显式完成:这里把解析时钟固定在很早的时间,
// 避免依赖 jjwt 内部边界语义,从而严格执行本文档的契约。
.clock(() -> Date.from(Instant.EPOCH))
.require(CLAIM_TYPE, expectedType)
.requireIssuer(issuer)
.requireAudience(expectedAudience)
.build() .build()
.parseSignedClaims(token) .parseSignedClaims(rawToken)
.getPayload() .getPayload();
.getSubject(); } catch (JwtException | IllegalArgumentException e) {
return Long.parseLong(subject); // 固定安全文本:不回显 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;
} }
public boolean validateToken(String token) { private Long requirePositiveSubject(Claims claims) {
String subject = claims.getSubject();
if (subject == null || subject.isBlank()) {
throw new InvalidTokenException();
}
long userId;
try { try {
Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token); userId = Long.parseLong(subject);
return true; } catch (NumberFormatException e) {
} catch (JwtException e) { throw new InvalidTokenException();
log.debug("JWT validation failed: {}", e.getMessage());
return false;
} }
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 { try {
return Jwts.parser().verifyWith(secretKey).build() parsed = UUID.fromString(value);
.parseSignedClaims(token).getPayload().getExpiration().before(new Date()); } catch (IllegalArgumentException e) {
} catch (JwtException e) { throw new InvalidTokenException();
return true;
} }
// 只接受规范小写连字符形式,避免宽松解析放行异常写法。
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; package com.mydreamjob.auth;
import com.mydreamjob.audit.AuditService; import com.mydreamjob.audit.AuditService;
import com.mydreamjob.auth.sms.SmsCodeSender;
import com.mydreamjob.common.exception.BusinessException; import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode; import com.mydreamjob.common.exception.ErrorCode;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@ -18,8 +19,10 @@ import java.util.regex.Pattern;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
/** /**
* Mock 短信验证码服务 * 短信验证码服务。
* 不接入真实短信网关,验证码直接生成并存入 Redis *
* <p>验证码只写入短信验证码表,并通过 {@link SmsCodeSender} 投递;
* <strong>任何级别的日志都不得包含验证码或完整手机号</strong>。</p>
*/ */
@Slf4j @Slf4j
@Service @Service
@ -29,6 +32,7 @@ public class SmsVerificationService {
private final SmsVerificationCodeRepository codeRepository; private final SmsVerificationCodeRepository codeRepository;
private final RedisTemplate<String, Object> redisTemplate; private final RedisTemplate<String, Object> redisTemplate;
private final AuditService auditService; private final AuditService auditService;
private final SmsCodeSender smsCodeSender;
@Value("${app.sms.mock.code-length:6}") @Value("${app.sms.mock.code-length:6}")
private int codeLength; private int codeLength;
@ -49,7 +53,10 @@ public class SmsVerificationService {
private static final Pattern CODE_PATTERN = Pattern.compile("[0-9]{6}"); private static final Pattern CODE_PATTERN = Pattern.compile("[0-9]{6}");
/** /**
* 发送验证码(Mock) * 发送验证码。
*
* <p>发送动作委派给 {@link SmsCodeSender}:
* {@code app.sms.mock.enabled=false} 或未配置时使用不可用实现,安全失败并回滚本次验证码落库。</p>
*/ */
@Transactional @Transactional
public void sendCode(String phone, String purpose, public void sendCode(String phone, String purpose,
@ -59,14 +66,14 @@ public class SmsVerificationService {
// 检查锁定 // 检查锁定
String lockoutKey = LOCKOUT_KEY_PREFIX + phone; String lockoutKey = LOCKOUT_KEY_PREFIX + phone;
if (redisTemplate.hasKey(lockoutKey)) { 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); throw new BusinessException(ErrorCode.SMS_RATE_LIMITED);
} }
// 检查频率限制(60秒内最多1次) // 检查频率限制(60秒内最多1次)
String rateLimitKey = RATE_LIMIT_KEY_PREFIX + phone; String rateLimitKey = RATE_LIMIT_KEY_PREFIX + phone;
if (redisTemplate.hasKey(rateLimitKey)) { 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); throw new BusinessException(ErrorCode.SMS_RATE_LIMITED);
} }
@ -89,14 +96,26 @@ public class SmsVerificationService {
// 设置频率限制 // 设置频率限制
redisTemplate.opsForValue().set(rateLimitKey, "1", 60, TimeUnit.SECONDS); 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, 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); 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 jakarta.validation.Valid;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity; 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 org.springframework.web.bind.annotation.*;
import java.util.Map; import java.util.Map;
@ -22,6 +20,11 @@ import java.util.Map;
@RequiredArgsConstructor @RequiredArgsConstructor
public class ApplicationMatchController { 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; private final BidirectionalMatchService matchService;
@Operation(summary = "提交求职者岗位意愿分", description = "仅投递本人(STUDENT);0-100;MATCHED/CLOSED 后不可修改") @Operation(summary = "提交求职者岗位意愿分", description = "仅投递本人(STUDENT);0-100;MATCHED/CLOSED 后不可修改")
@ -30,10 +33,10 @@ public class ApplicationMatchController {
@PathVariable Long id, @PathVariable Long id,
@Valid @RequestBody ScoreRequest req, @Valid @RequestBody ScoreRequest req,
HttpServletRequest request) { HttpServletRequest request) {
Long userId = resolveUserId(request); Long userId = currentUserAccessor.requireUserId();
ApplicationMatchRecord record = matchService.submitCandidateInterestScore( ApplicationMatchRecord record = matchService.submitCandidateInterestScore(
userId, id, req.getScore(), com.mydreamjob.audit.AuditContext.from(request)); 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 后不可修改") @Operation(summary = "提交企业 HR 适配分", description = "仅岗位所属企业;0-100;MATCHED/CLOSED 后不可修改")
@ -42,10 +45,10 @@ public class ApplicationMatchController {
@PathVariable Long id, @PathVariable Long id,
@Valid @RequestBody ScoreRequest req, @Valid @RequestBody ScoreRequest req,
HttpServletRequest request) { HttpServletRequest request) {
Long userId = resolveUserId(request); Long userId = currentUserAccessor.requireUserId();
ApplicationMatchRecord record = matchService.submitHrSuitabilityScore( ApplicationMatchRecord record = matchService.submitHrSuitabilityScore(
userId, id, req.getScore(), com.mydreamjob.audit.AuditContext.from(request)); 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") @Operation(summary = "查询投递匹配状态", description = "仅投递求职者或岗位所属企业;不暴露内部路径/事件 ID")
@ -53,8 +56,8 @@ public class ApplicationMatchController {
public ResponseEntity<ApiResponse<Map<String, Object>>> getMatch( public ResponseEntity<ApiResponse<Map<String, Object>>> getMatch(
@PathVariable Long id, @PathVariable Long id,
HttpServletRequest request) { HttpServletRequest request) {
Long userId = resolveUserId(request); Long userId = currentUserAccessor.requireUserId();
return ResponseEntity.ok(ApiResponse.ok(matchService.getMatchView(userId, id), getRequestId(request))); return ResponseEntity.ok(ApiResponse.ok(matchService.getMatchView(userId, id), requestIdAccessor.get(request)));
} }
/** 评分接口返回记录视图(脱敏,不含内部字段) */ /** 评分接口返回记录视图(脱敏,不含内部字段) */
@ -74,15 +77,4 @@ public class ApplicationMatchController {
return v; 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