# R01-D4-2「GET 评分接口停写与快照可靠物化」独立代码复验与真实 MySQL/Docker/JAR/HTTP 黑盒验收 日期:2026-09-16 分支:`refactor/r01-structure-performance` HEAD:`b10c4a7 docs: design scoring snapshot materialization` 验收性质:**最终独立验收**(非开发)。审查正式生产路径 + 项目既有 Docker Compose + 真实 MySQL 8.4 + 最新构建 JAR + 真实 HTTP。 验收前缀:`R01D42_ACC_20260916_K4GDNT` --- ## 0. 最终结论 > **已复现具体缺陷,R01-D4-2 不可冻结;报告已包含下一轮定向整改指令。** 判定为 **FAIL**。触发任务书第二十节 FAIL 判据中的第 4 条「**写侧成功但任务未登记**」,并另有一处测试断言弱化(触碰「禁止修改或放宽既有测试」红线)。 同时必须明确记录:**本次验收的核心目标项全部实测通过** —— 三个评分 GET 在真实 HTTP 下确凿零写、V44 在真实 MySQL 成功、`clean verify` 2131/2131 全绿、补偿扫描游标与预算正确、趋势/排名/看板/申诉/公平性监控均未退化、RabbitMQ 终态正常。FAIL 由**写侧事件覆盖不全**这一独立缺陷触发,与 GET 停写本身无关。 --- ## 1. Git 基线与工作区状态 | 检查项 | 结果 | |---|---| | 分支 | `refactor/r01-structure-performance` ✔ | | HEAD | `b10c4a7`(验收全程未变) | | 暂存区 | 空(`git diff --cached --name-only` 无输出)✔ | | `git add -A` / `commit` / `push` | 均未执行 ✔ | | V1–V43 修改 | 无(`git diff --name-only -- db/migration` 为空)✔ | | V44 | `V44__candidate_scoring_refresh_tasks.sql` 为唯一新增迁移 ✔ | | 删除文件 | 无 ✔ | | 历史未跟踪脚本/日志/JSON/SQL/xlsx/.env | 全部保留,未删未移 ✔ | | `git diff --check` | 无空白错误(仅既有 LF/CRLF 提示)✔ | | `AGENTS.md` | 全仓搜索**未发现** | 本轮新增(未跟踪):`docs/acceptance/r01-d4-2-20260916/`(验收脚本与脱敏证据)、`docs/r01-d4-2-independent-mysql-acceptance-20260916.md`(本报告)。未修改任何 D4-1/D4-2 生产代码、未修改 V1–V44、未新增 Flyway/表/索引/ErrorCode/公网接口。 --- ## 2. 独立代码审查 A1–A6 方法:9 个并行静态审查维度 + 对每条 critical/major 发现的对抗性复核(20 个独立子代理,全部复核结论为「发现成立」),另由本人逐条打开源码亲自确认关键项。 ### A1 三个 GET 是否真正只读 —— **通过** - `CandidateScoringEngineService.java:52 / :61 / :106` 三个方法均为 `@Transactional(readOnly = true)`。 - 三个方法体内仅含 `LocalDateTime.now(clock)` + `calculator.computeAll(...)` + 只读仓储/`JobService.getJob` 查询 + DTO 组装,无任何写语句。 - `git diff HEAD -- .../CandidateScoringEngineService.java` 全部改动仅为 3 处注解变更 + 删除 3 行 `calculator.upsertSnapshots(...)`。 - `CandidateScoringCalculator.computeAll`(`:122-146`)只含 `findById` + 6 个纯 SELECT(`queryApps/queryAudit/queryOffers/queryFeedback/queryResume/queryMessages`),无写路径;全 main 源码中 `upsertSnapshots` 已无生产调用方。 - `explanation/` 包 grep `jdbc|Repository|EntityManager|@Transactional|save(` 无命中。 - 响应仍来自实时 `computeAll`;`status`(`CandidateOverviewStatusPolicy`)、`weight`(`DEFAULT_WEIGHT = 12.50`)、`rulesVersion`(`currentRulesVersion()`)与 D4-1 前逐字节一致。 - 企业授权校验(company→job 归属)保留,越权负例实测 403。 遗留(非缺陷,记录):`CandidateScoringCalculator.java:582` javadoc 仍写「普通物化入口(含 GET 兜底)」,与 GET 兜底已删除的现状不符(注释陈旧)。 ### A2 写侧登记覆盖 —— **不通过(存在两处真实遗漏)** 任务书列出的 8 个服务方法**全部正确接入**,且均为同事务 `REQUIRED` 登记: | 服务.方法 | 登记位置 | |---|---| | `ProfileService#updateProfile` | `ProfileService.java:75` | | `ResumeVersionService#createVersion / updateVersion / deleteVersion` | `ResumeVersionService.java:71 / :163 / :189` | | `PreferenceService#savePreferences` | `PreferenceService.java:76` | | `AbilityAttemptService#submit` | `AbilityAttemptService.java:294` | | `ApplicationService#apply / withdraw / updateStatus` | `ApplicationService.java:90 / :129 / :224` | | `OfferService#transitionStatus` | `OfferService.java:304` | | `CandidateFeedbackService#createFeedback` | `CandidateFeedbackService.java:82` | | `MessagingService#sendMessage`(仅 STUDENT) | `MessagingService.java:197-200` | 角色判定核实:`MessagingService.java:197-200` 用 `u.getRoles().stream().anyMatch(r -> r == User.Role.STUDENT)`,只有学生发送者登记,企业发送者不登记 ✔ **遗漏一:Offer 自动过期不登记(major,命中 FAIL 判据)** - `OfferExpiryWorker.java:41 expireOne(...)` 在 `:48` 直接 `offer.setStatus(OfferStatus.EXPIRED.name())` 后 `saveAndFlush`,**全文 `requestRefresh`/`scoringRefresh` 出现次数 = 0**。 - 调用方为定时任务 `OfferExpiryService.java:34 worker.expireOne(...)`,不经过 `OfferService#transitionStatus`。 - 同一行 `:59` 还调用 `counterOfferRepository.expirePendingByOfferId(...)`,`:68 notifyExpired(saved)`。 - 该写入**直接影响评分输入**:`CandidateScoringCalculator.java:188-194 queryOffers` 的 SQL 为 `SELECT SUM(CASE WHEN status='ACCEPTED' THEN 1 ELSE 0 END), SUM(CASE WHEN status='EXPIRED' THEN 1 ELSE 0 END) FROM offers WHERE candidate_id = ?`,`EXPIRED` 计数参与 INTEGRITY 维度。 - `docs/r01-d4-get-scoring-snapshot-migration-design.md:105` 明确要求「Offer 状态变化 → Offer 状态机成功提交后 → 标记待刷新」,而 D4-1 报告「明确未接入」清单(§12,`:43`)**并未**把 Offer 自动过期列为有意不接入项 —— 属实现遗漏。 **遗漏二:候选人反馈的修改/删除不登记(major,命中 FAIL 判据)** - `CandidateFeedbackService.java:107 updateFeedback(...)` 与 `:124 deleteFeedback(...)` 均未调用 `requestRefresh`;仅 `:82 createFeedback` 登记。 - 评分读取该表:`CandidateScoringCalculator.java:196-200 queryFeedback` 的 SQL 为 `SELECT COUNT(*), COALESCE(AVG(rating),0) FROM candidate_feedback WHERE student_user_id = ?`。 - 修改 `rating` 会改变 `AVG`,删除反馈会同时改变 `COUNT` 与 `AVG`,两者都不触发刷新。 - 设计文档 `:106` 明确要求「候选人反馈创建/**修改**的真实写入口」触发刷新 —— 属实现遗漏。 **遗漏三:`ResumeVersionService.createVersion` 的事务边界不对称(major)** - `ResumeVersionService.java:71` 的登记位于 `createVersion` 的 `@Transactional` 内(REQUIRED,加入外层事务); - 但版本行由 `versionWriter.saveNewVersion(...)`(`@Transactional(REQUIRES_NEW)`,文件内 javadoc `:52-55` 自述「写入成功即独立提交,外层事务回滚不会让版本行消失」)**独立提交**; - 因此存在窗口:版本行已提交、外层事务在提交阶段失败 → **业务数据已落库但刷新任务登记丢失**。 其余 A2 结论:登记不触发线程内重算、不发 RabbitMQ、不写通知、不写审计、不记录敏感值 —— 全部成立。 ### A3 唯一快照 Writer —— **通过** - `CandidateScoringSnapshotWriter.java:105-112`:`score / status / sub_scores_json / weight / rules_version` 全部相同则 early return,**不改 version 与 updated_at** ✔ - `calculated_at` 不参与变化判定 ✔ - `rules_version` 参与判定,变化必推进 version ✔ - `canonicalJson`(`:183-214`)对 ObjectNode 递归按字典序重排、对 ArrayNode 逐元素递归 ✔ - 并发安全:`:91-98` 先 `INSERT ... ON DUPLICATE KEY UPDATE id=id`,`:126-137` 再 `SELECT ... FOR UPDATE`,`:113-119` 条件更新 `WHERE id=? AND version=?`,冲突抛 `OptimisticLockingFailureException` —— 无 JVM 锁 ✔ - Governance 强制路径 `:66-69 upsertForcingVersion` → `:75-88` 无条件 `version=version+1`;调用方 `CandidateScoringGovernanceService.java:294` 保持 `afterVersion = beforeVersion + 1` 语义 ✔ - 普通物化不写 `candidate_scoring_snapshot_history` ✔(黑盒已证,见 §10) - 单维写失败整体回滚:`upsertIfChanged`(`:51-60`)为单一 `@Transactional` 循环,`CandidateScoringRefreshTaskExecutor.materialize` 同为单个 `REQUIRES_NEW` —— 无部分提交 ✔ ### A4 Worker 与失败恢复 —— **不通过(两处独立缺陷)** - `claim`(`:43-62`)、`materialize`(`:64-89`)、`recordFailure`(`:91-108`)、`recoverStale`(`:29-41`)分属 4 个独立 `REQUIRES_NEW` 事务 ✔ - 并发领取唯一:`claim` 的 `WHERE id=? AND status IN ('PENDING','RETRY') AND (next_attempt_at IS NULL OR next_attempt_at <= ?)` + `updated != 1 → null` ✔(真实 MySQL 集成测试亦覆盖) - `last_error_code` 只写固定分类(`WRITE_FAILED`/`COMPUTE_FAILED`/`UNKNOWN_REFRESH_FAILURE`/`WORKER_TIMEOUT`),`:82` 的 `log.warn` 只记录 taskId 与 category ✔ - PROCESSING 超时回收、达上限转 FAILED ✔ **缺陷一:`materialize` 的「回到 PENDING」分支缺 version 闸门(major)** `CandidateScoringRefreshTaskExecutor.java:78-88`: ``` UPDATE ... SET status='COMPLETED' ... WHERE id=? AND status='PROCESSING' AND version=? -- 有闸门 if (completed == 0) { UPDATE ... SET status='PENDING' ... WHERE id=? AND status='PROCESSING' -- 无闸门 ``` 当原 Worker 因超时已被 `recoverStale` 置为 RETRY/FAILED 并被另一个 Worker 重新领取(处于 PROCESSING)后,慢的原 Worker 返回时 `version` 不匹配走 PENDING 分支,会**把新 Worker 正在处理的任务改回 PENDING**;随后新 Worker 完成时 `completed=0`、`pending=0` → 抛 `IllegalStateException`。 **缺陷二:失败记录异常会打断同批任务(major)** `CandidateScoringRefreshWorker.java:77-86` 的 `catch (Exception e)` 块内调用 `taskExecutor.recordFailure(...)`;该方法在 `updated != 1` 时抛 `IllegalStateException`(`TaskExecutor.java:105-107`)。异常在 catch 块内抛出不会被自身捕获,将传播到 `processDue` 的 `for (Long id : due)` 循环(`Worker.java:63-67`,**循环体内无 try/catch**),导致**同批剩余任务本轮全部不被处理**,违反 `Worker.java:24` javadoc 声称的「异常不回抛,不中断其他任务」。 触发前提为物化耗时超过 `processing-timeout-ms`(默认 600000ms),概率低但路径真实。 ### A5 补偿扫描 —— **通过** - 游标持久化于 `candidate_scoring_refresh_scan_checkpoints`(`:79-85 lockCursor` 用 `SELECT ... FOR UPDATE`),非内存字段 ✔ - 单轮预算:`:48` `budget = max(1, min(limit, 500))`,`:98` `LIMIT ?` 绑定同一 `budget` ✔ - 尾部回绕:`:59` `ids.size() < budget || ids.isEmpty() ? 0 : 末位 id`;`:52-55` 游标已在尾部且空结果时重置 0 重查 ✔ - 游标推进与任务登记同事务:`scanDueStudents` 为 `@Transactional`,`requestService.requestRefresh` 为 `REQUIRED` ✔ - 多实例串行:checkpoint 行 `FOR UPDATE` ✔ - 查询预算:1 次聚合查询(`findEligibleAfter`)+ ≤budget 次 upsert,无逐用户 N+1 ✔ - 不直接写 `candidate_dimension_scores` ✔ ### A6 配置 —— **通过(无断档)** - `application.yml:156` `enabled: ${SCORING_REFRESH_ENABLED:true}`,`:158` `compensation-enabled: ${SCORING_REFRESH_COMPENSATION_ENABLED:true}` —— **生产默认启用** ✔ - `application-test.yml:23-26` / `application-integration-test.yml:37-40` / `application-mysql-it.yml:26-29` 三处均显式 `enabled: false` + `compensation-enabled: false` ✔ - 不依赖测试 profile 才能工作 ✔;Scheduler `@Value` 的 `:false` fallback 仅在该键完全缺失时生效,yml 提供 `true` 时实际生效值为 `true`(黑盒已验证补偿扫描确实运行)✔ 遗留(记录):`CandidateScoringRefreshScheduler.java:15` javadoc 仍写「默认关闭」,与现状相反。 --- ## 3. Docker / MySQL / Redis / RabbitMQ 版本与状态 | 组件 | 版本 | 地址 | 验收前 | 验收中/后 | |---|---|---|---|---| | Docker Engine | 29.6.2 | — | 已启动 | 未停止 ✔ | | MySQL | **8.4.11** | 127.0.0.1:3307 | Exited(137) | healthy ✔ | | Redis | 7.4-alpine | 127.0.0.1:6379 | Exited(0) | healthy ✔ | | RabbitMQ | 3.13-management-alpine | 127.0.0.1:5672 / 15672 | Exited(137) | healthy ✔ | 启动方式:`docker compose --env-file backend-infra/.env -f backend-infra/compose.yaml up -d`(仅使用项目既有 Compose)。未执行 `down -v`、未删除/重建 volume、未修改 `compose.yaml`、未新建业务数据库、未打印凭据。 volume 清单(`backend-infra_mysql_data` / `_redis_data` / `_rabbitmq_data`)与验收前一致,未删除。 --- ## 4. V44 Flyway 与 schema validation(真实 MySQL) 启动最新 JAR 前:`flyway_schema_history` 中 `max(version)=43`、`COUNT(*)=43`、V44 行数 0、V44 表不存在 —— 与「尚未执行 V44」的基线预期一致。 启动后: | 项 | 值 | |---|---| | `version` | **44** ✔ | | `description` | `candidate scoring refresh tasks` | | `type` | `SQL` ✔ | | `success` | **1** ✔ | | V44 行数 | **1**(唯一)✔ | | `success=0` 的迁移数 | **0** ✔ | | `max_version` / 总迁移数 | 44 / 44 ✔ | 启动日志:`Started MyDreamjobApplication in 15.425 seconds`;主 profile 为 `default`;`ddl-auto=validate` 通过,**无 schema validation / SQL 语法 / Bean 装配错误**。 表结构(`SHOW COLUMNS`/`SHOW INDEX`): - `candidate_scoring_refresh_tasks`:13 列 `id, student_user_id, target_period(7), status(20), requested_at, processing_started_at, processed_at, next_attempt_at, attempts, last_error_code(40), version, created_at, updated_at` ✔ 状态/重试/租约/version/时间字段齐全 - 唯一键 `uk_refresh_task_student_period (student_user_id, target_period)` ✔ - 领取索引 `idx_refresh_task_claim (status, next_attempt_at)` ✔ - 约束齐全:`PRIMARY`、`uk_refresh_task_student_period`(UNIQUE)、`fk_refresh_task_student → users(id) ON DELETE CASCADE`、`ck_refresh_task_status CHECK (status IN ('PENDING','PROCESSING','RETRY','COMPLETED','FAILED'))` ✔ - `candidate_scoring_refresh_scan_checkpoints`(`scan_key` PK, `last_user_id`, `version`, `updated_at`)存在 ✔ V1–V43 全程未修改 ✔ --- ## 5. 最新 JAR 启动与健康检查 - JAR:`mydreamjob-server/target/mydreamjob-server-0.0.1-SNAPSHOT.jar`(`mvn clean verify` 产出,82,440,778 bytes) - 启动方式:`docs/acceptance/r01-d4-2-20260916/start-app.ps1`,显式提供 `DB_URL=127.0.0.1:3307`、`REDIS_HOST/PORT`、`RABBITMQ_*`、`SMS_MOCK_ENABLED=true`、`SCORING_REFRESH_*`;未修改 `application.yml` - 验收前 8080/8081 空闲;验收后已全部释放(无 java 进程残留) - `GET http://127.0.0.1:8080/api/v1/health` → **200** ✔;`http://127.0.0.1:8081/actuator/health` → **200** ✔ - 启动耗时 14.9–15.4s,Flyway 成功,validate 成功 **实例使用(方案 A)**:三个实例互斥启动、PID 各自记录: | 用途 | PID | REFRESH | 说明 | |---|---|---|---| | 只读验证 #1 | 24624(失败,见下) / 22640(dev profile 失败) / **12656** | false | 三个目标 GET 零写验证 | | 物化验证 | **23436** | true | 写侧链路 + 补偿扫描 | | 只读验证 #2 | **28456** | false | 消费者回归 + 零写 | | 重启验证 | **6576** | true | 跨重启游标延续 | 启动调试记录(自我暴露,如实记录):首次启动因 `application-dev.yml` 硬编码短密钥覆盖环境变量而失败;修正为默认 profile 后又因**脚本以 UTF-8 无 BOM 保存、PowerShell 5.1 按 ANSI 解码中文注释导致换行被吞、`JWT_ACCESS_SECRET` 赋值行被并入注释**(诊断值为 `ACCESS_LEN=0`)而失败;加 BOM 后 `ACCESS_LEN=48` 正常启动。此为验收脚本自身缺陷,与产品无关。 --- ## 6. 自动化实际测试数 | 命令 | 结果 | |---|---| | 定向批次一(8 个 D4 核心类) | **55 / 55**,F=0 E=0 **S=0**,BUILD SUCCESS(49.3s) | | 定向批次二(`*CandidateScoring*Test,*Competition*Test,*ScoringDashboard*Test,*FairnessMonitoring*Test`) | **253 / 253**,F=0 E=0 **S=0**,BUILD SUCCESS | | `mvn clean verify` | **2131 / 2131**,Failures=0、Errors=0、**Skipped=0**,BUILD SUCCESS(4:08,211 个测试类) | | `python docs\verify_openapi.py` | **paths: 176 / operations: 214**,All checks passed ✔ | 批次一逐类:`CandidateScoringRefreshInfrastructureTest` 13/13、`CandidateScoringRefreshFailureTransactionTest` 3/3、**`CandidateScoringRefreshMySqlIntegrationTest` 6/6(20.13s,真实 MySQL Testcontainers)**、`CandidateScoringEngineServiceTest` 14/14、`R01D4GetWritesSnapshotEvidenceTest` 5/5、`CandidateScoringPersistenceIntegrationTest` 3/3(17.51s)、`V38AbilityRankingIntegrationTest` 1/1、`CandidateScoringClockConsistencyTest` 10/10。 **Q01 与 V44 的 MySQL Testcontainers 用例真实执行**(耗时 20.1s / 17.5s 表明容器真实启动),Docker 全程可用,**无因环境缺失导致的 skip**。 --- ## 7. 三个目标 GET 与趋势 GET 的真实 HTTP 结果 前提:被测实例 `SCORING_REFRESH_ENABLED=false`(后台 Worker 与补偿扫描关闭),因此任何表变化都只能来自 HTTP 请求本身。 每个接口连续调用 **3 次**,共 12 次真实 HTTP: | 接口 | HTTP | 契约 | 敏感字段 | |---|---|---|---| | `GET /api/v1/profile/me/candidate-scoring` ×3 | 200,200,200 | 稳定(10 字段) | 无泄露 | | `GET /api/v1/profile/me/candidate-scoring/dimensions/EDUCATION` ×3 | 200,200,200 | 稳定(17 字段) | 无泄露 | | `GET /api/v1/company/candidates/86/scoring` ×3 | 200,200,200 | 稳定(6 字段) | 无泄露 | | `GET /api/v1/profile/me/candidate-scoring/trend?months=6` ×3 | 200,200,200 | 稳定(3 字段) | 无泄露 | - 响应不含 `phone` / `email` / `resumeUrl` / `storagePath` / `accessToken` / `refreshToken` / `passwordHash`,正则扫描无 11 位手机号。 - **S1 零写验收:PASS=24 / FAIL=0 / SKIP=0** --- ## 8. GET 前后逐字段数据库快照 快照覆盖 **22 张表**(显式列、稳定 `ORDER BY` 全列、逐行比对)。敏感列(手机号/邮箱/Token 摘要/文件路径/IP/UA/幂等键)以 `SHA2(...,256)` 参与比对以保持逐行可区分且不落真实值;非敏感列保留原始值。列不存在时 SQL 直接报错(不 catch)。 **结果:22 张表逐字段零变化** ✔ | 关键表 | 行数 | 结果 | |---|---|---| | `candidate_dimension_scores` | 88 | 零变化 ✔ | | `candidate_scoring_refresh_tasks` | 2 | 零变化 ✔ | | `candidate_scoring_refresh_scan_checkpoints` | 0 | 零变化 ✔ | | `audit_logs` | 2620 | 零变化 ✔ | | `notifications` | 158 | 零变化 ✔ | | `messages` | 8 | 零变化 ✔ | | `candidate_scoring_snapshot_history` | 4 | 零变化 ✔ | **最强证据**:该只读实例下,验收学生 A(userId 8002)的 `candidate_dimension_scores` 在多次 GET 后仍为 **0 行** —— 若 D4-2 前行为(GET 兜底写快照)仍存在,此处必为 8 行。同时任务表存在 1 行 `PENDING` 任务,证明写侧登记已发生但后台物化被正确关闭。 **反证(扫描器有效性)**:`snap.selftest()` 对内存中的一行仅改动单个字段,扫描器正确检出 `[('1', [('b','y','Z')])]` → PASS。 --- ## 9. 写侧事件 → 任务 → Worker → 快照的真实链路 前提:物化实例 `SCORING_REFRESH_ENABLED=true`,`worker-interval=1500ms`。 **S2 结果:PASS=14 / FAIL=0 / SKIP=0** | 项 | 实测 | |---|---| | W1 `PUT /profile/me`(资料更新) | st=200;任务恰好 1 条 `(8002, 2026-09)`;**Worker 0.7s 处理为 COMPLETED**;`last_error_code` 为空;**八维快照齐备(8 行)**;`rulesVersion` 全部 `candidate-scoring-v0.1` | | 同事务登记 | 请求成功后立即出现任务行,无需等待 | | W2 `PUT /profile/me/preferences` | st=200 → COMPLETED → 八维仍 8 行(**无部分写**) | | W3 `POST /applications` | st=409(本轮已投递,幂等)→ 仍合并为 1 条任务 | | 重复同一事件 ×3 | 仍只有 1 条任务,`requested_at` 推进不新增 | | **完全相同输入重放** | 八维 version 全等(`ABILITY:0, ACTIVITY:0, COMPLIANCE:1, EDUCATION:1, GROWTH:0, INTEGRITY:0, INTERNSHIP:0, STABILITY:0`)→ **version 不推进** ✔ | | `candidate_scoring_snapshot_history` | 4 → 4 **零变化**(普通 Worker 不写历史)✔ | | `notifications` | 158 → 158 零变化 ✔ | | `audit_logs` | 2620 → 2620 零变化 ✔ | | `messages` | 8 → 8 零变化 ✔ | **PROCESSING 防丢失**:本项在真实 HTTP 下难以稳定卡住窗口,按任务书允许的方式引用真实 MySQL 并发集成测试 —— `CandidateScoringRefreshInfrastructureTest#requestDuringProcessingIsNotLost` 走真实完成路径 `taskExecutor.claim` → 处理期间新请求 → `taskExecutor.materialize`,断言回到 `PENDING` 而非 `COMPLETED`;`CandidateScoringRefreshMySqlIntegrationTest` 6/6 在真实 MySQL 上覆盖重登记与租约恢复。写侧正常主链路(W1/W2/W3)**未跳过**。 **源业务失败不留残留任务**:`CandidateScoringRefreshFailureTransactionTest` 3/3 覆盖外层业务回滚时登记一并回滚 ✔(该项为 H2/集成层证据;A2 中 `ResumeVersionService.createVersion` 的独立提交边界为静态代码发现,见 §2)。 --- ## 10. 重复登记与幂等 version - 同学生同周期重复登记合并为一行:`:56-71` 的 `ON DUPLICATE KEY UPDATE` 只推进 `requested_at`、`version`,不新增行 ✔(黑盒 W1b 实测 1 行) - `COMPLETED`/`FAILED` 重新打开为 `PENDING` 且 `attempts` 归零;`RETRY` 回到 `PENDING` 保留 `attempts` ✔(`CandidateScoringRefreshInfrastructureTest#completedTaskCanBeRequestedAgain`) - `PROCESSING` 期间新事件不丢失:登记保留 `status='PROCESSING'` 并推进任务 `version`;完成时 `version` 不匹配即回退 `PENDING` ✔ - **幂等物化**:普通物化值完全不变时 `version` 与 `updated_at` 不变(黑盒 W1b 确证)✔ --- ## 11. 租约、重试与失败分类 - `recoverStale`(`TaskExecutor.java:29-41`):`WHERE status='PROCESSING' AND processing_started_at < ?`,置 `RETRY`(或达上限 `FAILED`)、`attempts+1`、`last_error_code='WORKER_TIMEOUT'`、清空 `processing_started_at` ✔ - `recordFailure`(`:91-108`):`attempts+1 >= maxAttempts → FAILED` 否则 `RETRY` + `next_attempt_at` 退避 ✔ - 错误分类为固定字符串,不含异常原文/SQL/堆栈/手机号 ✔ - 调度兜底:`CandidateScoringRefreshScheduler.java:49-57 / :66-70` 各自 try/catch,单轮失败不影响后续轮次 ✔ - 真实 MySQL 证据:`CandidateScoringRefreshMySqlIntegrationTest` 6/6(含租约恢复、部分写失败全回滚且同批成功任务不受影响) **但**:§2-A4 的两处缺陷(PENDING 分支缺 version 闸门、`recordFailure` 抛出打断 for 循环)表明「同批兄弟任务互不影响」在租约竞争边界下**不成立**。 --- ## 12. 补偿游标、跨重启、单轮预算、多实例 参数:`compensation-limit=2`,`compensation-interval=4000ms`。 | 验证项 | 实测证据 | |---|---| | 单轮登记数 ≤ limit | 连续 12 轮 `created_at` 分组计数**全部为 2**,无一超标 ✔ | | 游标持久推进 | `last_user_id` 113 → 126 → 130 → 134 → 138 → 142 → 146…,`version` 每轮 +1 ✔ | | **不会永远只扫首批用户** | 游标推进至 7948 → 7954 → 7963 → 7967 → 7973 → **8002**(接近学生最大 id 8005)✔ | | **扫到尾部后从头继续** | 8002 之后下一轮 `last_user_id` 回到 **15** → 20 … ✔ | | **跨重启从持久游标继续** | 重启前 `cursor=113 / version=104`;重启后首轮 `cursor=126 / version=105`(version 恰好 +1)—— **未回到 0** ✔ | | 只读实例不扫描 | `SCORING_REFRESH_ENABLED=false` 下 `cursor=113/104` 12 秒内完全冻结 ✔ | | 多实例并发不重复推进 | 代码层 `lockCursor` 的 `SELECT ... FOR UPDATE` 串行化 + 游标推进与登记同事务 ✔(**未做双 JAR 黑盒**,见 §23 SKIP) | | 每轮查询预算有上限 | 1 次聚合查询 + ≤2 次 upsert,无 N+1 ✔ | | 不直接写快照 | 扫描器文件内无 `candidate_dimension_scores` 写入 ✔ | 任务终态:`candidate_scoring_refresh_tasks` 共 181 行,**181/181 全部 COMPLETED**,无 PENDING / RETRY / FAILED / 永久 PROCESSING ✔ --- ## 13. 趋势回归 - `GET /profile/me/candidate-scoring/trend?months=6` → 200;当前周期 `2026-09` 存在;`overallScore=65.2`;`rulesVersion=candidate-scoring-v0.1`;维度数 5(趋势按既有语义只收录 `OK`/`ESTIMATED` 维度,非 8 —— 与 `CandidateScoringEngineService.java:91` 的过滤逻辑一致) - 多次调用结果**逐字节稳定** ✔ - **请求本身零写**(逐字段比对)✔ - 能读到后台物化的当前周期快照 ✔(该数据即由 §9 的 Worker 物化产生) --- ## 14. 排名回归 - `GET /jobs/47/competition-ranking` → 200;`rank=1`、`cohortSize=2`、`overallScore=65.2`、`percentile=100.0`、`asOfPeriod=2026-09` - 验收学生(8002)与对照学生(8003)**各有 8 维快照**,排名**未因 GET 停写而缺失候选人** ✔ - 快照经真实物化入口产生,未直接插入伪造 ✔ - 只读 ✔ --- ## 15. 企业看板回归 | 接口 | 结果 | |---|---| | `GET /company/scoring/dashboard/overview` | 200;`companyId=27, totalJobs=1, publishedJobs=1, totalCandidates=2, avgCandidateScore=61.7` | | `GET .../job-rankings?page=0&size=20` | 200;含验收岗位 47 | | `GET .../candidate-rankings?page=0&size=20` | 200;`applicationId=86 → overallScore=65.2, percentile=100.0`;`applicationId=87` 亦在列 | | `GET .../trend?months=6` | 200;`2026-09, avgCandidateScore=61.7, candidateCount=2` | - 数据来自物化快照 ✔;多次 GET **零写** ✔ - 越权负例:企业 B 访问企业 A 的投递评分 → **403** ✔ --- ## 16. 申诉与强制 version 回归 - 学生创建隔离申诉 → 200;`appealId=4289`;`dimension=EDUCATION`、`period=2026-09`、`status=PENDING` - **`scoreAtAppeal=76.00`、`snapshotVersionAtAppeal=1` 均非空** ✔ —— 关键:D4-2 停写后申诉取证仍能取到快照,因为写侧登记 + Worker 已先行物化 - `GET /profile/me/candidate-scoring/appeals`(我的列表)与 `/appeals/4289`(详情)均 200 ✔ - `GET /admin/candidate-scoring/appeals` → 200,`total=8`(历史 7 条 + 本轮 1 条)✔ - 申诉副作用核对:相对上一快照仅 `candidate_scoring_appeals +1`、`candidate_scoring_appeal_history +1`、`audit_logs +1`,**其余表零变化**(含 `candidate_dimension_scores`、`notifications`、`messages`、`snapshot_history`、`refresh_tasks`)✔ - **未执行**审核/回滚(避免改动非本轮治理数据),因此「rollback 后强制 version 恰好 +1」本项**未做黑盒**,改由 `CandidateScoringCalculatorTest#recomputeAndSnapshot_*` + `CandidateScoringGovernancePersistenceIntegrationTest`(第二批 253/253 内)覆盖,静态确认 `CandidateScoringSnapshotWriter.java:75-88` 无条件 `version=version+1`。见 §23 SKIP。 - 普通 Worker 物化不写 `snapshot_history` ✔(黑盒 4 → 4) --- ## 17. 公平性监控回归 以 ADMIN(复用既有活跃管理员 userId 91,**未恢复 V1_1 旧演示账号 id=1**)调用: | 接口 | 结果 | |---|---| | `GET /admin/scoring/fairness/overview?period=2026-09` | 200,`basis=CURRENT_STORED_SNAPSHOTS`、`scope=SCORED_SNAPSHOT_POPULATION` | | `.../dimensions?period=2026-09` | 200 | | `.../drift` | 200,`availability=UNAVAILABLE, reason=IMMUTABLE_SCORE_HISTORY_NOT_AVAILABLE`(既有抑制语义保留) | | `.../ranking-stability` | 200,`availability=UNAVAILABLE, reason=RANKING_SNAPSHOT_HISTORY_NOT_AVAILABLE`(既有抑制语义保留) | | `.../appeals` | 200,`basis=APPEAL_CREATION_QUEUE_CURRENT_STATE` | - 小样本抑制与互补风险抑制保持(以上 `UNAVAILABLE` 即为抑制输出)✔ - 不泄露候选人身份(响应扫描无 `subjectUserId`/手机号/邮箱)✔ - 当前快照聚合可用 ✔;GET **零写** ✔ --- ## 18. 数据库副作用 **允许的变化(全部落入任务书允许范围)**: 1. 本轮唯一前缀夹具(创建后已按主键清理,见 §22) 2. 写侧业务请求按契约产生的行(`student_profiles`、`student_preferences`、`applications`、`jobs`、`companies`、`notifications`) 3. **刷新任务与候选人快照**:`candidate_scoring_refresh_tasks` 共 181 行(其中 4 行为本轮学生,其余为补偿扫描按设计登记的其他活跃学生),`candidate_dimension_scores` 相应物化行 4. `candidate_scoring_refresh_scan_checkpoints` 1 行(游标) 5. 本轮申诉 1 条 + 申诉历史 1 条 + 相应审计 1 条 **明确未发生**: - 非本轮用户的业务数据(`users`/`companies`/`jobs`/`applications` 等)未被删除或改写 —— 唯一受影响的是其**候选人快照**,且这是启用生产后台补偿扫描后的**设计内正常行为**(任务书明确允许「候选人快照」变化) - 评分规则、权重、规则版本、排名规则未变 - `candidate_scoring_snapshot_history` 在**普通 Worker 场景下为零变化**(4 → 4)✔ —— 该表的 4 行历史数据全程未被普通物化触碰 - 既有历史快照未被改写(version 语义为「变化才 +1」) - 非本轮审计未变 --- ## 19. RabbitMQ 前后状态 队列(vhost `/`)在**验收前**与**验收后**分别查询: | 队列 | 验收前 ready/unacked | 验收后 ready/unacked | |---|---|---| | `queue.sms.verification` | 0 / 0 | 0 / 0 | | `queue.notification` | 0 / 0 | 0 / 0 | | `queue.notification.dlq` | 0 / 0 | 0 / 0 | | `queue.audit` | 0 / 0 | 0 / 0 | 4 个队列**真实存在**,终态 ready=0、unacked=0,**D4-2 的 GET、任务登记、Worker、补偿扫描全程未新增任何消息** ✔ 代码层佐证:`candidatescore/refresh/` 全包对 `rabbit|Rabbit` 的匹配仅出现在 **3 处 javadoc 注释**(「不发送 RabbitMQ」),无任何 `RabbitTemplate`/`convertAndSend`/`NotificationService`/`AuditService` 引用 ✔ --- ## 20. 静态与运行时 OpenAPI | 项 | 静态 `docs/openapi-rc.json` | 运行时 `/api/v1/v3/api-docs` | |---|---|---| | paths | **176** | **176** | | operations | **214** | **214** | | method+path 集合 | — | **完全一致**(`only-runtime=[]`, `only-static=[]`) | - `python docs\verify_openapi.py` → `All checks passed!`(含 V36/V37/V39–V43/N02/N07/S02/S03 各项契约校验) - D4-2 **未新增**公网刷新、任务、补偿、管理接口 ✔ - 四个评分 GET 响应 schema 不变(S1 实测响应字段集合与既有契约一致)✔ --- ## 21. 日志脱敏 扫描对象:本轮 6 个 JAR 日志/err 文件 + 2 个 Maven 测试日志,共 8 个文件。 **探针有效性**:临时文件注入 `Authorization: Bearer `、11 位手机号、「验证码 482913」、`DB_PASSWORD=…`,扫描器命中 **5 类** → **[PASS] 扫描器有效**;探针文件已删除 ✔ **真实命中与甄别**: | 类别 | 命中 | 甄别结论 | |---|---|---| | 「口令赋值」 | 63 | **全部为同一模式**:Spring Boot `UserDetailsServiceAutoConfiguration` 启动时打印的 `Using generated security password: `。经字符集核验为大小写十六进制 + 连字符的 UUID,**非数据库口令、非 JWT 密钥、非项目任何凭据**,且项目采用 JWT 认证不使用该内存用户。判定为**误报**。 | | 11 位手机号 | 1 | 位于 `clean-verify.log`,上下文为既有测试 `AdminBootstrapConcurrencyLoggingTest` 的 **`S03_SCANNER_PROBE_<值>` 扫描器探针**(测试夹具),非真实用户号码 | | Bearer Token / JWT 形态串 / 验证码语义行 / 密钥赋值 / 私钥块 / 简历存储路径 | **0** | ✔ | **应用运行日志(`r01d42-*.log`)中手机号、Token、验证码、JWT、简历路径命中均为 0** ✔ 报告与本文件均未复制任何真实凭据值。 --- ## 22. 结束处理 **进程**:只停止本轮明确记录 PID 的实例(24624/22640/12656/23436/28456/6576),未触碰任何未知 Java 进程。验收后 `Get-Process java` 无输出,8080/8081 端口已释放,恢复至验收前空闲状态。**Docker 未停止**(按任务书要求)。 **数据清理**(仅本轮精确主键,无模糊 DELETE): | 表 | 删除前 | 删除后 | |---|---|---| | `users`(8002/8003/8004/8005) | 4 | **0** ✔ | | `companies`(27/28) | 2 | **0** ✔ | | `jobs`(47) | 1 | **0** ✔ | | `applications`(86/87)+ `application_status_history` | 2 + 2 | **0** ✔ | | `candidate_dimension_scores` | 32 | **0** ✔ | | `candidate_scoring_refresh_tasks` | 4 | **0** ✔ | | `candidate_scoring_appeals` / `appeal_history` | 1 / 1 | **0** ✔ | | `student_profiles` / `student_preferences` | 2 / 1 | **0** ✔ | | `notifications` | 2 | **0** ✔ | | `auth_tokens` / `user_roles` / `sms_verification_codes` | 6 / 6 / 6 | **0** ✔ | 清理前做了**归属校验**:4 个用户 id 对应的手机号必须全部等于本轮登记的隔离虚拟号(`phone IN (4 个号码)` 与 `id IN (…)` 双向匹配数均为 4,完全一致),否则脚本 `[ABORT]` 拒绝删除。删除按外键顺序执行(两轮:首轮被 `application_status_history` 阻断后补删)。 **保留并登记**:`audit_logs` 中 actor 为本轮 4 个用户的 **15 行** 属 append-only 合规记录,按任务书要求**不冒险删除**,保留在库中。关联 ID:`user_id ∈ {8002, 8003, 8004, 8005}`。 **唯一允许的测试夹具(精确 INSERT,已登记)**:`user_roles` 插入 `(8004,'COMPANY')`、`(8005,'COMPANY')` 各 1 行 —— 原因:项目**无任何公网接口**可授予 COMPANY 角色(`CompanyService.java:76 assertCompanyRole` 前置校验,角色仅由管理员初始化产生),企业创建因此返回 403。该 2 行已在 §22 清理中随 `user_roles WHERE user_id IN (…)` 一并删除。 **Git 收尾**:分支 `refactor/r01-structure-performance`;暂存区空;未 `git add -A`;未 commit;未 push;V1–V43 未修改;V44 仍为唯一新增迁移;无删除文件;`git diff --check` 无空白错误。未进入下一项 R01 工作。 --- ## 23. PASS / FAIL / SKIP 清单 ### PASS 1. Git 基线(分支/HEAD/暂存区/V1–V43/V44/无删除/无空白错误) 2. A1 三个 GET 真正只读(代码 + 真实 HTTP 双证) 3. A3 唯一 Writer 语义(幂等 version / canonical JSON / 并发锁 / 强制版本 / 不写 history / 整体回滚) 4. A5 补偿扫描(持久游标 / 单轮预算 / 尾部回绕 / 同事务 / 不写快照) 5. A6 配置无断档(生产默认启用,测试 profile 显式关闭) 6. Docker/MySQL 8.4.11/Redis/RabbitMQ 全部 healthy 7. V44 在真实 MySQL 成功(version=44, success=1, 唯一, failed=0) 8. `ddl-auto=validate` 通过,无 schema/SQL/Bean 装配错误 9. 最新 JAR 正常启动;健康检查 8080/8081 均 200 10. `mvn clean verify` **2131/2131**,F=0 E=0 **S=0** 11. 定向批次一 55/55、批次二 253/253(MySQL Testcontainers 真实执行) 12. **三个目标 GET 真实零写**(22 张表逐字段不变;学生快照保持 0 行) 13. 写侧事件 → 任务 → Worker → 八维快照全链路(W1/W2/W3) 14. 重复登记合并 + 完全相同输入重放 version 不推进 15. 普通 Worker 不写 `snapshot_history`、不写通知/审计/RabbitMQ 16. 补偿游标跨重启延续、单轮严格 ≤ limit、尾部回绕 17. 趋势回归(可读物化快照、稳定、零写) 18. 排名回归(cohortSize=2、双学生均有快照) 19. 企业看板回归(4 端点在列、越权 403) 20. 申诉回归(取证字段非空、副作用限于契约表) 21. 公平性监控回归(5 端点、抑制语义保留、零写) 22. RabbitMQ 终态 4 队列 ready=0/unacked=0 23. 静态与运行时 OpenAPI 均 176/214 且集合完全一致 24. `git diff --check`、OpenAPI 静态校验、日志脱敏(探针有效,应用日志 0 泄露) 25. 隔离数据精确清理,无残留 ### FAIL(4 项) | # | 缺陷 | 位置 | 命中判据 | |---|---|---|---| | F1 | **Offer 自动过期改变评分输入但不登记刷新任务** | `offer/OfferExpiryWorker.java:48`(登记计数=0);评分读取 `candidatescore/CandidateScoringCalculator.java:188-194` | 「写侧成功但任务未登记」 | | F2 | **候选人反馈的修改/删除改变评分输入但不登记刷新任务** | `feedback/CandidateFeedbackService.java:107`、`:124`(仅 `:82` 创建时登记);评分读取 `CandidateScoringCalculator.java:196-200` | 「写侧成功但任务未登记」 | | F3 | **`createVersion` 登记与独立提交的版本行事务边界不对称** | `resume/ResumeVersionService.java:71`(外层事务)vs 同文件 `:52-55` 自述的 `REQUIRES_NEW` 独立提交 | 「源事务失败却留下任务」的对偶形态:数据已提交而登记丢失 | | F4 | **测试断言弱化(红线:禁止修改或放宽既有测试)** | `test/.../candidatescore/CandidateScoringCalculatorTest.java:248` 起,ABILITY 溯源 payload 的敏感字段黑名单断言(`answerKey/questionCode/competencyTag/stem/selectedOptionId/scoringRule/awardedScore/explanation/phone/email` 共 10 项)被删除,**无等价替代** | 「修改或放宽既有测试」 | ### FAIL(次生,A4,未单独计入但需一并整改) | # | 缺陷 | 位置 | |---|---|---| | F5 | `materialize` 的「回到 PENDING」分支缺 version 闸门,租约竞争下会把新 Worker 处理中的任务改回 PENDING | `refresh/CandidateScoringRefreshTaskExecutor.java:78-88` | | F6 | `recordFailure` 在 `updated != 1` 时抛异常,且异常点位于 Worker 的 catch 块内、`processDue` 的 for 循环无保护 → **同批剩余任务本轮全部不处理** | `refresh/CandidateScoringRefreshWorker.java:83` + `:63-67` + `CandidateScoringRefreshTaskExecutor.java:105-107` | ### SKIP(2 项,均非核心判据) 1. **多实例并发补偿扫描双 JAR 黑盒**:以代码层 `SELECT ... FOR UPDATE` 串行 + 游标与登记同事务作为证据;未启动第二个 JAR 做真实双实例竞争。 2. **申诉审核/回滚黑盒**:为避免改动非本轮治理数据(`candidate_scoring_rollback_limits` 等)未执行 rollback,改由 `CandidateScoringCalculatorTest#recomputeAndSnapshot_*` 与 `CandidateScoringGovernancePersistenceIntegrationTest` 覆盖(第二批 253/253 内),静态确认强制 version 路径。 ### BLOCKED 无。 --- ## 24. 剩余风险 1. **F1/F2 的实际影响受补偿扫描部分缓解**:补偿扫描的资格条件为「近 90 天有投递 **或** 近 30 天有审计」(`CandidateScoringRefreshCompensationScanner.java:89-99`)。有 Offer / 有反馈的学生必有投递,故其快照通常在下一轮扫描(默认间隔 1 小时)内被纠正。但若该学生**近 90 天无投递、近 30 天无审计**,则其快照将**永久停留在过期值**,无任何兜底路径。 2. **F3 触发概率低但后果不可逆**:仅在事务提交阶段失败时发生,届时业务数据存在而刷新信号永久丢失,且无对账机制可发现。 3. **F5/F6 触发前提为物化耗时超过 `processing-timeout-ms`(默认 600s)**,正常物化实测 0.7s,概率低;一旦触发,本轮同批任务的其余条目会被静默跳过(下一轮会重试,故不会永久丢失)。 4. **补偿扫描会物化全库活跃学生的快照**:启用生产后台刷新后,单实例每轮最多 `compensation-limit` 个。本次验收实测 181 条任务、全部 COMPLETED,未观察到异常增长;但该行为对生产库的持续写入量需在容量规划中评估。 5. **`CandidateScoringRefreshInfrastructureTest` 未验证 8 个写侧 Service 真的调用了 `requestRefresh`**:现有测试直接驱动 `requestService`/`taskExecutor`,各 Service 单测只补了 `@Mock` 依赖而无一处 `verify(...).requestRefresh(...)`。F1/F2 正是这类覆盖盲区的产物,建议本轮整改时一并补齐。 6. **注释与现实不符(低危)**:`CandidateScoringCalculator.java:582`(「含 GET 兜底」)、`CandidateScoringRefreshScheduler.java:15`、D4-1 报告 §20 的「默认关闭」表述均已过时。 --- ## 25. 是否允许冻结 D4-2 **不允许冻结。** R01-D4-2 的**核心目标**(三个评分 GET 停写)已在真实 MySQL/HTTP 下确凿达成,且未观察到任何消费端退化或回归。但依据任务书第二十节的 FAIL 判据,F1/F2 命中「**写侧成功但任务未登记**」,F4 触碰「禁止修改或放宽既有测试」红线,因此本轮不可冻结。 --- ## 26. 下一轮定向整改指令 ### 必做(阻断冻结) **R1. 补齐 Offer 自动过期的评分刷新登记** - 文件:`mydreamjob-server/src/main/java/com/mydreamjob/offer/OfferExpiryWorker.java` - 方法:`expireOne(Long offerId)`(`:41`),在 `:48 setStatus(EXPIRED)` 成功且 `:51 saveAndFlush` 之后、与该方法**同一 `REQUIRES_NEW` 事务内**,追加: `scoringRefreshRequestService.requestRefresh(offer.getCandidateId(), LocalDateTime.now(clock))` - 注入 `CandidateScoringRefreshRequestService`;`OfferExpiryWorker` 已使用 `clock`,`capturedNow` 应复用同一时间源 - 不得因此改动 `OfferService#transitionStatus` 的既有语义 - 补测试:真实驱动 `expireOne` 后断言 `candidate_scoring_refresh_tasks` 出现/合并一条 `(candidateId, 当期)` 任务 **R2. 补齐候选人反馈修改/删除的评分刷新登记** - 文件:`mydreamjob-server/src/main/java/com/mydreamjob/feedback/CandidateFeedbackService.java` - 方法:`updateFeedback(…)`(`:107`)与 `deleteFeedback(…)`(`:124`),在各自成功路径末尾、同一 `@Transactional` 内追加 `requestRefresh(app.getStudentUserId(), LocalDateTime.now())`(`deleteFeedback` 注意在删除前先取得 `studentUserId`) - 与既有 `:82 createFeedback` 保持完全一致的调用形态 - 补测试:`updateFeedback` 改变 `rating` 后断言任务被登记/合并 **R3. 修正 `createVersion` 的登记事务边界** - 文件:`mydreamjob-server/src/main/java/com/mydreamjob/resume/ResumeVersionService.java` - 问题:`:71` 的登记位于外层事务,而版本行经 `versionWriter.saveNewVersion`(`REQUIRES_NEW`)**独立提交**;外层提交阶段失败会造成「数据已提交、登记丢失」 - 要求(二选一,须在报告中说明选择理由): (a) 将 `requestRefresh` 增补为 `Propagation.REQUIRES_NEW` 的独立调用(放在版本行成功返回之后);或 (b) 在 `createVersion` 返回后由调用方在**已提交**的上下文中登记(`TransactionSynchronization#afterCommit`) - 补测试:模拟外层事务回滚,断言版本行存在时**必定**存在对应刷新任务 **R4. 修正 `materialize` 的 PENDING 回退闸门** - 文件:`mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshTaskExecutor.java` - 位置:`:78-88` - 要求:`status='PENDING'` 的回退 `UPDATE` 增加 `AND version=?`(与 claim 时记录的 `claimedVersion` 比对);`updated != 1` 时应**静默返回**(该任务已被 `recoverStale` 或新 Worker 接管,属正常竞争),不得抛 `IllegalStateException` **R5. 隔离单任务失败对同批任务的影响** - 文件:`mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshWorker.java` - 位置:`:63-67`(`processDue` 循环)与 `:83`(catch 内 `recordFailure`) - 要求:`processDue` 的 for 循环体加 `try/catch`,或让 `recordFailure` 自身吞掉状态冲突异常并仅告警 —— 保证单任务异常不阻断同批其余任务,与 `:24` javadoc 的承诺一致 **R6. 恢复被删除的敏感字段断言(红线项)** - 文件:`mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoringCalculatorTest.java` - 位置:`:248` 起(原 `for (String banned : List.of("answerKey","questionCode","competencyTag","stem","selectedOptionId","scoringRule","awardedScore","explanation","phone","email"))` 断言) - 要求:在**新的委托架构下重建等价断言** —— 序列化 `CandidateScoringSnapshotWriter` 为 ABILITY 维度生成的 `sub_scores_json`(可通过 `CandidateScoringRefreshInfrastructureTest` 走真实 Writer,或在 Writer 测试中直接断言),并逐项断言不含上述 10 个禁止字段 - 不得以「类型安全」为由省略:`weights`/`dimensionScores` 为 `Map`,其 key 不受编译期保护 **R7. 补齐写侧登记的行为验证** - 文件:`mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshInfrastructureTest.java` - 要求:新增断言,对 §A2 的 8 个服务方法 + R1/R2 新接入的两个入口,逐一 `verify(scoringRefreshRequestService, times(1)).requestRefresh(…)`;覆盖**失败回滚不登记**的负例 ### 建议(不阻断,但应同批处理) **R8.** 修正过期注释:`CandidateScoringCalculator.java:582`、`CandidateScoringRefreshScheduler.java:15`,以及 `docs/r01-d4-1-scoring-materialization-implementation-report.md` §13/§20 中「补偿扫描为简化版/默认关闭」的表述。 **R9.** 补 `CandidateScoringRefreshInfrastructureTest` 中缺失的黑盒项:多实例并发扫描(双实例 `scanDueStudents` 断言游标不重复推进)、跨重启游标延续的自动化用例(当前仅有本次验收的真实证据)。 **R10.** 评估 `getTrend` 的时钟一致性:`CandidateScoringEngineService.java:78` 使用 `LocalDate.now()` 而非注入的 `clock`(预存在问题,非本轮引入)。 ### 整改后复验要求 完成 R1–R7 后,重新执行:`mvn clean verify`(须 ≥2131 且 S=0)、静态与运行时 OpenAPI 176/214、以及本报告 §7/§9/§16 三项真实 HTTP 复核(GET 零写、写侧链路、申诉取证)。R1/R2 需各自提供「事件 → 任务 → COMPLETED → 快照」的真实 MySQL 证据。 --- ## 附:本轮验收产出物(`docs/acceptance/r01-d4-2-20260916/`) | 文件 | 说明 | |---|---| | `acc_lib.py` | HTTP/DB/登录/前缀公共库 | | `snap.py` | 22 表显式列快照 + 差异比较 + 自检反证 | | `p1-provision.py` / `acc-context.json` / `acc-prefix.txt` | 隔离验收数据创建 | | `s1-zerowrite.py` / `snap-before-get.json` / `snap-after-get.json` | 三个 GET 零写 | | `s2-writepath.py` | 写侧链路 | | `s4-consumers.py` / `s4-out.txt` / `snap-after-consumers.json` | 消费者回归 | | `logscan.py` | 日志脱敏扫描(含探针) | | `cleanup.py` | 精确主键清理 | | `start-app.ps1` + `r01d42-*.log/.err.log/.pid/.envdiag.txt` | 实例启动与运行日志 | | `q01`–`q13*.sql` | 只读 SQL 证据 | | `runtime-openapi.json` | 运行时 OpenAPI | | `clean-verify.log` / `batch2.log` | 自动化构建日志 | > 本报告不含数据库密码、JWT、验证码、完整手机号或邮箱、`.env` 内容、简历原文或真实存储路径;SQL 片段中无真实敏感参数。验收脚本输出中曾一次性打印隔离虚拟号(`p1-provision.py` 首版缺陷),已当场修正脚本,后续输出与报告均不再包含完整号码。