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:51saveAndFlush→:56requestRefresh(saved.getCandidateId(), LocalDateTime.now())→:57notifyExpired→:58log.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),:38save与:40requestRefresh(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. 剩余风险
- fence 失效的后果有限但不为零:即使触发,最坏结果是同一次刷新多算一遍(任务回到 PENDING),不会数据损坏或永久 PROCESSING。但对 SLA 敏感场景仍属正确性瑕疵。
processing-timeout-ms无下界校验:当前仅做<= 0 → 600000兜底,1–999 的取值未被拒绝。- 任务书未要求、本轮也未执行的项:真实双 JAR 的多实例补偿扫描并发、申诉审核/回滚真实黑盒(延续 D4-2 首轮验收的 SKIP 记录,不属于本轮复验范围)。
已复现具体缺陷,R01-D4-2 不可冻结;报告已包含下一轮定向整改指令。