轻松聘AI开发
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.
 
 
 

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_at
  • staleWorkerFailure_cannotOverwriteNewWorkerClaim —— 旧 claim 失败登记同样安静退出
  • requestDuringSameClaim_completionFallsBackToPending —— 同 claim 期间新请求 → 回到 PENDING
  • claim_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 项仍被 materialize
    • recordFailureThrowingInMiddle_doesNotAbortRemainingTasks —— 中间项失败不阻断前后
    • claimLosing_skipsTaskWithoutRecordingFailure —— claim 失败(被并发抢走)不记失败、继续下一项
  • CandidateScoringRefreshBatchIsolationTest(真实 H2 + mock 计算器):
    • firstTaskFails_secondTaskStillProcessed、failingTaskInMiddle_doesNotBlockRemainingTasks
    • lastErrorCode_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. 待最终独立增量复验项

  1. F1–F4 的修复在独立复验中重新验证(本轮为整改方自测)。
  2. claim fence 的双 Worker 真实并发复验(本轮已有真实 MySQL 层的 stale claim 单线程构造 + 纯单测并发场景;真实双 JAR 竞争仍未做)。
  3. PROCESSING 防丢失的真实 HTTP 窗口复验(沿用 D4-2 验收的方式引用真实 MySQL 并发集成测试)。
  4. 申诉审核/回滚的真实黑盒(本轮未改动申诉路径,仍属 D4-2 遗留 SKIP)。

R01-D4-2 阻断缺陷定向整改完成,待最终独立增量复验;GET 评分快照写入迁移尚未冻结。