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.
 
 
 

46 KiB

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