# 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 文本层 `""` 字面量断言 ✔ - 嵌套对象与数组:`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: `(框架提示,非项目凭据);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 不可冻结;报告已包含下一轮定向整改指令。**