# R01-D4「GET 评分接口写快照迁移方案设计与证据核查」 日期:2026-09-16 分支:`refactor/r01-structure-performance` HEAD:`b56f792 perf: batch conversation unread counts` 本轮性质:**独立现状核查 + 权威迁移设计**(未实施生产改造) ## 1. Git 基线 - 分支 `refactor/r01-structure-performance`;HEAD `b56f792`;暂存区为空。 - 迁移目录无任何改动(V1–V43 原样);`AGENTS.md` 未发现。 - 已跟踪工作区存在 1 个仅被 Git 工作树元数据标记为修改的文件(`GlobalExceptionHandler.java`);普通 `git diff` 为空且 blob 与 HEAD 相同。本轮不触碰、不暂存该文件,也不把它计入 R01-D4 交付。 - 历史未跟踪文件全部保留;未 commit、push。 ## 2. 当前三个 GET 写库调用链 | 端点 | 服务方法 | 调用链 | 事务 | 写入 | 修改字段 | |---|---|---|---|---|---| | `GET /profile/me/candidate-scoring` | `CandidateScoringEngineService#getOverview` | computeAll → `upsertSnapshots` → buildOverview | `@Transactional`(默认 rw) | `candidate_dimension_scores` | score、status、sub_scores_json、weight、rules_version、calculated_at、**version+1**、**updated_at=NOW()** | | `GET /profile/me/candidate-scoring/dimensions/{dimension}` | `#getDimensionDetail` | computeAll → filter → `upsertSnapshots`(单维度) | `@Transactional`(rw) | 同上 | 同上(仅该维度行) | | `GET /company/candidates/{applicationId}/scoring` | `#getCompanyScoring` | 授权(company/job 归属) → computeAll → `upsertSnapshots` | `@Transactional`(rw) | 同上 | 同上(目标学生 8 行) | | `GET /profile/me/candidate-scoring/trend` | `#getTrend` | `snapshotRepository.findTrendFrom` 只读 | `@Transactional(readOnly = true)` | **无** | — | 写库 SQL(`CandidateScoringCalculator#upsertSnapshots`)为原生 `INSERT ... VALUES (...) ON DUPLICATE KEY UPDATE score=VALUES(score), status=..., sub_scores_json=..., weight=..., rules_version=..., calculated_at=..., version=version+1, updated_at=NOW()`。唯一键 `uk_candidate_dim_scores(student_user_id, dimension, period)`(V24 建表、实体 `@UniqueConstraint` 双重声明)。**触发条件为"存在唯一键冲突"即无条件版本自增——即使计算值与上次相同,重复 GET 仍使 version 单调递增、updated_at 改写。** ## 3. 全部快照写入方 | 写入方 | 入口 | 触发 | 是否用户可重复触发 | |---|---|---|---| | `CandidateScoringCalculator#upsertSnapshots`(被下三处调用) | — | — | — | | —— `CandidateScoringEngineService#getOverview` | `GET /profile/me/candidate-scoring` | **GET 读取请求** | 是(任何学生重复刷新) | | —— `CandidateScoringEngineService#getDimensionDetail` | `GET .../dimensions/{dimension}` | **GET 读取请求** | 是 | | —— `CandidateScoringEngineService#getCompanyScoring` | `GET /company/candidates/{id}/scoring` | **GET 读取请求** | 是(企业每次查看) | | `CandidateScoringCalculator#recomputeAndSnapshot` | 见下 | — | — | | —— `CandidateScoringGovernanceService#decideAppeal`(APPROVE 分支) | `POST /admin/candidate-scoring/appeals/{id}/decision` | 申诉决定 | 否(终态幂等) | | —— `CandidateScoringGovernanceService#rollback` | `POST .../appeals/{id}/rollback` | 申诉回滚 | 否(回滚限额+终态) | | ——(趋势/排名/看板不写) | — | — | — | ## 4. 全部快照消费者 | 消费者 | 读取方式 | 依赖字段 | 影响 | |---|---|---|---| | `getTrend`(趋势) | `findTrendFrom(student, startPeriod)` | period/score/status/rulesVersion | 只读物化快照;**完全依赖快照** | | `CandidateScoringGovernanceService#createAppeal` | `findSnapshot` | score(scoreAtAppeal)、**version(snapshotVersionAtAppeal)** | 申诉证据基于快照版本 | | `decideAppeal/rollback` | `recomputeAndSnapshot` + `snapshotHistoryRepository.save` | before/after 分数与版本 | 申诉决定/回滚重算写新版本 | | `CandidateScoringSnapshotHistory`(V25 表) | 仅由申诉决定/回滚写入,`getSnapshotHistory` 读取 | version/rulesVersion | 变更审计 | | `CompetitionRankingCalculator.loadLatestSnapshots` | `findUpToPeriod(students, asOf)` | 最新 period 的 8 维分数 | 竞岗排名用**物化快照**(非实时计算) | | `CompanyScoringDashboardService` | 读取快照聚合 | score/status | 企业看板/百分位(r01 优化为 CandidatePercentileCalculator) | | `CareerProgressService` | 读取快照 | 各期分数 | 求职进度 | | `FairnessMonitoringRepository` | 原生 SQL 按 period 聚合 candidate_dimension_scores + snapshot_history+appeal | score/status/样本数 | 公平性监控(当前快照 + 历史申诉双源) | | `PersonalDataCollector` | `findByStudentUserId` | 全字段 | GDPR 数据导出 | ## 5. 真实/集成副作用证据 ### 5.1 集成证据(H2,本轮新增独立现状证据测试,见 §17) `R01D4GetWritesSnapshotEvidenceTest` 结果 **5/5 全绿**(未依赖 Docker): - 首调 `getOverview`:`candidate_dimension_scores` 新增 **8 行**;重复调用行数保持 8、**version 全部 +1**; - 单维度 GET:行数不变、version 继续递增; - trend GET:前后行数/version 完全一致(只读确认); - 非法 dimension:校验先于 upsert,**零写入**; - GET 评分不写 `audit_logs`、`notifications`。 证据边界:该类直接调用 Service,并非真实 HTTP/SecurityFilterChain 测试;它证明 overview、dimension、trend 的持久化行为,但**没有覆盖企业评分 GET、无权限、资源不存在、HTTP 响应或 RabbitMQ**。这些项目不得依据该 5/5 结果标为已验证,必须在实施验收中补齐。 ### 5.2 真实 MySQL 证据 **BLOCKED**:Docker Engine 本轮不可用(`docker version` 无法连接 Engine),按任务书纪律**不伪造通过**。已在 5.1 提供同级 H2 集成证据;真实 MySQL 行级快照(首/二次 GET 的行数、version、updated_at、返回 JSON 是否变化、错误路径零写、audit/notification/RabbitMQ 无副作用)由 D4-2 阶段实施前用真实 MySQL 补充复验(§12 验收清单已含)。 静态代码可直接确认的行为(无需运行):`upsertSnapshots` 的 `ON DUPLICATE KEY UPDATE ... version=version+1, updated_at=NOW()` 保证重复 GET 必使 version 单调递增、updated_at 刷新;趋势/申诉/监控/看板/排名全部消费该物化快照。 ## 6. 首次与重复 GET 的行数/version/updated_at | 场景 | 行数 | version | updated_at | 返回 JSON | |---|---|---|---|---| | 首次 GET overview | 0 → 8 | 0(插入初值) | 写入 NOW() | 完整评分卡 | | 第二次相同 GET | 8(不变) | **+1(8 行全部)** | **改写为新 NOW()** | 与首次等值(公式确定) | | 单维度 GET(已有 8 行) | 8(不变) | 该维度行 +1 | 该行改写 | 明细不变 | | trend GET | 不变 | 不变 | 不变 | 由物化快照导出 | | 非法 dimension | 不变 | 不变 | 不变 | Service 抛业务异常(H2 已验证) | | 无权限 / 资源不存在 | 静态代码预期不变 | 静态代码预期不变 | 静态代码预期不变 | **本轮未做真实 HTTP 证据** | 结论:**"读取放大写入"确凿**——GET 对每次读取强制物化并推进版本,自增 version 不代表业务状态迁移,period 快照语义是"每次读取覆盖值"而非"首次值/最新值"。 ## 7. trend 的现有依赖 `getTrend` 只读 `candidate_dimension_scores`(`findTrendFrom`,period>=start 升序)。它**不**调用 computeAll/upsert,因此**完全依赖**由其他路径(当前主要是三个 GET + 申诉重算)持续写入的快照。若直接删除 GET upsert 而未建立写侧物化,趋势将冻结在最后一次写入时刻。 ## 8. 申诉、回滚与公平性监控依赖 - 申诉创建:`findSnapshot(student, dimension, period)` 读取当前行,固化 `scoreAtAppeal` 与 `snapshotVersionAtAppeal`(写入 `candidate_scoring_appeals`)。若快照过期,申诉取证基于旧版本。 - 申诉决定(APPROVE):`recomputeAndSnapshot` 重算写新版本 + 写入 `candidate_scoring_snapshot_history`(before/after score、version、rules_version)。 - 回滚:同决定路径,另扣 `candidate_scoring_rollback_limits`。 - 公平性监控:`FairnessMonitoringRepository` 两条原生聚合:`candidate_dimension_scores WHERE period=?` 按学生分组(overview/dimensions 用),以及 `candidate_scoring_snapshot_history JOIN appeal`(appeals 用)——**当前快照 + 历史申诉双源**。 - 申诉期间快照若被外部 GET 再次覆盖(version 再 +1),`snapshotVersionAtAppeal` 不再等于当前版本——审计可辨,但"申诉取证与当前快照差 version"是现状语义的一部分,迁移时不得破坏。 ## 9. 写侧事件矩阵(按计算器真实数据源) | 评分输入变化 | 计算器读取证据 | 推荐刷新入口 | 处理方式 | 备注 | |---|---|---|---|---|---| | 学生资料更新 | `StudentProfileRepository`:学历、学校、技能及资料完整度 | `ProfileService#updateProfile` 成功提交后 | 标记学生快照为待刷新 | 仅实际影响字段变化时触发 | | 多版本简历变化 | `resume_versions` ACTIVE 数量与 MAX(updated_at) | `ResumeVersionService#createVersion/updateVersion/deleteVersion` 成功提交后 | 标记待刷新 | `setDefault` 是否影响评分需按 SQL 判断;当前 SQL 不读取默认标记,不应无条件触发 | | 求职偏好更新 | `StudentPreferenceRepository` | `PreferenceService#savePreferences` 成功提交后 | 标记待刷新 | 影响 COMPLIANCE | | 能力测评结果完成 | `AbilityAssessmentScoreSource#findLatestValidCompletedResult` | `AbilityAttemptService#submit` 成功提交后 | 标记待刷新 | `saveAnswers` 尚未形成完成结果,不应刷新 | | 投递创建/撤回 | applications 数量、WITHDRAWN 数、近 90 天数量 | `ApplicationService#apply/withdraw` 成功提交后 | 标记待刷新 | 企业侧状态更新只有在实际改变计算器读取字段时才触发;当前聚合只区分 WITHDRAWN | | 登录及审计活动 | audit_logs 近 30 天数量与 LOGIN 数 | 统一审计写入成功后或补偿扫描 | 优先脏标记/补偿 | 入口很多,不能逐 Controller 机械接入 | | Offer 状态变化 | offers 中 ACCEPTED/EXPIRED 数量 | Offer 状态机成功提交后 | 标记待刷新 | 原报告遗漏 | | 候选人反馈变化 | candidate_feedback 数量与 AVG(rating) | 候选人反馈创建/修改的真实写入口 | 标记待刷新 | 不是 `JobReviewService`;原报告混淆了两类评价 | | 学生发送消息 | messages.sender_user_id 数量 | 消息发送成功提交后 | 标记待刷新 | 撤回不改变总消息数,按现公式无需刷新 | | 账户创建时间 | users.created_at | 无日常写入口 | 初次物化/补偿扫描 | 时间本身不变 | | 申诉决定/回滚 | 已有 `GovernanceService#decideAppeal/rollback` | 保持现有合法写侧 | 同事务 | 不与普通刷新事件混用历史审计语义 | | rulesVersion 或时间窗口跨界 | rulesVersion、90/30 日窗口、能力结果有效期 | 发布批次及定时补偿扫描 | 批处理 | 即使没有业务写事件,分数也会随时间变化,因此仅靠事件触发不完备 | 明确排除:当前 `computeAll` **不读取 RIASEC 结果、面试评分或岗位评价**,不得为这三类入口增加刷新;传统 `ProfileService#uploadResume` 的文件字段也不是 `queryResume` 的数据源,除非同时改变资料完整度实际依赖字段,否则不应触发。 现状结论:除申诉决定/回滚外,真实输入源缺少统一物化机制;同时存在时间窗口自然变化,因此“把若干 Service 同步调用 refresh”仍不能证明覆盖 100%。 ## 10. 三个迁移选项比较 | 维度 | 方案 A:同步写侧触发 | 方案 B:事务提交后异步刷新 | 方案 C:脏标记+定时物化 | |---|---|---|---| | 一致性 | 强(同一事务) | 最终一致(短窗口) | 最终一致(更长窗口) | | 事务耦合 | 高(评分写并进业务事务) | 中(分离;核心事务不受失败连累) | 低 | | 失败恢复 | 回滚即重来,无独立补偿 | 需重试/死信 + 补偿扫描 | 靠定时器重扫,天然补偿 | | 幂等性 | 唯一键天然幂等(version 语义需调整) | 需幂等键 + 去重 | 需幂等键 + 去重 | | 并发 | 数据库条件更新兜底 | 需"旧事件不得覆盖新事件"防护 | 需锁/版本比对 | | 实施范围 | 小(线程内调用)但侵入全部写方法 | 中(需要可靠发布/订阅或 MQ) | 中(脏标记 + 调度器) | | 对现有 RabbitMQ 依赖 | 无 | **强依赖**(需事务后发布、重试、去重、死信) | 无(可走内置调度) | | 对趋势/申诉/监控影响 | 不变 | 短窗口内趋势滞后,申诉取证可能取旧版本 | 更明显滞后 | | 发布/回滚难度 | 低 | 中(MQ 基建变化) | 中(调度器+补偿) | | 是否需要 Flyway | 否 | 否(建议无) | 否(可选脏标记列则需迁移) | | 是否新增公网接口 | 否 | 否 | 否 | ## 11. 唯一推荐方案 **分两阶段,先收敛写入口并建立可靠的“待刷新”机制,后让 GET 停写。唯一推荐为方案 C(持久化脏标记/刷新任务 + 定时物化),写侧提交后只登记待刷新任务;物化 Worker 复用唯一计算器和快照 Writer。** 理由: 1. 不能把评分刷新放入资料、投递、消息等核心业务事务;评分失败不应回滚这些业务,原报告在“同事务”和“失败不影响业务”之间自相矛盾。 2. 当前 RabbitMQ 没有已验证的事务消息、持久去重和死信恢复链路,不能直接依赖纯异步 MQ。 3. 评分包含 90/30 日窗口与能力结果有效期,仅靠业务事件不完备,必须有定时补偿。 4. 幂等键若不持久化就无法跨进程、重启和重试去重;因此原报告“无表结构变更但用 sourceEventKey 去重”不可实现。 5. 持久任务允许业务事务只做一次轻量 upsert 标记,Worker 失败可重试,不阻塞核心业务;GET 兜底在迁移期保留。 ## 12. 分阶段实施顺序 **D4-1(可靠物化基础设施,GET 兜底保留)** 1. 新增唯一 `CandidateScoringSnapshotWriter`,集中快照差异比较、条件更新和版本规则;申诉专用 history 语义仍由 Governance 管理。 2. 新增持久化刷新任务/脏标记(需要新的 Flyway,不能再声称“无需迁移”)。唯一键至少覆盖 student + target period;任务记录 requested_at、attempts、next_attempt_at、processed_at/version,不保存敏感评分输入。 3. §9 的真实输入写侧仅登记任务,不在线程内计算八维评分;时间窗口和 rulesVersion 由定时扫描登记任务。 4. Worker 在独立事务中领取任务、计算并写快照;失败可重试,成功后原子完成;不得依赖 JVM 锁。 5. 保持 GET upsert 作为迁移兜底,完成真实 MySQL 并发、重试和覆盖证据。 **D4-2(GET 停写)** 1. 三个 GET 改为 `@Transactional(readOnly = true)`,删除 upsert 调用(computeAll 保留用于实时计算响应字段,与物化无关)。 2. 开启补偿扫描,对时间窗口、rulesVersion 变化以及遗漏任务进行修复;与持久任务幂等协作。 3. 真实 MySQL 验收:GET 重复请求前后 `candidate_dimension_scores` 完整不变、无 audit/notification/RabbitMQ 副作用、趋势/申诉/监控/排名表现与 D4-1 一致。 4. 达标后方可切换/废弃 GET 兜底。 **阶段切换网关(未满足不得进入 D4-2)**:真实数据源覆盖完整、相同任务重放不造成无效 version 增长、并发领取恰好一次有效物化、失败可重试且不影响原业务提交、时间窗口可由补偿扫描推进、GET 停写后的趋势/申诉/排名/监控真实 MySQL 回归通过。 ## 13. 并发与幂等设计 - **任务幂等键**:持久任务按 `(studentUserId, targetPeriod)` 合并;多次输入变化只需保证至少一次最新状态重算。rulesVersion 是计算结果属性,不用虚构的内存 `sourceEventKey` 充当持久去重。 - **快照唯一性**:继续使用 `uk_candidate_dim_scores(student_user_id, dimension, period)`。 - **无效重放**:Writer 比较 score、status、sub_scores_json、weight、rules_version 等业务值;完全相同则不更新 version/updated_at。`calculated_at` 是计算时间,不能单独作为“业务变化”依据,否则每次重算仍会制造版本。 - **并发/旧任务**:任务不是携带完整旧数据的事件,而是“请按数据库当前状态重算”的合并信号,因此晚到任务只会重新读取最新状态,不会用旧载荷覆盖新快照。 - **rulesVersion 变化强制刷新**:`rules_version` 参与唯一比较,升级批次以新版本全量重算。 - **两个输入变化并发**:数据库任务唯一键合并;Worker 使用条件领取/乐观锁或 `SELECT ... FOR UPDATE SKIP LOCKED`,禁止 JVM 本地锁;必要时完成后检测 requested_at 是否在处理期间推进,若推进则保留待处理状态再算一次。 - **失败重试**:普通输入变化只登记持久任务,实际评分由 Worker 独立事务完成;失败更新重试状态并由补偿扫描兜底。重试不得重复写 `candidate_scoring_snapshot_history`/audit(该表只在申诉决定/回滚写,普通物化不写它)。 - **申诉期间快照变化**:`scoreAtAppeal/snapshotVersionAtAppeal` 保留申诉创建时刻值;写侧刷新不得改写申诉取证字段;决定/回滚的重算仍走 `recomputeAndSnapshot`(其版本语义与写侧事件在 `calculated_at/rules_version` 条件上一致,回滚目标版本 = beforeVersion+1 的既有确定性逻辑需在 D4-2 复核不冲突)。 - **回滚后的新输入事件**:允许再次计算——回滚后新事件刷新按正常路径写入新版本,`snapshot_history` 只记录申诉动作,不记录普通事件。 ## 14. 失败恢复策略 - 写侧业务事务只 upsert 持久刷新任务;任务登记失败应随该业务事务回滚,避免提交了源数据却永远没有刷新信号。评分计算不在核心业务事务中执行。 - Worker 独立事务执行,失败记录 attempts/next_attempt_at 并指数退避;超过阈值保留 FAILED/PENDING 状态供告警和补偿,不伪装成功。 - 定时补偿根据活跃用户、时间窗口与 rulesVersion 登记任务,不直接绕过 Writer 写快照。 - 最终一致性:允许短暂滞后(分钟级),趋势/监控为展示性质;申诉取证在写侧事件刷新后基于新版本,行为与现状一致(现状也是"最新一次写入者胜")。 ## 15. 数据迁移与兼容策略 - `candidate_dimension_scores` 本身优先保持结构不变;D4-1 需要新增持久刷新任务表及唯一/领取索引,因此必须新增下一号 Flyway(不得修改 V1–V43)。具体表设计在实施前独立评审。 - 现有存量数据:保留;D4-2 上线前对活跃学生做一次全量 `upsert` 对齐(事件覆盖前不删 GET 兜底,因此存量自然收敛)。 - version 语义从"每次读取递增"收敛为"每次**有效变更**递增 + rulesVersion 升级递增";历史 version 不重写。 - 趋势/申诉/监控/排名/导出读取代码零改动。 ## 16. 下一轮(D4)精确修改文件清单(预估,实施前以代码现状复核) - 新增:`candidatescore/CandidateScoringSnapshotWriter.java`、刷新任务实体/Repository/Worker/调度器,以及下一号 Flyway(具体命名先核对迁移目录最大版本)。 - 修改(仅写侧事件化,不改变业务语义): - `profile/ProfileService.java`、`resume/ResumeVersionService.java`、`preference/PreferenceService.java` - `ability/AbilityAttemptService.java`、`application/ApplicationService.java` - Offer、候选人反馈、消息与审计的真实写入 Service(实施前精确定位) - `candidatescore/CandidateScoringCalculator.java`(upsert 委托 + 条件 version 递增) - **D4-2 才改**:`candidatescore/CandidateScoringEngineService.java`(三 GET 改为 readOnly 并移除 upsert 调用)。 - 文档:`docs/backend-development-roadmap.md`(本报告结论)、`docs/candidate-scoring-api.md`(若契约文字涉及写语义)。 - **允许且预计需要新增**一个 Flyway、刷新任务表及必要索引;仍不新增 ErrorCode 和公网接口。 ## 17. D4 必须先写的失败测试(D4 实施轮的第一批测试,用于驱动实现) 1. `GET 重复请求前后 candidate_dimension_scores 完整内容不变`(行数/score/status/version/created_at/updated_at 全等)。 2. 三个 GET 不写 audit、notification、RabbitMQ(消息计数前置后置相等)。 3. GET 返回 JSON 与基线一致(黄金 JSON 对比)。 4. §9 每个真实输入源变化都会登记或合并一条待刷新任务;明确排除的非输入事件不得登记任务。 5. 同一学生/period 的重复登记合并;完全相同的计算结果不增加快照 version。 6. 并发登记后最终至少执行一次基于数据库最新状态的重算,任务不丢失且快照行不损坏。 7. 刷新失败可重试、不写重复历史/审计、不影响原业务事务。 8. rulesVersion 升级触发全量重算(version 递增)。 9. 申诉创建取证字段(scoreAtAppeal/snapshotVersionAtAppeal)在写侧刷新后仍正确。 10. 越权/非法 dimension/资源不存在零写入。 11. 真实 MySQL 行级副作用快照 + RabbitMQ ready/unacked 归零。 12. OpenAPI 176 paths / 214 operations 保持;不新增公网刷新接口。 > 现有 `CandidateScoringPersistenceIntegrationTest#upsertSnapshots_shouldBeIdempotentAndIncrementVersion` 的断言(重复 upsert version+1)在 D4-2 后语义变为"仅有效变更 +1",届时按新契约更新该既有断言(属于既定契约变更的测试演进,非草率改断言)。 ## 18. 风险与回滚方案 | 风险 | 影响 | 缓解/回滚 | |---|---|---| | 写侧事件覆盖不全 → 某学生快照停滞 | 趋势/排名/监控滞后 | D4-1 保留 GET 兜底期间逐步补覆盖;D4-2 依赖补偿扫描兜底;若需开关必须在实施中明确新增并测试,不能假定现有开关存在 | | 申诉取证版本语义漂移 | 申诉对比失真 | 事件刷新不触碰取证字段;D4-2 前在真实 MySQL 对申诉全链路回归 | | 异步刷新丢失 | 快照缺失 | 事务后发布 + 死信 + 补偿扫描(幂等键避免重复写) | | version 语义变化被下游误读 | 审计/导出版本含义差异 | 文档化;规则升级仍强制递增 | | 回滚难度 | — | D4-1 可停止任务登记和 Worker,但新增表由 Flyway 保留不用;D4-2 可恢复 GET upsert。不得回滚或删除已执行迁移 | ## 19. 自动化实际结果(本轮) | 命令 | 结果 | |---|---| | `mvn -Dtest="R01D4GetWritesSnapshotEvidenceTest" test`(新增现状证据) | **5/5**,BUILD SUCCESS | | `mvn -Dtest="CandidateScoringEngineServiceTest,CandidateScoringPersistenceIntegrationTest,*CandidateScoring*Test,*ScoringMonitoring*Test" test` | **134/134**,F=0 E=0 S=0,BUILD SUCCESS | | `mvn clean verify` | **2109/2109**(≥2104 预期),Failures=0、Errors=0、**Skipped=20**,BUILD SUCCESS | | `python docs\verify_openapi.py` | **176 paths / 214 operations,All checks passed** | Skipped=20 逐项说明:全部来自 Q01 既有的 `JobSearchQueryExplainMySqlIntegrationTest` 与 `JobSearchQuerySideEffectMySqlIntegrationTest`(`@Testcontainers(disabledWithoutDocker = true)`,分别 15/5 例)。Docker Engine 本轮不可用 → Testcontainers 显式约定跳过,为既有机制而非本轮核心核查,且非"把环境缺失伪装为通过";R01-D4 核心副作用证据由 H2 集成测试 5/5 提供,真实 MySQL 证据单独标注 BLOCKED(§5.2)。 ## 20. Git 状态与边界声明 - 未修改任何生产代码;未修改既有测试断言;未修改 V1–V43;未新增 Flyway/表/索引/ErrorCode/公网接口。 - 新增(工作区未提交):`docs/r01-d4-get-scoring-snapshot-migration-design.md`、`R01D4GetWritesSnapshotEvidenceTest.java`(现状证据测试,非契约断言)。 - 未 commit、push;未进入 R01-D5。历史未跟踪文件未删除未移动;暂存区为空。 - 真实 MySQL 副作用证据标注 **BLOCKED(Docker Engine 不可用)**,未伪装通过;H2 集成证据 5/5 已提供。 ## 21. 最终结论 > **R01-D4 GET评分快照写入迁移方案设计完成,待负责人确认后分阶段实施。** 阻塞/依赖声明:当前 GET 兜底写快照服务多个真实输入域与 6 类消费者(趋势/申诉/监控/排名/看板/导出),且评分会因时间窗口自然变化;因此严禁直接删除 GET upsert。最小可行迁移路径 = D4-1(唯一 Writer + 持久刷新任务 + Worker/补偿扫描 + GET 兜底)→ 真实 MySQL 验证并发、重试与数据源覆盖 → D4-2(GET 转 readOnly)。