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.
 
 
 

19 KiB

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 不可冻结;报告已包含下一轮定向整改指令。