轻松聘AI开发
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

25 KiB

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