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