17 KiB
R01-D4-2 阻断缺陷定向整改报告(F1–F4 及两项次生缺陷)
日期:2026-09-16
分支:refactor/r01-structure-performance
HEAD:b10c4a732740e42353ca826f63141c3dd2a49915(整改全程未变)
来源:docs/r01-d4-2-independent-mysql-acceptance-20260916.md 判定的 4 项 FAIL + 2 项次生缺陷
整改性质:仅修复已确认缺陷,未进入其他 R01 工作
1. 结论
R01-D4-2 阻断缺陷定向整改完成,待最终独立增量复验;GET 评分快照写入迁移尚未冻结。
| 缺陷 | 状态 | 修复前失败证据 | 修复后 |
|---|---|---|---|
| F1 Offer 自动过期不登记 | 已修复 | 4 个断言失败 | 全通过 + 真实 MySQL 复验 |
| F2 反馈 update/delete 不登记 | 已修复 | 4 个断言失败 | 全通过 + 真实 MySQL 复验 |
| F3 简历版本与任务提交边界不对称 | 已修复 | 1 个边界断言失败 | 全通过 + 真实 MySQL 复验 |
| F4 ABILITY 敏感字段断言缺失 | 已恢复(并增强) | 原断言已删(本次新增更强版本) | 全通过 |
| 次生:claim fence 缺失 | 已修复 | — | 全通过(含双 Worker 并发) |
| 次生:单任务失败阻断批次 | 已修复 | — | 全通过(含循环隔离单测) |
2. 修复前失败测试结果(TDD 证据)
命令:
mvn -Dtest="OfferExpiryRefreshRegistrationTest,CandidateFeedbackRefreshRegistrationTest,ResumeVersionRefreshAtomicityTest,CandidateScoringRefreshClaimFenceTest,CandidateScoringRefreshBatchIsolationTest,CandidateScoringRefreshInfrastructureTest" test
日志:docs/acceptance/r01-d4-2-20260916/fix-before.log
| 测试方法 | 失败信息(节选) |
|---|---|
CandidateFeedbackRefreshRegistrationTest.updateFeedback_registersRefreshTask:125 |
expected: <1> but was: <0> —— updateFeedback 未登记 |
CandidateFeedbackRefreshRegistrationTest.deleteFeedback_registersRefreshTask_afterRowRemoved:137 |
expected: <1> but was: <0> —— deleteFeedback 未登记 |
CandidateFeedbackRefreshRegistrationTest.updateFeedback_ratingUnchanged_...:149 |
expected: <1> but was: <0> |
CandidateFeedbackRefreshRegistrationTest.feedbackLifecycle_...:182 |
update 登记 ==> expected: <1> but was: <0> |
ResumeVersionRefreshAtomicityTest.createVersion_outerTransactionRollsBack_...:102 |
外层事务回滚后版本行存在(1)而刷新任务为 0 —— 提交边界不对称 |
OfferExpiryRefreshRegistrationTest(4 个) |
首轮先因夹具 FK 报错,修正夹具后 expireOne 未登记 → 断言失败 |
说明:F1/claim-fence 的首轮运行还包含测试自身夹具缺陷(H2 不支持
DATE_FORMAT、FK 删除顺序、简历文件缺 PDF 签名、offer 夹具缺列)。这些已先行修正,产品缺陷证据以上表为准。
3. 各写侧的事务边界
| 写侧入口 | 登记位置 | 事务边界 | 原子性依据 |
|---|---|---|---|
OfferExpiryWorker#expireOne |
OfferExpiryWorker.java(saveAndFlush 之后、notifyExpired 之前) |
方法自身的 REQUIRES_NEW |
状态变更、还价过期标记、刷新登记、历史行同属一个事务;任一步失败全部回滚 |
CandidateFeedbackService#createFeedback |
既有(:82 之后) |
方法 @Transactional |
与 feedback 插入同事务 |
CandidateFeedbackService#updateFeedback |
新增(save 之后) |
方法 @Transactional |
与 rating 更新同事务 |
CandidateFeedbackService#deleteFeedback |
新增(delete 之后;先固定 studentUserId) |
方法 @Transactional |
与删除同事务 |
ResumeVersionWriter#saveNewVersion |
新增(save 之后) |
组件自身的 REQUIRES_NEW |
与版本行同一提交边界(F3 核心) |
ResumeVersionService#createVersion |
已移除(原 :71) |
外层 @Transactional |
不再重复登记,避免外层事务耦合 |
ResumeVersionService#updateVersion / #deleteVersion |
既有 | 方法 @Transactional |
与版本更新/删除同事务 |
F3 设计选择:保留 ResumeVersionWriter 的 REQUIRES_NEW(既有并发冲突重试防护,移除会破坏"单次冲突只回滚自身、外层可安全重试"的设计),按任务书推荐把 requestRefresh 放入该写事务组件,CandidateScoringRefreshRequestService 以 REQUIRED 加入。外层不再重复登记。
4. 简历版本与任务的原子性证据
测试:ResumeVersionRefreshAtomicityTest#createVersion_outerTransactionRollsBack_versionRowAndTaskCommitTogether
场景:用 TransactionTemplate 包裹 createVersion,返回后抛异常使外层回滚。
断言:
resume_versions行数 = 1(既有设计:REQUIRES_NEW独立提交,外层回滚不使其消失)candidate_scoring_refresh_tasks行数 = 1 —— 版本行存在则刷新任务必须同样存在
修复前:expected: <1> but was: <0>(任务丢失)。
修复后:通过。
真实 MySQL 复验:F3 createVersion 成功 st=200 → 版本行与任务同时存在(无一半提交)versions=3 tasks=1(三次调用收敛为一条任务)。
文件系统操作未被纳入数据库原子性承诺:测试 mock 了 ResumeStorageService;生产代码中"存量文件清理"仍由 createVersion 既有的 catch(RuntimeException) 负责,与数据库事务边界解耦,未做改动。
5. claim fence 的字段与 SQL 条件
所有权标识:processing_started_at(每次 claim 写入的领取时刻)。
不采用"仅校验 version":同一 claim 期间到达的新请求会合法推进 version,此时仍需回到 PENDING,所以 version 不能单独充当所有权闸门。
| 操作 | SQL 条件 |
|---|---|
claim |
WHERE id=? AND status IN ('PENDING','RETRY') AND (next_attempt_at IS NULL OR next_attempt_at <= ?);随后 SELECT ... processing_started_at 记入 ClaimedTask.claimedAt |
materialize → COMPLETED |
WHERE id=? AND status='PROCESSING' AND processing_started_at=? AND version=? |
materialize → PENDING 回退 |
WHERE id=? AND status='PROCESSING' AND processing_started_at=?(不带 version:新请求推进 version 正是回退的触发条件) |
recordFailure |
WHERE id=? AND status='PROCESSING' AND processing_started_at=? |
API 变更:ClaimedTask 新增 LocalDateTime claimedAt 字段;recordFailure(...) 新增 claimedAt 形参。
6. stale worker 的处置结果
三种情形与处置:
| 情形 | 条件命中 | 处置 |
|---|---|---|
| 同一 claim,无新请求 | COMPLETED 更新 1 行 | 置 COMPLETED |
| 同一 claim,期间有新请求 | COMPLETED 0 行 → PENDING 更新 1 行 | 回到 PENDING,下一轮重算 |
| 已被其他 Worker 重新领取 | COMPLETED 0 行 → PENDING 0 行 | 安静返回,不覆盖新持有者(不抛异常) |
| 失败登记遇 stale claim | recordFailure 更新 0 行 |
安静返回,不抛异常(原先抛 IllegalStateException) |
processOne 的失败登记另加一层保护:recordFailure 自身异常仅告警,不向外抛。
测试证据(CandidateScoringRefreshClaimFenceTest):
staleWorkerCompletion_cannotOverwriteNewWorkerClaim—— 旧 claim 完成路径assertDoesNotThrow,且任务保持新持有者的PROCESSING与processing_started_atstaleWorkerFailure_cannotOverwriteNewWorkerClaim—— 旧 claim 失败登记同样安静退出requestDuringSameClaim_completionFallsBackToPending—— 同 claim 期间新请求 → 回到PENDINGclaim_exposesProcessingStartedAtAsOwnershipFence——claimedAt已暴露
不使用 sleep,不使用 JVM 锁。
7. 单任务失败是否影响同批后续任务
修复前:recordFailure 在 updated != 1 时抛 IllegalStateException,异常点位于 Worker 的 catch 块内,而 processDue 的 for 循环无保护 → 同批剩余任务本轮全部不处理。
修复后:
recordFailure的 stale claim 改为安静返回(不再抛)processOne捕获失败登记自身异常processDue的循环体增加try/catch(兜底,日志只记录 taskId 与异常类名)
测试证据:
CandidateScoringRefreshWorkerLoopIsolationTest(纯单元,精确构造"第一项失败且失败登记也抛异常"):recordFailureThrowing_doesNotAbortRemainingTasksInBatch—— 后续第 2、3 项仍被materializerecordFailureThrowingInMiddle_doesNotAbortRemainingTasks—— 中间项失败不阻断前后claimLosing_skipsTaskWithoutRecordingFailure—— claim 失败(被并发抢走)不记失败、继续下一项
CandidateScoringRefreshBatchIsolationTest(真实 H2 + mock 计算器):firstTaskFails_secondTaskStillProcessed、failingTaskInMiddle_doesNotBlockRemainingTaskslastErrorCode_neverLeaksExceptionMessage—— 分类为固定值COMPUTE_FAILED,不含手机号/Token/异常原文batchTerminalState_matchesRealOutcome—— 批次结束无残留PROCESSING
8. 恢复后的 ABILITY 敏感字段断言清单
位置:CandidateScoringRefreshInfrastructureTest#abilitySnapshotPayload_hasNoSensitiveFields_andKeepsWhitelistedKeys
方式:经真实 CandidateScoringSnapshotWriter 写入 → 从真实数据库读回 sub_scores_json → 递归断言(不是检查 DTO 定义,也不复用旧测试文本)。
正向断言(允许字段必须存在且取值正确):
source(= V36_ABILITY_ASSESSMENT)、sourceResultId(= 123)、validUntil、questionSetVersion、assessmentRulesVersion、mappingRulesVersion、weights、dimensionScores
负向断言(递归、大小写不敏感、覆盖嵌套对象与数组) —— 以下 key 一律禁止:
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
(在任务书要求的 10 项之上,保留了原验收报告中的 answerKey/questionCode/competencyTag/stem/scoringRule/awardedScore/explanation/phone/email,并补充 mobile/token/password/accessToken)
大小写策略:key 小写化后精确匹配(避免白名单字段 questionSetVersion 因含子串 question 而误报);另有一层原始 JSON 文本层断言,禁止出现 "<banned>" 形式的 key 字面量。
9. 自动化测试数量
| 命令 | 结果 |
|---|---|
| 定向(12 个类,含 6 个新增) | 104 / 104,F=0 E=0 S=0 |
关联回归(*CandidateScoring*Test,*Scoring*Test,*Offer*Test,*Feedback*Test,*ResumeVersion*Test,*Application*Test,*Appeal*Test,*Ranking*Test,*Fairness*Test) |
558 / 558,F=0 E=0 S=0,BUILD SUCCESS |
mvn clean verify |
2163 / 2163,Failures=0、Errors=0、Skipped=0,BUILD SUCCESS(3:47) |
python docs\verify_openapi.py |
176 paths / 214 operations,All checks passed |
较整改前(2131)新增 32 个测试(6 个新测试类:Offer 过期登记 7、反馈登记 8、简历原子性 4、claim fence 6、批次隔离 4、循环隔离 3)。
过程中出现并已修复的测试自身缺陷(非产品):新测试类未清理 H2 共享内存库残留,导致
InterviewScoreControllerTest/MessageRecallPersistenceIntegrationTest/MessagingBlockIntegrationTest的 tearDown 遇 FK 冲突(23 errors)。已为全部新测试类补@AfterEach精确清理,复跑全绿。
10. 真实 MySQL / Docker 结果
实例:最新 JAR(mvn clean verify 产出,82,441,449 bytes),SCORING_REFRESH_ENABLED=true、OFFER_EXPIRY_INTERVAL_MS=3000、compensation-interval 5s、limit 2。PID 2856,验收后已停止。
docs/acceptance/r01-d4-2-20260916/v2-out.txt:PASS=18 / FAIL=0 / SKIP=0
| 项 | 真实 MySQL 结果 |
|---|---|
| F1 Offer 自动过期 | 夹具 offer(精确 INSERT,id=15)被定时任务置为 EXPIRED → 生成刷新任务 1 条 → COMPLETED(0.1s) |
| F2 create | POST /applications/88/feedback 200 → 任务 COMPLETED(0.7s) |
| F2 update | PUT /applications/88/feedback(rating 5→2)200 → 任务 COMPLETED(1.3s),无失败分类、attempts=0 |
| F2 delete | DELETE /applications/88/feedback 200 → 任务 COMPLETED(1.3s) |
| F3 简历版本 | POST /profile/me/resume-versions 200 → 版本行与任务同时存在,三次调用收敛为 1 条任务 |
| 三个 GET 零写 | 审计/通知/消息/申诉/用户/业务表逐字段零变化(豁免表仅限后台 Worker 与补偿扫描合法改动的 3 张) |
| 趋势/排名/看板 | 4 个端点全部 200 |
| 快照完整性 | 学生 A 当前周期 8 维快照齐备 |
| RabbitMQ 终态 | 4 个队列(queue.sms.verification / queue.notification / queue.notification.dlq / queue.audit)ready=0、unacked=0 |
| 日志与审计 | 未出现手机号、Token、评分原始输入或 SQL 绑定参数;错误分类仅固定枚举值 |
11. OpenAPI 数量
静态 docs/openapi-rc.json 与 docs/verify_openapi.py:176 paths / 214 operations,All checks passed! —— 与本轮整改前一致,未新增公网刷新、任务、补偿或管理接口。
12. 精确修改文件
生产代码(6 个,全部在任务书允许清单内)
| 文件 | 改动 |
|---|---|
offer/OfferExpiryWorker.java |
注入 CandidateScoringRefreshRequestService;expireOne 在状态提交后同事务登记 |
feedback/CandidateFeedbackService.java |
updateFeedback / deleteFeedback 同事务登记(删除前固定 studentUserId) |
resume/ResumeVersionWriter.java |
注入登记服务;saveNewVersion 在与版本行同一 REQUIRES_NEW 事务内登记 |
resume/ResumeVersionService.java |
移除 createVersion 中的重复登记(登记已移入 Writer) |
candidatescore/refresh/CandidateScoringRefreshTaskExecutor.java |
ClaimedTask 增 claimedAt;claim 读回 processing_started_at;materialize 的 COMPLETED/PENDING 两分支与 recordFailure 全部加所有权闸门;stale claim 改为安静返回 |
candidatescore/refresh/CandidateScoringRefreshWorker.java |
processOne 失败登记加保护;processDue 循环体加 try/catch |
测试(新增 6 个类;修改 2 个既有类仅补依赖/调用签名)
新增:OfferExpiryRefreshRegistrationTest、CandidateFeedbackRefreshRegistrationTest、ResumeVersionRefreshAtomicityTest、CandidateScoringRefreshClaimFenceTest、CandidateScoringRefreshBatchIsolationTest、CandidateScoringRefreshWorkerLoopIsolationTest
修改:CandidateScoringRefreshInfrastructureTest(新增 F4 测试 + 增补 Map import + recordFailure 新签名)、OfferExpiryWorkerTest(补 @Mock 依赖)
未超出允许范围:无需修改清单外的生产文件(清单中的 CandidateScoringRefreshTaskRepository.java 在项目内不存在 —— D4-1 采用 JdbcTemplate 而非 JPA Repository,属清单笔误,无需报告偏离)。
13. 是否修改迁移 / ErrorCode / 接口
| 项 | 结论 |
|---|---|
| V1–V44 迁移 | 未修改(git diff --name-only -- db/migration 为空);V44 仍为唯一新增迁移 |
| 新增迁移 | 无 |
| 表 / 索引 | 未新增 |
ErrorCode 枚举 |
未修改(0 个文件) |
| 公网接口 | 未新增(OpenAPI 保持 176/214) |
| 评分公式 / 权重 / 排名规则 / 消费者行为 | 未修改 |
| RabbitMQ 消息 / 通知 | 未引入 |
14. Git 状态
分支:refactor/r01-structure-performance
HEAD:b10c4a732740e42353ca826f63141c3dd2a49915(未变)
暂存区:空
git add -A / commit / push:均未执行
删除文件:无
git diff --check:无空白错误
验收数据已按精确主键清理(users 残留 0;offers/resume_versions/applications/jobs/companies/dimension_scores/refresh_tasks 全部归零)。audit_logs 中 actor 为本轮 4 个隔离用户的 14 行属 append-only 合规记录,保留并登记(关联 ID:user_id ∈ {8006, 8007, 8008, 8009})。
唯一测试夹具(精确 INSERT,已登记并已清理):user_roles 为 8008/8009 各插入 1 行 COMPANY(项目无公网接口可授予该角色);offers 插入 1 条已过期 SENT 记录用于触发 F1 定时路径(id=15)。
15. 明确声明
- 未暂存、未提交、未推送。
- 未进入其他 R01 工作。
- 未修改评分公式、权重、状态、规则版本、排名、申诉或公平性语义。
- 已按要求「先写失败测试 → 保存修复前失败结果 → 再改生产代码」,失败证据见
fix-before.log。
16. 待最终独立增量复验项
- F1–F4 的修复在独立复验中重新验证(本轮为整改方自测)。
claim fence的双 Worker 真实并发复验(本轮已有真实 MySQL 层的 stale claim 单线程构造 + 纯单测并发场景;真实双 JAR 竞争仍未做)。- PROCESSING 防丢失的真实 HTTP 窗口复验(沿用 D4-2 验收的方式引用真实 MySQL 并发集成测试)。
- 申诉审核/回滚的真实黑盒(本轮未改动申诉路径,仍属 D4-2 遗留 SKIP)。
R01-D4-2 阻断缺陷定向整改完成,待最终独立增量复验;GET 评分快照写入迁移尚未冻结。