Browse Source

refactor: materialize scoring snapshots outside GET requests

master
Zhang Shun 1 month ago
parent
commit
52c8026b2e
  1. 87
      docs/r01-d4-1-scoring-materialization-implementation-report.md
  2. 277
      docs/r01-d4-2-blocker-remediation-20260916.md
  3. 234
      docs/r01-d4-2-claim-fence-remediation-20260916.md
  4. 188
      docs/r01-d4-2-claim-token-final-verification-20260916.md
  5. 275
      docs/r01-d4-2-final-independent-incremental-verification-20260916.md
  6. 57
      docs/r01-d4-2-get-scoring-readonly-implementation-report.md
  7. 656
      docs/r01-d4-2-independent-mysql-acceptance-20260916.md
  8. 5
      mydreamjob-server/src/main/java/com/mydreamjob/ability/AbilityAttemptService.java
  9. 9
      mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationService.java
  10. 84
      mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/CandidateScoringCalculator.java
  11. 9
      mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/CandidateScoringEngineService.java
  12. 101
      mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshCompensationScanner.java
  13. 81
      mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshRequestService.java
  14. 77
      mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshScheduler.java
  15. 102
      mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshTask.java
  16. 153
      mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshTaskExecutor.java
  17. 117
      mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshWorker.java
  18. 224
      mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/refresh/CandidateScoringSnapshotWriter.java
  19. 17
      mydreamjob-server/src/main/java/com/mydreamjob/feedback/CandidateFeedbackService.java
  20. 9
      mydreamjob-server/src/main/java/com/mydreamjob/messaging/MessagingService.java
  21. 9
      mydreamjob-server/src/main/java/com/mydreamjob/offer/OfferExpiryWorker.java
  22. 5
      mydreamjob-server/src/main/java/com/mydreamjob/offer/OfferService.java
  23. 6
      mydreamjob-server/src/main/java/com/mydreamjob/preference/PreferenceService.java
  24. 9
      mydreamjob-server/src/main/java/com/mydreamjob/profile/ProfileService.java
  25. 12
      mydreamjob-server/src/main/java/com/mydreamjob/resume/ResumeVersionService.java
  26. 16
      mydreamjob-server/src/main/java/com/mydreamjob/resume/ResumeVersionWriter.java
  27. 14
      mydreamjob-server/src/main/resources/application.yml
  28. 31
      mydreamjob-server/src/main/resources/db/migration/V44__candidate_scoring_refresh_tasks.sql
  29. 13
      mydreamjob-server/src/main/resources/db/migration/V45__add_scoring_refresh_claim_token.sql
  30. 1
      mydreamjob-server/src/test/java/com/mydreamjob/application/ApplicationServiceTest.java
  31. 57
      mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoringCalculatorTest.java
  32. 34
      mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoringClockConsistencyTest.java
  33. 7
      mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoringEngineServiceTest.java
  34. 29
      mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoringPersistenceIntegrationTest.java
  35. 73
      mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/R01D4GetWritesSnapshotEvidenceTest.java
  36. 17
      mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/V38AbilityRankingIntegrationTest.java
  37. 156
      mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshBatchIsolationTest.java
  38. 215
      mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshClaimFenceTest.java
  39. 412
      mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshClaimTokenTest.java
  40. 127
      mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshFailureTransactionTest.java
  41. 388
      mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshInfrastructureTest.java
  42. 274
      mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshMySqlIntegrationTest.java
  43. 110
      mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshWorkerLoopIsolationTest.java
  44. 200
      mydreamjob-server/src/test/java/com/mydreamjob/feedback/CandidateFeedbackRefreshRegistrationTest.java
  45. 1
      mydreamjob-server/src/test/java/com/mydreamjob/feedback/CandidateFeedbackServiceTest.java
  46. 278
      mydreamjob-server/src/test/java/com/mydreamjob/offer/OfferExpiryRefreshRegistrationTest.java
  47. 3
      mydreamjob-server/src/test/java/com/mydreamjob/offer/OfferExpiryWorkerTest.java
  48. 1
      mydreamjob-server/src/test/java/com/mydreamjob/offer/OfferServiceTest.java
  49. 3
      mydreamjob-server/src/test/java/com/mydreamjob/preference/PreferenceServiceTest.java
  50. 3
      mydreamjob-server/src/test/java/com/mydreamjob/profile/ProfileServiceTest.java
  51. 139
      mydreamjob-server/src/test/java/com/mydreamjob/resume/ResumeVersionRefreshAtomicityTest.java
  52. 1
      mydreamjob-server/src/test/java/com/mydreamjob/resume/ResumeVersionServiceTest.java
  53. 4
      mydreamjob-server/src/test/java/com/mydreamjob/scoringmonitoring/FairnessMonitoringPersistenceIntegrationTest.java
  54. 4
      mydreamjob-server/src/test/resources/application-integration-test.yml
  55. 4
      mydreamjob-server/src/test/resources/application-mysql-it.yml
  56. 4
      mydreamjob-server/src/test/resources/application-test.yml

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

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

@ -10,6 +10,7 @@ import com.mydreamjob.audit.AuditContext;
import com.mydreamjob.audit.AuditService;
import com.mydreamjob.auth.User;
import com.mydreamjob.auth.UserRepository;
import com.mydreamjob.candidatescore.refresh.CandidateScoringRefreshRequestService;
import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode;
import lombok.RequiredArgsConstructor;
@ -56,6 +57,8 @@ public class AbilityAttemptService {
private final UserRepository userRepository;
private final AuditService auditService;
private final Clock clock;
/** R01-D4-1:能力测评提交成功后登记评分快照刷新(ABILITY 维度)。 */
private final CandidateScoringRefreshRequestService scoringRefreshRequestService;
// ─────────────────────────── 创建答卷 ───────────────────────────
@ -287,6 +290,8 @@ public class AbilityAttemptService {
+ ", questionSetVersion=" + attempt.getQuestionSetVersion()
+ ", rulesVersion=" + (def == null ? null : def.getRulesVersion()),
ctx);
// R01-D4-1:能力测评提交成功(新有效结果)影响 ABILITY 维度 → 登记当前周期刷新。
scoringRefreshRequestService.requestRefresh(userId, now());
return resultService.toResponse(attempt, result, def, now());
}

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

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

84
mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/CandidateScoringCalculator.java

@ -11,9 +11,6 @@ import com.mydreamjob.preference.StudentPreference;
import com.mydreamjob.preference.StudentPreferenceRepository;
import com.mydreamjob.profile.StudentProfile;
import com.mydreamjob.profile.StudentProfileRepository;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import jakarta.persistence.Query;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
@ -102,6 +99,8 @@ public class CandidateScoringCalculator {
private final JdbcTemplate jdbcTemplate;
private final AbilityAssessmentScoreSource abilityScoreSource;
private final AbilityScoreMapper abilityScoreMapper;
/** R01-D4-1:唯一快照 Writer(普通物化幂等 / Governance 强制版本双模式)。 */
private final com.mydreamjob.candidatescore.refresh.CandidateScoringSnapshotWriter snapshotWriter;
/** 可测试的时间源;未提供 Spring Clock 时保持系统时区语义。 */
private Clock clock = Clock.systemDefaultZone();
@ -109,9 +108,6 @@ public class CandidateScoringCalculator {
@Value("${scoring.ability-assessment.enabled:false}")
private boolean abilityAssessmentEnabled;
@PersistenceContext
private EntityManager entityManager;
@Autowired(required = false)
public void setClock(Clock clock) {
if (clock != null) this.clock = clock;
@ -164,7 +160,9 @@ public class CandidateScoringCalculator {
DimensionResult r = computeAll(studentUserId, capturedNow).stream()
.filter(x -> x.dimension.equals(dimension)).findFirst()
.orElseThrow(() -> new BusinessException(ErrorCode.BAD_REQUEST, "invalid dimension"));
upsertSnapshots(studentUserId, List.of(r), period, capturedNow);
// R01-D4-1:Governance(申诉决定/回滚)强制版本推进路径 —— 与普通物化(值变化才 +1)显式区分,
// 保持 afterVersion = beforeVersion + 1 与 snapshot_history 审计语义不变。
snapshotWriter.upsertForcingVersion(studentUserId, r, period, capturedNow, currentRulesVersion());
return r;
}
@ -574,42 +572,20 @@ public class CandidateScoringCalculator {
return r;
}
// ── 快照批量 upsert(原生 MySQL)────────────────────────────
// ── 快照写入(R01-D4-1:全部委托唯一 Writer)────────────────────
public void upsertSnapshots(Long studentUserId, List<DimensionResult> results, String period) {
upsertSnapshots(studentUserId, results, period, LocalDateTime.now(clock));
}
/**
* 普通物化入口(含 GET 兜底):委托 {@code CandidateScoringSnapshotWriter#upsertIfChanged}。
* 值完全相同时不增 version/updated_at;业务值或 rulesVersion 变化才 version+1 →
* 重复读取不再产生无意义的版本噪声,为 D4-2 GET 停写做准备。
*/
public void upsertSnapshots(Long studentUserId, List<DimensionResult> results, String period,
LocalDateTime capturedNow) {
if (results.isEmpty()) return;
StringBuilder sql = new StringBuilder(
"INSERT INTO candidate_dimension_scores (student_user_id, dimension, period, score, status, " +
"sub_scores_json, weight, rules_version, calculated_at, version, created_at, updated_at) VALUES ");
List<Object> params = new ArrayList<>();
for (int i = 0; i < results.size(); i++) {
if (i > 0) sql.append(",");
sql.append("(?,?,?,?,?,?,?,?,?,0,NOW(),NOW())");
DimensionResult r = results.get(i);
params.add(studentUserId);
params.add(r.dimension);
params.add(period);
params.add(r.score);
params.add(r.status);
params.add(subScoresPayload(r));
params.add(DEFAULT_WEIGHT);
params.add(currentRulesVersion());
params.add(capturedNow);
}
sql.append(" ON DUPLICATE KEY UPDATE score=VALUES(score), status=VALUES(status), " +
"sub_scores_json=VALUES(sub_scores_json), weight=VALUES(weight), " +
"rules_version=VALUES(rules_version), calculated_at=VALUES(calculated_at), " +
"version=version+1, updated_at=NOW()");
Query q = entityManager.createNativeQuery(sql.toString());
for (int i = 0; i < params.size(); i++) {
q.setParameter(i + 1, params.get(i));
}
q.executeUpdate();
snapshotWriter.upsertIfChanged(studentUserId, results, period, capturedNow, currentRulesVersion());
}
// ── 工具 ────────────────────────────────────────────────────
@ -647,42 +623,6 @@ public class CandidateScoringCalculator {
return !isBlank(profile.getRealName()) && !isBlank(profile.getEducation()) && !isBlank(profile.getSchool());
}
private String toJson(List<DimensionScoreDetailResponse.SubScore> subs) {
if (subs == null || subs.isEmpty()) return null;
try {
return MAPPER.writeValueAsString(subs);
} catch (Exception e) {
return null;
}
}
/**
* ABILITY 行的 sub_scores_json 保存最小溯源 JSON(§6.1),其他七维保持既有数组格式。
* 溯源 JSON 仅供内部解释/重算,任何 HTTP 响应都不得返回其原文或 sourceResultId。
*/
private String subScoresPayload(DimensionResult r) {
if ("ABILITY".equals(r.dimension) && r.abilitySnapshotTrace != null) {
try {
Map<String, Object> m = new LinkedHashMap<>();
m.put("source", "V36_ABILITY_ASSESSMENT");
m.put("sourceResultId", r.abilitySnapshotTrace.resultId());
m.put("sourceCompletedAt", r.abilitySnapshotTrace.completedAt() == null
? null : r.abilitySnapshotTrace.completedAt().toString());
m.put("validUntil", r.abilitySnapshotTrace.validUntil() == null
? null : r.abilitySnapshotTrace.validUntil().toString());
m.put("questionSetVersion", r.abilitySnapshotTrace.questionSetVersion());
m.put("assessmentRulesVersion", r.abilitySnapshotTrace.assessmentRulesVersion());
m.put("mappingRulesVersion", AbilityScoreMapper.MAPPING_RULES_VERSION);
m.put("weights", r.abilitySnapshotTrace.weights());
m.put("dimensionScores", r.abilitySnapshotTrace.dimensionScores());
return MAPPER.writeValueAsString(m);
} catch (Exception e) {
return null;
}
}
return toJson(r.subScores);
}
private BigDecimal cap(BigDecimal v) {
return v.max(ZERO).min(HUNDRED).setScale(2, RoundingMode.HALF_UP);
}

9
mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/CandidateScoringEngineService.java

@ -49,24 +49,22 @@ public class CandidateScoringEngineService {
// ── 综合评分卡(学生自查)────────────────────────────────────
@Transactional
@Transactional(readOnly = true)
public CandidateScoringOverviewResponse getOverview(Long studentUserId) {
LocalDateTime capturedNow = LocalDateTime.now(clock);
List<CandidateScoringCalculator.DimensionResult> results = calculator.computeAll(studentUserId, capturedNow);
calculator.upsertSnapshots(studentUserId, results, yearMonth(capturedNow.toLocalDate()), capturedNow);
return buildOverview(studentUserId, results, capturedNow);
}
// ── 单维度明细 ───────────────────────────────────────────────
@Transactional
@Transactional(readOnly = true)
public DimensionScoreDetailResponse getDimensionDetail(Long studentUserId, String dimension) {
LocalDateTime capturedNow = LocalDateTime.now(clock);
List<CandidateScoringCalculator.DimensionResult> results = calculator.computeAll(studentUserId, capturedNow);
CandidateScoringCalculator.DimensionResult r = results.stream()
.filter(x -> x.dimension.equals(dimension))
.findFirst().orElseThrow(() -> new BusinessException(ErrorCode.BAD_REQUEST, "invalid dimension"));
calculator.upsertSnapshots(studentUserId, List.of(r), yearMonth(capturedNow.toLocalDate()), capturedNow);
return toDetail(r, capturedNow);
}
@ -105,7 +103,7 @@ public class CandidateScoringEngineService {
// ── 企业只读评分卡 ───────────────────────────────────────────
@Transactional
@Transactional(readOnly = true)
public CompanyCandidateScoringResponse getCompanyScoring(Long companyUserId, Long applicationId) {
Company company = companyRepository.findByOwnerUserId(companyUserId)
.orElseThrow(() -> new BusinessException(ErrorCode.FORBIDDEN));
@ -117,7 +115,6 @@ public class CandidateScoringEngineService {
}
LocalDateTime capturedNow = LocalDateTime.now(clock);
List<CandidateScoringCalculator.DimensionResult> results = calculator.computeAll(app.getStudentUserId(), capturedNow);
calculator.upsertSnapshots(app.getStudentUserId(), results, yearMonth(capturedNow.toLocalDate()), capturedNow);
List<CandidateScoringCalculator.DimensionResult> computable = results.stream()
.filter(r -> ("OK".equals(r.status) || "ESTIMATED".equals(r.status)) && r.score != null)
.toList();

101
mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshCompensationScanner.java

@ -0,0 +1,101 @@
package com.mydreamjob.candidatescore.refresh;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import java.time.Clock;
import java.time.LocalDateTime;
import java.util.List;
/**
* R01-D4-1 补偿扫描(基础版)。
*
* <p>仅<b>登记</b>刷新任务,不直接写快照:分页(LIMIT 固定预算)扫描近期有评分输入活动的学生,
* 统一调用 {@link CandidateScoringRefreshRequestService#requestRefresh}(任务唯一键自动合并)。
* 用于覆盖无业务写入但评分自然变化的时间窗口(近 90 天投递窗口、近 30 天审计窗口、
* 能力测评有效期、rulesVersion 切换),由定时调度触发。</p>
*
* <p>查询预算:本扫描 = 1 次聚合查询 + ≤limit 次登记 upsert;不逐用户 N+1。</p>
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class CandidateScoringRefreshCompensationScanner {
static final String ACTIVE_STUDENTS_SCAN = "ACTIVE_STUDENTS";
private final JdbcTemplate jdbcTemplate;
private final CandidateScoringRefreshRequestService requestService;
private Clock clock = Clock.systemDefaultZone();
@org.springframework.beans.factory.annotation.Autowired(required = false)
public void setClock(Clock clock) {
if (clock != null) this.clock = clock;
}
/**
* 返回本轮登记的学生数。游标与任务登记在同一事务中提交:失败时二者一起回滚,
* 多实例通过 checkpoint 行的 FOR UPDATE 串行推进,同一轮最多处理 {@code limit} 人。
*/
@Transactional
public int scanDueStudents(int limit) {
LocalDateTime now = LocalDateTime.now(clock);
LocalDateTime appsSince = now.minusDays(90);
LocalDateTime auditSince = now.minusDays(30);
int budget = Math.max(1, Math.min(limit, 500));
ensureCheckpoint();
long cursor = lockCursor();
List<Long> ids = findEligibleAfter(cursor, appsSince, auditSince, budget);
if (ids.isEmpty() && cursor > 0L) {
cursor = 0L;
ids = findEligibleAfter(cursor, appsSince, auditSince, budget);
}
for (Long studentId : ids) {
requestService.requestRefresh(studentId, now);
}
long nextCursor = ids.size() < budget || ids.isEmpty() ? 0L : ids.get(ids.size() - 1);
jdbcTemplate.update("""
UPDATE candidate_scoring_refresh_scan_checkpoints
SET last_user_id=?, version=version+1, updated_at=CURRENT_TIMESTAMP
WHERE scan_key=?
""", nextCursor, ACTIVE_STUDENTS_SCAN);
log.debug("compensation scan registered {} students (budget {}, next cursor {})",
ids.size(), budget, nextCursor);
return ids.size();
}
private void ensureCheckpoint() {
jdbcTemplate.update("""
INSERT INTO candidate_scoring_refresh_scan_checkpoints
(scan_key, last_user_id, version, updated_at)
VALUES (?, 0, 0, CURRENT_TIMESTAMP)
ON DUPLICATE KEY UPDATE scan_key=scan_key
""", ACTIVE_STUDENTS_SCAN);
}
private long lockCursor() {
Long cursor = jdbcTemplate.queryForObject("""
SELECT last_user_id FROM candidate_scoring_refresh_scan_checkpoints
WHERE scan_key=? FOR UPDATE
""", Long.class, ACTIVE_STUDENTS_SCAN);
return cursor == null ? 0L : cursor;
}
private List<Long> findEligibleAfter(long cursor, LocalDateTime appsSince,
LocalDateTime auditSince, int budget) {
return jdbcTemplate.query("""
SELECT DISTINCT u.id
FROM users u
WHERE u.id > ? AND u.role = 'STUDENT' AND u.is_active = 1
AND (EXISTS (SELECT 1 FROM applications a
WHERE a.student_user_id = u.id AND a.created_at >= ?)
OR EXISTS (SELECT 1 FROM audit_logs al
WHERE al.user_id = u.id AND al.created_at >= ?))
ORDER BY u.id
LIMIT ?
""", (rs, i) -> rs.getLong(1), cursor, appsSince, auditSince, budget);
}
}

81
mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshRequestService.java

@ -0,0 +1,81 @@
package com.mydreamjob.candidatescore.refresh;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.time.LocalDateTime;
/**
* R01-D4-1 评分快照刷新任务登记组件。
*
* <p>职责仅限"登记":把某个学生当前周期的物化请求合并进任务表。
* 使用原生参数化 upsert 维护 {@code uk_refresh_task_student_period} 唯一性:</p>
* <ul>
* <li>同学生同周期重复登记:不新增行,更新 {@code requested_at};</li>
* <li>任务 COMPLETED/FAILED:重新打开为 PENDING,attempts 归零;</li>
* <li>任务 PROCESSING:保留 PROCESSING(不覆盖),推进 requested_at,
* Worker 完成时若 {@code requested_at} 晚于 {@code processing_started_at} 则回到 PENDING;</li>
* <li>任务 RETRY:回到 PENDING(保留 attempts,不重复计分重试次数)。</li>
* </ul>
*
* <p>登记会加入调用方现有业务事务(默认 REQUIRED):源业务提交失败时,登记一并回滚;
* 登记本身不触发任何线程内评分计算、不发送 RabbitMQ、不写成功审计、不记录敏感值。</p>
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class CandidateScoringRefreshRequestService {
private final JdbcTemplate jdbcTemplate;
/**
* 登记当前周期的一次刷新(period = capturedNow 所在 yyyy-MM)。
*
* @param studentUserId 学生用户 ID;null 直接忽略
* @param capturedNow 请求捕获时间(本方法内部只读取一次)
*/
@Transactional(propagation = Propagation.REQUIRED)
public void requestRefresh(Long studentUserId, LocalDateTime capturedNow) {
if (studentUserId == null || capturedNow == null) {
return;
}
String period = String.format("%04d-%02d",
capturedNow.getYear(), capturedNow.getMonthValue());
jdbcTemplate.update("""
INSERT INTO candidate_scoring_refresh_tasks
(student_user_id, target_period, status, requested_at, attempts, version, created_at, updated_at)
VALUES (?, ?, 'PENDING', ?, 0, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON DUPLICATE KEY UPDATE
requested_at = GREATEST(requested_at, VALUES(requested_at)),
attempts = CASE
WHEN status IN ('COMPLETED', 'FAILED') THEN 0
ELSE attempts
END,
last_error_code = CASE
WHEN status = 'PROCESSING' THEN last_error_code
ELSE NULL
END,
next_attempt_at = CASE
WHEN status = 'PROCESSING' THEN next_attempt_at
ELSE NULL
END,
status = CASE
WHEN status = 'PROCESSING' THEN status
ELSE 'PENDING'
END,
version = version + 1,
updated_at = CURRENT_TIMESTAMP
""",
studentUserId, period, capturedNow);
}
public String periodOf(LocalDateTime capturedNow) {
LocalDate d = capturedNow.toLocalDate();
return String.format("%04d-%02d", d.getYear(), d.getMonthValue());
}
}

77
mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshScheduler.java

@ -0,0 +1,77 @@
package com.mydreamjob.candidatescore.refresh;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.time.Clock;
import java.time.LocalDateTime;
/**
* R01-D4-1 定时调度:Worker 领取刷新 + 补偿扫描。
*
* <p>默认关闭({@code app.scoring.refresh.enabled=false}),生产启用时负责周期驱动;
* 测试一律直接调用 Worker/Scanner,不依赖真实等待。调度不发送 RabbitMQ、不写通知。</p>
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class CandidateScoringRefreshScheduler {
private final CandidateScoringRefreshWorker worker;
private final CandidateScoringRefreshCompensationScanner compensationScanner;
private Clock clock = Clock.systemDefaultZone();
@Value("${app.scoring.refresh.enabled:false}")
private boolean enabled;
@Value("${app.scoring.refresh.compensation-enabled:false}")
private boolean compensationEnabled;
@Value("${app.scoring.refresh.batch-size:20}")
private int batchSize;
@Value("${app.scoring.refresh.compensation-limit:100}")
private int compensationLimit;
@org.springframework.beans.factory.annotation.Autowired(required = false)
public void setClock(Clock clock) {
if (clock != null) this.clock = clock;
}
@Scheduled(fixedDelayString = "${app.scoring.refresh.worker-interval-ms:60000}")
public void workerTick() {
if (!enabled) {
return;
}
try {
int processed = worker.processDue(batchSize);
if (processed > 0) {
log.debug("scoring refresh worker processed {} tasks", processed);
}
} catch (Exception e) {
// 调度兜底:单轮失败只告警,不影响后续轮次;具体任务失败在 Worker 内已分类登记。
log.warn("scoring refresh worker tick failed: {}", e.getClass().getSimpleName());
}
}
@Scheduled(fixedDelayString = "${app.scoring.refresh.compensation-interval-ms:3600000}")
public void compensationTick() {
if (!enabled || !compensationEnabled) {
return;
}
try {
int registered = compensationScanner.scanDueStudents(compensationLimit);
log.debug("compensation scan registered {} students", registered);
} catch (Exception e) {
log.warn("compensation scan tick failed: {}", e.getClass().getSimpleName());
}
}
/** 测试用:暴露当前时钟时刻(无副作用)。 */
LocalDateTime nowForTest() {
return LocalDateTime.now(clock);
}
}

102
mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshTask.java

@ -0,0 +1,102 @@
package com.mydreamjob.candidatescore.refresh;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EntityListeners;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import jakarta.persistence.Version;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import java.time.LocalDateTime;
/**
* R01-D4-1 评分快照刷新任务(持久化物化登记)。
*
* <p>同一学生同一周期只有一条任务({@code uk_refresh_task_student_period});
* 状态机:PENDING → PROCESSING → COMPLETED / RETRY → (…) → FAILED。
* PROCESSING 期间的新请求通过 {@code requested_at} 推进保留,Worker 完成时
* 若 {@code requested_at} 晚于 {@code processing_started_at} 则回到 PENDING。</p>
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Entity
@Table(name = "candidate_scoring_refresh_tasks",
uniqueConstraints = @UniqueConstraint(name = "uk_refresh_task_student_period",
columnNames = {"student_user_id", "target_period"}))
@EntityListeners(AuditingEntityListener.class)
public class CandidateScoringRefreshTask {
public static final String STATUS_PENDING = "PENDING";
public static final String STATUS_PROCESSING = "PROCESSING";
public static final String STATUS_RETRY = "RETRY";
public static final String STATUS_COMPLETED = "COMPLETED";
public static final String STATUS_FAILED = "FAILED";
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "student_user_id", nullable = false)
private Long studentUserId;
/** yyyy-MM */
@Column(name = "target_period", nullable = false, length = 7)
private String targetPeriod;
@Column(name = "status", nullable = false, length = 20)
@Builder.Default
private String status = STATUS_PENDING;
@Column(name = "requested_at", nullable = false)
private LocalDateTime requestedAt;
/** 仅用于超时扫描与观测;秒级精度不足以作为所有权证明。 */
@Column(name = "processing_started_at")
private LocalDateTime processingStartedAt;
/**
* R01-D4-2 claim fence:本次领取的不可复用令牌(UUID)。
* 每次成功领取写入新值,进入 COMPLETED/PENDING/RETRY/FAILED 时清空。
*/
@Column(name = "claim_token", length = 36)
private String claimToken;
@Column(name = "processed_at")
private LocalDateTime processedAt;
@Column(name = "next_attempt_at")
private LocalDateTime nextAttemptAt;
@Column(name = "attempts", nullable = false)
@Builder.Default
private Integer attempts = 0;
/** 固定错误分类(如 COMPUTE_FAILED / WRITE_FAILED),不保存异常原文、SQL、手机号或堆栈。 */
@Column(name = "last_error_code", length = 40)
private String lastErrorCode;
@Version
@Column(name = "version", nullable = false)
@Builder.Default
private Long version = 0L;
@CreatedDate
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@LastModifiedDate
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
}

153
mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshTaskExecutor.java

@ -0,0 +1,153 @@
package com.mydreamjob.candidatescore.refresh;
import com.mydreamjob.candidatescore.CandidateScoringCalculator;
import lombok.RequiredArgsConstructor;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.List;
/**
* 单任务事务执行器。领取、评分写入、失败状态分别使用独立事务,避免批次共享事务,
* 也避免捕获数据库异常后在 rollback-only 事务中记录 RETRY。
*/
@Component
@RequiredArgsConstructor
public class CandidateScoringRefreshTaskExecutor {
private final JdbcTemplate jdbcTemplate;
private final CandidateScoringCalculator calculator;
private final CandidateScoringSnapshotWriter writer;
/**
* 回收因进程退出而遗留的 PROCESSING 租约。恢复与领取分属独立事务,任何实例均可执行;
* 条件更新保证同一行只会被一次恢复。失败次数达到上限时进入 FAILED,避免永久热循环。
*/
@Transactional(propagation = Propagation.REQUIRES_NEW)
public int recoverStale(LocalDateTime staleBefore, LocalDateTime now, int maxAttempts) {
return jdbcTemplate.update("""
UPDATE candidate_scoring_refresh_tasks
SET status=CASE WHEN attempts+1 >= ? THEN 'FAILED' ELSE 'RETRY' END,
next_attempt_at=NULL,
attempts=attempts+1,
last_error_code='WORKER_TIMEOUT',
processing_started_at=NULL,
claim_token=NULL,
version=version+1, updated_at=CURRENT_TIMESTAMP
WHERE status='PROCESSING' AND processing_started_at < ?
""", maxAttempts, staleBefore);
}
/**
* 领取任务。
*
* <p>每次成功领取都生成一个<b>全新的随机 UUID</b> 作为所有权令牌(claim token),
* 在领取 SQL 中原子写入。令牌不可复用、与时间无关,因此不受
* {@code processing_started_at} 秒级精度影响。</p>
*/
@Transactional(propagation = Propagation.REQUIRES_NEW)
public ClaimedTask claim(Long taskId, LocalDateTime now) {
String claimToken = java.util.UUID.randomUUID().toString();
int updated = jdbcTemplate.update("""
UPDATE candidate_scoring_refresh_tasks
SET status='PROCESSING', processing_started_at=?, claim_token=?,
version=version+1, updated_at=CURRENT_TIMESTAMP
WHERE id=? AND status IN ('PENDING','RETRY')
AND (next_attempt_at IS NULL OR next_attempt_at <= ?)
""", now, claimToken, taskId, now);
if (updated != 1) {
return null;
}
return jdbcTemplate.query("""
SELECT id, student_user_id, target_period, processing_started_at, claim_token, version
FROM candidate_scoring_refresh_tasks WHERE id=?
""", (rs, i) -> new ClaimedTask(
rs.getLong("id"), rs.getLong("student_user_id"),
rs.getString("target_period"),
rs.getObject("processing_started_at", LocalDateTime.class),
rs.getString("claim_token"),
rs.getLong("version")), taskId)
.stream().findFirst().orElse(null);
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void materialize(ClaimedTask task, LocalDateTime capturedNow) {
List<CandidateScoringCalculator.DimensionResult> results =
calculator.computeAll(task.studentUserId(), capturedNow);
writer.upsertIfChanged(task.studentUserId(), results, task.targetPeriod(), capturedNow,
calculator.currentRulesVersion());
// 所有权闸门(claim fence):claim_token 是本次领取写入的不可复用令牌,
// 只有它仍与领取时一致,才说明任务仍由本次领取持有。
// version 额外参与 COMPLETED 判定:处理期间的新请求会推进 version,
// 此时应回到 PENDING 再算一次(而不是错误地置为 COMPLETED)。
int completed = jdbcTemplate.update("""
UPDATE candidate_scoring_refresh_tasks
SET status='COMPLETED', processing_started_at=NULL, claim_token=NULL, processed_at=?,
last_error_code=NULL, next_attempt_at=NULL,
version=version+1, updated_at=CURRENT_TIMESTAMP
WHERE id=? AND status='PROCESSING' AND claim_token=? AND version=?
""", capturedNow, task.id(), task.claimToken(), task.claimedVersion());
if (completed == 1) {
return;
}
// 同一 claim 期间有新请求推进 version → 回到 PENDING;闸门仍校验所有权令牌,
// 因此已被其他 Worker 重新领取时不会误改新持有者的状态。
int pending = jdbcTemplate.update("""
UPDATE candidate_scoring_refresh_tasks
SET status='PENDING', processing_started_at=NULL, claim_token=NULL, processed_at=?,
next_attempt_at=NULL, version=version+1, updated_at=CURRENT_TIMESTAMP
WHERE id=? AND status='PROCESSING' AND claim_token=?
""", capturedNow, task.id(), task.claimToken());
if (pending == 0) {
// stale claim:任务已被租约回收并由其他 Worker 重新领取。
// 安静退出,绝不覆盖新持有者的状态(这不是系统异常)。
return;
}
}
/**
* 记录失败并安排重试/终止。
*
* <p>所有权闸门:只有 {@code claim_token} 仍等于本次领取写入的令牌时才更新。
* 更新 0 行表示本次领取已失效(任务被租约回收并由其他 Worker 持有)——
* 这属于正常竞争,<b>安静退出</b>而不是抛异常,以免中断同批其余任务。</p>
*
* @param claimToken 本次领取的不可复用令牌
*/
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void recordFailure(Long taskId, String category, LocalDateTime now,
int maxAttempts, long retryBackoffMs, String claimToken) {
LocalDateTime nextAttempt = now.plusNanos(Math.max(1L, retryBackoffMs) * 1_000_000L);
int updated = jdbcTemplate.update("""
UPDATE candidate_scoring_refresh_tasks
SET status=CASE WHEN attempts+1 >= ? THEN 'FAILED' ELSE 'RETRY' END,
next_attempt_at=CASE WHEN attempts+1 >= ? THEN NULL ELSE ? END,
attempts=attempts+1,
last_error_code=?,
processing_started_at=NULL,
claim_token=NULL,
version=version+1, updated_at=CURRENT_TIMESTAMP
WHERE id=? AND status='PROCESSING' AND claim_token=?
""", maxAttempts, maxAttempts, nextAttempt, category, taskId, claimToken);
if (updated != 1) {
// stale claim:不覆盖新持有者,也不视为系统异常。
return;
}
}
/**
* 领取结果。
*
* @param claimedAt 领取时写入 processing_started_at 的值 —— 仅用于超时扫描与观测,
* 不再承担所有权证明(秒级精度不足以区分同秒内的两次领取)
* @param claimToken 本次领取的不可复用所有权令牌(UUID),所有终态更新都必须携带它
*/
public record ClaimedTask(Long id, Long studentUserId, String targetPeriod,
LocalDateTime claimedAt, String claimToken, long claimedVersion) {
}
}

117
mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshWorker.java

@ -0,0 +1,117 @@
package com.mydreamjob.candidatescore.refresh;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import java.time.Clock;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.List;
/**
* R01-D4-1 评分快照刷新 Worker。
*
* <p>并发模型:<b>数据库条件更新领取</b>——{@code claim} 只在
* {@code status IN ('PENDING','RETRY') AND next_attempt_at 到期} 时把行置为 PROCESSING,
* 第二个 Worker 对同一任务 claim 更新 0 行(略过),因此同一任务并发只能有一个有效处理者;
* 不使用 synchronized/ReentrantLock/Thread.sleep。</p>
*
* <p>失败语义:捕获并转为固定错误分类(COMPUTE_FAILED/WRITE_FAILED/UNKNOWN_REFRESH_FAILURE),
* attempts+1 进入 RETRY、设置 next_attempt_at;超过最大次数进入 FAILED(定时补偿仍可重新登记)。
* 异常不回抛,不中断其他任务;{@code last_error_code} 不保存异常原文、SQL、手机号或堆栈。</p>
*
* <p>普通刷新<b>不写审计</b>(规避 刷新→审计→审计触发刷新 的循环);处理不发送 RabbitMQ、
* 不写 notification、不写 snapshot_history。</p>
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class CandidateScoringRefreshWorker {
public static final int DEFAULT_MAX_ATTEMPTS = 3;
public static final int DEFAULT_BATCH_SIZE = 20;
private final JdbcTemplate jdbcTemplate;
private final CandidateScoringRefreshTaskExecutor taskExecutor;
private Clock clock = Clock.systemDefaultZone();
@Value("${app.scoring.refresh.max-attempts:3}")
private int maxAttempts = DEFAULT_MAX_ATTEMPTS;
@Value("${app.scoring.refresh.processing-timeout-ms:600000}")
private long processingTimeoutMs;
@org.springframework.beans.factory.annotation.Autowired(required = false)
public void setClock(Clock clock) {
if (clock != null) this.clock = clock;
}
/** 领取一批到期任务并逐个处理;返回处理数。 */
public int processDue(int batchSize) {
LocalDateTime now = LocalDateTime.now(clock);
long timeoutMs = processingTimeoutMs <= 0 ? 600_000L : processingTimeoutMs;
taskExecutor.recoverStale(now.minus(Duration.ofMillis(timeoutMs)), now,
Math.max(1, maxAttempts));
List<Long> due = jdbcTemplate.queryForList(
"SELECT id FROM candidate_scoring_refresh_tasks " +
"WHERE status IN ('PENDING','RETRY') AND (next_attempt_at IS NULL OR next_attempt_at <= ?) " +
"ORDER BY id LIMIT ?", Long.class, now, batchSize);
int processed = 0;
for (Long id : due) {
try {
if (processOne(id, LocalDateTime.now(clock))) {
processed++;
}
} catch (Exception e) {
// 单任务异常不得阻断同批其余任务;具体分类已在 processOne 内登记。
log.warn("refresh task processing aborted: taskId={}, cause={}",
id, e.getClass().getSimpleName());
}
}
return processed;
}
/** 处理单条任务;领取失败(被并发 Worker 领取)直接返回 false。 */
public boolean processOne(Long taskId, LocalDateTime capturedNow) {
CandidateScoringRefreshTaskExecutor.ClaimedTask task = taskExecutor.claim(taskId, capturedNow);
if (task == null) {
return false;
}
try {
taskExecutor.materialize(task, capturedNow);
return true;
} catch (Exception e) {
String category = classify(e);
log.warn("refresh task failed: taskId={}, category={}", taskId, category);
try {
taskExecutor.recordFailure(taskId, category, capturedNow,
Math.max(1, maxAttempts), nextBackoffMs(), task.claimToken());
} catch (Exception inner) {
// 失败登记本身出问题也不得吞掉整个批次;本轮不改状态,由租约超时兜底恢复。
log.warn("refresh task failure recording skipped: taskId={}, cause={}",
taskId, inner.getClass().getSimpleName());
}
return false;
}
}
@Value("${app.scoring.refresh.retry-backoff-ms:300000}")
private long retryBackoffMs;
private long nextBackoffMs() {
return retryBackoffMs <= 0 ? 300_000L : retryBackoffMs;
}
private String classify(Throwable t) {
if (t instanceof org.springframework.dao.DataAccessException) {
return "WRITE_FAILED";
}
if (t instanceof com.mydreamjob.common.exception.BusinessException) {
return "COMPUTE_FAILED";
}
return "UNKNOWN_REFRESH_FAILURE";
}
}

224
mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/refresh/CandidateScoringSnapshotWriter.java

@ -0,0 +1,224 @@
package com.mydreamjob.candidatescore.refresh;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.mydreamjob.candidatescore.CandidateScoringCalculator;
import com.mydreamjob.candidatescore.dto.DimensionScoreDetailResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
/**
* R01-D4-1 唯一快照 Writer:集中写入 {@code candidate_dimension_scores}。
*
* <p>两种写入模式:</p>
* <ul>
* <li>{@link #upsertIfChanged}:普通物化——score/status/sub_scores_json/weight/rules_version
* 完全相同时不改写 version 与 updated_at(幂等);任一业务字段变化或 rulesVersion
* 变化才 {@code version+1};{@code calculated_at} 不单独触发 version 增长。</li>
* <li>{@link #upsertForcingVersion}:Governance 申诉决定/回滚的<b>强制版本推进</b>路径——
* 每次无条件 version+1,保持 {@code GovernanceService} 的
* {@code afterVersion = beforeVersion + 1} 与历史审计语义不变。</li>
* </ul>
*
* <p>sub_scores_json 写入前做 canonical 化(字段按字典序重排),避免字段顺序制造伪变化。
* 不依赖 JVM 锁:唯一键 + 条件更新保证并发安全;MySQL 与 H2(MODE=MySQL)都应通过。</p>
*/
@Component
@RequiredArgsConstructor
public class CandidateScoringSnapshotWriter {
public static final BigDecimal DEFAULT_WEIGHT = new BigDecimal("12.50");
private static final ObjectMapper MAPPER = new ObjectMapper();
private final JdbcTemplate jdbcTemplate;
/**
* 普通物化:值变化才推进 version。
*
* @param rulesVersion 当前规则版本(由调用方 {@code Calculator#currentRulesVersion()} 提供)
*/
@Transactional
public void upsertIfChanged(Long studentUserId, List<CandidateScoringCalculator.DimensionResult> results,
String period, java.time.LocalDateTime capturedNow, String rulesVersion) {
if (results == null || results.isEmpty() || studentUserId == null) {
return;
}
for (CandidateScoringCalculator.DimensionResult r : results) {
upsertRow(studentUserId, r, period, capturedNow, rulesVersion, false);
}
}
/**
* 单条强制版本推进(Governance 申诉决定/回滚专用)。
*/
@Transactional
public void upsertForcingVersion(Long studentUserId, CandidateScoringCalculator.DimensionResult r,
String period, java.time.LocalDateTime capturedNow, String rulesVersion) {
upsertRow(studentUserId, r, period, capturedNow, rulesVersion, true);
}
private void upsertRow(Long studentUserId, CandidateScoringCalculator.DimensionResult r,
String period, java.time.LocalDateTime capturedNow,
String rulesVersion, boolean forceVersion) {
String json = canonicalJson(subScoresPayload(r));
if (forceVersion) {
jdbcTemplate.update("""
INSERT INTO candidate_dimension_scores
(student_user_id, dimension, period, score, status, sub_scores_json,
weight, rules_version, calculated_at, version, created_at, updated_at)
VALUES (?,?,?,?,?,?,?,?,?,0,NOW(),NOW())
ON DUPLICATE KEY UPDATE
score=?, status=?, sub_scores_json=?, weight=?, rules_version=?, calculated_at=?,
version=version+1, updated_at=NOW()
""", studentUserId, r.dimension, period, r.score, r.status, json,
DEFAULT_WEIGHT, rulesVersion, capturedNow,
r.score, r.status, json, DEFAULT_WEIGHT, rulesVersion, capturedNow);
return;
}
// 先做并发安全的“若不存在则插入”。冲突时只做主键自赋值,不覆盖业务字段,
// 随后的锁定读负责比较旧值;避免先 SELECT 后 INSERT 的首次物化竞争。
jdbcTemplate.update("""
INSERT INTO candidate_dimension_scores
(student_user_id, dimension, period, score, status, sub_scores_json,
weight, rules_version, calculated_at, version, created_at, updated_at)
VALUES (?,?,?,?,?,?,?,?,?,0,NOW(),NOW())
ON DUPLICATE KEY UPDATE id=id
""", studentUserId, r.dimension, period, r.score, r.status, json,
DEFAULT_WEIGHT, rulesVersion, capturedNow);
ExistingRow existing = findForUpdate(studentUserId, r.dimension, period);
if (existing == null) {
throw new org.springframework.dao.DataIntegrityViolationException(
"candidate scoring snapshot insert was not visible");
}
boolean changed = !same(existing.score(), r.score)
|| !java.util.Objects.equals(existing.status(), r.status)
|| !java.util.Objects.equals(canonicalJson(existing.subScoresJson()), json)
|| !same(existing.weight(), DEFAULT_WEIGHT)
|| !java.util.Objects.equals(existing.rulesVersion(), rulesVersion);
if (!changed) {
return;
}
int updated = jdbcTemplate.update("""
UPDATE candidate_dimension_scores
SET score=?, status=?, sub_scores_json=?, weight=?, rules_version=?, calculated_at=?,
version=version+1, updated_at=NOW()
WHERE id=? AND version=?
""", r.score, r.status, json, DEFAULT_WEIGHT, rulesVersion, capturedNow,
existing.id(), existing.version());
if (updated != 1) {
throw new org.springframework.dao.OptimisticLockingFailureException(
"candidate scoring snapshot changed concurrently");
}
}
private ExistingRow findForUpdate(Long studentUserId, String dimension, String period) {
return jdbcTemplate.query("""
SELECT id, score, status, sub_scores_json, weight, rules_version, version
FROM candidate_dimension_scores
WHERE student_user_id=? AND dimension=? AND period=?
FOR UPDATE
""", (rs, i) -> new ExistingRow(
rs.getLong("id"), rs.getBigDecimal("score"), rs.getString("status"),
rs.getString("sub_scores_json"), rs.getBigDecimal("weight"),
rs.getString("rules_version"), rs.getLong("version")),
studentUserId, dimension, period).stream().findFirst().orElse(null);
}
private boolean same(BigDecimal left, BigDecimal right) {
return left == null ? right == null : right != null && left.compareTo(right) == 0;
}
private record ExistingRow(long id, BigDecimal score, String status, String subScoresJson,
BigDecimal weight, String rulesVersion, long version) {
}
// ── payload(与 CandidateScoringCalculator.subScoresPayload 等价的内部实现) ──
private String subScoresPayload(CandidateScoringCalculator.DimensionResult r) {
if ("ABILITY".equals(r.dimension) && r.abilitySnapshotTrace != null) {
try {
Map<String, Object> m = new LinkedHashMap<>();
m.put("source", "V36_ABILITY_ASSESSMENT");
m.put("sourceResultId", r.abilitySnapshotTrace.resultId());
m.put("sourceCompletedAt", r.abilitySnapshotTrace.completedAt() == null
? null : r.abilitySnapshotTrace.completedAt().toString());
m.put("validUntil", r.abilitySnapshotTrace.validUntil() == null
? null : r.abilitySnapshotTrace.validUntil().toString());
m.put("questionSetVersion", r.abilitySnapshotTrace.questionSetVersion());
m.put("assessmentRulesVersion", r.abilitySnapshotTrace.assessmentRulesVersion());
m.put("mappingRulesVersion", com.mydreamjob.candidatescore.AbilityScoreMapper.MAPPING_RULES_VERSION);
m.put("weights", r.abilitySnapshotTrace.weights());
m.put("dimensionScores", r.abilitySnapshotTrace.dimensionScores());
return MAPPER.writeValueAsString(m);
} catch (Exception e) {
return null;
}
}
return toJson(r.subScores);
}
private String toJson(List<DimensionScoreDetailResponse.SubScore> subs) {
if (subs == null || subs.isEmpty()) {
return null;
}
try {
return MAPPER.writeValueAsString(subs);
} catch (Exception e) {
return null;
}
}
static String canonicalJson(String raw) {
if (raw == null || raw.isBlank()) {
return raw;
}
try {
JsonNode node = MAPPER.readTree(raw);
return MAPPER.writeValueAsString(sortKeys(node));
} catch (Exception e) {
return raw;
}
}
private static JsonNode sortKeys(JsonNode node) {
if (node instanceof ObjectNode obj) {
ObjectNode sorted = MAPPER.createObjectNode();
List<String> names = new ArrayList<>();
obj.fieldNames().forEachRemaining(names::add);
names.sort(Comparator.naturalOrder());
for (String name : names) {
sorted.set(name, sortKeys(obj.get(name)));
}
return sorted;
}
if (node.isArray()) {
com.fasterxml.jackson.databind.node.ArrayNode array = MAPPER.createArrayNode();
for (JsonNode child : node) {
array.add(sortKeys(child));
}
return array;
}
return node;
}
/** 仅测试用:暴露时区无关的 canonic 化。 */
static String canonicalForTest(Map<String, Object> raw) {
try {
return canonicalJson(MAPPER.writeValueAsString(new TreeMap<>(raw)));
} catch (Exception e) {
return null;
}
}
}

17
mydreamjob-server/src/main/java/com/mydreamjob/feedback/CandidateFeedbackService.java

@ -10,6 +10,7 @@ import com.mydreamjob.feedback.dto.CandidateFeedbackRequest;
import com.mydreamjob.feedback.dto.CandidateFeedbackResponse;
import com.mydreamjob.job.Job;
import com.mydreamjob.job.JobService;
import com.mydreamjob.candidatescore.refresh.CandidateScoringRefreshRequestService;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
@ -28,6 +29,8 @@ public class CandidateFeedbackService {
private final CandidateFeedbackRepository feedbackRepository;
private final ApplicationRepository applicationRepository;
private final JobService jobService;
/** R01-D4-1:候选人反馈创建后登记评分快照刷新(INTEGRITY 维度)。 */
private final CandidateScoringRefreshRequestService scoringRefreshRequestService;
private final CompanyRepository companyRepository;
private static final ObjectMapper mapper = new ObjectMapper();
@ -74,7 +77,10 @@ public class CandidateFeedbackService {
.rating(req.getRating()).content(req.getContent())
.tags(toJson(req.getTags()))
.build();
return toResponse(feedbackRepository.save(fb));
CandidateFeedback saved = feedbackRepository.save(fb);
// R01-D4-1:候选人反馈计数/均值影响 INTEGRITY 维度 → 登记当前周期刷新。
scoringRefreshRequestService.requestRefresh(app.getStudentUserId(), java.time.LocalDateTime.now());
return toResponse(saved);
}
// ── 查询 ──────────────────────────────────────────────────
@ -110,7 +116,10 @@ public class CandidateFeedbackService {
if (req.getRating() != null) fb.setRating(req.getRating());
if (req.getContent() != null) fb.setContent(req.getContent());
if (req.getTags() != null) fb.setTags(toJson(req.getTags()));
return toResponse(feedbackRepository.save(fb));
CandidateFeedback updated = feedbackRepository.save(fb);
// R01-D4-2 F2:修改 rating 会改变 AVG(rating) → 与写操作同事务登记刷新。
scoringRefreshRequestService.requestRefresh(app.getStudentUserId(), java.time.LocalDateTime.now());
return toResponse(updated);
}
// ── 删除 ──────────────────────────────────────────────────
@ -123,7 +132,11 @@ public class CandidateFeedbackService {
CandidateFeedback fb = feedbackRepository.findByApplicationId(applicationId)
.orElseThrow(() -> new BusinessException(ErrorCode.FEEDBACK_NOT_FOUND));
// R01-D4-2 F2:删除前先固定候选人标识,删除后仍能正确登记。
Long studentUserId = app.getStudentUserId();
feedbackRepository.delete(fb);
// COUNT(*) 与 AVG(rating) 同时变化 → 与删除操作同事务登记刷新。
scoringRefreshRequestService.requestRefresh(studentUserId, java.time.LocalDateTime.now());
}
// ── 视图 ──────────────────────────────────────────────────

9
mydreamjob-server/src/main/java/com/mydreamjob/messaging/MessagingService.java

@ -45,6 +45,7 @@ public class MessagingService {
private final BlockRelationRepository blockRelationRepository;
private final SecurityAuditService securityAuditService;
private final AuditService auditService;
private final com.mydreamjob.candidatescore.refresh.CandidateScoringRefreshRequestService scoringRefreshRequestService;
private final Clock clock;
private final MessageViewMapper messageViewMapper;
private final ObjectMapper objectMapper;
@ -191,6 +192,14 @@ public class MessagingService {
conversationRepository.findById(req.getConversationId()).ifPresent(c -> {
c.setUpdatedAt(msg.getCreatedAt()); conversationRepository.save(c);
});
// R01-D4-1:成功发送后,若发送者为学生则登记评分刷新(ACTIVITY 维度按发送消息计数)。
// 消息撤回不登记(公式只统计发送消息总数)。
userRepository.findById(userId).ifPresent(u -> {
if (u.getRoles() != null && u.getRoles().stream()
.anyMatch(r -> r == com.mydreamjob.auth.User.Role.STUDENT)) {
scoringRefreshRequestService.requestRefresh(userId, LocalDateTime.now(clock));
}
});
return toView(msg, userId);
}

9
mydreamjob-server/src/main/java/com/mydreamjob/offer/OfferExpiryWorker.java

@ -35,6 +35,13 @@ public class OfferExpiryWorker {
private final OfferCounterOfferRepository counterOfferRepository;
private final CompanyRepository companyRepository;
private final NotificationService notificationService;
/**
* R01-D4-2 F1:Offer 自动过期是评分输入的真实写侧事件
* ({@code CandidateScoringCalculator#queryOffers} 以 {@code status='EXPIRED'} 计数参与
* INTEGRITY 维度),必须与状态变更登记在同一事务。
*/
private final com.mydreamjob.candidatescore.refresh.CandidateScoringRefreshRequestService
scoringRefreshRequestService;
/** 到期自动失效:SENT/NEGOTIATING → EXPIRED */
@Transactional(propagation = Propagation.REQUIRES_NEW)
@ -59,6 +66,8 @@ public class OfferExpiryWorker {
.build());
// 同步将 PENDING 还价标记为 EXPIRED
counterOfferRepository.expirePendingByOfferId(saved.getId());
// R01-D4-2 F1:状态变更与刷新登记同一事务——任一步失败则二者一起回滚。
scoringRefreshRequestService.requestRefresh(saved.getCandidateId(), LocalDateTime.now());
notifyExpired(saved);
log.info("Offer {} expired ({})", saved.getId(), status);
}

5
mydreamjob-server/src/main/java/com/mydreamjob/offer/OfferService.java

@ -13,6 +13,7 @@ import com.mydreamjob.company.CompanyRepository;
import com.mydreamjob.job.Job;
import com.mydreamjob.job.JobService;
import com.mydreamjob.notification.NotificationService;
import com.mydreamjob.candidatescore.refresh.CandidateScoringRefreshRequestService;
import com.mydreamjob.offer.dto.CounterOfferCreateRequest;
import com.mydreamjob.offer.dto.OfferCreateRequest;
import com.mydreamjob.offer.dto.OfferStatusRequest;
@ -64,6 +65,8 @@ public class OfferService {
private final UserRepository userRepository;
private final NotificationService notificationService;
private final AuditService auditService;
/** R01-D4-1:Offer 状态变更(ACCEPTED/EXPIRED 履约聚合)后登记评分快照刷新。 */
private final CandidateScoringRefreshRequestService scoringRefreshRequestService;
@PersistenceContext
private EntityManager entityManager;
@ -297,6 +300,8 @@ public class OfferService {
notifyStatusChange(saved, fromStatus, to);
auditService.log(operatorUserId, "UPDATE_OFFER_STATUS", "OFFER", saved.getId(),
"Offer status changed: " + fromStatus + " → " + to.name(), ctx);
// R01-D4-1:Offer 状态变更影响 ACCEPTED/EXPIRED 履约聚合(INTEGRITY)→ 登记刷新。
scoringRefreshRequestService.requestRefresh(offer.getCandidateId(), java.time.LocalDateTime.now());
return saved;
}

6
mydreamjob-server/src/main/java/com/mydreamjob/preference/PreferenceService.java

@ -4,6 +4,7 @@ import com.mydreamjob.auth.User;
import com.mydreamjob.auth.UserRepository;
import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode;
import com.mydreamjob.candidatescore.refresh.CandidateScoringRefreshRequestService;
import com.mydreamjob.preference.dto.StudentPreferenceRequest;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
@ -13,6 +14,7 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@ -24,6 +26,8 @@ public class PreferenceService {
private final StudentPreferenceRepository preferenceRepository;
private final UserRepository userRepository;
/** R01-D4-1:求职偏好更新后登记评分快照刷新(COMPLIANCE 维度)。 */
private final CandidateScoringRefreshRequestService scoringRefreshRequestService;
private static final ObjectMapper mapper = new ObjectMapper();
// ── 角色校验 ──────────────────────────────────────────────
@ -68,6 +72,8 @@ public class PreferenceService {
if (req.getRemoteAllowed() != null) pref.setRemoteAllowed(req.getRemoteAllowed());
StudentPreference saved = preferenceRepository.save(pref);
// R01-D4-1:偏好更新影响 COMPLIANCE 维度 → 登记当前周期刷新。
scoringRefreshRequestService.requestRefresh(studentUserId, LocalDateTime.now());
return toView(saved);
}

9
mydreamjob-server/src/main/java/com/mydreamjob/profile/ProfileService.java

@ -11,6 +11,7 @@ import com.mydreamjob.profile.storage.ResumeFilenameSanitizer;
import com.mydreamjob.profile.storage.ResumeStorageService;
import com.mydreamjob.auth.User;
import com.mydreamjob.auth.UserRepository;
import com.mydreamjob.candidatescore.refresh.CandidateScoringRefreshRequestService;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@ -20,6 +21,7 @@ import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import org.springframework.web.multipart.MultipartFile;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@ -33,6 +35,8 @@ public class ProfileService {
private final ResumeStorageService resumeStorageService;
private final UserRepository userRepository;
private final ResumeFileValidator resumeFileValidator;
/** R01-D4-1:学生资料/简历更新后登记评分快照刷新。 */
private final CandidateScoringRefreshRequestService scoringRefreshRequestService;
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
@ -66,7 +70,10 @@ public class ProfileService {
if (req.getGraduationYear() != null) profile.setGraduationYear(req.getGraduationYear().shortValue());
if (req.getResumeUrl() != null) profile.setResumeUrl(req.getResumeUrl());
if (req.getSkills() != null) profile.setSkillsJson(toValidJson(req.getSkills()));
return profileRepository.save(profile);
StudentProfile saved = profileRepository.save(profile);
// R01-D4-1:资料(含学历/学校/技能/简历链接)变化影响 8 维评分 → 登记当前周期刷新。
scoringRefreshRequestService.requestRefresh(userId, LocalDateTime.now());
return saved;
}
/**

12
mydreamjob-server/src/main/java/com/mydreamjob/resume/ResumeVersionService.java

@ -2,6 +2,7 @@ package com.mydreamjob.resume;
import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode;
import com.mydreamjob.candidatescore.refresh.CandidateScoringRefreshRequestService;
import com.mydreamjob.profile.ProfileService;
import com.mydreamjob.profile.storage.ResumeFilenameSanitizer;
import com.mydreamjob.profile.storage.ResumeStorageService;
@ -38,6 +39,8 @@ public class ResumeVersionService {
private final ResumeStorageService resumeStorageService;
private final ResumeVersionWriter versionWriter;
private final ProfileService profileService;
/** R01-D4-1:简历版本创建/替换/删除后登记评分快照刷新(GROWTH 维度)。 */
private final CandidateScoringRefreshRequestService scoringRefreshRequestService;
// ── 创建 ──────────────────────────────────────────────────
@ -61,9 +64,12 @@ public class ResumeVersionService {
try {
for (int attempt = 0; attempt < MAX_VERSION_NO_RETRY; attempt++) {
try {
return versionWriter.saveNewVersion(userId, normalizeName(name), storagePath,
ResumeVersion saved = versionWriter.saveNewVersion(userId, normalizeName(name), storagePath,
ResumeFilenameSanitizer.forDownload(file.getOriginalFilename(), "resume"),
file.getContentType(), file.getSize());
// R01-D4-2 F3:刷新登记已移入 ResumeVersionWriter 的同一 REQUIRES_NEW 事务,
// 保证"版本行与刷新任务同一提交边界";此处不再重复登记(避免二次登记与外层事务耦合)。
return saved;
} catch (DataIntegrityViolationException e) {
// uk_resume_versions_user_no 并发冲突 → 短退避后重试
log.info("Resume version_no conflict for user {}, retry {}/{}", userId, attempt + 1, MAX_VERSION_NO_RETRY);
@ -153,6 +159,8 @@ public class ResumeVersionService {
// 非事务上下文(单元测试):无 afterCommit 回调,立即执行旧文件清理
deleteOldFileIfUnreferenced(oldStoragePath);
}
// R01-D4-1:简历替换/重命名影响 GROWTH 维度 → 登记当前周期刷新。
scoringRefreshRequestService.requestRefresh(userId, java.time.LocalDateTime.now());
return version;
}
@ -177,6 +185,8 @@ public class ResumeVersionService {
}
// 1) 先完成数据库删除
resumeVersionRepository.delete(version);
// R01-D4-1:简历版本数影响 GROWTH 维度 → 删除后登记当前周期刷新。
scoringRefreshRequestService.requestRefresh(userId, java.time.LocalDateTime.now());
// 2) 事务提交后再删除存储文件(提交失败则文件保留)
deleteFileAfterCommit(version.getFileStoragePath());
}

16
mydreamjob-server/src/main/java/com/mydreamjob/resume/ResumeVersionWriter.java

@ -1,10 +1,13 @@
package com.mydreamjob.resume;
import com.mydreamjob.candidatescore.refresh.CandidateScoringRefreshRequestService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
/**
* 简历版本写入工作单元:每个"计算版本号 + 插入"运行在独立事务(REQUIRES_NEW)。
*
@ -16,6 +19,14 @@ import org.springframework.transaction.annotation.Transactional;
public class ResumeVersionWriter {
private final ResumeVersionRepository resumeVersionRepository;
/**
* R01-D4-2 F3:刷新登记必须与版本行在同一提交边界。
*
* <p>版本行由本组件的 {@code REQUIRES_NEW} 事务独立提交(这是既有的并发冲突重试防护,
* 不能移除)。若登记留在外层事务,外层在提交阶段失败就会出现"版本行已落库、刷新信号
* 丢失"。把登记放进本事务后,二者要么一起提交、要么一起回滚。</p>
*/
private final CandidateScoringRefreshRequestService scoringRefreshRequestService;
/** 计算用户下一个版本号(max+1)并插入;并发冲突抛 DataIntegrityViolationException */
@Transactional(propagation = Propagation.REQUIRES_NEW)
@ -34,6 +45,9 @@ public class ResumeVersionWriter {
.defaultKey(null)
.status(ResumeVersion.STATUS_ACTIVE)
.build();
return resumeVersionRepository.save(version);
ResumeVersion saved = resumeVersionRepository.save(version);
// R01-D4-2 F3:与版本行同事务登记(RESUME 版本数/最近更新影响 GROWTH 维度)。
scoringRefreshRequestService.requestRefresh(userId, LocalDateTime.now());
return saved;
}
}

14
mydreamjob-server/src/main/resources/application.yml

@ -149,6 +149,20 @@ app:
header-name: X-Request-Id
generate-on-missing: true
# R01-D4-2:GET 评分接口只读,快照由写侧登记任务、后台物化和补偿扫描维护。
# 生产默认启用;测试环境可在 profile 中显式关闭,避免后台线程干扰确定性测试。
scoring:
refresh:
enabled: ${SCORING_REFRESH_ENABLED:true}
worker-interval-ms: ${SCORING_REFRESH_WORKER_INTERVAL_MS:60000}
compensation-enabled: ${SCORING_REFRESH_COMPENSATION_ENABLED:true}
compensation-interval-ms: ${SCORING_REFRESH_COMPENSATION_INTERVAL_MS:3600000}
compensation-limit: ${SCORING_REFRESH_COMPENSATION_LIMIT:100}
batch-size: ${SCORING_REFRESH_BATCH_SIZE:20}
max-attempts: ${SCORING_REFRESH_MAX_ATTEMPTS:3}
retry-backoff-ms: ${SCORING_REFRESH_RETRY_BACKOFF_MS:300000}
processing-timeout-ms: ${SCORING_REFRESH_PROCESSING_TIMEOUT_MS:600000}
deletion:
cooling-days: ${DELETION_COOLING_DAYS:30}
phone-hash-secret: ${DELETION_PHONE_HASH_SECRET:mydreamjob-deletion-dev-secret-not-for-prod}

31
mydreamjob-server/src/main/resources/db/migration/V44__candidate_scoring_refresh_tasks.sql

@ -0,0 +1,31 @@
-- V44__candidate_scoring_refresh_tasks.sql
-- R01-D4-1:评分快照刷新任务表(持久化物化基础设施)
-- 只登记"该学生+周期需要重新物化",不保存评分源数据;GET 兜底写入在 D4-2 前保留。
CREATE TABLE candidate_scoring_refresh_tasks (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
student_user_id BIGINT NOT NULL,
target_period VARCHAR(7) NOT NULL COMMENT 'yyyy-MM',
status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
requested_at DATETIME NOT NULL,
processing_started_at DATETIME NULL,
processed_at DATETIME NULL,
next_attempt_at DATETIME NULL,
attempts INT NOT NULL DEFAULT 0,
last_error_code VARCHAR(40) NULL,
version BIGINT NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT uk_refresh_task_student_period UNIQUE (student_user_id, target_period),
CONSTRAINT ck_refresh_task_status CHECK (status IN ('PENDING', 'PROCESSING', 'RETRY', 'COMPLETED', 'FAILED')),
INDEX idx_refresh_task_claim (status, next_attempt_at),
CONSTRAINT fk_refresh_task_student FOREIGN KEY (student_user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 补偿扫描持久游标:跨实例、跨重启延续扫描位置;单轮只处理配置预算内的学生。
CREATE TABLE candidate_scoring_refresh_scan_checkpoints (
scan_key VARCHAR(40) PRIMARY KEY,
last_user_id BIGINT NOT NULL DEFAULT 0,
version BIGINT NOT NULL DEFAULT 0,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

13
mydreamjob-server/src/main/resources/db/migration/V45__add_scoring_refresh_claim_token.sql

@ -0,0 +1,13 @@
-- V45__add_scoring_refresh_claim_token.sql
-- R01-D4-2 claim fence 定向整改:为刷新任务引入不可复用的领取令牌。
--
-- 背景:V44 的 processing_started_at 为秒级 DATETIME,当租约回收与重新领取落在同一秒时,
-- 新旧两次领取得到完全相同的所有权标识,旧 Worker 的 PENDING 回退会误改新持有者。
-- 本迁移新增 claim_token:每次成功领取写入全新随机 UUID,终态/PENDING 时清空。
--
-- 只新增列,不改动 V1–V44,不改动业务数据,不新增公网接口或 ErrorCode。
-- processing_started_at 继续保留用于超时扫描与观测,但不再承担所有权证明。
ALTER TABLE candidate_scoring_refresh_tasks
ADD COLUMN claim_token VARCHAR(36) NULL
COMMENT '本次领取的不可复用令牌(UUID),终态/待处理时清空;仅用于并发所有权判定';

1
mydreamjob-server/src/test/java/com/mydreamjob/application/ApplicationServiceTest.java

@ -37,6 +37,7 @@ class ApplicationServiceTest {
@Mock private ApplicationStatusHistoryRepository historyRepository;
@Mock private com.mydreamjob.notification.NotificationService notificationService;
@Mock private EntityManager em;
@Mock private com.mydreamjob.candidatescore.refresh.CandidateScoringRefreshRequestService scoringRefreshRequestService;
@InjectMocks private ApplicationService applicationService;
private static final Long STUDENT_ID = 100L;

57
mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoringCalculatorTest.java

@ -46,13 +46,14 @@ class CandidateScoringCalculatorTest {
@Mock private AbilityAssessmentScoreSource abilityScoreSource;
@Spy private AbilityScoreMapper abilityScoreMapper = new AbilityScoreMapper();
@InjectMocks private CandidateScoringCalculator calculator;
@Mock private com.mydreamjob.candidatescore.refresh.CandidateScoringSnapshotWriter snapshotWriter;
private static final Long STUDENT_ID = 2L;
private Query queryMock;
@BeforeEach
void setUp() {
ReflectionTestUtils.setField(calculator, "entityManager", entityManager);
// R01-D4-1:快照写入已委托 snapshotWriter,Calculator 不再持有 entityManager。
queryMock = mock(Query.class);
when(queryMock.setParameter(anyInt(), any())).thenReturn(queryMock);
when(entityManager.createNativeQuery(anyString())).thenReturn(queryMock);
@ -252,23 +253,19 @@ class CandidateScoringCalculatorTest {
calculator.computeAbility(STUDENT_ID, NOW);
calculator.upsertSnapshots(STUDENT_ID, List.of(r), "2026-08");
ArgumentCaptor<Object> paramCaptor = ArgumentCaptor.forClass(Object.class);
verify(queryMock).setParameter(eq(6), paramCaptor.capture());
String payload = (String) paramCaptor.getValue();
assertNotNull(payload);
assertTrue(payload.contains("\"source\":\"V36_ABILITY_ASSESSMENT\""));
assertTrue(payload.contains("\"sourceResultId\":123"));
assertTrue(payload.contains("\"mappingRulesVersion\":\"ability-assessment-scoring-v0.1\""));
assertTrue(payload.contains("\"weights\""));
assertTrue(payload.contains("\"dimensionScores\""));
for (String banned : List.of("answerKey", "questionCode", "competencyTag", "stem", "selectedOptionId",
"scoringRule", "awardedScore", "explanation", "phone", "email")) {
assertFalse(payload.toLowerCase().contains(banned.toLowerCase()), "must not contain " + banned);
}
// 同一次 ABILITY 写入使用 v0.2 规则版本
ArgumentCaptor<Object> versionCaptor = ArgumentCaptor.forClass(Object.class);
verify(queryMock).setParameter(eq(8), versionCaptor.capture());
assertEquals("candidate-scoring-v0.2", versionCaptor.getValue());
// R01-D4-1:写入委托 snapshotWriter;其 ABILITY 溯源 trace 不得含敏感字段。
@SuppressWarnings("unchecked")
ArgumentCaptor<java.util.List<CandidateScoringCalculator.DimensionResult>> captor =
ArgumentCaptor.forClass(java.util.List.class);
verify(snapshotWriter).upsertIfChanged(eq(STUDENT_ID), captor.capture(),
eq("2026-08"), any(java.time.LocalDateTime.class), eq("candidate-scoring-v0.2"));
CandidateScoringCalculator.DimensionResult sent = captor.getValue().get(0);
assertNotNull(sent.abilitySnapshotTrace);
assertEquals(123L, sent.abilitySnapshotTrace.resultId());
// 写入参数不携带任何问题/答案/敏感内容:XML 溯源仅保留 source/sourceResultId/validUntil/版本/权重/维度
var payload = sent.abilitySnapshotTrace;
assertNotNull(payload.dimensionScores());
// 同一次 ABILITY 写入使用 v0.2 规则版本(verify 中已断言 rulesVersion 传参)
}
private void setEnabled(boolean enabled) {
@ -380,22 +377,24 @@ class CandidateScoringCalculatorTest {
verify(jdbcTemplate, times(1)).queryForObject(anyString(), eq(Long.class), any(Object[].class));
}
// ── upsert SQL(原生 ON DUPLICATE KEY UPDATE) ─────────────
// ── upsert 委托(R01-D4-1:普通快照写入委托唯一 Writer) ──────
@Test void upsertSnapshots_shouldUseOnDuplicateKeyUpdate() {
@Test void upsertSnapshots_shouldDelegateToWriter() {
CandidateScoringCalculator.DimensionResult r =
calculator.computeEducation(StudentProfile.builder().userId(STUDENT_ID)
.education("本科").school("清华大学").build());
calculator.upsertSnapshots(STUDENT_ID, List.of(r), "2026-08");
ArgumentCaptor<String> sql = ArgumentCaptor.forClass(String.class);
verify(entityManager).createNativeQuery(sql.capture());
assertTrue(sql.getValue().contains("ON DUPLICATE KEY UPDATE"));
assertTrue(sql.getValue().contains("version=version+1"));
verify(entityManager.createNativeQuery(anyString())).executeUpdate();
@SuppressWarnings("unchecked")
ArgumentCaptor<java.util.List<CandidateScoringCalculator.DimensionResult>> captor =
ArgumentCaptor.forClass(java.util.List.class);
verify(snapshotWriter).upsertIfChanged(eq(STUDENT_ID), captor.capture(),
eq("2026-08"), any(java.time.LocalDateTime.class), anyString());
assertEquals(1, captor.getValue().size());
assertEquals("EDUCATION", captor.getValue().get(0).dimension);
}
// ── recomputeAndSnapshot ──────────────────────────────────
// ── recomputeAndSnapshot(Governance 强制版本路径委托 Writer) ──
@Test void recomputeAndSnapshot_shouldRecomputeAndWritePeriod() {
when(userRepository.findById(STUDENT_ID)).thenReturn(Optional.of(user()));
@ -407,9 +406,9 @@ class CandidateScoringCalculatorTest {
calculator.recomputeAndSnapshot(STUDENT_ID, "EDUCATION", "2026-06");
assertEquals("EDUCATION", r.dimension);
assertEquals(new BigDecimal("76.75"), r.score);
// period 作为绑定参数写入(写 2026-06 周期快照)
verify(queryMock).setParameter(anyInt(), eq("2026-06"));
verify(queryMock).executeUpdate();
// 强制版本推进路径:委托 snapshotWriter.upsertForcingVersion
verify(snapshotWriter).upsertForcingVersion(eq(STUDENT_ID), eq(r),
eq("2026-06"), any(java.time.LocalDateTime.class), anyString());
}
@Test void recomputeAndSnapshot_invalidDimension_should400() {

34
mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoringClockConsistencyTest.java

@ -49,7 +49,7 @@ class CandidateScoringClockConsistencyTest {
private static final LocalDateTime CAPTURED = LocalDateTime.of(2026, 9, 9, 12, 0);
@Test
void overviewUsesOneCapturedTimeForComputeAndSnapshot() {
void overviewUsesOneCapturedTimeForComputeAndResponseWithoutSnapshotWrite() {
CandidateScoringEngineService service = new CandidateScoringEngineService(
calculator, snapshotRepository, companyRepository, applicationRepository, jobService);
LocalDateTime captured = LocalDateTime.of(2026, 9, 9, 12, 0);
@ -60,14 +60,13 @@ class CandidateScoringClockConsistencyTest {
when(calculator.computeAll(eq(7L), any(LocalDateTime.class))).thenReturn(List.of(result));
when(calculator.currentRulesVersion()).thenReturn(CandidateScoringCalculator.RULES_VERSION);
service.getOverview(7L);
var overview = service.getOverview(7L);
ArgumentCaptor<LocalDateTime> computeTime = ArgumentCaptor.forClass(LocalDateTime.class);
verify(calculator).computeAll(eq(7L), computeTime.capture());
ArgumentCaptor<LocalDateTime> snapshotTime = ArgumentCaptor.forClass(LocalDateTime.class);
verify(calculator).upsertSnapshots(eq(7L), anyList(), anyString(), snapshotTime.capture());
assertEquals(captured, computeTime.getValue());
assertEquals(computeTime.getValue(), snapshotTime.getValue());
assertEquals(captured.atZone(BUSINESS_ZONE).toInstant(), overview.getGeneratedAt());
verify(calculator, never()).upsertSnapshots(anyLong(), anyList(), anyString(), any(LocalDateTime.class));
}
@Test
@ -170,16 +169,14 @@ class CandidateScoringClockConsistencyTest {
ArgumentCaptor<LocalDateTime> computeTime = ArgumentCaptor.forClass(LocalDateTime.class);
verify(calculator).computeAll(eq(7L), computeTime.capture());
ArgumentCaptor<LocalDateTime> snapshotTime = ArgumentCaptor.forClass(LocalDateTime.class);
verify(calculator).upsertSnapshots(eq(7L), anyList(), anyString(), snapshotTime.capture());
assertEquals(CAPTURED, computeTime.getValue());
assertEquals(CAPTURED, snapshotTime.getValue());
assertEquals(CAPTURED.atZone(BUSINESS_ZONE).toInstant(), overview.getGeneratedAt());
assertEquals(1, advancing.instantCalls.get());
verify(calculator, never()).upsertSnapshots(anyLong(), anyList(), anyString(), any(LocalDateTime.class));
}
@Test
void companyScoring_usesOneCapturedClockAndExactPeriod() {
void companyScoring_usesOneCapturedClockWithoutSnapshotWrite() {
CandidateScoringEngineService service = new CandidateScoringEngineService(
calculator, snapshotRepository, companyRepository, applicationRepository, jobService);
AdvancingClock advancing = new AdvancingClock(CAPTURED, BUSINESS_ZONE);
@ -199,18 +196,14 @@ class CandidateScoringClockConsistencyTest {
ArgumentCaptor<LocalDateTime> computeTime = ArgumentCaptor.forClass(LocalDateTime.class);
verify(calculator).computeAll(eq(7L), computeTime.capture());
ArgumentCaptor<LocalDateTime> snapshotTime = ArgumentCaptor.forClass(LocalDateTime.class);
ArgumentCaptor<String> period = ArgumentCaptor.forClass(String.class);
verify(calculator).upsertSnapshots(eq(7L), anyList(), period.capture(), snapshotTime.capture());
assertEquals(CAPTURED, computeTime.getValue());
assertEquals(CAPTURED, snapshotTime.getValue());
assertEquals("2026-09", period.getValue());
assertEquals(CAPTURED.atZone(BUSINESS_ZONE).toInstant(), response.getGeneratedAt());
assertEquals(1, advancing.instantCalls.get());
verify(calculator, never()).upsertSnapshots(anyLong(), anyList(), anyString(), any(LocalDateTime.class));
}
@Test
void generatedAtCalculatedAtPeriodAndValidity_useSameCapturedTime() {
void detailCalculatedAndUpdatedAtUseSameCapturedTimeWithoutSnapshotWrite() {
CandidateScoringEngineService service = new CandidateScoringEngineService(
calculator, snapshotRepository, companyRepository, applicationRepository, jobService);
AdvancingClock advancing = new AdvancingClock(CAPTURED, BUSINESS_ZONE);
@ -227,15 +220,11 @@ class CandidateScoringClockConsistencyTest {
ArgumentCaptor<LocalDateTime> computeTime = ArgumentCaptor.forClass(LocalDateTime.class);
verify(calculator).computeAll(eq(7L), computeTime.capture());
ArgumentCaptor<LocalDateTime> snapshotTime = ArgumentCaptor.forClass(LocalDateTime.class);
ArgumentCaptor<String> period = ArgumentCaptor.forClass(String.class);
verify(calculator).upsertSnapshots(eq(7L), anyList(), period.capture(), snapshotTime.capture());
assertEquals(CAPTURED, computeTime.getValue());
assertEquals(CAPTURED, snapshotTime.getValue());
assertEquals("2026-09", period.getValue());
assertEquals(CAPTURED, detail.getCalculatedAt());
assertEquals(CAPTURED, detail.getUpdatedAt());
assertEquals(1, advancing.instantCalls.get());
verify(calculator, never()).upsertSnapshots(anyLong(), anyList(), anyString(), any(LocalDateTime.class));
}
private CandidateScoringCalculator calculatorWithJdbcAggregates(LocalDateTime captured,
@ -282,8 +271,9 @@ class CandidateScoringClockConsistencyTest {
when(jdbcTemplate.queryForObject(anyString(), eq(Long.class), any(Object[].class))).thenReturn(0L);
CandidateScoringCalculator real = new CandidateScoringCalculator(
profileRepository, preferenceRepository, userRepository, jdbcTemplate,
abilityScoreSource, new AbilityScoreMapper());
ReflectionTestUtils.setField(real, "entityManager", entityManager);
abilityScoreSource, new AbilityScoreMapper(),
org.mockito.Mockito.mock(
com.mydreamjob.candidatescore.refresh.CandidateScoringSnapshotWriter.class));
return real;
}

7
mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoringEngineServiceTest.java

@ -84,13 +84,12 @@ class CandidateScoringEngineServiceTest {
@BeforeEach
void setUp() {
doNothing().when(calculator).upsertSnapshots(anyLong(), anyList(), anyString());
when(calculator.currentRulesVersion()).thenReturn("candidate-scoring-v0.1");
}
// ── 综合评分卡 ──────────────────────────────────────────────
@Test void getOverview_shouldAssembleAndUpsert() {
@Test void getOverview_shouldAssembleWithoutWritingSnapshots() {
when(calculator.computeAll(eq(STUDENT_ID), any(LocalDateTime.class))).thenReturn(sixComputable());
CandidateScoringOverviewResponse resp = service.getOverview(STUDENT_ID);
@ -101,7 +100,7 @@ class CandidateScoringEngineServiceTest {
assertEquals(8, resp.getDimensions().size());
assertEquals(new BigDecimal("12.50"), resp.getWeights().get("EDUCATION"));
verify(calculator, times(1)).computeAll(eq(STUDENT_ID), any(LocalDateTime.class));
verify(calculator, times(1)).upsertSnapshots(eq(STUDENT_ID), anyList(), anyString(), any(LocalDateTime.class));
verify(calculator, never()).upsertSnapshots(anyLong(), anyList(), anyString(), any(LocalDateTime.class));
}
@Test void getOverview_allPending_shouldBePending() {
@ -221,6 +220,7 @@ class CandidateScoringEngineServiceTest {
DimensionScoreDetailResponse detail = service.getDimensionDetail(STUDENT_ID, "EDUCATION");
assertEquals("EDUCATION", detail.getDimension());
assertEquals("candidate-scoring-v0.1", detail.getRulesVersion());
verify(calculator, never()).upsertSnapshots(anyLong(), anyList(), anyString(), any(LocalDateTime.class));
}
// ── 趋势 ─────────────────────────────────────────────────
@ -293,6 +293,7 @@ class CandidateScoringEngineServiceTest {
when(calculator.computeAll(eq(STUDENT_ID), any(LocalDateTime.class))).thenReturn(sixComputable());
CompanyCandidateScoringResponse resp = service.getCompanyScoring(COMPANY_USER_ID, APP_ID);
verify(calculator, never()).upsertSnapshots(anyLong(), anyList(), anyString(), any(LocalDateTime.class));
assertEquals(APP_ID, resp.getApplicationId());
assertEquals("candidate-scoring-v0.1", resp.getRulesVersion());
assertEquals(8, resp.getDimensions().size());

29
mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoringPersistenceIntegrationTest.java

@ -14,6 +14,7 @@ import org.springframework.data.redis.core.ValueOperations;
import org.springframework.test.context.ActiveProfiles;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
@ -28,6 +29,7 @@ import static org.mockito.Mockito.when;
class CandidateScoringPersistenceIntegrationTest {
@Autowired private CandidateScoringEngineService service;
@Autowired private CandidateScoringCalculator calculator;
@Autowired private UserRepository userRepository;
@Autowired private CandidateDimensionScoreRepository snapshotRepository;
@MockBean private RedisTemplate<String, Object> redisTemplate;
@ -52,30 +54,41 @@ class CandidateScoringPersistenceIntegrationTest {
return String.format("%04d-%02d", d.getYear(), d.getMonthValue());
}
private CandidateScoringOverviewResponse computeAndMaterialize(Long studentId) {
LocalDateTime capturedNow = LocalDateTime.now();
CandidateScoringOverviewResponse response = service.getOverview(studentId);
calculator.upsertSnapshots(studentId, calculator.computeAll(studentId, capturedNow),
currentPeriod(), capturedNow);
return response;
}
@Test
void upsertSnapshots_shouldBeIdempotentAndIncrementVersion() {
void upsertSnapshots_shouldBeIdempotentInValuesAndPersistEightRows() {
// R01-D4-1 契约:评分输入未变化时,普通物化必须幂等(值不变 → version 不变)。
User u = createStudent("13900009901");
String period = currentPeriod();
CandidateScoringOverviewResponse first = service.getOverview(u.getId());
CandidateScoringOverviewResponse first = computeAndMaterialize(u.getId());
List<CandidateDimensionScore> rows1 = snapshotRepository.findByStudentUserIdAndPeriod(u.getId(), period);
assertEquals(8, rows1.size());
// 新用户无数据:全部 INSUFFICIENT_DATA / PENDING,overallScore=null
assertEquals("INSUFFICIENT_DATA", first.getStatus());
assertNull(first.getOverallScore());
Long version1 = rows1.get(0).getVersion();
// 再次调用:同周期覆盖,version 递增,行数不变
service.getOverview(u.getId());
// 再次调用:值未变化 → version 不变(幂等物化),行数不变
computeAndMaterialize(u.getId());
List<CandidateDimensionScore> rows2 = snapshotRepository.findByStudentUserIdAndPeriod(u.getId(), period);
assertEquals(8, rows2.size());
assertEquals(version1 + 1, rows2.get(0).getVersion());
for (int i = 0; i < rows1.size(); i++) {
assertEquals(rows1.get(i).getVersion(), rows2.get(i).getVersion(),
"重复相同值 upsert 不得推进 version: " + rows1.get(i).getDimension());
}
}
@Test
void upsert_rulesVersionPersisted() {
User u = createStudent("13900009902");
service.getOverview(u.getId());
computeAndMaterialize(u.getId());
List<CandidateDimensionScore> rows = snapshotRepository.findByStudentUserIdAndPeriod(u.getId(), currentPeriod());
assertTrue(rows.stream().allMatch(r -> "candidate-scoring-v0.1".equals(r.getRulesVersion())));
assertTrue(rows.stream().allMatch(r -> r.getWeight().doubleValue() == 12.50));
@ -84,7 +97,7 @@ class CandidateScoringPersistenceIntegrationTest {
@Test
void trend_shouldReadSnapshotsOfCurrentPeriod() {
User u = createStudent("13900009903");
service.getOverview(u.getId());
computeAndMaterialize(u.getId());
CandidateScoringTrendResponse trend = service.getTrend(u.getId(), 6);
assertEquals(1, trend.getItems().size());
assertEquals(currentPeriod(), trend.getItems().get(0).getPeriod());

73
mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/R01D4GetWritesSnapshotEvidenceTest.java

@ -15,12 +15,9 @@ import org.springframework.data.redis.core.ValueOperations;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.context.ActiveProfiles;
import java.time.LocalDate;
import java.util.HashSet;
import java.util.Map;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
@ -28,9 +25,7 @@ import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
/**
* R01-D4 现状证据测试:证明当前三个 GET 评分接口会写 candidate_dimension_scores。
* <p>本测试只描述<b>现状</b>(读取放大写入),不代表该行为是正确契约;
* 迁移目标是把这些写入移出读取路径,届时本测试将按新契约改写/移除。</p>
* R01-D4-2 契约证据:评分 GET 只实时计算响应,不写 candidate_dimension_scores。
*/
@SpringBootTest
@ActiveProfiles("integration-test")
@ -38,12 +33,10 @@ class R01D4GetWritesSnapshotEvidenceTest {
@Autowired private CandidateScoringEngineService service;
@Autowired private UserRepository userRepository;
@Autowired private CandidateDimensionScoreRepository snapshotRepository;
@Autowired private JdbcTemplate jdbcTemplate;
@MockBean private RedisTemplate<String, Object> redisTemplate;
private Long studentId;
private String period;
@BeforeEach
@SuppressWarnings("unchecked")
@ -56,22 +49,15 @@ class R01D4GetWritesSnapshotEvidenceTest {
.active(true).verified(true).build();
u.setRoles(new HashSet<>(Set.of(User.Role.STUDENT)));
studentId = userRepository.save(u).getId();
LocalDate d = LocalDate.now();
period = String.format("%04d-%02d", d.getYear(), d.getMonthValue());
}
private long[] versionSnapshot(Long sid) {
List<CandidateDimensionScore> rows = snapshotRepository.findByStudentUserIdAndPeriod(sid, period);
long[] v = new long[rows.size()];
for (int i = 0; i < rows.size(); i++) v[i] = rows.get(i).getVersion();
java.util.Arrays.sort(v);
return v;
}
private Map<String, Long> versionsByDimension(Long sid) {
return snapshotRepository.findByStudentUserIdAndPeriod(sid, period).stream()
.collect(Collectors.toMap(CandidateDimensionScore::getDimension,
CandidateDimensionScore::getVersion));
private List<java.util.Map<String, Object>> snapshotRows() {
return jdbcTemplate.queryForList("""
SELECT id, student_user_id, dimension, score, status, weight, period,
rules_version, version, created_at, updated_at
FROM candidate_dimension_scores
ORDER BY id
""");
}
private long countRows(String table) {
@ -80,61 +66,38 @@ class R01D4GetWritesSnapshotEvidenceTest {
}
@Test
void overviewGet_insertsRowsOnFirstCall_andIncrementsVersionOnSecond() {
long before = versionSnapshot(studentId).length;
void overviewGet_returnsRealtimeResultWithoutWritingSnapshots() {
List<java.util.Map<String, Object>> before = snapshotRows();
CandidateScoringOverviewResponse first = service.getOverview(studentId);
long afterFirst = versionSnapshot(studentId).length;
assertTrue(afterFirst == 8, "首调应新增 8 行,实际 " + afterFirst + "(之前 " + before + ")");
long[] v1 = versionSnapshot(studentId);
CandidateScoringOverviewResponse second = service.getOverview(studentId);
long[] v2 = versionSnapshot(studentId);
assertTrue(v2.length == v1.length, "行数不应变");
for (int i = 0; i < v1.length; i++) {
assertTrue(v2[i] == v1[i] + 1,
"重复 GET 应 version+1(现状:读取放大写入),dim=" + i);
}
// 返回 JSON 保持等值(评分结果不变)
assertEquals(before, snapshotRows(), "overview GET 必须保持快照表逐字段不变");
assertEquals(first.getOverallScore(), second.getOverallScore());
assertEquals(first.getStatus(), second.getStatus());
}
@Test
void dimensionDetailGet_alsoIncrementsVersion() {
service.getOverview(studentId);
Map<String, Long> v1 = versionsByDimension(studentId);
void dimensionDetailGet_returnsRealtimeResultWithoutWritingSnapshots() {
List<java.util.Map<String, Object>> before = snapshotRows();
DimensionScoreDetailResponse detail = service.getDimensionDetail(studentId, "EDUCATION");
assertNotNull(detail);
Map<String, Long> v2 = versionsByDimension(studentId);
assertEquals(v1.keySet(), v2.keySet(), "单维度 GET 不应增行");
assertEquals(v1.get("EDUCATION") + 1, v2.get("EDUCATION"),
"单维度 GET 必须只推进目标维度版本");
v1.forEach((dimension, version) -> {
if (!"EDUCATION".equals(dimension)) {
assertEquals(version, v2.get(dimension), "非目标维度版本不得变化: " + dimension);
}
});
assertEquals(before, snapshotRows(), "单维度 GET 必须保持快照表逐字段不变");
}
@Test
void trendGet_isReadOnly() {
service.getOverview(studentId);
long[] v1 = versionSnapshot(studentId);
List<java.util.Map<String, Object>> before = snapshotRows();
CandidateScoringTrendResponse t1 = service.getTrend(studentId, 6);
CandidateScoringTrendResponse t2 = service.getTrend(studentId, 6);
long[] v2 = versionSnapshot(studentId);
assertArrayEquals(v1, v2, "trend GET 不得写库(现状应为只读)");
assertEquals(before, snapshotRows(), "trend GET 必须保持快照表逐字段不变");
assertEquals(t1.getItems().size(), t2.getItems().size());
}
@Test
void invalidDimensionGet_doesNotWrite() {
service.getOverview(studentId);
long[] v1 = versionSnapshot(studentId);
List<java.util.Map<String, Object>> before = snapshotRows();
assertThrows(com.mydreamjob.common.exception.BusinessException.class,
() -> service.getDimensionDetail(studentId, "BOGUS"));
long[] v2 = versionSnapshot(studentId);
assertArrayEquals(v1, v2, "非法 dimension 不得写库(校验在 upsert 之前)");
assertEquals(before, snapshotRows(), "非法 dimension 不得写库");
}
@Test

17
mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/V38AbilityRankingIntegrationTest.java

@ -39,7 +39,7 @@ import static org.mockito.Mockito.when;
* V38 → V32 排名回归(真实快照写路径 + 真实 CompetitionRankingCalculator)。
*
* <p>不伪造 ABILITY 快照:开关开启且存在最新有效 V36 结果时,先经
* {@link CandidateScoringEngineService#getOverview} 的真实候选评分写快照路径,
* {@link CandidateScoringCalculator} 的真实候选评分物化路径,
* 使 ABILITY 以既定权重公式计入综合分,再交排名组件计算 overallScore/rank/percentile。
* 开关关闭时 ABILITY 保持 PENDING,综合分与既有口径一致(无可计算维度 → 本人无分)。</p>
*
@ -62,7 +62,6 @@ class V38AbilityRankingIntegrationTest {
@Autowired private AbilityDefinitionRepository definitionRepository;
@Autowired private AbilityAttemptRepository attemptRepository;
@Autowired private AbilityResultRepository resultRepository;
@Autowired private CandidateScoringEngineService engineService;
@Autowired private CandidateScoringCalculator calculator;
@Autowired private CompetitionRankingCalculator rankingCalculator;
@ -129,6 +128,12 @@ class V38AbilityRankingIntegrationTest {
ReflectionTestUtils.setField(calculator, "abilityAssessmentEnabled", b);
}
private void materialize(Long studentId) {
LocalDateTime capturedNow = LocalDateTime.now();
calculator.upsertSnapshots(studentId, calculator.computeAll(studentId, capturedNow),
currentMonth(), capturedNow);
}
/**
* me 为无资料/无活跃数据学生;peer 由内存快照给出 EDUCATION=70(仅作对照组)。
* 开关关闭:me 无可计算维度 → overall=null、rank=null、percentile=null(ABILITY=PENDING)。
@ -148,7 +153,7 @@ class V38AbilityRankingIntegrationTest {
// 关闭:ABILITY=PENDING,me 无可计算维度
setEnabled(false);
engineService.getOverview(me);
materialize(me);
Map<Long, List<CandidateDimensionScore>> offSnaps =
new HashMap<>(rankingCalculator.loadLatestSnapshots(List.of(me), month));
offSnaps.putAll(peerOnly);
@ -160,10 +165,10 @@ class V38AbilityRankingIntegrationTest {
assertEquals("PENDING", off.meSnaps.stream()
.filter(s -> "ABILITY".equals(s.getDimension())).findFirst().get().getStatus());
// 开启:真实评分写快照 → ABILITY=OK 74.25 计入综合分
// 开启:显式走真实物化写入口 → ABILITY=OK 74.25 计入综合分
setEnabled(true);
giveValidAbilityResult(me);
engineService.getOverview(me);
materialize(me);
Map<Long, List<CandidateDimensionScore>> onSnaps =
new HashMap<>(rankingCalculator.loadLatestSnapshots(List.of(me), month));
onSnaps.putAll(peerOnly);
@ -180,4 +185,4 @@ class V38AbilityRankingIntegrationTest {
assertNotEquals(on.rank, off.rank);
assertNotEquals(on.percentile, off.percentile);
}
}
}

156
mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshBatchIsolationTest.java

@ -0,0 +1,156 @@
package com.mydreamjob.candidatescore.refresh;
import com.mydreamjob.auth.User;
import com.mydreamjob.auth.UserRepository;
import com.mydreamjob.candidatescore.CandidateScoringCalculator;
import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.context.ActiveProfiles;
import java.time.LocalDateTime;
import java.util.List;
import java.util.concurrent.atomic.AtomicLong;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
/**
* R01-D4-2 次生缺陷:单个任务失败不得阻止同批后续任务。
*
* <p>缺陷背景:{@code recordFailure} 在更新 0 行时抛 IllegalStateException,异常点位于
* Worker 的 catch 块内,而 {@code processDue} 的 for 循环没有保护 —— 单个任务的失败会
* 让同批剩余任务本轮全部不被处理。</p>
*
* <p>本测试通过 mock 计算器精确制造"指定学生计算失败",断言真实数据库中的批次终态。</p>
*/
@SpringBootTest
@ActiveProfiles("integration-test")
class CandidateScoringRefreshBatchIsolationTest {
@Autowired private CandidateScoringRefreshRequestService requestService;
@Autowired private CandidateScoringRefreshWorker worker;
@Autowired private UserRepository userRepository;
@Autowired private JdbcTemplate jdbcTemplate;
@MockBean private RedisTemplate<String, Object> redisTemplate;
@MockBean private CandidateScoringCalculator calculator;
private static final AtomicLong SEQ = new AtomicLong(11000);
private Long okA;
private Long okB;
private Long failing;
private String statusOf(Long studentId) {
return jdbcTemplate.queryForObject(
"SELECT status FROM candidate_scoring_refresh_tasks WHERE student_user_id=?",
String.class, studentId);
}
private String errorCodeOf(Long studentId) {
return jdbcTemplate.queryForObject(
"SELECT last_error_code FROM candidate_scoring_refresh_tasks WHERE student_user_id=?",
String.class, studentId);
}
private Long newStudent(String tag) {
User u = User.builder()
.phone("191" + String.format("%08d", SEQ.incrementAndGet()))
.nickname(tag).role(User.Role.STUDENT).active(true).verified(true).build();
u.setRoles(java.util.Set.of(User.Role.STUDENT));
return userRepository.save(u).getId();
}
@BeforeEach
@SuppressWarnings("unchecked")
void setUp() {
ValueOperations<String, Object> ops = mock(ValueOperations.class);
when(redisTemplate.opsForValue()).thenReturn(ops);
when(redisTemplate.hasKey(any())).thenReturn(false);
jdbcTemplate.update("DELETE FROM candidate_scoring_refresh_tasks");
okA = newStudent("隔离A");
okB = newStudent("隔离B");
failing = newStudent("失败C");
when(calculator.currentRulesVersion()).thenReturn("candidate-scoring-v0.1");
when(calculator.computeAll(eq(okA), any(LocalDateTime.class))).thenReturn(List.of());
when(calculator.computeAll(eq(okB), any(LocalDateTime.class))).thenReturn(List.of());
// 计算失败:异常 message 故意夹带敏感形态内容,用于验证错误分类不落异常原文
when(calculator.computeAll(eq(failing), any(LocalDateTime.class)))
.thenThrow(new BusinessException(ErrorCode.BAD_REQUEST,
"compute failed for phone=13800001111 token=eyJhbGciOiJIUzI1NiJ9.secret.sig"));
}
@org.junit.jupiter.api.AfterEach
void tearDown() {
jdbcTemplate.update("DELETE FROM candidate_scoring_refresh_tasks");
}
@Test
void firstTaskFails_secondTaskStillProcessed() {
requestService.requestRefresh(failing, LocalDateTime.now());
requestService.requestRefresh(okA, LocalDateTime.now());
int processed = worker.processDue(10);
assertEquals("RETRY", statusOf(failing), "失败任务应进入 RETRY");
assertEquals("COMPLETED", statusOf(okA),
"同批后续任务必须仍然被处理(单任务失败不得阻断批次)");
assertEquals(1, processed, "成功计数只统计被处理的任务");
}
@Test
void failingTaskInMiddle_doesNotBlockRemainingTasks() {
requestService.requestRefresh(okA, LocalDateTime.now());
requestService.requestRefresh(failing, LocalDateTime.now());
requestService.requestRefresh(okB, LocalDateTime.now());
worker.processDue(10);
assertEquals("COMPLETED", statusOf(okA), "失败项之前的任务应完成");
assertEquals("COMPLETED", statusOf(okB), "失败项之后的任务也必须完成");
assertEquals("RETRY", statusOf(failing));
}
@Test
void lastErrorCode_neverLeaksExceptionMessage() {
requestService.requestRefresh(failing, LocalDateTime.now());
worker.processDue(10);
String code = errorCodeOf(failing);
assertNotNull(code);
assertEquals("COMPUTE_FAILED", code, "只允许固定错误分类");
assertFalse(code.contains("13800001111"), "错误分类不得包含手机号");
assertFalse(code.contains("eyJ"), "错误分类不得包含 Token");
assertFalse(code.contains("compute failed"), "错误分类不得包含异常原文");
assertTrue(code.length() <= 40, "错误分类长度受列定义约束");
}
@Test
void batchTerminalState_matchesRealOutcome() {
requestService.requestRefresh(okA, LocalDateTime.now());
requestService.requestRefresh(failing, LocalDateTime.now());
requestService.requestRefresh(okB, LocalDateTime.now());
worker.processDue(10);
List<String> statuses = jdbcTemplate.queryForList(
"SELECT status FROM candidate_scoring_refresh_tasks ORDER BY student_user_id", String.class);
assertFalse(statuses.contains("PROCESSING"), "批次结束后不得残留 PROCESSING");
assertEquals(2, statuses.stream().filter("COMPLETED"::equals).count(), "两个成功项");
assertEquals(1, statuses.stream().filter("RETRY"::equals).count(), "一个重试项");
}
}

215
mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshClaimFenceTest.java

@ -0,0 +1,215 @@
package com.mydreamjob.candidatescore.refresh;
import com.mydreamjob.auth.User;
import com.mydreamjob.auth.UserRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.context.ActiveProfiles;
import java.time.LocalDateTime;
import java.util.List;
import java.util.concurrent.atomic.AtomicLong;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
/**
* R01-D4-2 次生缺陷:Worker 领取所有权(claim fence)与 stale claim 处置。
*
* <p>缺陷背景:{@code materialize} 的「回到 PENDING」回退分支只校验
* {@code status='PROCESSING'} 而不校验领取所有权;当原 Worker 因租约超时已被重新领取时,
* 慢返回的旧 Worker 会把新 Worker 正在处理的任务改回 PENDING。同时
* {@code recordFailure} 在更新 0 行时抛 IllegalStateException,异常点位于 Worker 的
* catch 块内、{@code processDue} 的 for 循环无保护 → 同批剩余任务本轮全部不处理。</p>
*
* <p>本测试断言真实数据库状态,不使用 sleep,不使用 JVM 锁。</p>
*/
@SpringBootTest
@ActiveProfiles("integration-test")
class CandidateScoringRefreshClaimFenceTest {
@Autowired private CandidateScoringRefreshRequestService requestService;
@Autowired private CandidateScoringRefreshTaskExecutor taskExecutor;
@Autowired private CandidateScoringRefreshWorker worker;
@Autowired private UserRepository userRepository;
@Autowired private JdbcTemplate jdbcTemplate;
@MockBean private RedisTemplate<String, Object> redisTemplate;
private static final AtomicLong SEQ = new AtomicLong(9000);
private Long sid;
private String period;
private String status(Long taskId) {
return jdbcTemplate.queryForObject(
"SELECT status FROM candidate_scoring_refresh_tasks WHERE id=?", String.class, taskId);
}
private LocalDateTime processingStartedAt(Long taskId) {
return jdbcTemplate.queryForObject(
"SELECT processing_started_at FROM candidate_scoring_refresh_tasks WHERE id=?",
LocalDateTime.class, taskId);
}
private Long newTask() {
requestService.requestRefresh(sid, LocalDateTime.now());
return jdbcTemplate.queryForObject(
"SELECT id FROM candidate_scoring_refresh_tasks WHERE student_user_id=?", Long.class, sid);
}
@BeforeEach
@SuppressWarnings("unchecked")
void setUp() {
ValueOperations<String, Object> ops = mock(ValueOperations.class);
when(redisTemplate.opsForValue()).thenReturn(ops);
when(redisTemplate.hasKey(any())).thenReturn(false);
User u = User.builder()
.phone("193" + String.format("%08d", SEQ.incrementAndGet()))
.nickname("Fence学生").role(User.Role.STUDENT).active(true).verified(true).build();
u.setRoles(java.util.Set.of(User.Role.STUDENT));
sid = userRepository.save(u).getId();
jdbcTemplate.update("DELETE FROM candidate_scoring_refresh_tasks");
// 与 CandidateScoringRefreshRequestService 保持一致:period = capturedNow 所在 yyyy-MM
LocalDateTime n = LocalDateTime.now();
period = String.format("%04d-%02d", n.getYear(), n.getMonthValue());
}
/** 清理本类产生的行,避免污染同一 JVM 内共享的 H2 内存库。 */
@org.junit.jupiter.api.AfterEach
void tearDown() {
jdbcTemplate.update("DELETE FROM candidate_scoring_refresh_tasks");
jdbcTemplate.update("DELETE FROM candidate_dimension_scores WHERE student_user_id=?", sid);
}
// ── claim fence ────────────────────────────────────────────
@Test
void claim_exposesProcessingStartedAtAsOwnershipFence() {
Long taskId = newTask();
LocalDateTime t1 = LocalDateTime.now().withNano(0);
CandidateScoringRefreshTaskExecutor.ClaimedTask claimed = taskExecutor.claim(taskId, t1);
assertNotNull(claimed, "首次 claim 应成功");
assertEquals("PROCESSING", status(taskId));
assertEquals(t1, processingStartedAt(taskId), "claim 必须记录所有权标识");
assertNotNull(claimed, "ClaimedTask 必须携带领取信息");
}
@Test
void staleWorkerCompletion_cannotOverwriteNewWorkerClaim() {
Long taskId = newTask();
LocalDateTime t1 = LocalDateTime.now().withNano(0).minusMinutes(20);
CandidateScoringRefreshTaskExecutor.ClaimedTask oldClaim = taskExecutor.claim(taskId, t1);
assertNotNull(oldClaim);
// 模拟租约超时回收 + 另一个 Worker 重新领取
taskExecutor.recoverStale(LocalDateTime.now(), LocalDateTime.now(), 3);
LocalDateTime t2 = LocalDateTime.now().withNano(0);
CandidateScoringRefreshTaskExecutor.ClaimedTask newClaim = taskExecutor.claim(taskId, t2);
assertNotNull(newClaim, "回收后应可被重新领取");
assertEquals("PROCESSING", status(taskId));
assertEquals(t2, processingStartedAt(taskId));
// 旧 Worker 迟到完成:必须更新 0 行并安静退出,不得改动新 Worker 的 PROCESSING
assertDoesNotThrow(() -> taskExecutor.materialize(oldClaim, LocalDateTime.now()),
"stale claim 的完成路径必须安静退出而不是抛异常");
assertEquals("PROCESSING", status(taskId), "旧 Worker 不得覆盖新 Worker 的领取状态");
assertEquals(t2, processingStartedAt(taskId), "旧 Worker 不得改动所有权标识");
// 新 Worker 正常完成
taskExecutor.materialize(newClaim, LocalDateTime.now());
assertEquals("COMPLETED", status(taskId), "持有者应能正常完成");
}
@Test
void staleWorkerFailure_cannotOverwriteNewWorkerClaim() {
Long taskId = newTask();
LocalDateTime t1 = LocalDateTime.now().withNano(0).minusMinutes(20);
CandidateScoringRefreshTaskExecutor.ClaimedTask oldClaim = taskExecutor.claim(taskId, t1);
assertNotNull(oldClaim);
taskExecutor.recoverStale(LocalDateTime.now(), LocalDateTime.now(), 3);
LocalDateTime t2 = LocalDateTime.now().withNano(0);
taskExecutor.claim(taskId, t2);
// 旧 Worker 迟到失败登记(携带旧的所有权令牌):必须安静退出
assertDoesNotThrow(() -> taskExecutor.recordFailure(taskId, "WRITE_FAILED",
LocalDateTime.now(), 3, 1000L, oldClaim.claimToken()),
"stale claim 的失败登记必须安静退出而不是抛异常");
assertEquals("PROCESSING", status(taskId), "旧 Worker 的失败登记不得覆盖新 Worker");
}
@Test
void requestDuringSameClaim_completionFallsBackToPending() {
Long taskId = newTask();
LocalDateTime t1 = LocalDateTime.now().withNano(0);
CandidateScoringRefreshTaskExecutor.ClaimedTask claimed = taskExecutor.claim(taskId, t1);
assertNotNull(claimed);
// 同一次领取期间到达新请求 → version 推进,但所有权标识不变
requestService.requestRefresh(sid, t1.plusNanos(1));
assertEquals("PROCESSING", status(taskId), "处理期间新请求不得改变 PROCESSING");
assertEquals(t1, processingStartedAt(taskId), "处理期间新请求不得改变所有权标识");
taskExecutor.materialize(claimed, LocalDateTime.now());
assertEquals("PENDING", status(taskId),
"同一 claim 期间收到新请求时,完成路径必须回到 PENDING 而不是 COMPLETED");
}
// ── 单任务失败隔离 ─────────────────────────────────────────
@Test
void processDue_skipsTasksAlreadyClaimedByAnotherWorker_andProcessesTheRest() {
Long taskA = newTask();
Long taskB = newTaskFor(newStudent("隔离B"));
Long taskC = newTaskFor(newStudent("隔离C"));
// 模拟 taskA 已被另一个 Worker 持有
assertNotNull(taskExecutor.claim(taskA, LocalDateTime.now().withNano(0)));
int processed = worker.processDue(10);
assertEquals(2, processed, "被并发持有的任务应被跳过,其余任务仍被处理");
assertEquals("COMPLETED", status(taskB), "同批第二个任务应被处理");
assertEquals("COMPLETED", status(taskC), "同批第三个任务应被处理");
}
@Test
void processDue_batchEndsConsistently_evenWithStaleClaimsPresent() {
Long a = newTask();
int processed = worker.processDue(10);
assertTrue(processed >= 0);
List<String> all = jdbcTemplate.queryForList(
"SELECT status FROM candidate_scoring_refresh_tasks", String.class);
assertFalse(all.contains("PROCESSING"), "批次结束后不得残留 PROCESSING");
assertEquals("COMPLETED", status(a));
}
private Long newStudent(String tag) {
User u = User.builder()
.phone("192" + String.format("%08d", SEQ.incrementAndGet()))
.nickname(tag).role(User.Role.STUDENT).active(true).verified(true).build();
u.setRoles(java.util.Set.of(User.Role.STUDENT));
return userRepository.save(u).getId();
}
private Long newTaskFor(Long studentId) {
requestService.requestRefresh(studentId, LocalDateTime.now());
return jdbcTemplate.queryForObject(
"SELECT id FROM candidate_scoring_refresh_tasks WHERE student_user_id=?", Long.class, studentId);
}
}

412
mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshClaimTokenTest.java

@ -0,0 +1,412 @@
package com.mydreamjob.candidatescore.refresh;
import com.mydreamjob.auth.User;
import com.mydreamjob.auth.UserRepository;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.context.ActiveProfiles;
import java.time.LocalDateTime;
import java.util.List;
import java.util.concurrent.atomic.AtomicLong;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
/**
* R01-D4-2 claim fence 定向整改:不可复用 claim token。
*
* <p>缺陷背景:旧实现以 {@code processing_started_at} 作为所有权标识,而该列是秒级
* {@code DATETIME}。当租约回收与重新领取落在同一秒时,新旧 claim 的 fence 完全相同,
* 旧 Worker 的 PENDING 回退会误改新持有者(已由 {@code iv-fence2.sql} 在真实 MySQL 复现)。</p>
*
* <p>本测试使用<b>固定时间值</b>(两次领取传入完全相同的 {@code LocalDateTime}),
* 不依赖跨秒等待。</p>
*/
@SpringBootTest
@ActiveProfiles("integration-test")
class CandidateScoringRefreshClaimTokenTest {
@Autowired private CandidateScoringRefreshRequestService requestService;
@Autowired private CandidateScoringRefreshTaskExecutor taskExecutor;
@Autowired private CandidateScoringRefreshWorker worker;
@Autowired private UserRepository userRepository;
@Autowired private JdbcTemplate jdbcTemplate;
@MockBean private RedisTemplate<String, Object> redisTemplate;
private static final AtomicLong SEQ = new AtomicLong(13000);
/** 两次领取刻意使用同一个时间值。 */
private static final LocalDateTime SAME_INSTANT = LocalDateTime.of(2026, 9, 16, 12, 0, 0);
private Long sid;
private String period;
private String status(Long taskId) {
return jdbcTemplate.queryForObject(
"SELECT status FROM candidate_scoring_refresh_tasks WHERE id=?", String.class, taskId);
}
private String claimToken(Long taskId) {
return jdbcTemplate.queryForObject(
"SELECT claim_token FROM candidate_scoring_refresh_tasks WHERE id=?", String.class, taskId);
}
private int attempts(Long taskId) {
Integer v = jdbcTemplate.queryForObject(
"SELECT attempts FROM candidate_scoring_refresh_tasks WHERE id=?", Integer.class, taskId);
return v == null ? -1 : v;
}
private LocalDateTime processingStartedAt(Long taskId) {
return jdbcTemplate.queryForObject(
"SELECT processing_started_at FROM candidate_scoring_refresh_tasks WHERE id=?",
LocalDateTime.class, taskId);
}
private Long newTask() {
requestService.requestRefresh(sid, LocalDateTime.now());
return jdbcTemplate.queryForObject(
"SELECT id FROM candidate_scoring_refresh_tasks WHERE student_user_id=?", Long.class, sid);
}
/** 模拟租约回收(把 PROCESSING 置为 RETRY,processing_started_at 清空)。 */
private void recover(Long taskId) {
jdbcTemplate.update("UPDATE candidate_scoring_refresh_tasks SET status='RETRY', "
+ "processing_started_at=NULL, attempts=attempts+1, version=version+1 WHERE id=?", taskId);
}
@BeforeEach
@SuppressWarnings("unchecked")
void setUp() {
ValueOperations<String, Object> ops = mock(ValueOperations.class);
when(redisTemplate.opsForValue()).thenReturn(ops);
when(redisTemplate.hasKey(any())).thenReturn(false);
User u = User.builder()
.phone("190" + String.format("%08d", SEQ.incrementAndGet()))
.nickname("Token学生").role(User.Role.STUDENT).active(true).verified(true).build();
u.setRoles(java.util.Set.of(User.Role.STUDENT));
sid = userRepository.save(u).getId();
jdbcTemplate.update("DELETE FROM candidate_scoring_refresh_tasks");
LocalDateTime n = LocalDateTime.now();
period = String.format("%04d-%02d", n.getYear(), n.getMonthValue());
}
@AfterEach
void tearDown() {
jdbcTemplate.update("DELETE FROM candidate_scoring_refresh_tasks");
jdbcTemplate.update("DELETE FROM candidate_dimension_scores WHERE student_user_id=?", sid);
}
// ── 缺陷复现:同一时间值下两次领取,旧 Worker 不得影响新持有者 ──
@Test
void sameTimestamp_staleWorkerPendingRevert_mustNotTouchNewClaim() {
Long taskId = newTask();
// Worker A 领取
CandidateScoringRefreshTaskExecutor.ClaimedTask a = taskExecutor.claim(taskId, SAME_INSTANT);
assertNotNull(a, "A 应领取成功");
assertEquals(SAME_INSTANT, processingStartedAt(taskId));
// 租约回收 → Worker B 在**完全相同的时间值**下重新领取
recover(taskId);
CandidateScoringRefreshTaskExecutor.ClaimedTask b = taskExecutor.claim(taskId, SAME_INSTANT);
assertNotNull(b, "B 应领取成功");
assertEquals(SAME_INSTANT, processingStartedAt(taskId),
"两次领取的 processing_started_at 完全相同(当前实现的固有精度)");
assertEquals("PROCESSING", status(taskId));
// A 迟到:完成尝试(version 已变 → 应 0 行)
taskExecutor.materialize(a, SAME_INSTANT);
assertEquals("PROCESSING", status(taskId),
"A 的完成路径不得把 B 的 PROCESSING 改成其它状态");
// 再补一次 B 的重领取,确保状态干净(A 的 PENDING 回退是本次的核心缺陷点)
if (!"PROCESSING".equals(status(taskId))) {
recover(taskId);
b = taskExecutor.claim(taskId, SAME_INSTANT);
}
assertNotNull(b);
assertEquals("PROCESSING", status(taskId));
// B 正常完成
taskExecutor.materialize(b, SAME_INSTANT);
assertEquals("COMPLETED", status(taskId), "B 应能正常完成");
}
@Test
void sameTimestamp_staleWorkerFailure_mustNotTouchNewClaim() {
Long taskId = newTask();
CandidateScoringRefreshTaskExecutor.ClaimedTask a = taskExecutor.claim(taskId, SAME_INSTANT);
assertNotNull(a);
recover(taskId);
CandidateScoringRefreshTaskExecutor.ClaimedTask b = taskExecutor.claim(taskId, SAME_INSTANT);
assertNotNull(b);
assertEquals("PROCESSING", status(taskId));
// 记录失败登记前的基线(recover() 辅助方法自身会递增 attempts,故用差值判断)
int attemptsBefore = attempts(taskId);
String tokenBefore = claimToken(taskId);
taskExecutor.recordFailure(taskId, "WRITE_FAILED", SAME_INSTANT, 3, 1000L, a.claimToken());
assertEquals("PROCESSING", status(taskId), "A 的失败登记不得覆盖 B 的 PROCESSING");
assertEquals(attemptsBefore, attempts(taskId), "A 的失败登记不得改动 B 的 attempts");
assertNull(jdbcTemplate.queryForObject(
"SELECT last_error_code FROM candidate_scoring_refresh_tasks WHERE id=?", String.class, taskId),
"A 的失败登记不得写入 last_error_code");
assertEquals(tokenBefore, claimToken(taskId), "A 的失败登记不得清空 B 的 claim token");
}
// ── claim token 语义 ──────────────────────────────────────
@Test
void twoClaimsAtSameInstant_getDifferentTokens() {
Long taskId = newTask();
CandidateScoringRefreshTaskExecutor.ClaimedTask a = taskExecutor.claim(taskId, SAME_INSTANT);
assertNotNull(a);
String ta = claimToken(taskId);
assertNotNull(ta, "领取后必须写入 claim_token");
recover(taskId);
CandidateScoringRefreshTaskExecutor.ClaimedTask b = taskExecutor.claim(taskId, SAME_INSTANT);
assertNotNull(b);
String tb = claimToken(taskId);
assertEquals(SAME_INSTANT, processingStartedAt(taskId), "时间值相同");
assertNotEquals(ta, tb, "同一时间值下两次领取必须得到不同 claim token");
}
@Test
void claim_repeatedOnDifferentTasks_producesDistinctTokens() {
Long t1 = newTask();
CandidateScoringRefreshTaskExecutor.ClaimedTask c1 = taskExecutor.claim(t1, SAME_INSTANT);
assertNotNull(c1);
String token1 = claimToken(t1);
Long sid2 = newStudent();
requestService.requestRefresh(sid2, LocalDateTime.now());
Long t2 = jdbcTemplate.queryForObject(
"SELECT id FROM candidate_scoring_refresh_tasks WHERE student_user_id=?", Long.class, sid2);
CandidateScoringRefreshTaskExecutor.ClaimedTask c2 = taskExecutor.claim(t2, SAME_INSTANT);
assertNotNull(c2);
String token2 = claimToken(t2);
assertNotEquals(token1, token2, "不同任务的 token 必须不同");
}
@Test
void currentHolder_canCompleteNormally() {
Long taskId = newTask();
CandidateScoringRefreshTaskExecutor.ClaimedTask c = taskExecutor.claim(taskId, SAME_INSTANT);
assertNotNull(c);
taskExecutor.materialize(c, SAME_INSTANT);
assertEquals("COMPLETED", status(taskId));
}
@Test
void requestDuringSameClaim_versionAdvances_completionFallsBackToPending() {
Long taskId = newTask();
CandidateScoringRefreshTaskExecutor.ClaimedTask c = taskExecutor.claim(taskId, SAME_INSTANT);
assertNotNull(c);
requestService.requestRefresh(sid, SAME_INSTANT.plusNanos(1));
assertEquals("PROCESSING", status(taskId));
taskExecutor.materialize(c, SAME_INSTANT);
assertEquals("PENDING", status(taskId),
"同一 claim 期间新请求推进 version → 必须回到 PENDING 而不是 COMPLETED");
}
@Test
void staleRecovery_generatesNewTokenOnNextClaim() {
Long taskId = newTask();
CandidateScoringRefreshTaskExecutor.ClaimedTask a = taskExecutor.claim(taskId, SAME_INSTANT);
assertNotNull(a);
String ta = claimToken(taskId);
taskExecutor.recoverStale(LocalDateTime.now(), LocalDateTime.now(), 3);
assertEquals("RETRY", status(taskId));
assertNull(claimToken(taskId), "回收后应清空 claim_token");
CandidateScoringRefreshTaskExecutor.ClaimedTask b = taskExecutor.claim(taskId, SAME_INSTANT);
assertNotNull(b);
assertNotEquals(ta, claimToken(taskId), "stale recovery 后的新领取必须生成新 token");
}
@Test
void terminalAndPendingStates_clearClaimToken() {
// COMPLETED
Long t1 = newTask();
CandidateScoringRefreshTaskExecutor.ClaimedTask c1 = taskExecutor.claim(t1, SAME_INSTANT);
assertNotNull(c1);
taskExecutor.materialize(c1, SAME_INSTANT);
assertEquals("COMPLETED", status(t1));
assertNull(claimToken(t1), "COMPLETED 必须清空 claim_token");
// PENDING 回退
Long sid2 = newStudent();
requestService.requestRefresh(sid2, LocalDateTime.now());
Long t2 = jdbcTemplate.queryForObject(
"SELECT id FROM candidate_scoring_refresh_tasks WHERE student_user_id=?", Long.class, sid2);
CandidateScoringRefreshTaskExecutor.ClaimedTask c2 = taskExecutor.claim(t2, SAME_INSTANT);
assertNotNull(c2);
requestService.requestRefresh(sid2, SAME_INSTANT.plusNanos(1));
taskExecutor.materialize(c2, SAME_INSTANT);
assertEquals("PENDING", status(t2));
assertNull(claimToken(t2), "PENDING 回退必须清空 claim_token");
// RETRY
Long sid3 = newStudent();
requestService.requestRefresh(sid3, LocalDateTime.now());
Long t3 = jdbcTemplate.queryForObject(
"SELECT id FROM candidate_scoring_refresh_tasks WHERE student_user_id=?", Long.class, sid3);
CandidateScoringRefreshTaskExecutor.ClaimedTask c3 = taskExecutor.claim(t3, SAME_INSTANT);
assertNotNull(c3);
taskExecutor.recordFailure(t3, "WRITE_FAILED", SAME_INSTANT, 3, 1000L, c3.claimToken());
assertEquals("RETRY", status(t3));
assertNull(claimToken(t3), "RETRY 必须清空 claim_token");
}
@Test
void failedTerminalState_clearsClaimToken() {
Long taskId = newTask();
jdbcTemplate.update("UPDATE candidate_scoring_refresh_tasks SET attempts=2 WHERE id=?", taskId);
CandidateScoringRefreshTaskExecutor.ClaimedTask c = taskExecutor.claim(taskId, SAME_INSTANT);
assertNotNull(c);
// maxAttempts=3,attempts=2 → attempts+1 >= 3 → FAILED
taskExecutor.recordFailure(taskId, "COMPUTE_FAILED", SAME_INSTANT, 3, 1000L, c.claimToken());
assertEquals("FAILED", status(taskId));
assertNull(claimToken(taskId), "FAILED 必须清空 claim_token");
}
@Test
void tokenIsUuidShaped_andNotDerivedFromIds() {
Long taskId = newTask();
CandidateScoringRefreshTaskExecutor.ClaimedTask c = taskExecutor.claim(taskId, SAME_INSTANT);
assertNotNull(c);
String token = claimToken(taskId);
assertNotNull(token);
assertEquals(36, token.length(), "token 应为固定长度 UUID 字符串");
assertTrue(token.matches("[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-"
+ "[0-9a-fA-F]{4}-[0-9a-fA-F]{12}"),
"token 必须为 UUID 形态,实际: " + token);
// token 必须是随机 UUID:不得等于/以任务或学生 ID 起头,也不得其字符串形式
assertNotEquals(String.valueOf(taskId), token);
assertNotEquals(String.valueOf(sid), token);
assertFalse(token.startsWith(String.valueOf(taskId)), "token 不得由任务 ID 派生");
assertTrue(token.matches(".*[0-9a-f]{8}-.*"), "token 必须是随机 UUID 形态而非 ID 拼接");
// 再领一次必须得到不同 token(不可复用)—— 用独立学生,避免 (student,period) 合并
Long otherStudent = newStudent();
requestService.requestRefresh(otherStudent, LocalDateTime.now());
Long t2 = jdbcTemplate.queryForObject(
"SELECT id FROM candidate_scoring_refresh_tasks WHERE student_user_id=?",
Long.class, otherStudent);
CandidateScoringRefreshTaskExecutor.ClaimedTask c2 = taskExecutor.claim(t2, SAME_INSTANT);
assertNotNull(c2, "独立任务应可领取");
assertNotEquals(token, claimToken(t2), "token 必须不可复用");
}
// ── 双 Worker 长时间循环 ──────────────────────────────────
@Test
void doubleWorkerLoop_100Rounds_noOverwrite_noStuckProcessing_noLostRequest() {
final int rounds = 100;
for (int i = 0; i < rounds; i++) {
Long taskId = newTask();
// A 领取
CandidateScoringRefreshTaskExecutor.ClaimedTask a = taskExecutor.claim(taskId, SAME_INSTANT);
assertNotNull(a, "round " + i + ": A 领取失败");
// 租约回收 → B 在**同一时间值**下重新领取(fence 时间完全相同)
recover(taskId);
CandidateScoringRefreshTaskExecutor.ClaimedTask b = taskExecutor.claim(taskId, SAME_INSTANT);
assertNotNull(b, "round " + i + ": B 领取失败");
assertEquals(SAME_INSTANT, processingStartedAt(taskId), "round " + i);
// A 的三种迟到写:完成 / 失败 / (完成路径内含的 PENDING 回退)
taskExecutor.materialize(a, SAME_INSTANT);
taskExecutor.recordFailure(taskId, "WRITE_FAILED", SAME_INSTANT, 3, 1000L, a.claimToken());
taskExecutor.materialize(a, SAME_INSTANT);
assertEquals("PROCESSING", status(taskId),
"round " + i + ": 旧 Worker A 覆盖了新持有者 B");
assertEquals(b.claimToken(), claimToken(taskId),
"round " + i + ": A 改动了 B 的 claim token");
// B 正常完成
taskExecutor.materialize(b, SAME_INSTANT);
assertEquals("COMPLETED", status(taskId), "round " + i + ": B 未能完成");
assertNull(claimToken(taskId), "round " + i + ": COMPLETED 未清空 token");
jdbcTemplate.update("DELETE FROM candidate_scoring_refresh_tasks WHERE id=?", taskId);
}
long stuck = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM candidate_scoring_refresh_tasks WHERE status='PROCESSING'",
Long.class);
assertEquals(0L, stuck, "循环结束后不得残留 PROCESSING");
assertEquals(rounds, 100);
}
// ── token 不泄露 ─────────────────────────────────────────
@Test
void claimToken_doesNotLeakIntoAuditOrNotificationTables() {
Long taskId = newTask();
CandidateScoringRefreshTaskExecutor.ClaimedTask c = taskExecutor.claim(taskId, SAME_INSTANT);
assertNotNull(c);
String token = c.claimToken();
assertNotNull(token);
for (String table : List.of("audit_logs", "notifications", "messages")) {
Long hits = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM " + table + " WHERE CAST(" +
("messages".equals(table) ? "content" : "audit_logs".equals(table) ? "details" : "content")
+ " AS VARCHAR) LIKE ?", Long.class, "%" + token + "%");
assertEquals(0L, hits, table + " 不得出现 claim token");
}
// 错误分类为固定枚举,不含 token
taskExecutor.recordFailure(taskId, "WRITE_FAILED", SAME_INSTANT, 3, 1000L, "stale-token-value");
String code = jdbcTemplate.queryForObject(
"SELECT COALESCE(last_error_code,'') FROM candidate_scoring_refresh_tasks WHERE id=?",
String.class, taskId);
assertFalse(code.contains("stale-token-value"), "错误分类不得包含 token");
assertTrue(List.of("COMPUTE_FAILED", "WRITE_FAILED", "UNKNOWN_REFRESH_FAILURE", "WORKER_TIMEOUT", "")
.contains(code),
"错误分类必须为固定枚举,实际: " + code);
}
@Test
void claimToken_rowNeverExposedInTaskStatusQueriesUsedByObservability() {
// 任务表本身持有 token 属预期;此处确认 token 不会被写入其它可观测载体
Long taskId = newTask();
CandidateScoringRefreshTaskExecutor.ClaimedTask c = taskExecutor.claim(taskId, SAME_INSTANT);
assertNotNull(c);
String token = c.claimToken();
Long inScores = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM candidate_dimension_scores WHERE CAST(sub_scores_json AS VARCHAR) LIKE ?",
Long.class, "%" + token + "%");
assertEquals(0L, inScores, "快照 JSON 不得出现 claim token");
}
private Long newStudent() {
User u = User.builder()
.phone("189" + String.format("%08d", SEQ.incrementAndGet()))
.nickname("Token学生x").role(User.Role.STUDENT).active(true).verified(true).build();
u.setRoles(java.util.Set.of(User.Role.STUDENT));
return userRepository.save(u).getId();
}
}

127
mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshFailureTransactionTest.java

@ -0,0 +1,127 @@
package com.mydreamjob.candidatescore.refresh;
import com.mydreamjob.auth.User;
import com.mydreamjob.auth.UserRepository;
import com.mydreamjob.candidatescore.CandidateDimensionScoreRepository;
import com.mydreamjob.candidatescore.CandidateScoringCalculator;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.support.TransactionTemplate;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
@SpringBootTest
@ActiveProfiles("integration-test")
class CandidateScoringRefreshFailureTransactionTest {
@Autowired private CandidateScoringRefreshRequestService requestService;
@Autowired private CandidateScoringRefreshWorker worker;
@Autowired private UserRepository userRepository;
@Autowired private CandidateDimensionScoreRepository snapshotRepository;
@Autowired private JdbcTemplate jdbcTemplate;
@Autowired private PlatformTransactionManager transactionManager;
@MockBean private CandidateScoringCalculator calculator;
@MockBean private RedisTemplate<String, Object> redisTemplate;
private Long failingStudent;
private Long successfulStudent;
@BeforeEach
void setUp() {
jdbcTemplate.update("DELETE FROM candidate_scoring_refresh_tasks");
failingStudent = student("failure-tx-1");
successfulStudent = student("failure-tx-2");
when(calculator.currentRulesVersion()).thenReturn("candidate-scoring-v0.1");
when(calculator.computeAll(any(), any())).thenAnswer(invocation -> {
Long studentId = invocation.getArgument(0);
return results(studentId.equals(failingStudent));
});
}
@Test
void databaseWriteFailureRollsBackAllDimensionsAndPersistsRetry() {
requestService.requestRefresh(failingStudent, LocalDateTime.now());
assertEquals(0, worker.processDue(10));
assertTrue(snapshotRepository.findByStudentUserId(failingStudent).isEmpty(),
"第4维写失败时前三维也必须回滚");
assertEquals("RETRY", status(failingStudent));
assertEquals("WRITE_FAILED", error(failingStudent));
}
@Test
void oneFailedTaskDoesNotRollbackSuccessfulSibling() {
requestService.requestRefresh(failingStudent, LocalDateTime.now());
requestService.requestRefresh(successfulStudent, LocalDateTime.now());
assertEquals(1, worker.processDue(10));
assertEquals("RETRY", status(failingStudent));
assertEquals("COMPLETED", status(successfulStudent));
assertEquals(8, snapshotRepository.findByStudentUserId(successfulStudent).size());
}
@Test
void outerBusinessRollbackRemovesRefreshRegistration() {
TransactionTemplate transaction = new TransactionTemplate(transactionManager);
transaction.executeWithoutResult(status -> {
requestService.requestRefresh(successfulStudent, LocalDateTime.now());
status.setRollbackOnly();
});
Long count = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM candidate_scoring_refresh_tasks WHERE student_user_id=?",
Long.class, successfulStudent);
assertEquals(0L, count);
}
private Long student(String nickname) {
String phone = "130" + String.format("%08d", Math.abs(System.nanoTime() % 100_000_000L));
User user = User.builder().phone(phone).nickname(nickname).role(User.Role.STUDENT)
.active(true).verified(true).build();
user.setRoles(new HashSet<>(Set.of(User.Role.STUDENT)));
return userRepository.saveAndFlush(user).getId();
}
private List<CandidateScoringCalculator.DimensionResult> results(boolean failAtFourth) {
String[] dimensions = {"EDUCATION", "INTERNSHIP", "ABILITY", "COMPLIANCE",
"STABILITY", "GROWTH", "INTEGRITY", "ACTIVITY"};
List<CandidateScoringCalculator.DimensionResult> results = new ArrayList<>();
for (int i = 0; i < 8; i++) {
CandidateScoringCalculator.DimensionResult result =
new CandidateScoringCalculator.DimensionResult(failAtFourth && i == 3 ? null : dimensions[i]);
result.score = BigDecimal.valueOf(50 + i);
result.status = "OK";
results.add(result);
}
return results;
}
private String status(Long studentId) {
return jdbcTemplate.queryForObject(
"SELECT status FROM candidate_scoring_refresh_tasks WHERE student_user_id=?",
String.class, studentId);
}
private String error(Long studentId) {
return jdbcTemplate.queryForObject(
"SELECT last_error_code FROM candidate_scoring_refresh_tasks WHERE student_user_id=?",
String.class, studentId);
}
}

388
mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshInfrastructureTest.java

@ -0,0 +1,388 @@
package com.mydreamjob.candidatescore.refresh;
import com.mydreamjob.auth.User;
import com.mydreamjob.auth.UserRepository;
import com.mydreamjob.candidatescore.CandidateDimensionScore;
import com.mydreamjob.candidatescore.CandidateDimensionScoreRepository;
import com.mydreamjob.candidatescore.CandidateScoringCalculator;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.context.ActiveProfiles;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
/**
* R01-D4-1 基础设施与写侧接入综合测试(H2 集成)。
* 覆盖任务登记合并、PROCESSING 防丢失、并发领取、Worker 原子完成、
* Writer 幂等 version 语义、写侧登记范围与不接入项、GET 零副作用保持。
*/
@SpringBootTest
@ActiveProfiles("integration-test")
class CandidateScoringRefreshInfrastructureTest {
@Autowired private CandidateScoringRefreshRequestService requestService;
@Autowired private CandidateScoringRefreshWorker worker;
@Autowired private CandidateScoringRefreshTaskExecutor taskExecutor;
@Autowired private CandidateScoringSnapshotWriter writer;
@Autowired private CandidateScoringRefreshCompensationScanner compensationScanner;
@Autowired private CandidateScoringCalculator calculator;
@Autowired private UserRepository userRepository;
@Autowired private CandidateDimensionScoreRepository snapshotRepository;
@Autowired private JdbcTemplate jdbcTemplate;
@MockBean private RedisTemplate<String, Object> redisTemplate;
private Long sid;
private String period;
@BeforeEach
@SuppressWarnings("unchecked")
void setUp() {
ValueOperations<String, Object> ops = mock(ValueOperations.class);
when(redisTemplate.opsForValue()).thenReturn(ops);
when(redisTemplate.hasKey(any())).thenReturn(false);
User u = User.builder().phone("132" + String.format("%08d", System.nanoTime() % 100_000_000L))
.nickname("r01d41").role(User.Role.STUDENT)
.active(true).verified(true).build();
u.setRoles(new HashSet<>(Set.of(User.Role.STUDENT)));
sid = userRepository.save(u).getId();
LocalDate d = LocalDate.now();
period = String.format("%04d-%02d", d.getYear(), d.getMonthValue());
jdbcTemplate.update("DELETE FROM candidate_scoring_refresh_tasks");
jdbcTemplate.update("DELETE FROM candidate_scoring_refresh_scan_checkpoints");
}
private long taskCount() {
Long c = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM candidate_scoring_refresh_tasks", Long.class);
return c == null ? -1 : c;
}
private String taskStatus() {
return jdbcTemplate.queryForObject(
"SELECT status FROM candidate_scoring_refresh_tasks WHERE student_user_id=? AND target_period=?",
String.class, sid, period);
}
private LocalDateTime now() {
return LocalDateTime.now();
}
// 1. 重复登记只保留一条任务
@Test
void duplicateRefreshRequestCreatesOneTask() {
requestService.requestRefresh(sid, now());
LocalDateTime t1 = jdbcTemplate.queryForObject(
"SELECT requested_at FROM candidate_scoring_refresh_tasks WHERE student_user_id=?", LocalDateTime.class, sid);
requestService.requestRefresh(sid, now());
assertEquals(1, taskCount(), "同学生同周期重复登记只保留一条任务");
LocalDateTime t2 = jdbcTemplate.queryForObject(
"SELECT requested_at FROM candidate_scoring_refresh_tasks WHERE student_user_id=?", LocalDateTime.class, sid);
assertFalse(t2.isBefore(t1), "重复登记应更新 requested_at 而非新增");
}
// 2. COMPLETED 任务再次登记 → 回到 PENDING 且 attempts 归零
@Test
void completedTaskCanBeRequestedAgain() {
requestService.requestRefresh(sid, now());
jdbcTemplate.update("UPDATE candidate_scoring_refresh_tasks SET status='COMPLETED', attempts=5 WHERE student_user_id=?",
sid);
requestService.requestRefresh(sid, now());
assertEquals("PENDING", taskStatus());
Integer attempts = jdbcTemplate.queryForObject(
"SELECT attempts FROM candidate_scoring_refresh_tasks WHERE student_user_id=?", Integer.class, sid);
assertEquals(0, attempts, "重新登记后 attempts 应归零");
}
// 3. PROCESSING 期间新请求不丢失:状态保持 PROCESSING、requested_at 持续推进;完成后可处理
@Test
void requestDuringProcessingIsNotLost() {
requestService.requestRefresh(sid, now());
LocalDateTime pst = now().minusSeconds(5);
jdbcTemplate.update("UPDATE candidate_scoring_refresh_tasks SET status='PROCESSING', processing_started_at=?, "
+ "requested_at=? WHERE student_user_id=?", pst, pst, sid);
// 处理期间产生新请求(requested_at 更新,status 保持不变,任务仍唯一)
requestService.requestRefresh(sid, now());
assertEquals("PROCESSING", taskStatus(), "处理期间新请求不得覆盖 PROCESSING 状态");
assertEquals(1, taskCount(), "处理期间新请求不得新增任务行");
// 完成语义:若 requested_at 晚于 processing_started_at,Worker 完成时回退 PENDING(数据可验证)
LocalDateTime req2 = jdbcTemplate.queryForObject(
"SELECT requested_at FROM candidate_scoring_refresh_tasks WHERE student_user_id=?", LocalDateTime.class, sid);
assertTrue(req2.isAfter(pst), "处理期间新请求必须推进 requested_at(不丢失)");
// 使用真实完成路径:领取版本早于处理期间的新请求,完成时必须回到 PENDING。
jdbcTemplate.update("UPDATE candidate_scoring_refresh_tasks SET status='PENDING', processing_started_at=NULL "
+ "WHERE student_user_id=?", sid);
Long taskId = jdbcTemplate.queryForObject(
"SELECT id FROM candidate_scoring_refresh_tasks WHERE student_user_id=?", Long.class, sid);
LocalDateTime claimedAt = now();
CandidateScoringRefreshTaskExecutor.ClaimedTask claimed = taskExecutor.claim(taskId, claimedAt);
assertNotNull(claimed);
requestService.requestRefresh(sid, claimedAt.plusNanos(1));
taskExecutor.materialize(claimed, claimedAt);
assertEquals("PENDING", taskStatus(), "处理期间的新请求必须让真实完成路径回到 PENDING");
}
// 4. 两个并发 Worker 只能有效处理一次(数据库条件领取)
@Test
void twoWorkersCannotProcessSameTaskTwice() throws Exception {
requestService.requestRefresh(sid, now());
List<Long> ids = jdbcTemplate.queryForList(
"SELECT id FROM candidate_scoring_refresh_tasks WHERE student_user_id=?", Long.class, sid);
Long taskId = ids.get(0);
ExecutorService pool = Executors.newFixedThreadPool(2);
CountDownLatch ready = new CountDownLatch(2);
CountDownLatch start = new CountDownLatch(1);
List<Future<Boolean>> futures = java.util.stream.IntStream.range(0, 2).<Future<Boolean>>mapToObj(i ->
pool.submit(() -> {
ready.countDown();
start.await(5, TimeUnit.SECONDS);
return worker.processOne(taskId, now());
})).toList();
assertTrue(ready.await(5, TimeUnit.SECONDS));
start.countDown();
boolean r1 = futures.get(0).get(30, TimeUnit.SECONDS);
boolean r2 = futures.get(1).get(30, TimeUnit.SECONDS);
pool.shutdownNow();
assertEquals(1, (r1 ? 1 : 0) + (r2 ? 1 : 0), "同一任务并发只能有一个 Worker 有效处理");
List<CandidateDimensionScore> snaps = snapshotRepository.findByStudentUserIdAndPeriod(sid, period);
assertTrue(snaps.size() == 8, "并发处理恰一次物化(8 维)");
}
// 6/9. 相同计算值重复 Writer 写入不增 version;Worker 重放同结果不增
@Test
void sameCalculatedValuesDoNotIncrementSnapshotVersion() {
List<CandidateScoringCalculator.DimensionResult> results =
calculator.computeAll(sid, now());
writer.upsertIfChanged(sid, results, period, now(), calculator.currentRulesVersion());
List<CandidateDimensionScore> r1 = snapshotRepository.findByStudentUserIdAndPeriod(sid, period);
assertEquals(8, r1.size());
writer.upsertIfChanged(sid, results, period, now(), calculator.currentRulesVersion());
List<CandidateDimensionScore> r2 = snapshotRepository.findByStudentUserIdAndPeriod(sid, period);
for (int i = 0; i < r1.size(); i++) {
assertEquals(r1.get(i).getVersion(), r2.get(i).getVersion(), "值不变不得推进 version: " + r1.get(i).getDimension());
}
}
// 8. rulesVersion 变化强制递增
@Test
void rulesVersionChangeIncrementsVersion() {
List<CandidateScoringCalculator.DimensionResult> results = calculator.computeAll(sid, now());
writer.upsertIfChanged(sid, results, period, now(), "candidate-scoring-v0.1");
writer.upsertIfChanged(sid, results, period, now(), "candidate-scoring-v0.2");
List<CandidateDimensionScore> rows = snapshotRepository.findByStudentUserIdAndPeriod(sid, period);
assertTrue(rows.stream().allMatch(s -> s.getVersion() == 1L), "rulesVersion 变化应强制 version+1");
}
// 21. 任务错误分类为固定值且不含异常原文/SQL/手机号/堆栈
@Test
void taskErrorCategoryIsFixedAndSafe() {
requestService.requestRefresh(sid, now());
Long taskId = jdbcTemplate.queryForObject(
"SELECT id FROM candidate_scoring_refresh_tasks WHERE student_user_id=?", Long.class, sid);
CandidateScoringRefreshTaskExecutor.ClaimedTask claimed = taskExecutor.claim(taskId, now());
assertNotNull(claimed);
taskExecutor.recordFailure(taskId, "UNKNOWN_REFRESH_FAILURE", now(), 3, 1000L, claimed.claimToken());
String code = jdbcTemplate.queryForObject(
"SELECT last_error_code FROM candidate_scoring_refresh_tasks WHERE id=?", String.class, taskId);
assertTrue(Set.of("COMPUTE_FAILED", "WRITE_FAILED", "UNKNOWN_REFRESH_FAILURE", "WORKER_TIMEOUT").contains(code),
"错误分类必须为固定值,实际: " + code);
assertFalse(code.contains("Exception") || code.contains(" at com.mydreamjob"),
"错误分类不得含异常原文/堆栈");
}
// 22. Worker 不发布 RabbitMQ/Notification(快照仅有 8 维行,无 notifications 行)
@Test
void workerDoesNotPublishRabbitMqOrNotification() {
requestService.requestRefresh(sid, now());
long notifBefore = count("notifications");
worker.processDue(10);
assertEquals(notifBefore, count("notifications"), "Worker 物化不得写 notifications");
}
// 20. 普通刷新路径不写 snapshot_history
@Test
void noRefreshPathWritesSnapshotHistory() {
requestService.requestRefresh(sid, now());
long histBefore = count("candidate_scoring_snapshot_history");
worker.processDue(10);
assertEquals(histBefore, count("candidate_scoring_snapshot_history"),
"普通物化不得写 candidate_scoring_snapshot_history(仅申诉决定/回滚写入)");
}
// 5. 到期 RETRY 重新领取 + 未到期不领取
@Test
void retryTaskClaimedOnlyWhenDue() {
requestService.requestRefresh(sid, now());
Long taskId = jdbcTemplate.queryForObject(
"SELECT id FROM candidate_scoring_refresh_tasks WHERE student_user_id=?", Long.class, sid);
jdbcTemplate.update("UPDATE candidate_scoring_refresh_tasks SET status='RETRY', next_attempt_at=? WHERE id=?",
now().plusHours(1), taskId);
assertFalse(worker.processOne(taskId, now()), "未到 next_attempt_at 不得领取");
jdbcTemplate.update("UPDATE candidate_scoring_refresh_tasks SET next_attempt_at=NULL WHERE id=?", taskId);
assertTrue(worker.processOne(taskId, now()), "到期任务应可领取");
}
@Test
void staleProcessingLeaseIsRecoveredAndCanBeClaimedAgain() {
requestService.requestRefresh(sid, now());
Long taskId = jdbcTemplate.queryForObject(
"SELECT id FROM candidate_scoring_refresh_tasks WHERE student_user_id=?", Long.class, sid);
LocalDateTime claimedAt = now().minusMinutes(20);
assertNotNull(taskExecutor.claim(taskId, claimedAt));
assertEquals(1, taskExecutor.recoverStale(now().minusMinutes(10), now(), 3));
assertEquals("RETRY", taskStatus());
assertEquals("WORKER_TIMEOUT", jdbcTemplate.queryForObject(
"SELECT last_error_code FROM candidate_scoring_refresh_tasks WHERE id=?", String.class, taskId));
assertNotNull(taskExecutor.claim(taskId, now()), "恢复后的租约必须可被其他实例重新领取");
}
// 无外层事务时登记独立提交成功;外层回滚由专门事务测试覆盖。
@Test
void requestRegistrationCommitsIndependently() {
requestService.requestRefresh(sid, now());
assertEquals(1, taskCount());
}
@Test
void compensationScanTraversesMoreThanThreePagesWithoutStarvation() {
jdbcTemplate.update("DELETE FROM candidate_scoring_refresh_tasks");
List<Long> expected = new java.util.ArrayList<>();
for (int i = 0; i < 7; i++) {
User u = User.builder().phone("131" + String.format("%08d", i + 7000))
.nickname("scan-" + i).role(User.Role.STUDENT)
.active(true).verified(true).build();
u.setRoles(new HashSet<>(Set.of(User.Role.STUDENT)));
Long id = userRepository.saveAndFlush(u).getId();
expected.add(id);
jdbcTemplate.update("INSERT INTO audit_logs(user_id,action,created_at) VALUES (?,'LOGIN',?)",
id, now());
}
jdbcTemplate.update("""
INSERT INTO candidate_scoring_refresh_scan_checkpoints
(scan_key,last_user_id,version,updated_at)
VALUES ('ACTIVE_STUDENTS',?,0,CURRENT_TIMESTAMP)
""", expected.get(0) - 1L);
int first = compensationScanner.scanDueStudents(2);
long cursorAfterFirst = jdbcTemplate.queryForObject(
"SELECT last_user_id FROM candidate_scoring_refresh_scan_checkpoints WHERE scan_key='ACTIVE_STUDENTS'",
Long.class);
int second = compensationScanner.scanDueStudents(2);
int third = compensationScanner.scanDueStudents(2);
int fourth = compensationScanner.scanDueStudents(2);
List<Long> actual = jdbcTemplate.queryForList(
"SELECT student_user_id FROM candidate_scoring_refresh_tasks WHERE student_user_id IN ("
+ String.join(",", java.util.Collections.nCopies(expected.size(), "?")) + ")",
Long.class, expected.toArray());
assertEquals(2, first, "单轮必须严格受预算限制");
assertTrue(cursorAfterFirst > 0L, "游标必须持久化以供下一轮或重启后续扫");
assertTrue(second <= 2 && third <= 2 && fourth <= 2, "每轮不得突破固定预算");
assertEquals(Set.copyOf(expected), Set.copyOf(actual),
"持久 keyset 游标必须跨四轮覆盖每个合格学生");
}
// ── F4:ABILITY 快照 payload 敏感字段防泄漏(真实 Writer → 真实 DB → 递归断言) ──
/** 禁止作为 JSON key 出现的字段名(小写精确匹配,避免 questionSetVersion 之类白名单字段误报)。 */
private static final java.util.List<String> BANNED_KEYS = java.util.List.of(
"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");
private void assertNoBannedKeyDeep(com.fasterxml.jackson.databind.JsonNode node,
String path, java.util.List<String> hits) {
if (node == null) return;
if (node.isObject()) {
java.util.Iterator<String> it = node.fieldNames();
java.util.List<String> names = new java.util.ArrayList<>();
it.forEachRemaining(names::add);
for (String name : names) {
String lower = name.toLowerCase();
if (BANNED_KEYS.contains(lower)) hits.add(path + "." + name);
assertNoBannedKeyDeep(node.get(name), path + "." + name, hits);
}
} else if (node.isArray()) {
for (int i = 0; i < node.size(); i++) {
assertNoBannedKeyDeep(node.get(i), path + "[" + i + "]", hits);
}
}
}
@Test
void abilitySnapshotPayload_hasNoSensitiveFields_andKeepsWhitelistedKeys() throws Exception {
String p = period;
CandidateScoringCalculator.DimensionResult r =
new CandidateScoringCalculator.DimensionResult("ABILITY");
r.score = new java.math.BigDecimal("74.25");
r.status = "OK";
r.abilitySnapshotTrace = new CandidateScoringCalculator.AbilitySnapshotTrace(
123L, now(), now().plusDays(30), "qs-v1", "ar-v1",
Map.of("KNOWLEDGE", 30, "SKILL", 70),
Map.of("KNOWLEDGE", new java.math.BigDecimal("70.00"),
"SKILL", new java.math.BigDecimal("78.50")));
writer.upsertIfChanged(sid, List.of(r), p, now(), "candidate-scoring-v0.2");
String json = jdbcTemplate.queryForObject(
"SELECT sub_scores_json FROM candidate_dimension_scores "
+ "WHERE student_user_id=? AND dimension='ABILITY' AND period=?",
String.class, sid, p);
assertNotNull(json, "ABILITY 快照 JSON 必须经真实 Writer 写入数据库");
com.fasterxml.jackson.databind.JsonNode node =
new com.fasterxml.jackson.databind.ObjectMapper().readTree(json);
// 正向:白名单字段必须存在且取值正确
for (String allowed : List.of("source", "sourceResultId", "validUntil", "questionSetVersion",
"assessmentRulesVersion", "mappingRulesVersion", "weights", "dimensionScores")) {
assertTrue(node.has(allowed), "允许字段必须存在: " + allowed);
}
assertEquals("V36_ABILITY_ASSESSMENT", node.get("source").asText());
assertEquals(123L, node.get("sourceResultId").asLong());
assertTrue(node.get("weights").has("KNOWLEDGE"), "weights 应保留白名单内的维度键");
assertTrue(node.get("dimensionScores").has("SKILL"), "dimensionScores 应保留白名单内的维度键");
// 负向(递归,含嵌套对象与数组,大小写不敏感)
java.util.List<String> hits = new java.util.ArrayList<>();
assertNoBannedKeyDeep(node, "$", hits);
assertTrue(hits.isEmpty(), "ABILITY payload 递归不得含敏感字段,命中: " + hits);
// 负向(原始 JSON 文本层,禁止出现这些 key 字面量)
String lower = json.toLowerCase();
for (String banned : List.of("answer", "answers", "rawanswer", "questionid", "questioncode",
"optionid", "selectedoption", "answerkey", "competencytag", "stem",
"scoringrule", "awardedscore", "explanation", "phone", "email", "token")) {
assertFalse(lower.contains("\"" + banned + "\""),
"原始 JSON 不得出现敏感 key 字面量: " + banned);
}
}
private long count(String table) {
Long c = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM " + table, Long.class);
return c == null ? -1 : c;
}
}

274
mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshMySqlIntegrationTest.java

@ -0,0 +1,274 @@
package com.mydreamjob.candidatescore.refresh;
import com.mydreamjob.auth.User;
import com.mydreamjob.auth.UserRepository;
import com.mydreamjob.candidatescore.CandidateDimensionScoreRepository;
import com.mydreamjob.candidatescore.CandidateScoringCalculator;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.annotation.DirtiesContext;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.testcontainers.containers.MySQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
/** R01-D4-1 真实 MySQL 8.4 网关:正式 Flyway、validate、锁与事务语义。 */
@Testcontainers(disabledWithoutDocker = true)
@SpringBootTest
@ActiveProfiles("mysql-it")
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS)
class CandidateScoringRefreshMySqlIntegrationTest {
@Container
static final MySQLContainer<?> MYSQL = new MySQLContainer<>("mysql:8.4")
.withDatabaseName("mydreamjob_r01_d4")
.withUsername("r01d4")
.withPassword("r01-d4-test-only");
@DynamicPropertySource
static void datasource(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", MYSQL::getJdbcUrl);
registry.add("spring.datasource.username", MYSQL::getUsername);
registry.add("spring.datasource.password", MYSQL::getPassword);
registry.add("spring.jpa.hibernate.ddl-auto", () -> "validate");
}
@Autowired private CandidateScoringRefreshRequestService requestService;
@Autowired private CandidateScoringRefreshTaskExecutor executor;
@Autowired private CandidateScoringRefreshWorker worker;
@Autowired private CandidateScoringRefreshCompensationScanner compensationScanner;
@Autowired private CandidateScoringSnapshotWriter writer;
@Autowired private CandidateDimensionScoreRepository snapshotRepository;
@Autowired private UserRepository userRepository;
@Autowired private JdbcTemplate jdbcTemplate;
@MockBean private CandidateScoringCalculator calculator;
@MockBean private RedisTemplate<String, Object> redisTemplate;
private Long failingStudent;
private Long successfulStudent;
private final LocalDateTime now = LocalDateTime.of(2026, 9, 16, 12, 0);
@BeforeEach
void setUp() {
jdbcTemplate.update("DELETE FROM candidate_scoring_refresh_tasks");
jdbcTemplate.update("DELETE FROM candidate_scoring_refresh_scan_checkpoints");
snapshotRepository.deleteAll();
failingStudent = student("mysql-failing");
successfulStudent = student("mysql-success");
when(calculator.currentRulesVersion()).thenReturn("candidate-scoring-v0.1");
when(calculator.computeAll(any(), any())).thenAnswer(call ->
results(call.<Long>getArgument(0).equals(failingStudent)));
}
@Test
void v44MigrationAndSchemaValidationAreApplied() {
Integer success = jdbcTemplate.queryForObject("""
SELECT success FROM flyway_schema_history
WHERE version='44' AND description='candidate scoring refresh tasks'
""", Integer.class);
assertEquals(1, success);
Long columns = jdbcTemplate.queryForObject("""
SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema=DATABASE() AND table_name='candidate_scoring_refresh_tasks'
""", Long.class);
// V44 建表 13 列;R01-D4-2 claim fence 整改的 V45 追加 claim_token → 14 列
assertEquals(14L, columns);
Long claimTokenColumns = jdbcTemplate.queryForObject("""
SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema=DATABASE() AND table_name='candidate_scoring_refresh_tasks'
AND column_name='claim_token'
""", Long.class);
assertEquals(1L, claimTokenColumns, "V45 必须新增 claim_token 列");
// V45 自身必须成功且唯一
Integer v45 = jdbcTemplate.queryForObject("""
SELECT success FROM flyway_schema_history WHERE version='45'
""", Integer.class);
assertEquals(1, v45, "V45 必须成功");
Long v45Rows = jdbcTemplate.queryForObject("""
SELECT COUNT(*) FROM flyway_schema_history WHERE version='45'
""", Long.class);
assertEquals(1L, v45Rows, "V45 必须唯一");
Long checkpointColumns = jdbcTemplate.queryForObject("""
SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema=DATABASE() AND table_name='candidate_scoring_refresh_scan_checkpoints'
""", Long.class);
assertEquals(4L, checkpointColumns);
}
@Test
void mysqlWriterIsIdempotentAndForcePathStillAdvancesVersion() {
List<CandidateScoringCalculator.DimensionResult> values = results(false);
writer.upsertIfChanged(successfulStudent, values, "2026-09", now, "candidate-scoring-v0.1");
var first = snapshotRepository.findByStudentUserIdAndPeriod(successfulStudent, "2026-09");
assertEquals(8, first.size());
assertTrue(first.stream().allMatch(row -> row.getVersion() == 0L));
writer.upsertIfChanged(successfulStudent, values, "2026-09", now.plusMinutes(1),
"candidate-scoring-v0.1");
var unchanged = snapshotRepository.findByStudentUserIdAndPeriod(successfulStudent, "2026-09");
assertTrue(unchanged.stream().allMatch(row -> row.getVersion() == 0L));
values.get(0).score = new BigDecimal("88.00");
writer.upsertIfChanged(successfulStudent, values, "2026-09", now.plusMinutes(2),
"candidate-scoring-v0.1");
var changed = snapshotRepository.findByStudentUserIdAndPeriod(successfulStudent, "2026-09");
assertEquals(1L, changed.stream().filter(row -> "EDUCATION".equals(row.getDimension()))
.findFirst().orElseThrow().getVersion());
assertTrue(changed.stream().filter(row -> !"EDUCATION".equals(row.getDimension()))
.allMatch(row -> row.getVersion() == 0L));
writer.upsertForcingVersion(successfulStudent, values.get(0), "2026-09", now.plusMinutes(3),
"candidate-scoring-v0.1");
assertEquals(2L, snapshotRepository.findByStudentUserIdAndPeriod(successfulStudent, "2026-09")
.stream().filter(row -> "EDUCATION".equals(row.getDimension()))
.findFirst().orElseThrow().getVersion());
}
@Test
void mysqlConcurrentClaimHasExactlyOneWinner() throws Exception {
requestService.requestRefresh(successfulStudent, now);
Long taskId = taskId(successfulStudent);
ExecutorService pool = Executors.newFixedThreadPool(2);
CountDownLatch ready = new CountDownLatch(2);
CountDownLatch start = new CountDownLatch(1);
List<Future<CandidateScoringRefreshTaskExecutor.ClaimedTask>> futures = new ArrayList<>();
for (int i = 0; i < 2; i++) {
futures.add(pool.submit(() -> {
ready.countDown();
start.await(10, TimeUnit.SECONDS);
return executor.claim(taskId, now);
}));
}
assertTrue(ready.await(10, TimeUnit.SECONDS));
start.countDown();
long winners = 0;
for (Future<CandidateScoringRefreshTaskExecutor.ClaimedTask> future : futures) {
if (future.get(30, TimeUnit.SECONDS) != null) winners++;
}
pool.shutdownNow();
assertEquals(1L, winners);
}
@Test
void mysqlFailureRollsBackPartialSnapshotsAndDoesNotRollbackSibling() {
requestService.requestRefresh(failingStudent, now);
requestService.requestRefresh(successfulStudent, now);
assertEquals(1, worker.processDue(10));
assertTrue(snapshotRepository.findByStudentUserId(failingStudent).isEmpty());
assertEquals(8, snapshotRepository.findByStudentUserId(successfulStudent).size());
assertEquals("RETRY", status(failingStudent));
assertEquals("WRITE_FAILED", error(failingStudent));
assertEquals("COMPLETED", status(successfulStudent));
}
@Test
void mysqlProcessingReregistrationAndStaleRecoveryDoNotLoseWork() {
requestService.requestRefresh(successfulStudent, now);
Long taskId = taskId(successfulStudent);
var claimed = executor.claim(taskId, now);
assertNotNull(claimed);
requestService.requestRefresh(successfulStudent, now.plusSeconds(1));
executor.materialize(claimed, now);
assertEquals("PENDING", status(successfulStudent));
var staleClaim = executor.claim(taskId, now.minusMinutes(20));
assertNotNull(staleClaim);
assertEquals(1, executor.recoverStale(now.minusMinutes(10), now, 3));
assertEquals("RETRY", status(successfulStudent));
assertNotNull(executor.claim(taskId, now));
}
@Test
void mysqlCompensationCursorPersistsAndHonorsPerRunBudget() {
List<Long> students = new ArrayList<>();
for (int i = 0; i < 5; i++) {
Long id = student("mysql-scan-" + i);
students.add(id);
jdbcTemplate.update("INSERT INTO audit_logs(user_id,action,created_at) VALUES (?,'LOGIN',?)", id, now);
}
jdbcTemplate.update("""
INSERT INTO candidate_scoring_refresh_scan_checkpoints
(scan_key,last_user_id,version,updated_at)
VALUES ('ACTIVE_STUDENTS',?,0,CURRENT_TIMESTAMP)
""", students.get(0) - 1L);
assertEquals(2, compensationScanner.scanDueStudents(2));
long cursor = jdbcTemplate.queryForObject("""
SELECT last_user_id FROM candidate_scoring_refresh_scan_checkpoints
WHERE scan_key='ACTIVE_STUDENTS'
""", Long.class);
assertEquals(students.get(1).longValue(), cursor);
assertEquals(2, compensationScanner.scanDueStudents(2));
assertEquals(1, compensationScanner.scanDueStudents(2));
Long registered = jdbcTemplate.queryForObject("""
SELECT COUNT(*) FROM candidate_scoring_refresh_tasks
WHERE student_user_id IN (?,?,?,?,?)
""", Long.class, students.toArray());
assertEquals(5L, registered);
}
private Long student(String nickname) {
User user = User.builder()
.phone("131" + String.format("%08d", Math.abs(System.nanoTime() % 100_000_000L)))
.nickname(nickname).role(User.Role.STUDENT).active(true).verified(true).build();
user.setRoles(new HashSet<>(Set.of(User.Role.STUDENT)));
return userRepository.saveAndFlush(user).getId();
}
private Long taskId(Long studentId) {
return jdbcTemplate.queryForObject(
"SELECT id FROM candidate_scoring_refresh_tasks WHERE student_user_id=?",
Long.class, studentId);
}
private String status(Long studentId) {
return jdbcTemplate.queryForObject(
"SELECT status FROM candidate_scoring_refresh_tasks WHERE student_user_id=?",
String.class, studentId);
}
private String error(Long studentId) {
return jdbcTemplate.queryForObject(
"SELECT last_error_code FROM candidate_scoring_refresh_tasks WHERE student_user_id=?",
String.class, studentId);
}
private List<CandidateScoringCalculator.DimensionResult> results(boolean failAtFourth) {
String[] dimensions = {"EDUCATION", "INTERNSHIP", "ABILITY", "COMPLIANCE",
"STABILITY", "GROWTH", "INTEGRITY", "ACTIVITY"};
List<CandidateScoringCalculator.DimensionResult> values = new ArrayList<>();
for (int i = 0; i < dimensions.length; i++) {
var value = new CandidateScoringCalculator.DimensionResult(failAtFourth && i == 3 ? null : dimensions[i]);
value.score = BigDecimal.valueOf(60L + i);
value.status = "OK";
values.add(value);
}
return values;
}
}

110
mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshWorkerLoopIsolationTest.java

@ -0,0 +1,110 @@
package com.mydreamjob.candidatescore.refresh;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import org.springframework.jdbc.core.JdbcTemplate;
import java.time.LocalDateTime;
import java.util.List;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* R01-D4-2 次生缺陷:{@code processDue} 的批次循环必须逐任务隔离。
*
* <p>缺陷背景:{@code recordFailure} 更新 0 行时抛 IllegalStateException,该异常在
* Worker 的 catch 块内抛出,而 {@code processDue} 的 for 循环没有保护 —— 会导致同批
* 剩余任务本轮全部不被处理。</p>
*
* <p>纯单元测试:精确控制"第一项失败且失败登记也抛异常"的时序,不依赖 sleep 或 JVM 锁。</p>
*/
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class CandidateScoringRefreshWorkerLoopIsolationTest {
@Mock private JdbcTemplate jdbcTemplate;
@Mock private CandidateScoringRefreshTaskExecutor taskExecutor;
@InjectMocks private CandidateScoringRefreshWorker worker;
/** 固定时间,保证 record 的 equals 在参数匹配时可稳定比较。 */
private static final LocalDateTime CLAIMED_AT = LocalDateTime.of(2026, 9, 16, 12, 0);
private CandidateScoringRefreshTaskExecutor.ClaimedTask claim(long id) {
return new CandidateScoringRefreshTaskExecutor.ClaimedTask(id, id * 10, "2026-09",
CLAIMED_AT, "token-" + id, 0L);
}
@Test
void recordFailureThrowing_doesNotAbortRemainingTasksInBatch() {
when(jdbcTemplate.queryForList(anyString(), eq(Long.class), any(), anyInt()))
.thenReturn(List.of(1L, 2L, 3L));
when(taskExecutor.claim(anyLong(), any(LocalDateTime.class)))
.thenAnswer(inv -> claim(inv.getArgument(0)));
when(taskExecutor.recoverStale(any(), any(), anyInt())).thenReturn(0);
// 第一个任务计算失败
doThrow(new RuntimeException("compute blew up"))
.when(taskExecutor).materialize(eq(claim(1L)), any(LocalDateTime.class));
// 且其失败登记也抛异常(旧实现会抛出 IllegalStateException)
doThrow(new IllegalStateException("refresh task failure state conflict"))
.when(taskExecutor).recordFailure(eq(1L), anyString(), any(LocalDateTime.class),
anyInt(), anyLong(), anyString());
worker.processDue(10);
// 关键:后续两个任务必须仍被处理
verify(taskExecutor, times(1)).materialize(eq(claim(2L)), any(LocalDateTime.class));
verify(taskExecutor, times(1)).materialize(eq(claim(3L)), any(LocalDateTime.class));
}
@Test
void recordFailureThrowingInMiddle_doesNotAbortRemainingTasks() {
when(jdbcTemplate.queryForList(anyString(), eq(Long.class), any(), anyInt()))
.thenReturn(List.of(11L, 12L, 13L));
when(taskExecutor.claim(anyLong(), any(LocalDateTime.class)))
.thenAnswer(inv -> claim(inv.getArgument(0)));
when(taskExecutor.recoverStale(any(), any(), anyInt())).thenReturn(0);
doThrow(new RuntimeException("boom")).when(taskExecutor)
.materialize(eq(claim(12L)), any(LocalDateTime.class));
doThrow(new IllegalStateException("state conflict")).when(taskExecutor)
.recordFailure(eq(12L), anyString(), any(LocalDateTime.class), anyInt(), anyLong(),
anyString());
worker.processDue(10);
verify(taskExecutor, times(1)).materialize(eq(claim(11L)), any(LocalDateTime.class));
verify(taskExecutor, times(1)).materialize(eq(claim(13L)), any(LocalDateTime.class));
}
@Test
void claimLosing_skipsTaskWithoutRecordingFailure() {
when(jdbcTemplate.queryForList(anyString(), eq(Long.class), any(), anyInt()))
.thenReturn(List.of(21L, 22L));
when(taskExecutor.recoverStale(any(), any(), anyInt())).thenReturn(0);
// 21 被并发 Worker 抢走(claim 返回 null)
when(taskExecutor.claim(eq(21L), any(LocalDateTime.class))).thenReturn(null);
when(taskExecutor.claim(eq(22L), any(LocalDateTime.class))).thenReturn(claim(22L));
worker.processDue(10);
verify(taskExecutor, never()).recordFailure(eq(21L), anyString(), any(LocalDateTime.class),
anyInt(), anyLong(), anyString());
verify(taskExecutor, times(1)).materialize(eq(claim(22L)), any(LocalDateTime.class));
}
}

200
mydreamjob-server/src/test/java/com/mydreamjob/feedback/CandidateFeedbackRefreshRegistrationTest.java

@ -0,0 +1,200 @@
package com.mydreamjob.feedback;
import com.mydreamjob.application.Application;
import com.mydreamjob.application.ApplicationRepository;
import com.mydreamjob.auth.User;
import com.mydreamjob.auth.UserRepository;
import com.mydreamjob.company.Company;
import com.mydreamjob.company.CompanyRepository;
import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.feedback.dto.CandidateFeedbackRequest;
import com.mydreamjob.job.Job;
import com.mydreamjob.job.JobRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.context.ActiveProfiles;
import java.util.List;
import java.util.concurrent.atomic.AtomicLong;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
/**
* R01-D4-2 阻断缺陷 F2:候选人反馈的创建/修改/删除都必须登记评分刷新任务。
*
* <p>缺陷背景:{@code CandidateScoringCalculator#queryFeedback} 以
* {@code COUNT(*) / AVG(rating)} 参与 INTEGRITY 维度,但只有 createFeedback 登记了刷新,
* updateFeedback(改 rating 会改变 AVG)与 deleteFeedback(改变 COUNT 与 AVG)此前不登记。</p>
*/
@SpringBootTest
@ActiveProfiles("integration-test")
class CandidateFeedbackRefreshRegistrationTest {
@Autowired private CandidateFeedbackService service;
@Autowired private CandidateFeedbackRepository feedbackRepository;
@Autowired private ApplicationRepository applicationRepository;
@Autowired private JobRepository jobRepository;
@Autowired private CompanyRepository companyRepository;
@Autowired private UserRepository userRepository;
@Autowired private JdbcTemplate jdbcTemplate;
@MockBean private RedisTemplate<String, Object> redisTemplate;
private static final AtomicLong SEQ = new AtomicLong(5000);
private Long companyUserId;
private Long studentId;
private Long applicationId;
private long taskCount(Long studentUserId) {
Long c = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM candidate_scoring_refresh_tasks WHERE student_user_id=?",
Long.class, studentUserId);
return c == null ? -1 : c;
}
private CandidateFeedbackRequest req(int rating, String content) {
CandidateFeedbackRequest r = new CandidateFeedbackRequest();
r.setRating(rating);
r.setContent(content);
r.setTags(List.of("t"));
return r;
}
@BeforeEach
@SuppressWarnings("unchecked")
void setUp() {
ValueOperations<String, Object> ops = mock(ValueOperations.class);
when(redisTemplate.opsForValue()).thenReturn(ops);
when(redisTemplate.hasKey(any())).thenReturn(false);
jdbcTemplate.update("DELETE FROM candidate_scoring_refresh_tasks");
feedbackRepository.deleteAll();
applicationRepository.deleteAll();
jobRepository.deleteAll();
companyRepository.deleteAll();
User student = userRepository.save(User.builder()
.phone("196" + String.format("%08d", SEQ.incrementAndGet()))
.nickname("F2学生").role(User.Role.STUDENT).active(true).verified(true).build());
student.setRoles(java.util.Set.of(User.Role.STUDENT));
studentId = student.getId();
User companyUser = userRepository.save(User.builder()
.phone("195" + String.format("%08d", SEQ.incrementAndGet()))
.nickname("F2企业").role(User.Role.COMPANY).active(true).verified(true).build());
companyUser.setRoles(java.util.Set.of(User.Role.COMPANY));
companyUserId = companyUser.getId();
Company company = companyRepository.save(Company.builder()
.ownerUserId(companyUserId).name("F2公司").build());
Job job = jobRepository.save(Job.builder()
.companyId(company.getId()).title("F2岗位").description("d").city("上海")
.employmentType("FULL_TIME").status("PUBLISHED").build());
Application app = applicationRepository.save(Application.builder()
.jobId(job.getId()).studentUserId(studentId).status("SUBMITTED").build());
applicationId = app.getId();
}
private void clearTasks() {
jdbcTemplate.update("DELETE FROM candidate_scoring_refresh_tasks");
}
/** 清理本类产生的全部行,避免污染同一 JVM 内共享的 H2 内存库。 */
@org.junit.jupiter.api.AfterEach
void tearDown() {
jdbcTemplate.update("DELETE FROM candidate_scoring_refresh_tasks");
jdbcTemplate.update("DELETE FROM candidate_feedback");
jdbcTemplate.update("DELETE FROM offers");
jdbcTemplate.update("DELETE FROM application_status_history");
jdbcTemplate.update("DELETE FROM applications");
jdbcTemplate.update("DELETE FROM jobs");
jdbcTemplate.update("DELETE FROM companies");
}
@Test
void createFeedback_registersRefreshTask() {
clearTasks();
service.createFeedback(companyUserId, applicationId, req(5, "优秀"));
assertEquals(1L, taskCount(studentId), "createFeedback 必须登记刷新任务");
}
@Test
void updateFeedback_registersRefreshTask() {
service.createFeedback(companyUserId, applicationId, req(5, "优秀"));
clearTasks(); // 只观察 update 自身是否登记
service.updateFeedback(companyUserId, applicationId, req(2, "较差"));
assertEquals(1L, taskCount(studentId),
"updateFeedback 改变 rating 会影响 AVG(rating),必须登记刷新任务");
}
@Test
void deleteFeedback_registersRefreshTask_afterRowRemoved() {
service.createFeedback(companyUserId, applicationId, req(5, "优秀"));
clearTasks();
service.deleteFeedback(companyUserId, applicationId);
assertEquals(0L, feedbackRepository.count(), "反馈行应已删除");
assertEquals(1L, taskCount(studentId),
"deleteFeedback 改变 COUNT(*) 与 AVG(rating),必须登记刷新任务");
}
@Test
void updateFeedback_ratingUnchanged_stillRegistersButConvergesToOneTask() {
service.createFeedback(companyUserId, applicationId, req(4, "一般"));
clearTasks();
service.updateFeedback(companyUserId, applicationId, req(4, "一般-改文案"));
service.updateFeedback(companyUserId, applicationId, req(4, "一般-再改"));
assertEquals(1L, taskCount(studentId),
"重复登记必须收敛为同一学生同一周期的一条任务");
}
@Test
void updateFeedback_forbiddenCaller_leavesNoOrphanTask() {
service.createFeedback(companyUserId, applicationId, req(5, "优秀"));
clearTasks();
assertThrows(BusinessException.class,
() -> service.updateFeedback(999999L, applicationId, req(1, "越权")));
assertEquals(0L, taskCount(studentId), "失败路径不得留下孤立刷新任务");
}
@Test
void deleteFeedback_missingFeedback_leavesNoOrphanTask() {
clearTasks();
assertThrows(BusinessException.class,
() -> service.deleteFeedback(companyUserId, applicationId));
assertEquals(0L, taskCount(studentId), "失败路径不得留下孤立刷新任务");
}
@Test
void feedbackLifecycle_createUpdateDelete_allRegisteredAndSnapshotRefreshable() {
clearTasks();
service.createFeedback(companyUserId, applicationId, req(5, "优秀"));
assertEquals(1L, taskCount(studentId), "create 登记");
clearTasks();
service.updateFeedback(companyUserId, applicationId, req(1, "很差"));
assertEquals(1L, taskCount(studentId), "update 登记");
clearTasks();
service.deleteFeedback(companyUserId, applicationId);
assertEquals(1L, taskCount(studentId), "delete 登记");
}
}

1
mydreamjob-server/src/test/java/com/mydreamjob/feedback/CandidateFeedbackServiceTest.java

@ -29,6 +29,7 @@ class CandidateFeedbackServiceTest {
@Mock private ApplicationRepository applicationRepository;
@Mock private JobService jobService;
@Mock private CompanyRepository companyRepository;
@Mock private com.mydreamjob.candidatescore.refresh.CandidateScoringRefreshRequestService scoringRefreshRequestService;
@InjectMocks private CandidateFeedbackService feedbackService;
private static final Long COMPANY_USER_ID = 10L; // 企业账号 userId

278
mydreamjob-server/src/test/java/com/mydreamjob/offer/OfferExpiryRefreshRegistrationTest.java

@ -0,0 +1,278 @@
package com.mydreamjob.offer;
import com.mydreamjob.application.Application;
import com.mydreamjob.application.ApplicationRepository;
import com.mydreamjob.auth.User;
import com.mydreamjob.auth.UserRepository;
import com.mydreamjob.company.Company;
import com.mydreamjob.company.CompanyRepository;
import com.mydreamjob.job.Job;
import com.mydreamjob.job.JobRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.context.ActiveProfiles;
import java.time.LocalDateTime;
import java.util.List;
import java.util.concurrent.atomic.AtomicLong;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
/**
* R01-D4-2 阻断缺陷 F1:Offer 自动过期必须登记评分刷新任务。
*
* <p>缺陷背景:{@code OfferExpiryWorker#expireOne} 直接把 Offer 置为 EXPIRED,
* 而 {@code CandidateScoringCalculator#queryOffers} 以 {@code status='EXPIRED'} 计数
* 参与 INTEGRITY 维度评分 —— 该写侧事件此前不登记刷新任务。</p>
*
* <p>本测试断言真实数据库状态(offers / candidate_scoring_refresh_tasks / notifications),
* 不依赖对 requestRefresh 的 mock 验证。</p>
*/
@SpringBootTest
@ActiveProfiles("integration-test")
class OfferExpiryRefreshRegistrationTest {
@Autowired private OfferExpiryWorker worker;
@Autowired private com.mydreamjob.candidatescore.refresh.CandidateScoringRefreshRequestService requestService;
@Autowired private OfferRepository offerRepository;
@Autowired private OfferStatusHistoryRepository historyRepository;
@Autowired private ApplicationRepository applicationRepository;
@Autowired private JobRepository jobRepository;
@Autowired private CompanyRepository companyRepository;
@Autowired private UserRepository userRepository;
@Autowired private JdbcTemplate jdbcTemplate;
@MockBean private RedisTemplate<String, Object> redisTemplate;
/** 仅用于制造"事务内后续步骤失败"以验证原子性;正常路径行为不变。 */
@MockBean private OfferCounterOfferRepository counterOfferRepository;
private static final AtomicLong SEQ = new AtomicLong(3000);
private Long companyId;
private Long companyUserId;
/** 与 CandidateScoringRefreshRequestService 保持一致:period = capturedNow 所在 yyyy-MM。 */
private String period() {
LocalDateTime n = LocalDateTime.now();
return String.format("%04d-%02d", n.getYear(), n.getMonthValue());
}
private long taskCount(Long studentId) {
Long c = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM candidate_scoring_refresh_tasks WHERE student_user_id=?",
Long.class, studentId);
return c == null ? -1 : c;
}
/** userId 为 null 时统计全部通知行。 */
private long notificationCount(Long userId) {
Long c = (userId == null)
? jdbcTemplate.queryForObject("SELECT COUNT(*) FROM notifications", Long.class)
: jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM notifications WHERE user_id=?", Long.class, userId);
return c == null ? -1 : c;
}
@BeforeEach
@SuppressWarnings("unchecked")
void setUp() {
ValueOperations<String, Object> ops = mock(ValueOperations.class);
when(redisTemplate.opsForValue()).thenReturn(ops);
when(redisTemplate.hasKey(any())).thenReturn(false);
jdbcTemplate.update("DELETE FROM candidate_scoring_refresh_tasks");
jdbcTemplate.update("DELETE FROM candidate_feedback");
historyRepository.deleteAll(); // offer_status_history 引用 offers,必须先删
jdbcTemplate.update("DELETE FROM offer_reminders");
jdbcTemplate.update("DELETE FROM offer_counter_offers");
offerRepository.deleteAll();
applicationRepository.deleteAll();
jobRepository.deleteAll();
companyRepository.deleteAll();
User companyUser = userRepository.save(User.builder()
.phone("198" + String.format("%08d", SEQ.incrementAndGet()))
.nickname("F1企业").role(User.Role.COMPANY).active(true).verified(true).build());
companyUser.setRoles(java.util.Set.of(User.Role.COMPANY));
companyUserId = companyUser.getId();
Company company = companyRepository.save(Company.builder()
.ownerUserId(companyUserId).name("F1公司").build());
companyId = company.getId();
Job job = jobRepository.save(Job.builder()
.companyId(companyId).title("F1岗位").description("d").city("上海")
.employmentType("FULL_TIME").status("PUBLISHED").build());
// 原状态非 ACTIVE,避免 activeKey 唯一约束在多 Offer 场景下互相冲突
jdbcTemplate.update("UPDATE jobs SET status='PUBLISHED' WHERE id=?", job.getId());
}
/** 清理本类产生的全部行,避免污染同一 JVM 内共享的 H2 内存库。 */
@org.junit.jupiter.api.AfterEach
void tearDown() {
jdbcTemplate.update("DELETE FROM candidate_scoring_refresh_tasks");
jdbcTemplate.update("DELETE FROM candidate_feedback");
historyRepository.deleteAll();
jdbcTemplate.update("DELETE FROM offer_reminders");
jdbcTemplate.update("DELETE FROM offer_counter_offers");
offerRepository.deleteAll();
jdbcTemplate.update("DELETE FROM application_status_history");
applicationRepository.deleteAll();
jobRepository.deleteAll();
companyRepository.deleteAll();
}
/** 建一个属于指定学生的 SENT Offer(activeKey 置空以便同库多 Offer 共存)。 */
private Offer newSentOffer(Long studentId) {
Job job = jobRepository.findAll().get(0);
Application app = applicationRepository.save(Application.builder()
.jobId(job.getId()).studentUserId(studentId).status("SUBMITTED").build());
return offerRepository.saveAndFlush(Offer.builder()
.applicationId(app.getId()).jobId(job.getId()).companyId(companyId)
.candidateId(studentId)
.title("F1 Offer").description("d").expireAt(LocalDateTime.now().minusDays(1))
.status(OfferStatus.SENT.name()).activeKey(null)
.createdBy(companyUserId).updatedBy(companyUserId)
.build());
}
private Long newStudent(String tag) {
User u = userRepository.save(User.builder()
.phone("197" + String.format("%08d", SEQ.incrementAndGet()))
.nickname("F1学生" + tag).role(User.Role.STUDENT).active(true).verified(true).build());
u.setRoles(java.util.Set.of(User.Role.STUDENT));
return u.getId();
}
@Test
void expireOne_marksOfferExpired_andRegistersRefreshTaskForCandidate() {
Long studentId = newStudent("A");
Offer offer = newSentOffer(studentId);
worker.expireOne(offer.getId());
// 1) Offer 状态确实被改为 EXPIRED(真实数据库)
assertEquals(OfferStatus.EXPIRED.name(),
offerRepository.findById(offer.getId()).orElseThrow().getStatus());
// 2) 同一业务事务内登记了该学生的刷新任务(真实数据库)
assertEquals(1L, taskCount(studentId),
"Offer 自动过期必须为该候选人登记恰好一条评分刷新任务");
String taskPeriod = jdbcTemplate.queryForObject(
"SELECT target_period FROM candidate_scoring_refresh_tasks WHERE student_user_id=?",
String.class, studentId);
assertEquals(period(), taskPeriod);
}
@Test
void expireOne_repeatedExecution_keepsSingleTask() {
Long studentId = newStudent("B");
Offer offer = newSentOffer(studentId);
worker.expireOne(offer.getId());
worker.expireOne(offer.getId()); // 第二次:状态已是 EXPIRED,幂等返回
worker.expireOne(offer.getId());
assertEquals(1L, taskCount(studentId), "重复执行不得产生多条刷新任务");
}
@Test
void expireOne_multipleOffersOfDifferentStudents_registerSeparately() {
Long studentA = newStudent("C");
Long studentB = newStudent("D");
Offer a = newSentOffer(studentA);
Offer b = newSentOffer(studentB);
worker.expireOne(a.getId());
worker.expireOne(b.getId());
assertEquals(1L, taskCount(studentA), "学生 A 应各自登记一条");
assertEquals(1L, taskCount(studentB), "学生 B 应各自登记一条");
Long total = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM candidate_scoring_refresh_tasks", Long.class);
assertEquals(2L, total, "两个不同学生应产生两条独立任务");
}
@Test
void expireOne_rollback_rollsBackOfferStatusAndRefreshTaskTogether() {
Long studentId = newStudent("E");
Offer offer = newSentOffer(studentId);
// 制造"同一事务内后续步骤失败" → 整个 expireOne 事务回滚
doThrow(new org.springframework.dao.DataAccessException("boom") {})
.when(counterOfferRepository).expirePendingByOfferId(anyLong());
assertThrows(RuntimeException.class, () -> worker.expireOne(offer.getId()));
assertEquals(OfferStatus.SENT.name(),
offerRepository.findById(offer.getId()).orElseThrow().getStatus(),
"事务回滚后 Offer 状态必须保持原值");
assertEquals(0L, taskCount(studentId),
"事务回滚后不得留下孤立的刷新任务");
}
@Test
void expireOne_refreshRegistrationAddsNoNotificationRows() {
Long studentId = newStudent("F");
Offer offer = newSentOffer(studentId);
long globalBefore = notificationCount(null);
worker.expireOne(offer.getId());
long globalAfter = notificationCount(null);
// Offer 过期通知由既有 notifyExpired 走通知通道(测试 profile 下经 MQ 开关关闭的路径),
// 本断言只要求:新增的刷新登记不得额外插入通知行。
assertEquals(globalBefore, globalAfter,
"刷新登记不得引入任何 notifications 行");
}
@Test
void requestRefresh_alone_createsNoNotificationsAndNoMessages() {
Long studentId = newStudent("H");
long nBefore = notificationCount(null);
long mBefore = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM messages", Long.class);
requestService.requestRefresh(studentId, LocalDateTime.now());
assertEquals(nBefore, notificationCount(null), "纯登记不得写通知");
assertEquals(mBefore, jdbcTemplate.queryForObject("SELECT COUNT(*) FROM messages", Long.class),
"纯登记不得写消息");
assertEquals(1L, taskCount(studentId), "纯登记只登记任务");
}
@Test
void expireOne_refreshTaskCarriesNoSensitiveFields() {
Long studentId = newStudent("G");
Offer offer = newSentOffer(studentId);
worker.expireOne(offer.getId());
List<String> cols = jdbcTemplate.queryForList(
"SELECT column_name FROM information_schema.columns "
+ "WHERE table_name='CANDIDATE_SCORING_REFRESH_TASKS' AND table_schema='PUBLIC'",
String.class);
if (cols.isEmpty()) { // H2 大小写差异兜底
cols = List.of("id", "student_user_id", "target_period", "status", "requested_at",
"processing_started_at", "processed_at", "next_attempt_at", "attempts",
"last_error_code", "version", "created_at", "updated_at");
}
// claim_token 是 V45 引入的所有权令牌(随机 UUID),不是用户凭据,属允许列;
// 其余用户凭据/隐私列一律禁止。
for (String banned : List.of("PHONE", "EMAIL", "REASON", "CONTENT", "SALARY",
"ACCESS_TOKEN", "REFRESH_TOKEN", "PASSWORD", "SECRET")) {
assertTrue(cols.stream().noneMatch(c -> c.toUpperCase().contains(banned)),
"刷新任务表不得含敏感列: " + banned);
}
assertTrue(cols.stream().anyMatch(c -> "CLAIM_TOKEN".equalsIgnoreCase(c)),
"V45 必须提供 claim_token 所有权令牌列");
}
}

3
mydreamjob-server/src/test/java/com/mydreamjob/offer/OfferExpiryWorkerTest.java

@ -27,6 +27,9 @@ class OfferExpiryWorkerTest {
@Mock private OfferCounterOfferRepository counterOfferRepository;
@Mock private CompanyRepository companyRepository;
@Mock private NotificationService notificationService;
/** R01-D4-2 F1:Offer 过期必须登记评分刷新任务。 */
@Mock private com.mydreamjob.candidatescore.refresh.CandidateScoringRefreshRequestService
scoringRefreshRequestService;
@InjectMocks private OfferExpiryWorker worker;

1
mydreamjob-server/src/test/java/com/mydreamjob/offer/OfferServiceTest.java

@ -51,6 +51,7 @@ class OfferServiceTest {
@Mock private UserRepository userRepository;
@Mock private NotificationService notificationService;
@Mock private AuditService auditService;
@Mock private com.mydreamjob.candidatescore.refresh.CandidateScoringRefreshRequestService scoringRefreshRequestService;
@InjectMocks private OfferService offerService;

3
mydreamjob-server/src/test/java/com/mydreamjob/preference/PreferenceServiceTest.java

@ -28,6 +28,9 @@ class PreferenceServiceTest {
@Mock
private UserRepository userRepository;
@Mock
private com.mydreamjob.candidatescore.refresh.CandidateScoringRefreshRequestService scoringRefreshRequestService;
@InjectMocks
private PreferenceService preferenceService;

3
mydreamjob-server/src/test/java/com/mydreamjob/profile/ProfileServiceTest.java

@ -21,6 +21,9 @@ class ProfileServiceTest {
@Mock
private StudentProfileRepository profileRepository;
@Mock
private com.mydreamjob.candidatescore.refresh.CandidateScoringRefreshRequestService scoringRefreshRequestService;
@InjectMocks
private ProfileService profileService;

139
mydreamjob-server/src/test/java/com/mydreamjob/resume/ResumeVersionRefreshAtomicityTest.java

@ -0,0 +1,139 @@
package com.mydreamjob.resume;
import com.mydreamjob.auth.User;
import com.mydreamjob.auth.UserRepository;
import com.mydreamjob.profile.storage.ResumeStorageService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.transaction.support.TransactionTemplate;
import java.util.concurrent.atomic.AtomicLong;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
/**
* R01-D4-2 阻断缺陷 F3:简历版本行与评分刷新任务必须同一提交边界。
*
* <p>缺陷背景:{@code ResumeVersionWriter#saveNewVersion} 以 REQUIRES_NEW 独立提交版本行
* (这是既有的一致性问题防护设计,不能简单移除),而 {@code createVersion} 在外层事务中
* 登记刷新任务 —— 外层事务在提交阶段失败时,版本行已落库而刷新任务丢失。</p>
*
* <p>本测试断言真实数据库状态:外层事务回滚后,版本行存在则刷新任务必须同样存在。</p>
*
* <p>文件系统操作被 mock:本测试只验证数据库原子性,不把文件存储纳入数据库事务承诺。</p>
*/
@SpringBootTest
@ActiveProfiles("integration-test")
class ResumeVersionRefreshAtomicityTest {
@Autowired private ResumeVersionService service;
@Autowired private ResumeVersionRepository resumeVersionRepository;
@Autowired private UserRepository userRepository;
@Autowired private JdbcTemplate jdbcTemplate;
@Autowired private TransactionTemplate txTemplate;
@MockBean private ResumeStorageService resumeStorageService;
@MockBean private RedisTemplate<String, Object> redisTemplate;
private static final AtomicLong SEQ = new AtomicLong(7000);
private Long userId;
private long taskCount(Long uid) {
Long c = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM candidate_scoring_refresh_tasks WHERE student_user_id=?",
Long.class, uid);
return c == null ? -1 : c;
}
/** 必须是带真实 PDF 签名的内容,否则 ResumeFileValidator 会拒绝。 */
private MockMultipartFile file() {
return new MockMultipartFile("file", "resume.pdf", "application/pdf",
"%PDF-1.4\n1 0 obj\n<<>>\nendobj\ntrailer\n<<>>\n%%EOF\n".getBytes());
}
@BeforeEach
@SuppressWarnings("unchecked")
void setUp() {
ValueOperations<String, Object> ops = mock(ValueOperations.class);
when(redisTemplate.opsForValue()).thenReturn(ops);
when(redisTemplate.hasKey(any())).thenReturn(false);
when(resumeStorageService.store(anyLong(), any())).thenReturn("test/resume.pdf");
jdbcTemplate.update("DELETE FROM candidate_scoring_refresh_tasks");
resumeVersionRepository.deleteAll();
User student = userRepository.save(User.builder()
.phone("194" + String.format("%08d", SEQ.incrementAndGet()))
.nickname("F3学生").role(User.Role.STUDENT).active(true).verified(true).build());
student.setRoles(java.util.Set.of(User.Role.STUDENT));
userId = student.getId();
}
/** 清理本类产生的行,避免污染同一 JVM 内共享的 H2 内存库。 */
@org.junit.jupiter.api.AfterEach
void tearDown() {
jdbcTemplate.update("DELETE FROM candidate_scoring_refresh_tasks");
jdbcTemplate.update("DELETE FROM resume_export_tasks");
resumeVersionRepository.deleteAll();
}
@Test
void createVersion_success_registersRefreshTask() {
service.createVersion(userId, "我的简历", file());
assertEquals(1L, resumeVersionRepository.count(), "版本行已创建");
assertEquals(1L, taskCount(userId), "createVersion 必须登记刷新任务");
}
@Test
void createVersion_outerTransactionRollsBack_versionRowAndTaskCommitTogether() {
// 外层事务包裹 createVersion,并在返回后抛出 → 外层回滚
assertThrows(RuntimeException.class, () -> txTemplate.execute(status -> {
service.createVersion(userId, "外层回滚简历", file());
throw new RuntimeException("outer transaction failed after createVersion");
}));
long versions = resumeVersionRepository.count();
long tasks = taskCount(userId);
// 既有设计:版本行由 REQUIRES_NEW 独立提交 → 外层回滚后仍然存在
assertEquals(1L, versions, "版本行经独立事务提交,外层回滚不应使其消失");
// F3 核心断言:版本行存在则刷新任务必须同样存在(同一提交边界)
assertEquals(versions > 0 ? 1L : 0L, tasks,
"版本行已提交时刷新任务不得丢失(必须与版本行同一提交边界)");
}
@Test
void createVersion_neverLeavesTaskWithoutVersionRow() {
// 反向:不允许出现"有任务但没有版本行"
service.createVersion(userId, "正常简历", file());
long versions = resumeVersionRepository.count();
long tasks = taskCount(userId);
assertTrue(versions >= tasks,
"不得出现任务多于版本行的不一致状态 (versions=" + versions + ", tasks=" + tasks + ")");
}
@Test
void createVersion_repeatedRegistrations_convergeToOneTaskPerPeriod() {
service.createVersion(userId, "简历一", file());
service.createVersion(userId, "简历二", file());
service.createVersion(userId, "简历三", file());
assertEquals(3L, resumeVersionRepository.count(), "三个版本行");
assertEquals(1L, taskCount(userId),
"同学生同周期重复登记必须收敛为一条任务");
}
}

1
mydreamjob-server/src/test/java/com/mydreamjob/resume/ResumeVersionServiceTest.java

@ -31,6 +31,7 @@ class ResumeVersionServiceTest {
@Mock private ResumeStorageService resumeStorageService;
@Mock private ResumeVersionWriter versionWriter;
@Mock private ProfileService profileService;
@Mock private com.mydreamjob.candidatescore.refresh.CandidateScoringRefreshRequestService scoringRefreshRequestService;
@InjectMocks private ResumeVersionService resumeVersionService;

4
mydreamjob-server/src/test/java/com/mydreamjob/scoringmonitoring/FairnessMonitoringPersistenceIntegrationTest.java

@ -102,7 +102,7 @@ class FairnessMonitoringPersistenceIntegrationTest {
String prefix = UUID.randomUUID().toString().substring(0,8);
List<List<String>> cases = List.of(List.of("10.00"), List.of("10.00","10.01"),
List.of("10.00","10.00","10.01"), List.of("10.00","10.01","10.01"));
var calculator = new CandidateScoringCalculator(null,null,null,null,null,null);
var calculator = new CandidateScoringCalculator(null, null, null, null, null, null, null);
for (int c=0;c<cases.size();c++) {
String period="1999-0"+(c+1);
jdbcTemplate.update("INSERT INTO users(phone,role,is_active,is_verified) VALUES (?,'STUDENT',1,0)",prefix+c);
@ -276,7 +276,7 @@ class FairnessMonitoringPersistenceIntegrationTest {
.period(period).score(scores.get(i)).status("OK").rulesVersion("candidate-scoring-v0.2")
.calculatedAt(java.time.LocalDateTime.now()).build());
}
CandidateScoringCalculator calculator = new CandidateScoringCalculator(null, null, null, null, null, null);
CandidateScoringCalculator calculator = new CandidateScoringCalculator(null, null, null, null, null, null, null);
BigDecimal expected = calculator.overallScoreFromSnapshots(snapshots);
FairnessMonitoringRepository.OverviewAggregateData aggregate = repository.findOverviewAggregateData(period);
assertThat(expected).isEqualByComparingTo("45.05");

4
mydreamjob-server/src/test/resources/application-integration-test.yml

@ -34,6 +34,10 @@ spring:
enabled: false
app:
scoring:
refresh:
enabled: false
compensation-enabled: false
redis:
enabled: false
notification:

4
mydreamjob-server/src/test/resources/application-mysql-it.yml

@ -23,6 +23,10 @@ spring:
enabled: false
app:
scoring:
refresh:
enabled: false
compensation-enabled: false
redis:
enabled: false
notification:

4
mydreamjob-server/src/test/resources/application-test.yml

@ -20,6 +20,10 @@ spring:
enabled: false
app:
scoring:
refresh:
enabled: false
compensation-enabled: false
jwt:
secret: test-jwt-secret-key-for-unit-tests-only-not-for-production
access-secret: test-access-secret-key-for-unit-tests-only-not-for-production

Loading…
Cancel
Save