56 changed files with 5214 additions and 208 deletions
@ -0,0 +1,87 @@ |
|||
# R01-D4-1「候选人评分快照可靠物化基础设施」实施报告 |
|||
|
|||
日期:2026-09-16 |
|||
分支:`refactor/r01-structure-performance`;HEAD 检查点:`b10c4a7 docs: design scoring snapshot materialization`(后于 `b56f792`) |
|||
性质:D4-1 实施(**GET 兜底未删除**,三 GET 仍经 upsertSnapshots 写库) |
|||
|
|||
## 1. Git 基线 |
|||
- 分支正确;设计检查点已提交 `b10c4a7`(3 个设计文件)。 |
|||
- 暂存区空;未 commit/push 本轮实现;V1–V43 未修改;V44 为唯一新增迁移。 |
|||
- `GlobalExceptionHandler.java` 保持行尾符标记现象(内容 diff 为空)未动。 |
|||
|
|||
## 2. V44 表结构(`V44__candidate_scoring_refresh_tasks.sql`) |
|||
`candidate_scoring_refresh_tasks`: id PK、student_user_id、target_period(7)、status、requested_at、processing_started_at、processed_at、next_attempt_at、attempts、last_error_code(40)、version、created_at、updated_at;`uk_refresh_task_student_period(student_user_id,target_period)`、`ck_refresh_task_status`(PENDING/PROCESSING/RETRY/COMPLETED/FAILED)、`idx_refresh_task_claim(status,next_attempt_at)`、FK→users(id) ON DELETE CASCADE。不保存 Token/手机号/简历/评分源数据。 |
|||
|
|||
## 3. 任务唯一键与状态机 |
|||
唯一键 (student_user_id, target_period)。状态机:PENDING→PROCESSING→COMPLETED;失败→RETRY(attempts+1、next_attempt_at 退避)→达上限 FAILED;COMPLETED/FAILED 再登记→PENDING+attempts=0;RETRY 再登记→PENDING(attempts 保留)。 |
|||
|
|||
## 4. 登记事务语义(`CandidateScoringRefreshRequestService#requestRefresh`) |
|||
原生参数化 upsert 合并;REQUIRED 传播——随调用方业务事务,**源业务提交失败则登记一并回滚**;登记不触发线程内计算、不发 RabbitMQ、不写审计、无敏感值;period 取 capturedNow 单次读取。 |
|||
|
|||
## 5. Worker 领取与并发(`CandidateScoringRefreshWorker`) |
|||
数据库条件更新领取(`status IN (PENDING,RETRY) AND next_attempt_at 到期` 置 PROCESSING,0 行即被并发方领取);无 JVM 锁/Thread.sleep。领取使用独立短事务;计算、8 维 Writer 与完成状态使用另一个 `REQUIRES_NEW` 原子事务;失败状态再用独立事务持久化。因此单维写失败会回滚该任务全部快照,但不会污染同批其他任务。并发双 Worker 仅一次有效物化。 |
|||
|
|||
## 6. PROCESSING 期间新请求防丢失 |
|||
登记对 PROCESSING 保留状态并推进任务 version;Worker 领取时记录 claimedVersion,完成时仅在 version 未变化时置 COMPLETED;处理期间有新请求导致 version 变化时回到 PENDING。另有 10 分钟(可配置)PROCESSING 租约超时回收:置 RETRY/WORKER_TIMEOUT 并允许其他实例重新领取,达到重试上限转 FAILED,避免进程退出造成永久卡死。 |
|||
|
|||
## 7. 重试与 FAILED |
|||
失败捕获→固定分类(COMPUTE_FAILED/WRITE_FAILED/UNKNOWN_REFRESH_FAILURE;租约超时为 WORKER_TIMEOUT)入 last_error_code(不含异常原文/SQL/手机号/堆栈)→独立事务 attempts+1→RETRY+next_attempt_at;达上限 FAILED;失败不回抛、不回滚同批其他任务;补偿扫描可重新登记。 |
|||
|
|||
## 8. Writer 比较字段(`CandidateScoringSnapshotWriter`) |
|||
score、status、sub_scores_json、weight、rules_version 全部相同→version/updated_at 不变;任一变化→version+1;calculated_at 不触发;JSON 写入前 canonical(字段字典序)防伪变化;null-safe 相等用 `(= ? OR (IS NULL AND ? IS NULL))`(MySQL/H2 双兼容)。 |
|||
|
|||
## 9. version 精确语义 |
|||
普通物化:值变才 +1(D4-1 已授权语义收敛——重复相同 GET/重放不再制造版本噪声);rulesVersion 变化强制 +1。 |
|||
|
|||
## 10. Governance 强制路径 |
|||
`recomputeAndSnapshot`(申诉决定/回滚)委托 `upsertForcingVersion` 无条件 +1,保持 `afterVersion=beforeVersion+1`、snapshot_history 与 scoreAtAppeal/snapshotVersionAtAppeal/回滚限额语义不变;普通物化不写 snapshot_history。 |
|||
|
|||
## 11. 实际接入的真实输入源(写侧 Service,同事务登记) |
|||
ProfileService#updateProfile;ResumeVersionService#createVersion/updateVersion/deleteVersion;PreferenceService#savePreferences;AbilityAttemptService#submit;ApplicationService#apply/withdraw/updateStatus;OfferService#transitionStatus;CandidateFeedbackService#createFeedback;MessagingService#sendMessage(仅 STUDENT 发送者登记)。补偿扫描兜底 audit/时间窗口。 |
|||
|
|||
## 12. 明确未接入 |
|||
RIASEC(AssessmentAttemptService);InterviewScoreService;JobReviewService;AbilityAttemptService#saveAnswers;ResumeVersionService#setDefault(公式未读默认标记);消息撤回;只读查询;AuditService 实时登记(防 刷新→审计→刷新 循环,审计变化由补偿扫描覆盖)。 |
|||
|
|||
## 13. 时间窗口与补偿扫描(`CandidateScoringRefreshCompensationScanner`) |
|||
基础版:按 user id 做 keyset 分页(页大小默认 100)遍历近 90 天投递/近 30 天审计活跃学生并登记刷新任务;一次扫描会跨越全部页面,不再反复只处理首批 ID;不直接写快照。rulesVersion 切换由事件/重扫覆盖。**补偿扫描仍是 D4-1 简化版,尚无持久化扫描游标与单轮总预算,完整窗口和大数据量调度规则留待 D4-2 前完善;GET 兜底因此必须保留**。 |
|||
|
|||
## 14. 修复前失败测试(TDD 证据) |
|||
实现前基线即检查点后现状(b10c4a7 上证据测试 5/5 全绿,其中"重复 GET version+1"断言证明旧行为);实施中 Writer 两次 H2 语法不兼容(`<=>`、VALUES() 表达式)→ Errors=7→4→0 收敛;@InjectMocks 缺新依赖 → Errors=55→30→0;Persistence 旧断言与新契约冲突 → 1 Failure 被改写为幂等断言。最终全绿。 |
|||
|
|||
## 15. H2 与真实 MySQL 结果 |
|||
H2 定向集成已覆盖:登记合并、并发领取、处理中重登记、租约超时恢复、跨三页以上补偿扫描、单任务中途写失败全回滚、失败状态独立提交、同批任务事务隔离及外层业务回滚。2026-09-16 Docker 恢复后新增 `CandidateScoringRefreshMySqlIntegrationTest`,使用独立 `mysql:8.4` Testcontainers、正式 V1–V44 Flyway 与 `ddl-auto=validate`,5/5 通过:V44/schema、Writer 幂等与强制版本、双线程领取唯一胜者、部分写失败全回滚且同批成功任务不受影响、PROCESSING 重登记及超时恢复。未复用或修改开发库。 |
|||
|
|||
## 16. 查询预算 |
|||
登记:1 条 upsert;Worker:到期 ID 查询 + 每任务独立 claim/load + computeAll + 8 维锁定比较/条件写入 + complete;失败另有 1 次独立状态写。补偿扫描每页 1 条 keyset 查询并为命中学生逐条登记;总查询数随符合条件学生数增长,尚未满足最终查询预算,不能据此进入 D4-2。 |
|||
|
|||
## 17. RabbitMQ 与数据库副作用 |
|||
Worker/登记不发布 RabbitMQ、不写 notification、不写 snapshot_history、不写 audit;测试 20/22 验证。快照仅 candidate_dimension_scores 与任务表。 |
|||
|
|||
## 18. 全量测试 |
|||
Docker 恢复后的最终实测 `mvn clean verify`:**2130/2130,Failures=0、Errors=0、Skipped=0,BUILD SUCCESS**。其中既有 Q01 MySQL 20 项与新增 D4-1 MySQL 5 项均真实执行。OpenAPI **176 paths / 214 operations**,校验通过。 |
|||
|
|||
## 19. OpenAPI |
|||
176 paths / 214 operations,Actuator 不进业务契约;无新增公网刷新接口。 |
|||
|
|||
## 20. 精确修改文件 |
|||
新增:V44 迁移;`candidatescore/refresh/`:CandidateScoringRefreshTask、CandidateScoringRefreshRequestService、CandidateScoringSnapshotWriter、CandidateScoringRefreshWorker、CandidateScoringRefreshTaskExecutor、CandidateScoringRefreshCompensationScanner、CandidateScoringRefreshScheduler;测试 `CandidateScoringRefreshInfrastructureTest`、`CandidateScoringRefreshFailureTransactionTest`。 |
|||
修改:CandidateScoringCalculator(upsert 委托 Writer + recompute 强制路径);写侧 8 Service(登记接入);application.yml(app.scoring.refresh.* 默认关闭);测试适配:R01D4GetWritesSnapshotEvidenceTest/CandidateScoringPersistenceIntegrationTest(version 幂等契约演进)、CandidateScoringCalculatorTest/ClockConsistencyTest/FairnessMonitoringPersistenceIntegrationTest(构造/委托验证)、6 个 Service 测试补 Mock 依赖(未放宽断言)。 |
|||
|
|||
## 21. Git 状态 |
|||
未 commit/push;暂存区空;V44 唯一新增迁移;历史未跟踪文件未删未移;GlobalExceptionHandler 行尾现象保持。 |
|||
|
|||
## 22. 进入 D4-2 的网关(当前:**未达到**) |
|||
- [x] 唯一 Writer + 幂等 version 语义(H2 验证) |
|||
- [x] 任务登记/防丢失/并发领取/重试/failure 分类(H2 验证) |
|||
- [x] 写侧 8 类事件登记 + 不接入项确认 |
|||
- [x] 真实 MySQL 8.4 核心基础设施验收(V44/validate、Writer、并发领取、事务隔离、重登记与超时恢复) |
|||
- [ ] D4-2 前消费者黑盒(GET 零写切换、趋势/申诉/排名/看板/监控、副作用与 RabbitMQ) |
|||
- [ ] 补偿扫描完整窗口规则 + 查询预算真实库验证 |
|||
- [ ] 写侧覆盖证明(滚动运行期对账) |
|||
→ 未满足 → **不得移除 GET 写入** |
|||
|
|||
## 23. 剩余风险 |
|||
补偿扫描仍缺持久化游标与单轮总预算;POST/PUT 申诉决定/回滚在真实库对 version 语义的回归未验(H2 已验);普通 Writer 的 MySQL 幂等、差异更新、强制版本、`SELECT FOR UPDATE` 与 no-op upsert 已有专用真实库证据。 |
|||
|
|||
## 最终状态 |
|||
> **R01-D4-1 核心基础设施及真实 MySQL 8.4 验证已完成;GET 兜底仍保留。** 进入 D4-2 前仍需把补偿扫描收束为可持续的持久游标/单轮预算,并在 D4-2 同批完成消费者、副作用及 RabbitMQ 黑盒,不得直接删除 GET upsert。 |
|||
@ -0,0 +1,277 @@ |
|||
# 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 评分快照写入迁移尚未冻结。 |
|||
@ -0,0 +1,234 @@ |
|||
# R01-D4-2 claim fence 定向整改报告(不可复用 claim token) |
|||
|
|||
日期:2026-09-16 |
|||
分支:`refactor/r01-structure-performance` |
|||
HEAD:`b10c4a732740e42353ca826f63141c3dd2a49915`(整改全程未变) |
|||
来源:`docs/r01-d4-2-final-independent-incremental-verification-20260916.md` 的 FAIL 项(R-1) |
|||
整改范围:**仅修复 fence 唯一性**,未进入其他 R01 项目 |
|||
|
|||
--- |
|||
|
|||
## 1. 结论 |
|||
|
|||
> **R01-D4-2 claim fence 定向整改完成,待最终独立增量复验;GET 评分快照写入迁移尚未冻结。** |
|||
|
|||
采用任务书指定的**不可复用 claim token** 方案。`processing_started_at` 秒级精度导致的 fence 可重复问题已消除 —— 所有权判定不再依赖任何时间值、时间精度或配置下界。 |
|||
|
|||
--- |
|||
|
|||
## 2. 修复前失败测试(TDD 证据) |
|||
|
|||
新增 `CandidateScoringRefreshClaimTokenTest`(13 个测试),在**只加入 V45 列、未改 Java**时运行: |
|||
|
|||
命令:`mvn -Dtest="CandidateScoringRefreshClaimTokenTest" test` |
|||
日志:`docs/acceptance/r01-d4-2-20260916/fence-before.log` |
|||
|
|||
**结果:Tests run: 10, Failures: 6** |
|||
|
|||
任务书要求复现的三条,全部复现成功: |
|||
|
|||
| 要求 | 测试方法 | 失败信息 | |
|||
|---|---|---| |
|||
| ① 两次领取拥有完全相同的 `processing_started_at` | `sameTimestamp_staleWorkerPendingRevert_mustNotTouchNewClaim` | 断言 `assertEquals(SAME_INSTANT, processingStartedAt(taskId))` **通过**,证明两次领取时间值相同 | |
|||
| ② Worker B 已重新领取 | 同上 | 断言 `assertEquals("PROCESSING", status(taskId))` **通过** | |
|||
| ③ **Worker A 的 PENDING 回退可以错误修改 B 的任务** | 同上 | **`expected: <PROCESSING> but was: <PENDING>`** ← 核心缺陷复现 | |
|||
|
|||
同时复现了失败登记的同类覆盖: |
|||
`sameTimestamp_staleWorkerFailure_mustNotTouchNewClaim` → **`expected: <PROCESSING> but was: <RETRY>`** |
|||
|
|||
其余 4 条失败为 `claim_token` 尚未写入(值为 `null`)。 |
|||
|
|||
**关键点**:测试使用常量 `SAME_INSTANT = LocalDateTime.of(2026, 9, 16, 12, 0, 0)`,两次领取传入**完全相同**的时间值,**未使用任何 sleep 或跨秒等待**。 |
|||
|
|||
--- |
|||
|
|||
## 3. 实现 |
|||
|
|||
### 3.1 迁移 V45 |
|||
|
|||
`mydreamjob-server/src/main/resources/db/migration/V45__add_scoring_refresh_claim_token.sql` |
|||
|
|||
```sql |
|||
ALTER TABLE candidate_scoring_refresh_tasks |
|||
ADD COLUMN claim_token VARCHAR(36) NULL |
|||
COMMENT '本次领取的不可复用令牌(UUID),终态/待处理时清空;仅用于并发所有权判定'; |
|||
``` |
|||
|
|||
- **仅新增列**,未增加索引 —— 查询依据:`claim_token` 只出现在既有的**按主键 `id` 定位**的 UPDATE 中(`WHERE id=? AND ...`),不参与扫描或排序,因此无需新索引。 |
|||
- 未修改 V1–V44,未修改业务表数据,未新增公网接口或 ErrorCode。 |
|||
|
|||
### 3.2 `CandidateScoringRefreshTaskExecutor` |
|||
|
|||
| 操作 | 关键改动 | |
|||
|---|---| |
|||
| `claim` | 生成 `java.util.UUID.randomUUID().toString()`,在领取 SQL 中**原子写入** `claim_token`;读回并放入 `ClaimedTask` | |
|||
| `materialize` → COMPLETED | `WHERE id=? AND status='PROCESSING' AND claim_token=? AND version=?`;`SET claim_token=NULL` | |
|||
| `materialize` → PENDING 回退 | `WHERE id=? AND status='PROCESSING' AND claim_token=?`;`SET claim_token=NULL`;0 行 → 安静返回 | |
|||
| `recordFailure` | 参数由 `LocalDateTime claimedAt` 改为 `String claimToken`;`WHERE id=? AND status='PROCESSING' AND claim_token=?`;`SET claim_token=NULL`;0 行 → 安静返回 | |
|||
| `recoverStale` | `SET claim_token=NULL`(回收即释放所有权) | |
|||
| `ClaimedTask` | 新增 `String claimToken`;保留 `claimedAt` 但**仅用于超时扫描与观测**,不再参与任何 WHERE 条件 | |
|||
|
|||
`processing_started_at` 的用途收敛为:超时扫描(`WHERE status='PROCESSING' AND processing_started_at < ?`)与观测。所有权证明完全由 `claim_token` 承担。 |
|||
|
|||
### 3.3 语义保持 |
|||
|
|||
- 同一 claim 期间出现新请求 → `version` 前进 → COMPLETED 更新 0 行 → **PENDING 回退**(`claim_token` 未变,闸门放行)✔ |
|||
- 新 Worker 已重新领取 → `claim_token` 已变 → 完成、失败、回退**全部更新 0 行并安静退出** ✔ |
|||
- 进入 PENDING / COMPLETED / RETRY / FAILED 均清空 `claim_token` ✔ |
|||
- 不依赖 `Thread.sleep`、时间精度或配置下界 ✔ |
|||
|
|||
--- |
|||
|
|||
## 4. 测试覆盖(任务书清单逐项) |
|||
|
|||
`CandidateScoringRefreshClaimTokenTest`(13 个测试,全部通过): |
|||
|
|||
| 任务书要求 | 测试方法 | |
|||
|---|---| |
|||
| 同一时间戳下两次领取的 claim token 不同 | `twoClaimsAtSameInstant_getDifferentTokens` | |
|||
| 旧 Worker 不能完成新 claim | `sameTimestamp_staleWorkerPendingRevert_mustNotTouchNewClaim`(含完成尝试) | |
|||
| 旧 Worker 不能将新 claim 回退 PENDING | 同上(核心断言) | |
|||
| 旧 Worker 不能记录新 claim 失败 | `sameTimestamp_staleWorkerFailure_mustNotTouchNewClaim` | |
|||
| 当前 Worker 可以正常完成 | `currentHolder_canCompleteNormally` | |
|||
| 同一 claim 期间 version 前进会正确回到 PENDING | `requestDuringSameClaim_versionAdvances_completionFallsBackToPending` | |
|||
| stale recovery 后生成新 token | `staleRecovery_generatesNewTokenOnNextClaim` | |
|||
| terminal/PENDING 状态 token 被清空 | `terminalAndPendingStates_clearClaimToken`、`failedTerminalState_clearsClaimToken` | |
|||
| 双 Worker 循环至少 100 次无覆盖、无永久 PROCESSING | `doubleWorkerLoop_100Rounds_noOverwrite_noStuckProcessing_noLostRequest` | |
|||
| token 不进入日志、审计、响应或错误信息 | `claimToken_doesNotLeakIntoAuditOrNotificationTables`、`claimToken_rowNeverExposedInTaskStatusQueriesUsedByObservability` | |
|||
|
|||
补充:`claim_repeatedOnDifferentTasks_producesDistinctTokens`、`tokenIsUuidShaped_andNotDerivedFromIds`(UUID 形态、不可复用、不由 ID 派生)。 |
|||
|
|||
**全部使用固定时间值**,无 sleep、无跨秒等待。 |
|||
|
|||
--- |
|||
|
|||
## 5. 回归 |
|||
|
|||
| 命令 | 结果 | |
|||
|---|---| |
|||
| `mvn -Dtest="*CandidateScoringRefresh*Test" test` 等 7 个 fence 相关类 | **49 / 49**,F=0 E=0 S=0 | |
|||
| 关联回归(9 组通配符) | **571 / 571**,F=0 E=0 S=0 | |
|||
| `mvn clean verify` | **2176 / 2176**,Failures=0、Errors=0、**Skipped=0**,BUILD SUCCESS | |
|||
| `python docs\verify_openapi.py` | **176 paths / 214 operations**,All checks passed | |
|||
|
|||
较整改前(2163)新增 **13** 个测试,全部为 claim token 相关。 |
|||
|
|||
**过程中修正的测试侧问题(非产品缺陷)**: |
|||
|
|||
1. `CandidateScoringRefreshMySqlIntegrationTest#v44MigrationAndSchemaValidationAreApplied` 原断言任务表 13 列 —— V45 后为 14 列,同步更新并**新增** V45 成功性、唯一性、`claim_token` 存在性断言(属本次授权迁移的计划内演进,非放宽)。 |
|||
2. `OfferExpiryRefreshRegistrationTest#expireOne_refreshTaskCarriesNoSensitiveFields` 原禁止列名含 `TOKEN` —— `claim_token` 是随机 UUID 而非用户凭据,禁止列表改为 `ACCESS_TOKEN/REFRESH_TOKEN/PASSWORD/SECRET` 等真实凭据列,并**新增**断言要求 `claim_token` 必须存在。 |
|||
3. 本人新增测试中的两处断言缺陷(UUID 随机字符集几乎必然含任务 ID 数字;`newTask()` 复用同学生导致任务合并),已改为有意义的断言。 |
|||
|
|||
--- |
|||
|
|||
## 6. 真实 MySQL 最小增量复验 |
|||
|
|||
使用本轮 `clean verify` 产出的最新 JAR。物化实例 PID 11104(`SCORING_REFRESH_ENABLED=true`),只读实例 PID 18372(`false`),均已停止,8080/8081 空闲。 |
|||
|
|||
### 6.1 V45 迁移与 schema validation |
|||
|
|||
``` |
|||
version | description | success |
|||
44 | candidate scoring refresh tasks | 1 |
|||
45 | add scoring refresh claim token | 1 |
|||
v45_rows = 1 failed_migrations = 0 |
|||
claim_token | varchar(36) | YES |
|||
task_cols = 14 |
|||
``` |
|||
Hibernate `ddl-auto=validate` 通过,日志中 `schema-validation`/`SchemaManagementException` 命中 **0** ✔ |
|||
|
|||
### 6.2 claim fence 100 轮竞争(固定相同 processing_started_at) |
|||
|
|||
脚本 `iv-fence-loop2.py`:使用与生产 `claim`/`recoverStale`/`materialize`/`recordFailure` **逐字一致**的 SQL,两次领取的 `processing_started_at` **固定为 `2026-09-16 12:00:00`**。 |
|||
|
|||
``` |
|||
轮次 = 100(两次领取的 processing_started_at 固定为 2026-09-16 12:00:00) |
|||
[PASS] 100 轮:A 的完成/失败/回退全部 0 行、未覆盖 B、B 均完成、无永久 PROCESSING |
|||
``` |
|||
|
|||
每轮断言: |
|||
- A 用旧 token 的完成 / 失败 / PENDING 回退 → **受影响行数均为 0** |
|||
- A 未改动 B 的 `status`、`claim_token`、`attempts`、`last_error_code` |
|||
- B 用新 token 完成 → 1 行 |
|||
- 终态无残留 `PROCESSING` |
|||
|
|||
(脚本首轮运行暴露了自身缺陷:`ROW_COUNT()` 跨 `docker exec` 连接失效、回收步骤的 `WORKER_TIMEOUT` 被误判为污染;已修正后重跑。) |
|||
|
|||
### 6.3 三个 GET 零写 |
|||
|
|||
**PASS=24 / FAIL=0 / SKIP=0** —— 三个目标 GET ×3 + 趋势 GET ×3 全部 HTTP 200,**22 张表逐字段零变化** ✔ |
|||
|
|||
### 6.4 无快照学生 |
|||
|
|||
新学生 8019:`before=0` → 3 次 GET(均 200)→ `after=0` ✔ |
|||
|
|||
### 6.5 五条写侧链路 |
|||
|
|||
**PASS=44 / FAIL=0 / SKIP=0** —— Offer 自动过期、Feedback create/update/delete、简历版本创建;每条均验证「业务变化 → 唯一任务 → 周期正确 → Worker COMPLETED → 无失败分类 → 八维齐备 → 不写 snapshot_history」;相同输入重放不推进 version ✔ |
|||
|
|||
### 6.6 消费者最小回归 |
|||
|
|||
**PASS=33 / FAIL=0 / SKIP=0** —— 趋势(当前周期 2026-09)、排名(rank/percentile/cohortSize)、企业看板 4 端点 + 越权 403、公平性 5 端点(抑制语义保留)、申诉取证非空;消费者 GET 逐字段零写 ✔ |
|||
|
|||
### 6.7 RabbitMQ |
|||
|
|||
4 个队列(`queue.sms.verification` / `queue.notification` / `queue.notification.dlq` / `queue.audit`)**ready=0、unacked=0** ✔ |
|||
|
|||
### 6.8 token 不泄露 |
|||
|
|||
- 测试断言:token 不出现在 `audit_logs` / `notifications` / `messages` / `candidate_dimension_scores.sub_scores_json`;`last_error_code` 仅固定枚举 ✔ |
|||
- 运行日志:未出现 token 形态内容;`claim_token` 不参与任何日志输出语句 ✔ |
|||
- 数据库:token 仅存于 `candidate_scoring_refresh_tasks.claim_token`,且在终态/PENDING 时被清空 ✔ |
|||
|
|||
--- |
|||
|
|||
## 7. 精确修改文件 |
|||
|
|||
**生产代码(3 个)** |
|||
|
|||
| 文件 | 改动 | |
|||
|---|---| |
|||
| `db/migration/V45__add_scoring_refresh_claim_token.sql` | **新增**:仅加 `claim_token VARCHAR(36) NULL` | |
|||
| `candidatescore/refresh/CandidateScoringRefreshTaskExecutor.java` | `claim` 生成并原子写入 UUID;COMPLETED / PENDING 回退 / `recordFailure` 的全部 WHERE 由 `processing_started_at` 改为 `claim_token`;四处(含 `recoverStale`)`SET claim_token=NULL`;`ClaimedTask` 增 `claimToken`;`recordFailure` 形参改 `String claimToken` | |
|||
| `candidatescore/refresh/CandidateScoringRefreshTask.java` | 实体新增 `claimToken` 映射;`processingStartedAt` 注释标明不再作为所有权证明 | |
|||
|
|||
**配置/接口/数据**:未修改 `application.yml`;未新增 ErrorCode;未新增公网接口;未修改评分公式、权重、快照内容、任务合并语义;未把 `DATETIME(6)` 当作最终修复(`processing_started_at` 精度保持不变)。 |
|||
|
|||
**测试(新增 1 个类;适配 4 个既有类)** |
|||
|
|||
新增:`CandidateScoringRefreshClaimTokenTest`(13 个测试) |
|||
适配:`CandidateScoringRefreshMySqlIntegrationTest`(列数 13→14 + V45 断言)、`OfferExpiryRefreshRegistrationTest`(禁止列名调整 + `claim_token` 存在性断言)、`CandidateScoringRefreshClaimFenceTest` 与 `CandidateScoringRefreshInfrastructureTest`(`recordFailure` 传 `claimToken()`)、`CandidateScoringRefreshWorkerLoopIsolationTest`(mock 签名 + `ClaimedTask` 构造) |
|||
|
|||
--- |
|||
|
|||
## 8. Git 状态 |
|||
|
|||
``` |
|||
分支:refactor/r01-structure-performance |
|||
HEAD:b10c4a732740e42353ca826f63141c3dd2a49915(未变) |
|||
暂存区:空 |
|||
git add -A / commit / push:均未执行 |
|||
V1–V44:未修改 |
|||
新增迁移:V45(唯一本轮新增;V44 为 D4-1 产物) |
|||
删除文件:无 |
|||
git diff --check:无空白错误 |
|||
``` |
|||
|
|||
验收数据已按精确主键清理(`users` 残留 0:8015/8016/8017/8018/8019 均删除;公司 33/34、岗位 50、投递 92/93、offer、简历版本、申诉及关联行全部清除)。`audit_logs` 中 actor 为本轮 5 个隔离用户的 **15 + 2 = 17 行**属 append-only 合规记录,保留并登记(关联 ID:`user_id ∈ {8015, 8016, 8017, 8018, 8019}`)。 |
|||
|
|||
唯一测试夹具(精确 INSERT,已登记并已清理):`user_roles` 为 8017/8018 各插入 1 行 `COMPANY`。 |
|||
|
|||
Docker 保持运行(三容器 healthy);仅停止本轮记录 PID 的实例;8080/8081 空闲。 |
|||
|
|||
--- |
|||
|
|||
## 9. 明确声明 |
|||
|
|||
- **未暂存、未提交、未推送。** |
|||
- 未进入其他 R01 工作。 |
|||
- 原始验收产物继续排除在后续提交之外。 |
|||
- 已按要求「先补失败测试 → 保存修复前失败结果 → 再改生产代码」。 |
|||
|
|||
--- |
|||
|
|||
> R01-D4-2 claim fence 定向整改完成,待最终独立增量复验;GET 评分快照写入迁移尚未冻结。 |
|||
@ -0,0 +1,188 @@ |
|||
# R01-D4-2 claim token 最终独立复验报告(冻结前最后一次) |
|||
|
|||
日期:2026-09-16 |
|||
分支:`refactor/r01-structure-performance` |
|||
HEAD:`b10c4a732740e42353ca826f63141c3dd2a49915`(复验全程未变) |
|||
复验对象:`docs/r01-d4-2-claim-fence-remediation-20260916.md` 的 claim token 整改 |
|||
性质:**仅复验** —— 未修改生产代码、未修改既有测试、未暂存、未提交、未推送 |
|||
|
|||
--- |
|||
|
|||
## 0. 最终结论 |
|||
|
|||
> **R01-D4-2独立代码复验和真实MySQL/Docker增量黑盒全部通过,GET评分快照写入迁移可以冻结并进入精确暂存检查点。** |
|||
|
|||
**FAIL = 0,SKIP = 0。** claim token 闸门在代码审查、自动化测试与真实 MySQL 100 轮黑盒三个层面均确证有效:旧 Worker 无法以任何形式修改新持有者。 |
|||
|
|||
--- |
|||
|
|||
## 1. 基线 |
|||
|
|||
| 检查项 | 结果 | |
|||
|---|---| |
|||
| 分支 | `refactor/r01-structure-performance` ✔ | |
|||
| HEAD | `b10c4a732740e42353ca826f63141c3dd2a49915` ✔(= b10c4a7) | |
|||
| 暂存区 | 空 ✔ | |
|||
| `git diff --check` | 无空白错误 ✔ | |
|||
| V1–V44 | 未修改 ✔ | |
|||
| 新增迁移 | **V45 为唯一本轮新增**(V44 为 D4-1 产物)✔ | |
|||
| JAR | `mydreamjob-server-0.0.1-SNAPSHOT.jar`,2026-09-16 **17:26:51**(最新 `clean verify` 产出)✔ | |
|||
|
|||
--- |
|||
|
|||
## 2. 独立代码审查 |
|||
|
|||
`CandidateScoringRefreshTaskExecutor.java`(153 行,逐行核对): |
|||
|
|||
| 核查项 | 结论 | 证据 | |
|||
|---|---|---| |
|||
| token 在领取 SQL 中原子生成并写入 | ✔ | `:53` `UUID.randomUUID().toString()`;`:56` `SET status='PROCESSING', processing_started_at=?, claim_token=?, ...` 同一 UPDATE | |
|||
| token 不由调用方复用 | ✔ | `claim(Long, LocalDateTime)` 无 token 形参,每次调用新生成 | |
|||
| COMPLETED 条件 = id + PROCESSING + claim_token + version | ✔ | `:92` `WHERE id=? AND status='PROCESSING' AND claim_token=? AND version=?` | |
|||
| PENDING 条件 = id + PROCESSING + claim_token | ✔ | `:104` | |
|||
| RETRY / FAILED 条件 = id + PROCESSING + claim_token | ✔ | `:135`(`recordFailure` 依 `attempts+1 >= maxAttempts` 落到 RETRY 或 FAILED) | |
|||
| 离开 PROCESSING 后 token 全部清空 | ✔ | `recoverStale` `:38`;COMPLETED `:89`;PENDING `:102`;RETRY/FAILED `:133` —— **四处全覆盖** | |
|||
| recoverStale 清空旧 token | ✔ | `:38` `claim_token=NULL` | |
|||
| 下一次领取生成全新 token | ✔ | `:53` 每次调用重新生成 | |
|||
| `processing_started_at` 不再出现在所有权判断 WHERE 中 | ✔ | 全文件唯一 WHERE 用法为 `:40` 的**超时扫描** `WHERE status='PROCESSING' AND processing_started_at < ?`;所有权判定全部使用 `claim_token` | |
|||
| 更新 0 行按 stale claim 处理 | ✔ | `claim` `:61-63` 返回 null;`materialize` PENDING 分支 `:106-110` 安静返回;`recordFailure` `:137-140` 安静返回 —— **均不抛异常、不误报成功** | |
|||
| 0 行时不覆盖现持有者 | ✔ | 三个更新语句的 WHERE 均含 `claim_token=?` | |
|||
| token 不进入日志/审计/响应/通知/消息/快照 JSON | ✔ | 全仓 grep:`claim_token`/`claimToken` 仅出现在实体映射(`CandidateScoringRefreshTask.java:73-74`)、Executor 的 SQL/注释/record、以及 `CandidateScoringRefreshWorker.java:91` 的传参;refresh 包内**无任何含 token 的日志语句** | |
|||
|
|||
--- |
|||
|
|||
## 3. 自动化复验 |
|||
|
|||
| 命令 | 实际 | 预期 | 结论 | |
|||
|---|---|---|---| |
|||
| `mvn -Dtest="*CandidateScoringRefresh*Test" test` | **49 / 49**,F=0 E=0 **S=0** | ≥ 49 | ✔ | |
|||
| 关联回归(9 组通配符) | **571 / 571**,F=0 E=0 **S=0**,BUILD SUCCESS | ≥ 571 | ✔ | |
|||
| `mvn clean verify` | **2176 / 2176**,F=0 E=0 **S=0**,BUILD SUCCESS | ≥ 2176 | ✔ | |
|||
| `python docs\verify_openapi.py` | **176 paths / 214 operations**,All checks passed | 176/214 | ✔ | |
|||
|
|||
日志:`fv-fence.log`、`fv-regression.log`、`fv-clean-verify.log` |
|||
|
|||
--- |
|||
|
|||
## 4. 真实 MySQL 最小黑盒 |
|||
|
|||
脚本 `fv-fence.py` —— 使用与生产 `claim`/`recoverStale`/`materialize`/`recordFailure` **逐字一致**的 SQL,**所有领取的 `processing_started_at` 固定为 `2026-09-16 12:00:00`**。 |
|||
|
|||
**结果:PASS** |
|||
|
|||
### A. 100 轮竞争 |
|||
|
|||
每轮:A 领取(token_A)→ 回收(token 清空)→ B 在**相同时间值**下重新领取(token_B)→ A 以 token_A 执行四种迟到写 → B 以 token_B 完成。 |
|||
|
|||
| 断言 | 结果 | |
|||
|---|---| |
|||
| A 的 **COMPLETED** 更新 0 行 | ✔ 100 轮均为 0 | |
|||
| A 的 **PENDING** 更新 0 行 | ✔ | |
|||
| A 的 **RETRY** 更新 0 行 | ✔ | |
|||
| A 的 **FAILED** 更新 0 行 | ✔ | |
|||
| B 的 token / status / version 未被 A 修改 | ✔ 逐轮比对 `status='PROCESSING'`、`claim_token=token_B`、`version=version_B` 均不变 | |
|||
| B 正常完成 | ✔ 每轮 B 完成后 `status='COMPLETED'` 且 token 清空 | |
|||
| 终态无永久 PROCESSING | ✔ | |
|||
| 无重复完成、无任务丢失 | ✔ 终态任务数 = 1 | |
|||
|
|||
### B. token 生命周期 |
|||
|
|||
- `recoverStale` 后 `claim_token` 为 **NULL** ✔ |
|||
- 再次领取后 token **非空且不同于此前所有 token** ✔ |
|||
|
|||
### C. 无效 token 不可更新 |
|||
|
|||
| 场景 | 行数 | |
|||
|---|---| |
|||
| 空 token(`claim_token=''`) | **0** ✔ | |
|||
| 伪造 token(随机新 UUID) | **0** ✔ | |
|||
| 旧 token(A 的 token_A) | **0** ✔ | |
|||
| NULL token | **0** ✔ | |
|||
|
|||
且四种尝试后任务状态**完全未变**(前后 `st()` 全等)✔ |
|||
|
|||
### D. version 推进 → PENDING → 下一轮新 token 完成 |
|||
|
|||
- 同一 claim 期间新请求推进 `version` → COMPLETED 更新 **0 行**(version 不匹配) |
|||
- 当前 Worker 走 PENDING 回退 → **1 行**,`status='PENDING'` 且 token 清空 ✔ |
|||
- 下一轮领取使用**新 token** ✔ → 完成 **1 行**,`status='COMPLETED'` ✔ |
|||
|
|||
--- |
|||
|
|||
## 5. 最小业务回归 |
|||
|
|||
实例:最新 JAR;物化实例 PID 11884(`SCORING_REFRESH_ENABLED=true`)、只读实例 PID 6988(`false`),均已停止。 |
|||
|
|||
| 项 | 结果 | |
|||
|---|---| |
|||
| **三个 GET 零写** | **PASS=24 / FAIL=0 / SKIP=0** —— 三个目标 GET ×3 + 趋势 GET ×3 全部 200,**22 张表逐字段零变化** ✔ | |
|||
| **无快照学生** | 新学生 8024:`before=0` → 3 次 GET(均 200)→ `after=0` ✔ | |
|||
| **五条写侧链路** | **PASS=44 / FAIL=0 / SKIP=0** —— Offer 自动过期、Feedback create/update/delete、简历版本创建;每条验证「业务变化 → 唯一任务 → 周期正确 → Worker COMPLETED → 无失败分类 → 八维齐备 → 不写 snapshot_history」;重放不推进 version ✔ | |
|||
| **消费者回归** | **PASS=33 / FAIL=0 / SKIP=0** —— 趋势 / 排名 / 企业看板 / 申诉 / 公平性监控;消费者 GET 逐字段零写 ✔ | |
|||
| **V45** | `success=1` 且 `v45_rows=1`(唯一);`failed_migrations=0` ✔ | |
|||
| **Hibernate validate** | 通过;日志中 `schema-validation`/`SchemaManagementException` 命中 **0** ✔ | |
|||
| **RabbitMQ** | 4 队列(`queue.sms.verification` / `queue.notification` / `queue.notification.dlq` / `queue.audit`)**ready=0、unacked=0** ✔ | |
|||
| **token 泄漏扫描** | **PASS** —— 日志非框架 UUID 命中 **0**;HTTP 响应 token 形态命中 **0**;数据库其它载体命中 **0**(当前无活跃 token,终态/PENDING 已全部清空)✔ | |
|||
| **扫描器探针** | 临时文件注入 UUID → 正则命中;向 `audit_logs` 插入探针 → DB 命中 1 → 清理后 0 —— **探针 PASS,证明扫描器非空转**;探针文件已删除 ✔ | |
|||
|
|||
--- |
|||
|
|||
## 6. 收尾 |
|||
|
|||
| 项 | 结果 | |
|||
|---|---| |
|||
| 本轮 Java 实例 | 仅停止本轮记录的两个 PID(11884、6988);`tasklist` java.exe 计数 = **0** ✔ | |
|||
| 8080 / 8081 | 空闲 ✔ | |
|||
| Docker | 保持运行,三容器 healthy ✔ | |
|||
| 数据清理 | `users` 残留 **0**(8020/8021/8022/8023/8024 及公司、岗位 51、投递 94/95、offer、简历版本全部按精确主键删除)✔ | |
|||
| append-only 审计 | `audit_logs` 中 actor 为本轮 5 个隔离用户的 **15 + 2 = 17 行**保留并登记(关联 ID:`user_id ∈ {8020, 8021, 8022, 8023, 8024}`)✔ | |
|||
| 测试夹具(精确 INSERT,已登记并已清理) | `user_roles` 为 8022/8023 各插入 1 行 `COMPANY` ✔ | |
|||
| Git | 分支未变、HEAD 未变、暂存区空、V1–V44 未改、V45 唯一新增、无删除文件、无空白错误 ✔ | |
|||
| 未暂存 / 未提交 / 未推送 | 是 ✔ | |
|||
|
|||
本轮新增产物(`docs/acceptance/r01-d4-2-20260916/`,未进入暂存区):`fv-fence.py` + `fv-fence-out.txt`、`fv-tokenscan.py`、`fv-fence.log`、`fv-regression.log`、`fv-clean-verify.log`、`v3-fv-out.txt`、`s1-fv-out.txt`、`s4-fv-out.txt`。 |
|||
|
|||
--- |
|||
|
|||
## 7. PASS / FAIL / SKIP 清单 |
|||
|
|||
**FAIL**:无。 |
|||
|
|||
**SKIP**:无。 |
|||
|
|||
**PASS**: |
|||
|
|||
1. 基线(分支/HEAD/暂存区/V1–V44/V45 唯一/最新 JAR) |
|||
2. 代码审查 12 项(token 原子生成、不复用、COMPLETED 四条件、PENDING/RETRY/FAILED 三条件、四处清空、recoverStale 清空、新 token、`processing_started_at` 退出所有权判定、0 行 stale 处理、不覆盖、不泄漏) |
|||
3. fence 定向 49/49 |
|||
4. 关联回归 571/571 |
|||
5. 全量 clean verify 2176/2176(S=0) |
|||
6. OpenAPI 176/214 |
|||
7. 真实 MySQL 100 轮竞争(四种迟到写全 0 行、B 未被修改、B 均完成、无永久 PROCESSING、无丢失) |
|||
8. token 生命周期(回收后 NULL、重领后新值) |
|||
9. 空/伪造/旧/NULL token 均无法更新 |
|||
10. version 推进 → PENDING → 新 token 完成 |
|||
11. 三个 GET 零写 24/24(22 表逐字段) |
|||
12. 无快照学生 0 行 |
|||
13. 五条写侧 44/44 |
|||
14. 消费者 33/33 |
|||
15. V45 成功且唯一 |
|||
16. Hibernate validate |
|||
17. RabbitMQ 四队列为 0 |
|||
18. token 泄漏扫描 0 + 探针有效 |
|||
|
|||
--- |
|||
|
|||
## 8. 复验过程中的自暴露问题(非产品缺陷) |
|||
|
|||
| # | 问题 | 处置 | |
|||
|---|---|---| |
|||
| 1 | `fv-fence.py` 的 `retry_sql`/`failed_sql` 存在两个 `%s` 占位符只传一个参数的 Python 格式化错误 | 改为 `% (TABLE, token)`;已修正 | |
|||
| 2 | `fv-tokenscan.py` 的 DB 探针使用 `messages` 表触发外键失败 | 改用无外键依赖的 `audit_logs`,并增加「插入前=0 / 插入后=1 / 清理后=0」三段验证;已修正 | |
|||
| 3 | 探针插入的 `audit_logs` 行 | 探针执行后立即删除,已确认清理后计数为 0 | |
|||
|
|||
以上均为**验收脚本自身**问题,与产品代码无关;修正后重跑全部通过。 |
|||
|
|||
--- |
|||
|
|||
> **冻结判定:R01-D4-2独立代码复验和真实MySQL/Docker增量黑盒全部通过,GET评分快照写入迁移可以冻结并进入精确暂存检查点。** |
|||
@ -0,0 +1,275 @@ |
|||
# 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 文本层 `"<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. 剩余风险 |
|||
|
|||
1. **fence 失效的后果有限但不为零**:即使触发,最坏结果是同一次刷新多算一遍(任务回到 PENDING),不会数据损坏或永久 PROCESSING。但对 SLA 敏感场景仍属正确性瑕疵。 |
|||
2. **`processing-timeout-ms` 无下界校验**:当前仅做 `<= 0 → 600000` 兜底,1–999 的取值未被拒绝。 |
|||
3. 任务书未要求、本轮也未执行的项:真实双 JAR 的多实例补偿扫描并发、申诉审核/回滚真实黑盒(延续 D4-2 首轮验收的 SKIP 记录,不属于本轮复验范围)。 |
|||
|
|||
--- |
|||
|
|||
> **已复现具体缺陷,R01-D4-2 不可冻结;报告已包含下一轮定向整改指令。** |
|||
@ -0,0 +1,57 @@ |
|||
# R01-D4-2 GET 评分快照停写实施报告 |
|||
|
|||
日期:2026-09-16 |
|||
状态:实现与自动化复验通过,待独立真实环境黑盒验收。 |
|||
|
|||
## 1. 变更结论 |
|||
|
|||
`CandidateScoringEngineService#getOverview`、`getDimensionDetail`、`getCompanyScoring` 已改为 |
|||
`@Transactional(readOnly = true)`,并移除 `CandidateScoringCalculator#upsertSnapshots` 调用。 |
|||
三个接口仍以同一次 `capturedNow` 实时计算并返回原响应结构,趋势接口继续读取已物化快照。 |
|||
|
|||
快照更新改由 D4-1 的写侧登记、后台 Worker 和补偿扫描负责。生产配置默认启用刷新与补偿; |
|||
test、integration-test、mysql-it profile 显式关闭调度,仅由测试主动驱动,避免后台线程干扰断言。 |
|||
|
|||
## 2. D4-1 网关补齐 |
|||
|
|||
V44 同时创建 `candidate_scoring_refresh_scan_checkpoints`。补偿扫描使用持久化游标、固定单轮预算和 |
|||
`SELECT ... FOR UPDATE` 串行推进;游标与任务登记同事务提交。扫描到尾部后从 0 开始下一轮,避免首 |
|||
页饥饿,也不会单次遍历全量用户。 |
|||
|
|||
真实 MySQL 8.4 Testcontainers 已验证:V1–V44 迁移和 schema validate、游标持久化、每轮预算、 |
|||
Writer 幂等与强制版本、并发唯一领取、处理中重登记、租约恢复、失败事务隔离。 |
|||
|
|||
## 3. 测试迁移 |
|||
|
|||
- `R01D4GetWritesSnapshotEvidenceTest` 改为逐字段快照,证明 overview、dimension、trend GET 零写入。 |
|||
- `CandidateScoringEngineServiceTest` 与时钟一致性测试显式断言三个 GET 从不调用 upsert。 |
|||
- `CandidateScoringPersistenceIntegrationTest` 显式调用 Calculator/Writer 验证物化幂等,不再借 GET 造数据。 |
|||
- `V38AbilityRankingIntegrationTest` 显式走真实物化入口,原排名公式和结果断言保持不变。 |
|||
|
|||
首次干净全量构建据此发现 V38 排名测试仍依赖 GET 副作用(1 Error);修正夹具后专项测试通过, |
|||
再次执行干净全量构建成功。 |
|||
|
|||
## 4. 实测结果 |
|||
|
|||
| 项目 | 结果 | |
|||
| --- | --- | |
|||
| D4-2 + D4-1 H2/MySQL 组合定向 | 38 / 38,0 failure,0 error,0 skip | |
|||
| 时钟与持久化定向 | 13 / 13,0 failure,0 error,0 skip | |
|||
| V38 排名回归 | 1 / 1,原断言不变 | |
|||
| `mvn clean verify` | 2131 / 2131,Failures=0,Errors=0,Skipped=0,BUILD SUCCESS | |
|||
| MySQL Testcontainers | Q01 与 V44 用例均真实执行,无跳过 | |
|||
| OpenAPI | 176 paths / 214 operations,校验通过 | |
|||
| `git diff --check` | 无空白错误,仅既有 LF/CRLF 提示 | |
|||
|
|||
## 5. 边界 |
|||
|
|||
V44 是唯一新增迁移,V1–V43 未修改;无新增公网接口、ErrorCode、RabbitMQ 消息或通知;未暂存、 |
|||
未提交、未推送;历史未跟踪文件未删除或移动。 |
|||
|
|||
## 6. 待独立验收 |
|||
|
|||
仍需使用最新 JAR 与项目真实 MySQL/Docker 环境完成:三条真实 HTTP GET 前后快照逐字段零变化; |
|||
写侧事件到任务登记再到快照可见;趋势、申诉、排名、看板、公平性监控不退化;RabbitMQ 队列终态 |
|||
为零;并精确清理或记录隔离验收数据。自动化与 Testcontainers 结果不能替代该黑盒步骤。 |
|||
|
|||
> R01-D4-2 实现及自动化复验完成,待独立真实 MySQL/Docker 黑盒验收。 |
|||
@ -0,0 +1,656 @@ |
|||
# 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 <JWT>`、11 位手机号、「验证码 482913」、`DB_PASSWORD=…`,扫描器命中 **5 类** → **[PASS] 扫描器有效**;探针文件已删除 ✔ |
|||
|
|||
**真实命中与甄别**: |
|||
|
|||
| 类别 | 命中 | 甄别结论 | |
|||
|---|---|---| |
|||
| 「口令赋值」 | 63 | **全部为同一模式**:Spring Boot `UserDetailsServiceAutoConfiguration` 启动时打印的 `Using generated security password: <UUID>`。经字符集核验为大小写十六进制 + 连字符的 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` 首版缺陷),已当场修正脚本,后续输出与报告均不再包含完整号码。 |
|||
@ -0,0 +1,101 @@ |
|||
package com.mydreamjob.candidatescore.refresh; |
|||
|
|||
import lombok.RequiredArgsConstructor; |
|||
import lombok.extern.slf4j.Slf4j; |
|||
import org.springframework.jdbc.core.JdbcTemplate; |
|||
import org.springframework.stereotype.Component; |
|||
import org.springframework.transaction.annotation.Transactional; |
|||
|
|||
import java.time.Clock; |
|||
import java.time.LocalDateTime; |
|||
import java.util.List; |
|||
|
|||
/** |
|||
* R01-D4-1 补偿扫描(基础版)。 |
|||
* |
|||
* <p>仅<b>登记</b>刷新任务,不直接写快照:分页(LIMIT 固定预算)扫描近期有评分输入活动的学生, |
|||
* 统一调用 {@link CandidateScoringRefreshRequestService#requestRefresh}(任务唯一键自动合并)。 |
|||
* 用于覆盖无业务写入但评分自然变化的时间窗口(近 90 天投递窗口、近 30 天审计窗口、 |
|||
* 能力测评有效期、rulesVersion 切换),由定时调度触发。</p> |
|||
* |
|||
* <p>查询预算:本扫描 = 1 次聚合查询 + ≤limit 次登记 upsert;不逐用户 N+1。</p> |
|||
*/ |
|||
@Slf4j |
|||
@Component |
|||
@RequiredArgsConstructor |
|||
public class CandidateScoringRefreshCompensationScanner { |
|||
|
|||
static final String ACTIVE_STUDENTS_SCAN = "ACTIVE_STUDENTS"; |
|||
|
|||
private final JdbcTemplate jdbcTemplate; |
|||
private final CandidateScoringRefreshRequestService requestService; |
|||
private Clock clock = Clock.systemDefaultZone(); |
|||
|
|||
@org.springframework.beans.factory.annotation.Autowired(required = false) |
|||
public void setClock(Clock clock) { |
|||
if (clock != null) this.clock = clock; |
|||
} |
|||
|
|||
/** |
|||
* 返回本轮登记的学生数。游标与任务登记在同一事务中提交:失败时二者一起回滚, |
|||
* 多实例通过 checkpoint 行的 FOR UPDATE 串行推进,同一轮最多处理 {@code limit} 人。 |
|||
*/ |
|||
@Transactional |
|||
public int scanDueStudents(int limit) { |
|||
LocalDateTime now = LocalDateTime.now(clock); |
|||
LocalDateTime appsSince = now.minusDays(90); |
|||
LocalDateTime auditSince = now.minusDays(30); |
|||
int budget = Math.max(1, Math.min(limit, 500)); |
|||
ensureCheckpoint(); |
|||
long cursor = lockCursor(); |
|||
List<Long> ids = findEligibleAfter(cursor, appsSince, auditSince, budget); |
|||
if (ids.isEmpty() && cursor > 0L) { |
|||
cursor = 0L; |
|||
ids = findEligibleAfter(cursor, appsSince, auditSince, budget); |
|||
} |
|||
for (Long studentId : ids) { |
|||
requestService.requestRefresh(studentId, now); |
|||
} |
|||
long nextCursor = ids.size() < budget || ids.isEmpty() ? 0L : ids.get(ids.size() - 1); |
|||
jdbcTemplate.update(""" |
|||
UPDATE candidate_scoring_refresh_scan_checkpoints |
|||
SET last_user_id=?, version=version+1, updated_at=CURRENT_TIMESTAMP |
|||
WHERE scan_key=? |
|||
""", nextCursor, ACTIVE_STUDENTS_SCAN); |
|||
log.debug("compensation scan registered {} students (budget {}, next cursor {})", |
|||
ids.size(), budget, nextCursor); |
|||
return ids.size(); |
|||
} |
|||
|
|||
private void ensureCheckpoint() { |
|||
jdbcTemplate.update(""" |
|||
INSERT INTO candidate_scoring_refresh_scan_checkpoints |
|||
(scan_key, last_user_id, version, updated_at) |
|||
VALUES (?, 0, 0, CURRENT_TIMESTAMP) |
|||
ON DUPLICATE KEY UPDATE scan_key=scan_key |
|||
""", ACTIVE_STUDENTS_SCAN); |
|||
} |
|||
|
|||
private long lockCursor() { |
|||
Long cursor = jdbcTemplate.queryForObject(""" |
|||
SELECT last_user_id FROM candidate_scoring_refresh_scan_checkpoints |
|||
WHERE scan_key=? FOR UPDATE |
|||
""", Long.class, ACTIVE_STUDENTS_SCAN); |
|||
return cursor == null ? 0L : cursor; |
|||
} |
|||
|
|||
private List<Long> findEligibleAfter(long cursor, LocalDateTime appsSince, |
|||
LocalDateTime auditSince, int budget) { |
|||
return jdbcTemplate.query(""" |
|||
SELECT DISTINCT u.id |
|||
FROM users u |
|||
WHERE u.id > ? AND u.role = 'STUDENT' AND u.is_active = 1 |
|||
AND (EXISTS (SELECT 1 FROM applications a |
|||
WHERE a.student_user_id = u.id AND a.created_at >= ?) |
|||
OR EXISTS (SELECT 1 FROM audit_logs al |
|||
WHERE al.user_id = u.id AND al.created_at >= ?)) |
|||
ORDER BY u.id |
|||
LIMIT ? |
|||
""", (rs, i) -> rs.getLong(1), cursor, appsSince, auditSince, budget); |
|||
} |
|||
} |
|||
@ -0,0 +1,81 @@ |
|||
package com.mydreamjob.candidatescore.refresh; |
|||
|
|||
import lombok.RequiredArgsConstructor; |
|||
import lombok.extern.slf4j.Slf4j; |
|||
import org.springframework.jdbc.core.JdbcTemplate; |
|||
import org.springframework.stereotype.Service; |
|||
import org.springframework.transaction.annotation.Propagation; |
|||
import org.springframework.transaction.annotation.Transactional; |
|||
|
|||
import java.time.LocalDate; |
|||
import java.time.LocalDateTime; |
|||
|
|||
/** |
|||
* R01-D4-1 评分快照刷新任务登记组件。 |
|||
* |
|||
* <p>职责仅限"登记":把某个学生当前周期的物化请求合并进任务表。 |
|||
* 使用原生参数化 upsert 维护 {@code uk_refresh_task_student_period} 唯一性:</p> |
|||
* <ul> |
|||
* <li>同学生同周期重复登记:不新增行,更新 {@code requested_at};</li> |
|||
* <li>任务 COMPLETED/FAILED:重新打开为 PENDING,attempts 归零;</li> |
|||
* <li>任务 PROCESSING:保留 PROCESSING(不覆盖),推进 requested_at, |
|||
* Worker 完成时若 {@code requested_at} 晚于 {@code processing_started_at} 则回到 PENDING;</li> |
|||
* <li>任务 RETRY:回到 PENDING(保留 attempts,不重复计分重试次数)。</li> |
|||
* </ul> |
|||
* |
|||
* <p>登记会加入调用方现有业务事务(默认 REQUIRED):源业务提交失败时,登记一并回滚; |
|||
* 登记本身不触发任何线程内评分计算、不发送 RabbitMQ、不写成功审计、不记录敏感值。</p> |
|||
*/ |
|||
@Slf4j |
|||
@Service |
|||
@RequiredArgsConstructor |
|||
public class CandidateScoringRefreshRequestService { |
|||
|
|||
private final JdbcTemplate jdbcTemplate; |
|||
|
|||
/** |
|||
* 登记当前周期的一次刷新(period = capturedNow 所在 yyyy-MM)。 |
|||
* |
|||
* @param studentUserId 学生用户 ID;null 直接忽略 |
|||
* @param capturedNow 请求捕获时间(本方法内部只读取一次) |
|||
*/ |
|||
@Transactional(propagation = Propagation.REQUIRED) |
|||
public void requestRefresh(Long studentUserId, LocalDateTime capturedNow) { |
|||
if (studentUserId == null || capturedNow == null) { |
|||
return; |
|||
} |
|||
String period = String.format("%04d-%02d", |
|||
capturedNow.getYear(), capturedNow.getMonthValue()); |
|||
jdbcTemplate.update(""" |
|||
INSERT INTO candidate_scoring_refresh_tasks |
|||
(student_user_id, target_period, status, requested_at, attempts, version, created_at, updated_at) |
|||
VALUES (?, ?, 'PENDING', ?, 0, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) |
|||
ON DUPLICATE KEY UPDATE |
|||
requested_at = GREATEST(requested_at, VALUES(requested_at)), |
|||
attempts = CASE |
|||
WHEN status IN ('COMPLETED', 'FAILED') THEN 0 |
|||
ELSE attempts |
|||
END, |
|||
last_error_code = CASE |
|||
WHEN status = 'PROCESSING' THEN last_error_code |
|||
ELSE NULL |
|||
END, |
|||
next_attempt_at = CASE |
|||
WHEN status = 'PROCESSING' THEN next_attempt_at |
|||
ELSE NULL |
|||
END, |
|||
status = CASE |
|||
WHEN status = 'PROCESSING' THEN status |
|||
ELSE 'PENDING' |
|||
END, |
|||
version = version + 1, |
|||
updated_at = CURRENT_TIMESTAMP |
|||
""", |
|||
studentUserId, period, capturedNow); |
|||
} |
|||
|
|||
public String periodOf(LocalDateTime capturedNow) { |
|||
LocalDate d = capturedNow.toLocalDate(); |
|||
return String.format("%04d-%02d", d.getYear(), d.getMonthValue()); |
|||
} |
|||
} |
|||
@ -0,0 +1,77 @@ |
|||
package com.mydreamjob.candidatescore.refresh; |
|||
|
|||
import lombok.RequiredArgsConstructor; |
|||
import lombok.extern.slf4j.Slf4j; |
|||
import org.springframework.beans.factory.annotation.Value; |
|||
import org.springframework.scheduling.annotation.Scheduled; |
|||
import org.springframework.stereotype.Component; |
|||
|
|||
import java.time.Clock; |
|||
import java.time.LocalDateTime; |
|||
|
|||
/** |
|||
* R01-D4-1 定时调度:Worker 领取刷新 + 补偿扫描。 |
|||
* |
|||
* <p>默认关闭({@code app.scoring.refresh.enabled=false}),生产启用时负责周期驱动; |
|||
* 测试一律直接调用 Worker/Scanner,不依赖真实等待。调度不发送 RabbitMQ、不写通知。</p> |
|||
*/ |
|||
@Slf4j |
|||
@Component |
|||
@RequiredArgsConstructor |
|||
public class CandidateScoringRefreshScheduler { |
|||
|
|||
private final CandidateScoringRefreshWorker worker; |
|||
private final CandidateScoringRefreshCompensationScanner compensationScanner; |
|||
private Clock clock = Clock.systemDefaultZone(); |
|||
|
|||
@Value("${app.scoring.refresh.enabled:false}") |
|||
private boolean enabled; |
|||
|
|||
@Value("${app.scoring.refresh.compensation-enabled:false}") |
|||
private boolean compensationEnabled; |
|||
|
|||
@Value("${app.scoring.refresh.batch-size:20}") |
|||
private int batchSize; |
|||
|
|||
@Value("${app.scoring.refresh.compensation-limit:100}") |
|||
private int compensationLimit; |
|||
|
|||
@org.springframework.beans.factory.annotation.Autowired(required = false) |
|||
public void setClock(Clock clock) { |
|||
if (clock != null) this.clock = clock; |
|||
} |
|||
|
|||
@Scheduled(fixedDelayString = "${app.scoring.refresh.worker-interval-ms:60000}") |
|||
public void workerTick() { |
|||
if (!enabled) { |
|||
return; |
|||
} |
|||
try { |
|||
int processed = worker.processDue(batchSize); |
|||
if (processed > 0) { |
|||
log.debug("scoring refresh worker processed {} tasks", processed); |
|||
} |
|||
} catch (Exception e) { |
|||
// 调度兜底:单轮失败只告警,不影响后续轮次;具体任务失败在 Worker 内已分类登记。
|
|||
log.warn("scoring refresh worker tick failed: {}", e.getClass().getSimpleName()); |
|||
} |
|||
} |
|||
|
|||
@Scheduled(fixedDelayString = "${app.scoring.refresh.compensation-interval-ms:3600000}") |
|||
public void compensationTick() { |
|||
if (!enabled || !compensationEnabled) { |
|||
return; |
|||
} |
|||
try { |
|||
int registered = compensationScanner.scanDueStudents(compensationLimit); |
|||
log.debug("compensation scan registered {} students", registered); |
|||
} catch (Exception e) { |
|||
log.warn("compensation scan tick failed: {}", e.getClass().getSimpleName()); |
|||
} |
|||
} |
|||
|
|||
/** 测试用:暴露当前时钟时刻(无副作用)。 */ |
|||
LocalDateTime nowForTest() { |
|||
return LocalDateTime.now(clock); |
|||
} |
|||
} |
|||
@ -0,0 +1,102 @@ |
|||
package com.mydreamjob.candidatescore.refresh; |
|||
|
|||
import jakarta.persistence.Column; |
|||
import jakarta.persistence.Entity; |
|||
import jakarta.persistence.EntityListeners; |
|||
import jakarta.persistence.GeneratedValue; |
|||
import jakarta.persistence.GenerationType; |
|||
import jakarta.persistence.Id; |
|||
import jakarta.persistence.Table; |
|||
import jakarta.persistence.UniqueConstraint; |
|||
import jakarta.persistence.Version; |
|||
import lombok.AllArgsConstructor; |
|||
import lombok.Builder; |
|||
import lombok.Data; |
|||
import lombok.NoArgsConstructor; |
|||
import org.springframework.data.annotation.CreatedDate; |
|||
import org.springframework.data.annotation.LastModifiedDate; |
|||
import org.springframework.data.jpa.domain.support.AuditingEntityListener; |
|||
|
|||
import java.time.LocalDateTime; |
|||
|
|||
/** |
|||
* R01-D4-1 评分快照刷新任务(持久化物化登记)。 |
|||
* |
|||
* <p>同一学生同一周期只有一条任务({@code uk_refresh_task_student_period}); |
|||
* 状态机:PENDING → PROCESSING → COMPLETED / RETRY → (…) → FAILED。 |
|||
* PROCESSING 期间的新请求通过 {@code requested_at} 推进保留,Worker 完成时 |
|||
* 若 {@code requested_at} 晚于 {@code processing_started_at} 则回到 PENDING。</p> |
|||
*/ |
|||
@Data |
|||
@Builder |
|||
@NoArgsConstructor |
|||
@AllArgsConstructor |
|||
@Entity |
|||
@Table(name = "candidate_scoring_refresh_tasks", |
|||
uniqueConstraints = @UniqueConstraint(name = "uk_refresh_task_student_period", |
|||
columnNames = {"student_user_id", "target_period"})) |
|||
@EntityListeners(AuditingEntityListener.class) |
|||
public class CandidateScoringRefreshTask { |
|||
|
|||
public static final String STATUS_PENDING = "PENDING"; |
|||
public static final String STATUS_PROCESSING = "PROCESSING"; |
|||
public static final String STATUS_RETRY = "RETRY"; |
|||
public static final String STATUS_COMPLETED = "COMPLETED"; |
|||
public static final String STATUS_FAILED = "FAILED"; |
|||
|
|||
@Id |
|||
@GeneratedValue(strategy = GenerationType.IDENTITY) |
|||
private Long id; |
|||
|
|||
@Column(name = "student_user_id", nullable = false) |
|||
private Long studentUserId; |
|||
|
|||
/** yyyy-MM */ |
|||
@Column(name = "target_period", nullable = false, length = 7) |
|||
private String targetPeriod; |
|||
|
|||
@Column(name = "status", nullable = false, length = 20) |
|||
@Builder.Default |
|||
private String status = STATUS_PENDING; |
|||
|
|||
@Column(name = "requested_at", nullable = false) |
|||
private LocalDateTime requestedAt; |
|||
|
|||
/** 仅用于超时扫描与观测;秒级精度不足以作为所有权证明。 */ |
|||
@Column(name = "processing_started_at") |
|||
private LocalDateTime processingStartedAt; |
|||
|
|||
/** |
|||
* R01-D4-2 claim fence:本次领取的不可复用令牌(UUID)。 |
|||
* 每次成功领取写入新值,进入 COMPLETED/PENDING/RETRY/FAILED 时清空。 |
|||
*/ |
|||
@Column(name = "claim_token", length = 36) |
|||
private String claimToken; |
|||
|
|||
@Column(name = "processed_at") |
|||
private LocalDateTime processedAt; |
|||
|
|||
@Column(name = "next_attempt_at") |
|||
private LocalDateTime nextAttemptAt; |
|||
|
|||
@Column(name = "attempts", nullable = false) |
|||
@Builder.Default |
|||
private Integer attempts = 0; |
|||
|
|||
/** 固定错误分类(如 COMPUTE_FAILED / WRITE_FAILED),不保存异常原文、SQL、手机号或堆栈。 */ |
|||
@Column(name = "last_error_code", length = 40) |
|||
private String lastErrorCode; |
|||
|
|||
@Version |
|||
@Column(name = "version", nullable = false) |
|||
@Builder.Default |
|||
private Long version = 0L; |
|||
|
|||
@CreatedDate |
|||
@Column(name = "created_at", nullable = false, updatable = false) |
|||
private LocalDateTime createdAt; |
|||
|
|||
@LastModifiedDate |
|||
@Column(name = "updated_at", nullable = false) |
|||
private LocalDateTime updatedAt; |
|||
} |
|||
@ -0,0 +1,153 @@ |
|||
package com.mydreamjob.candidatescore.refresh; |
|||
|
|||
import com.mydreamjob.candidatescore.CandidateScoringCalculator; |
|||
import lombok.RequiredArgsConstructor; |
|||
import org.springframework.jdbc.core.JdbcTemplate; |
|||
import org.springframework.stereotype.Component; |
|||
import org.springframework.transaction.annotation.Propagation; |
|||
import org.springframework.transaction.annotation.Transactional; |
|||
|
|||
import java.time.LocalDateTime; |
|||
import java.util.List; |
|||
|
|||
/** |
|||
* 单任务事务执行器。领取、评分写入、失败状态分别使用独立事务,避免批次共享事务, |
|||
* 也避免捕获数据库异常后在 rollback-only 事务中记录 RETRY。 |
|||
*/ |
|||
@Component |
|||
@RequiredArgsConstructor |
|||
public class CandidateScoringRefreshTaskExecutor { |
|||
|
|||
private final JdbcTemplate jdbcTemplate; |
|||
private final CandidateScoringCalculator calculator; |
|||
private final CandidateScoringSnapshotWriter writer; |
|||
|
|||
/** |
|||
* 回收因进程退出而遗留的 PROCESSING 租约。恢复与领取分属独立事务,任何实例均可执行; |
|||
* 条件更新保证同一行只会被一次恢复。失败次数达到上限时进入 FAILED,避免永久热循环。 |
|||
*/ |
|||
@Transactional(propagation = Propagation.REQUIRES_NEW) |
|||
public int recoverStale(LocalDateTime staleBefore, LocalDateTime now, int maxAttempts) { |
|||
return jdbcTemplate.update(""" |
|||
UPDATE candidate_scoring_refresh_tasks |
|||
SET status=CASE WHEN attempts+1 >= ? THEN 'FAILED' ELSE 'RETRY' END, |
|||
next_attempt_at=NULL, |
|||
attempts=attempts+1, |
|||
last_error_code='WORKER_TIMEOUT', |
|||
processing_started_at=NULL, |
|||
claim_token=NULL, |
|||
version=version+1, updated_at=CURRENT_TIMESTAMP |
|||
WHERE status='PROCESSING' AND processing_started_at < ? |
|||
""", maxAttempts, staleBefore); |
|||
} |
|||
|
|||
/** |
|||
* 领取任务。 |
|||
* |
|||
* <p>每次成功领取都生成一个<b>全新的随机 UUID</b> 作为所有权令牌(claim token), |
|||
* 在领取 SQL 中原子写入。令牌不可复用、与时间无关,因此不受 |
|||
* {@code processing_started_at} 秒级精度影响。</p> |
|||
*/ |
|||
@Transactional(propagation = Propagation.REQUIRES_NEW) |
|||
public ClaimedTask claim(Long taskId, LocalDateTime now) { |
|||
String claimToken = java.util.UUID.randomUUID().toString(); |
|||
int updated = jdbcTemplate.update(""" |
|||
UPDATE candidate_scoring_refresh_tasks |
|||
SET status='PROCESSING', processing_started_at=?, claim_token=?, |
|||
version=version+1, updated_at=CURRENT_TIMESTAMP |
|||
WHERE id=? AND status IN ('PENDING','RETRY') |
|||
AND (next_attempt_at IS NULL OR next_attempt_at <= ?) |
|||
""", now, claimToken, taskId, now); |
|||
if (updated != 1) { |
|||
return null; |
|||
} |
|||
return jdbcTemplate.query(""" |
|||
SELECT id, student_user_id, target_period, processing_started_at, claim_token, version |
|||
FROM candidate_scoring_refresh_tasks WHERE id=? |
|||
""", (rs, i) -> new ClaimedTask( |
|||
rs.getLong("id"), rs.getLong("student_user_id"), |
|||
rs.getString("target_period"), |
|||
rs.getObject("processing_started_at", LocalDateTime.class), |
|||
rs.getString("claim_token"), |
|||
rs.getLong("version")), taskId) |
|||
.stream().findFirst().orElse(null); |
|||
} |
|||
|
|||
@Transactional(propagation = Propagation.REQUIRES_NEW) |
|||
public void materialize(ClaimedTask task, LocalDateTime capturedNow) { |
|||
List<CandidateScoringCalculator.DimensionResult> results = |
|||
calculator.computeAll(task.studentUserId(), capturedNow); |
|||
writer.upsertIfChanged(task.studentUserId(), results, task.targetPeriod(), capturedNow, |
|||
calculator.currentRulesVersion()); |
|||
|
|||
// 所有权闸门(claim fence):claim_token 是本次领取写入的不可复用令牌,
|
|||
// 只有它仍与领取时一致,才说明任务仍由本次领取持有。
|
|||
// version 额外参与 COMPLETED 判定:处理期间的新请求会推进 version,
|
|||
// 此时应回到 PENDING 再算一次(而不是错误地置为 COMPLETED)。
|
|||
int completed = jdbcTemplate.update(""" |
|||
UPDATE candidate_scoring_refresh_tasks |
|||
SET status='COMPLETED', processing_started_at=NULL, claim_token=NULL, processed_at=?, |
|||
last_error_code=NULL, next_attempt_at=NULL, |
|||
version=version+1, updated_at=CURRENT_TIMESTAMP |
|||
WHERE id=? AND status='PROCESSING' AND claim_token=? AND version=? |
|||
""", capturedNow, task.id(), task.claimToken(), task.claimedVersion()); |
|||
if (completed == 1) { |
|||
return; |
|||
} |
|||
|
|||
// 同一 claim 期间有新请求推进 version → 回到 PENDING;闸门仍校验所有权令牌,
|
|||
// 因此已被其他 Worker 重新领取时不会误改新持有者的状态。
|
|||
int pending = jdbcTemplate.update(""" |
|||
UPDATE candidate_scoring_refresh_tasks |
|||
SET status='PENDING', processing_started_at=NULL, claim_token=NULL, processed_at=?, |
|||
next_attempt_at=NULL, version=version+1, updated_at=CURRENT_TIMESTAMP |
|||
WHERE id=? AND status='PROCESSING' AND claim_token=? |
|||
""", capturedNow, task.id(), task.claimToken()); |
|||
if (pending == 0) { |
|||
// stale claim:任务已被租约回收并由其他 Worker 重新领取。
|
|||
// 安静退出,绝不覆盖新持有者的状态(这不是系统异常)。
|
|||
return; |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 记录失败并安排重试/终止。 |
|||
* |
|||
* <p>所有权闸门:只有 {@code claim_token} 仍等于本次领取写入的令牌时才更新。 |
|||
* 更新 0 行表示本次领取已失效(任务被租约回收并由其他 Worker 持有)—— |
|||
* 这属于正常竞争,<b>安静退出</b>而不是抛异常,以免中断同批其余任务。</p> |
|||
* |
|||
* @param claimToken 本次领取的不可复用令牌 |
|||
*/ |
|||
@Transactional(propagation = Propagation.REQUIRES_NEW) |
|||
public void recordFailure(Long taskId, String category, LocalDateTime now, |
|||
int maxAttempts, long retryBackoffMs, String claimToken) { |
|||
LocalDateTime nextAttempt = now.plusNanos(Math.max(1L, retryBackoffMs) * 1_000_000L); |
|||
int updated = jdbcTemplate.update(""" |
|||
UPDATE candidate_scoring_refresh_tasks |
|||
SET status=CASE WHEN attempts+1 >= ? THEN 'FAILED' ELSE 'RETRY' END, |
|||
next_attempt_at=CASE WHEN attempts+1 >= ? THEN NULL ELSE ? END, |
|||
attempts=attempts+1, |
|||
last_error_code=?, |
|||
processing_started_at=NULL, |
|||
claim_token=NULL, |
|||
version=version+1, updated_at=CURRENT_TIMESTAMP |
|||
WHERE id=? AND status='PROCESSING' AND claim_token=? |
|||
""", maxAttempts, maxAttempts, nextAttempt, category, taskId, claimToken); |
|||
if (updated != 1) { |
|||
// stale claim:不覆盖新持有者,也不视为系统异常。
|
|||
return; |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 领取结果。 |
|||
* |
|||
* @param claimedAt 领取时写入 processing_started_at 的值 —— 仅用于超时扫描与观测, |
|||
* 不再承担所有权证明(秒级精度不足以区分同秒内的两次领取) |
|||
* @param claimToken 本次领取的不可复用所有权令牌(UUID),所有终态更新都必须携带它 |
|||
*/ |
|||
public record ClaimedTask(Long id, Long studentUserId, String targetPeriod, |
|||
LocalDateTime claimedAt, String claimToken, long claimedVersion) { |
|||
} |
|||
} |
|||
@ -0,0 +1,117 @@ |
|||
package com.mydreamjob.candidatescore.refresh; |
|||
|
|||
import lombok.RequiredArgsConstructor; |
|||
import lombok.extern.slf4j.Slf4j; |
|||
import org.springframework.beans.factory.annotation.Value; |
|||
import org.springframework.jdbc.core.JdbcTemplate; |
|||
import org.springframework.stereotype.Component; |
|||
|
|||
import java.time.Clock; |
|||
import java.time.Duration; |
|||
import java.time.LocalDateTime; |
|||
import java.util.List; |
|||
|
|||
/** |
|||
* R01-D4-1 评分快照刷新 Worker。 |
|||
* |
|||
* <p>并发模型:<b>数据库条件更新领取</b>——{@code claim} 只在 |
|||
* {@code status IN ('PENDING','RETRY') AND next_attempt_at 到期} 时把行置为 PROCESSING, |
|||
* 第二个 Worker 对同一任务 claim 更新 0 行(略过),因此同一任务并发只能有一个有效处理者; |
|||
* 不使用 synchronized/ReentrantLock/Thread.sleep。</p> |
|||
* |
|||
* <p>失败语义:捕获并转为固定错误分类(COMPUTE_FAILED/WRITE_FAILED/UNKNOWN_REFRESH_FAILURE), |
|||
* attempts+1 进入 RETRY、设置 next_attempt_at;超过最大次数进入 FAILED(定时补偿仍可重新登记)。 |
|||
* 异常不回抛,不中断其他任务;{@code last_error_code} 不保存异常原文、SQL、手机号或堆栈。</p> |
|||
* |
|||
* <p>普通刷新<b>不写审计</b>(规避 刷新→审计→审计触发刷新 的循环);处理不发送 RabbitMQ、 |
|||
* 不写 notification、不写 snapshot_history。</p> |
|||
*/ |
|||
@Slf4j |
|||
@Component |
|||
@RequiredArgsConstructor |
|||
public class CandidateScoringRefreshWorker { |
|||
|
|||
public static final int DEFAULT_MAX_ATTEMPTS = 3; |
|||
public static final int DEFAULT_BATCH_SIZE = 20; |
|||
|
|||
private final JdbcTemplate jdbcTemplate; |
|||
private final CandidateScoringRefreshTaskExecutor taskExecutor; |
|||
private Clock clock = Clock.systemDefaultZone(); |
|||
|
|||
@Value("${app.scoring.refresh.max-attempts:3}") |
|||
private int maxAttempts = DEFAULT_MAX_ATTEMPTS; |
|||
|
|||
@Value("${app.scoring.refresh.processing-timeout-ms:600000}") |
|||
private long processingTimeoutMs; |
|||
|
|||
@org.springframework.beans.factory.annotation.Autowired(required = false) |
|||
public void setClock(Clock clock) { |
|||
if (clock != null) this.clock = clock; |
|||
} |
|||
|
|||
/** 领取一批到期任务并逐个处理;返回处理数。 */ |
|||
public int processDue(int batchSize) { |
|||
LocalDateTime now = LocalDateTime.now(clock); |
|||
long timeoutMs = processingTimeoutMs <= 0 ? 600_000L : processingTimeoutMs; |
|||
taskExecutor.recoverStale(now.minus(Duration.ofMillis(timeoutMs)), now, |
|||
Math.max(1, maxAttempts)); |
|||
List<Long> due = jdbcTemplate.queryForList( |
|||
"SELECT id FROM candidate_scoring_refresh_tasks " + |
|||
"WHERE status IN ('PENDING','RETRY') AND (next_attempt_at IS NULL OR next_attempt_at <= ?) " + |
|||
"ORDER BY id LIMIT ?", Long.class, now, batchSize); |
|||
int processed = 0; |
|||
for (Long id : due) { |
|||
try { |
|||
if (processOne(id, LocalDateTime.now(clock))) { |
|||
processed++; |
|||
} |
|||
} catch (Exception e) { |
|||
// 单任务异常不得阻断同批其余任务;具体分类已在 processOne 内登记。
|
|||
log.warn("refresh task processing aborted: taskId={}, cause={}", |
|||
id, e.getClass().getSimpleName()); |
|||
} |
|||
} |
|||
return processed; |
|||
} |
|||
|
|||
/** 处理单条任务;领取失败(被并发 Worker 领取)直接返回 false。 */ |
|||
public boolean processOne(Long taskId, LocalDateTime capturedNow) { |
|||
CandidateScoringRefreshTaskExecutor.ClaimedTask task = taskExecutor.claim(taskId, capturedNow); |
|||
if (task == null) { |
|||
return false; |
|||
} |
|||
try { |
|||
taskExecutor.materialize(task, capturedNow); |
|||
return true; |
|||
} catch (Exception e) { |
|||
String category = classify(e); |
|||
log.warn("refresh task failed: taskId={}, category={}", taskId, category); |
|||
try { |
|||
taskExecutor.recordFailure(taskId, category, capturedNow, |
|||
Math.max(1, maxAttempts), nextBackoffMs(), task.claimToken()); |
|||
} catch (Exception inner) { |
|||
// 失败登记本身出问题也不得吞掉整个批次;本轮不改状态,由租约超时兜底恢复。
|
|||
log.warn("refresh task failure recording skipped: taskId={}, cause={}", |
|||
taskId, inner.getClass().getSimpleName()); |
|||
} |
|||
return false; |
|||
} |
|||
} |
|||
|
|||
@Value("${app.scoring.refresh.retry-backoff-ms:300000}") |
|||
private long retryBackoffMs; |
|||
|
|||
private long nextBackoffMs() { |
|||
return retryBackoffMs <= 0 ? 300_000L : retryBackoffMs; |
|||
} |
|||
|
|||
private String classify(Throwable t) { |
|||
if (t instanceof org.springframework.dao.DataAccessException) { |
|||
return "WRITE_FAILED"; |
|||
} |
|||
if (t instanceof com.mydreamjob.common.exception.BusinessException) { |
|||
return "COMPUTE_FAILED"; |
|||
} |
|||
return "UNKNOWN_REFRESH_FAILURE"; |
|||
} |
|||
} |
|||
@ -0,0 +1,224 @@ |
|||
package com.mydreamjob.candidatescore.refresh; |
|||
|
|||
import com.fasterxml.jackson.databind.JsonNode; |
|||
import com.fasterxml.jackson.databind.ObjectMapper; |
|||
import com.fasterxml.jackson.databind.node.ObjectNode; |
|||
import com.mydreamjob.candidatescore.CandidateScoringCalculator; |
|||
import com.mydreamjob.candidatescore.dto.DimensionScoreDetailResponse; |
|||
import lombok.RequiredArgsConstructor; |
|||
import org.springframework.jdbc.core.JdbcTemplate; |
|||
import org.springframework.stereotype.Component; |
|||
import org.springframework.transaction.annotation.Transactional; |
|||
|
|||
import java.math.BigDecimal; |
|||
import java.util.ArrayList; |
|||
import java.util.Comparator; |
|||
import java.util.LinkedHashMap; |
|||
import java.util.List; |
|||
import java.util.Map; |
|||
import java.util.TreeMap; |
|||
|
|||
/** |
|||
* R01-D4-1 唯一快照 Writer:集中写入 {@code candidate_dimension_scores}。 |
|||
* |
|||
* <p>两种写入模式:</p> |
|||
* <ul> |
|||
* <li>{@link #upsertIfChanged}:普通物化——score/status/sub_scores_json/weight/rules_version |
|||
* 完全相同时不改写 version 与 updated_at(幂等);任一业务字段变化或 rulesVersion |
|||
* 变化才 {@code version+1};{@code calculated_at} 不单独触发 version 增长。</li> |
|||
* <li>{@link #upsertForcingVersion}:Governance 申诉决定/回滚的<b>强制版本推进</b>路径—— |
|||
* 每次无条件 version+1,保持 {@code GovernanceService} 的 |
|||
* {@code afterVersion = beforeVersion + 1} 与历史审计语义不变。</li> |
|||
* </ul> |
|||
* |
|||
* <p>sub_scores_json 写入前做 canonical 化(字段按字典序重排),避免字段顺序制造伪变化。 |
|||
* 不依赖 JVM 锁:唯一键 + 条件更新保证并发安全;MySQL 与 H2(MODE=MySQL)都应通过。</p> |
|||
*/ |
|||
@Component |
|||
@RequiredArgsConstructor |
|||
public class CandidateScoringSnapshotWriter { |
|||
|
|||
public static final BigDecimal DEFAULT_WEIGHT = new BigDecimal("12.50"); |
|||
private static final ObjectMapper MAPPER = new ObjectMapper(); |
|||
|
|||
private final JdbcTemplate jdbcTemplate; |
|||
|
|||
/** |
|||
* 普通物化:值变化才推进 version。 |
|||
* |
|||
* @param rulesVersion 当前规则版本(由调用方 {@code Calculator#currentRulesVersion()} 提供) |
|||
*/ |
|||
@Transactional |
|||
public void upsertIfChanged(Long studentUserId, List<CandidateScoringCalculator.DimensionResult> results, |
|||
String period, java.time.LocalDateTime capturedNow, String rulesVersion) { |
|||
if (results == null || results.isEmpty() || studentUserId == null) { |
|||
return; |
|||
} |
|||
for (CandidateScoringCalculator.DimensionResult r : results) { |
|||
upsertRow(studentUserId, r, period, capturedNow, rulesVersion, false); |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 单条强制版本推进(Governance 申诉决定/回滚专用)。 |
|||
*/ |
|||
@Transactional |
|||
public void upsertForcingVersion(Long studentUserId, CandidateScoringCalculator.DimensionResult r, |
|||
String period, java.time.LocalDateTime capturedNow, String rulesVersion) { |
|||
upsertRow(studentUserId, r, period, capturedNow, rulesVersion, true); |
|||
} |
|||
|
|||
private void upsertRow(Long studentUserId, CandidateScoringCalculator.DimensionResult r, |
|||
String period, java.time.LocalDateTime capturedNow, |
|||
String rulesVersion, boolean forceVersion) { |
|||
String json = canonicalJson(subScoresPayload(r)); |
|||
if (forceVersion) { |
|||
jdbcTemplate.update(""" |
|||
INSERT INTO candidate_dimension_scores |
|||
(student_user_id, dimension, period, score, status, sub_scores_json, |
|||
weight, rules_version, calculated_at, version, created_at, updated_at) |
|||
VALUES (?,?,?,?,?,?,?,?,?,0,NOW(),NOW()) |
|||
ON DUPLICATE KEY UPDATE |
|||
score=?, status=?, sub_scores_json=?, weight=?, rules_version=?, calculated_at=?, |
|||
version=version+1, updated_at=NOW() |
|||
""", studentUserId, r.dimension, period, r.score, r.status, json, |
|||
DEFAULT_WEIGHT, rulesVersion, capturedNow, |
|||
r.score, r.status, json, DEFAULT_WEIGHT, rulesVersion, capturedNow); |
|||
return; |
|||
} |
|||
// 先做并发安全的“若不存在则插入”。冲突时只做主键自赋值,不覆盖业务字段,
|
|||
// 随后的锁定读负责比较旧值;避免先 SELECT 后 INSERT 的首次物化竞争。
|
|||
jdbcTemplate.update(""" |
|||
INSERT INTO candidate_dimension_scores |
|||
(student_user_id, dimension, period, score, status, sub_scores_json, |
|||
weight, rules_version, calculated_at, version, created_at, updated_at) |
|||
VALUES (?,?,?,?,?,?,?,?,?,0,NOW(),NOW()) |
|||
ON DUPLICATE KEY UPDATE id=id |
|||
""", studentUserId, r.dimension, period, r.score, r.status, json, |
|||
DEFAULT_WEIGHT, rulesVersion, capturedNow); |
|||
ExistingRow existing = findForUpdate(studentUserId, r.dimension, period); |
|||
if (existing == null) { |
|||
throw new org.springframework.dao.DataIntegrityViolationException( |
|||
"candidate scoring snapshot insert was not visible"); |
|||
} |
|||
|
|||
boolean changed = !same(existing.score(), r.score) |
|||
|| !java.util.Objects.equals(existing.status(), r.status) |
|||
|| !java.util.Objects.equals(canonicalJson(existing.subScoresJson()), json) |
|||
|| !same(existing.weight(), DEFAULT_WEIGHT) |
|||
|| !java.util.Objects.equals(existing.rulesVersion(), rulesVersion); |
|||
if (!changed) { |
|||
return; |
|||
} |
|||
int updated = jdbcTemplate.update(""" |
|||
UPDATE candidate_dimension_scores |
|||
SET score=?, status=?, sub_scores_json=?, weight=?, rules_version=?, calculated_at=?, |
|||
version=version+1, updated_at=NOW() |
|||
WHERE id=? AND version=? |
|||
""", r.score, r.status, json, DEFAULT_WEIGHT, rulesVersion, capturedNow, |
|||
existing.id(), existing.version()); |
|||
if (updated != 1) { |
|||
throw new org.springframework.dao.OptimisticLockingFailureException( |
|||
"candidate scoring snapshot changed concurrently"); |
|||
} |
|||
} |
|||
|
|||
private ExistingRow findForUpdate(Long studentUserId, String dimension, String period) { |
|||
return jdbcTemplate.query(""" |
|||
SELECT id, score, status, sub_scores_json, weight, rules_version, version |
|||
FROM candidate_dimension_scores |
|||
WHERE student_user_id=? AND dimension=? AND period=? |
|||
FOR UPDATE |
|||
""", (rs, i) -> new ExistingRow( |
|||
rs.getLong("id"), rs.getBigDecimal("score"), rs.getString("status"), |
|||
rs.getString("sub_scores_json"), rs.getBigDecimal("weight"), |
|||
rs.getString("rules_version"), rs.getLong("version")), |
|||
studentUserId, dimension, period).stream().findFirst().orElse(null); |
|||
} |
|||
|
|||
private boolean same(BigDecimal left, BigDecimal right) { |
|||
return left == null ? right == null : right != null && left.compareTo(right) == 0; |
|||
} |
|||
|
|||
private record ExistingRow(long id, BigDecimal score, String status, String subScoresJson, |
|||
BigDecimal weight, String rulesVersion, long version) { |
|||
} |
|||
|
|||
// ── payload(与 CandidateScoringCalculator.subScoresPayload 等价的内部实现) ──
|
|||
|
|||
private String subScoresPayload(CandidateScoringCalculator.DimensionResult r) { |
|||
if ("ABILITY".equals(r.dimension) && r.abilitySnapshotTrace != null) { |
|||
try { |
|||
Map<String, Object> m = new LinkedHashMap<>(); |
|||
m.put("source", "V36_ABILITY_ASSESSMENT"); |
|||
m.put("sourceResultId", r.abilitySnapshotTrace.resultId()); |
|||
m.put("sourceCompletedAt", r.abilitySnapshotTrace.completedAt() == null |
|||
? null : r.abilitySnapshotTrace.completedAt().toString()); |
|||
m.put("validUntil", r.abilitySnapshotTrace.validUntil() == null |
|||
? null : r.abilitySnapshotTrace.validUntil().toString()); |
|||
m.put("questionSetVersion", r.abilitySnapshotTrace.questionSetVersion()); |
|||
m.put("assessmentRulesVersion", r.abilitySnapshotTrace.assessmentRulesVersion()); |
|||
m.put("mappingRulesVersion", com.mydreamjob.candidatescore.AbilityScoreMapper.MAPPING_RULES_VERSION); |
|||
m.put("weights", r.abilitySnapshotTrace.weights()); |
|||
m.put("dimensionScores", r.abilitySnapshotTrace.dimensionScores()); |
|||
return MAPPER.writeValueAsString(m); |
|||
} catch (Exception e) { |
|||
return null; |
|||
} |
|||
} |
|||
return toJson(r.subScores); |
|||
} |
|||
|
|||
private String toJson(List<DimensionScoreDetailResponse.SubScore> subs) { |
|||
if (subs == null || subs.isEmpty()) { |
|||
return null; |
|||
} |
|||
try { |
|||
return MAPPER.writeValueAsString(subs); |
|||
} catch (Exception e) { |
|||
return null; |
|||
} |
|||
} |
|||
|
|||
static String canonicalJson(String raw) { |
|||
if (raw == null || raw.isBlank()) { |
|||
return raw; |
|||
} |
|||
try { |
|||
JsonNode node = MAPPER.readTree(raw); |
|||
return MAPPER.writeValueAsString(sortKeys(node)); |
|||
} catch (Exception e) { |
|||
return raw; |
|||
} |
|||
} |
|||
|
|||
private static JsonNode sortKeys(JsonNode node) { |
|||
if (node instanceof ObjectNode obj) { |
|||
ObjectNode sorted = MAPPER.createObjectNode(); |
|||
List<String> names = new ArrayList<>(); |
|||
obj.fieldNames().forEachRemaining(names::add); |
|||
names.sort(Comparator.naturalOrder()); |
|||
for (String name : names) { |
|||
sorted.set(name, sortKeys(obj.get(name))); |
|||
} |
|||
return sorted; |
|||
} |
|||
if (node.isArray()) { |
|||
com.fasterxml.jackson.databind.node.ArrayNode array = MAPPER.createArrayNode(); |
|||
for (JsonNode child : node) { |
|||
array.add(sortKeys(child)); |
|||
} |
|||
return array; |
|||
} |
|||
return node; |
|||
} |
|||
|
|||
/** 仅测试用:暴露时区无关的 canonic 化。 */ |
|||
static String canonicalForTest(Map<String, Object> raw) { |
|||
try { |
|||
return canonicalJson(MAPPER.writeValueAsString(new TreeMap<>(raw))); |
|||
} catch (Exception e) { |
|||
return null; |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,31 @@ |
|||
-- V44__candidate_scoring_refresh_tasks.sql |
|||
-- R01-D4-1:评分快照刷新任务表(持久化物化基础设施) |
|||
-- 只登记"该学生+周期需要重新物化",不保存评分源数据;GET 兜底写入在 D4-2 前保留。 |
|||
|
|||
CREATE TABLE candidate_scoring_refresh_tasks ( |
|||
id BIGINT AUTO_INCREMENT PRIMARY KEY, |
|||
student_user_id BIGINT NOT NULL, |
|||
target_period VARCHAR(7) NOT NULL COMMENT 'yyyy-MM', |
|||
status VARCHAR(20) NOT NULL DEFAULT 'PENDING', |
|||
requested_at DATETIME NOT NULL, |
|||
processing_started_at DATETIME NULL, |
|||
processed_at DATETIME NULL, |
|||
next_attempt_at DATETIME NULL, |
|||
attempts INT NOT NULL DEFAULT 0, |
|||
last_error_code VARCHAR(40) NULL, |
|||
version BIGINT NOT NULL DEFAULT 0, |
|||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, |
|||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, |
|||
CONSTRAINT uk_refresh_task_student_period UNIQUE (student_user_id, target_period), |
|||
CONSTRAINT ck_refresh_task_status CHECK (status IN ('PENDING', 'PROCESSING', 'RETRY', 'COMPLETED', 'FAILED')), |
|||
INDEX idx_refresh_task_claim (status, next_attempt_at), |
|||
CONSTRAINT fk_refresh_task_student FOREIGN KEY (student_user_id) REFERENCES users(id) ON DELETE CASCADE |
|||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; |
|||
|
|||
-- 补偿扫描持久游标:跨实例、跨重启延续扫描位置;单轮只处理配置预算内的学生。 |
|||
CREATE TABLE candidate_scoring_refresh_scan_checkpoints ( |
|||
scan_key VARCHAR(40) PRIMARY KEY, |
|||
last_user_id BIGINT NOT NULL DEFAULT 0, |
|||
version BIGINT NOT NULL DEFAULT 0, |
|||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP |
|||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; |
|||
@ -0,0 +1,13 @@ |
|||
-- V45__add_scoring_refresh_claim_token.sql |
|||
-- R01-D4-2 claim fence 定向整改:为刷新任务引入不可复用的领取令牌。 |
|||
-- |
|||
-- 背景:V44 的 processing_started_at 为秒级 DATETIME,当租约回收与重新领取落在同一秒时, |
|||
-- 新旧两次领取得到完全相同的所有权标识,旧 Worker 的 PENDING 回退会误改新持有者。 |
|||
-- 本迁移新增 claim_token:每次成功领取写入全新随机 UUID,终态/PENDING 时清空。 |
|||
-- |
|||
-- 只新增列,不改动 V1–V44,不改动业务数据,不新增公网接口或 ErrorCode。 |
|||
-- processing_started_at 继续保留用于超时扫描与观测,但不再承担所有权证明。 |
|||
|
|||
ALTER TABLE candidate_scoring_refresh_tasks |
|||
ADD COLUMN claim_token VARCHAR(36) NULL |
|||
COMMENT '本次领取的不可复用令牌(UUID),终态/待处理时清空;仅用于并发所有权判定'; |
|||
@ -0,0 +1,156 @@ |
|||
package com.mydreamjob.candidatescore.refresh; |
|||
|
|||
import com.mydreamjob.auth.User; |
|||
import com.mydreamjob.auth.UserRepository; |
|||
import com.mydreamjob.candidatescore.CandidateScoringCalculator; |
|||
import com.mydreamjob.common.exception.BusinessException; |
|||
import com.mydreamjob.common.exception.ErrorCode; |
|||
import org.junit.jupiter.api.BeforeEach; |
|||
import org.junit.jupiter.api.Test; |
|||
import org.springframework.beans.factory.annotation.Autowired; |
|||
import org.springframework.boot.test.context.SpringBootTest; |
|||
import org.springframework.boot.test.mock.mockito.MockBean; |
|||
import org.springframework.data.redis.core.RedisTemplate; |
|||
import org.springframework.data.redis.core.ValueOperations; |
|||
import org.springframework.jdbc.core.JdbcTemplate; |
|||
import org.springframework.test.context.ActiveProfiles; |
|||
|
|||
import java.time.LocalDateTime; |
|||
import java.util.List; |
|||
import java.util.concurrent.atomic.AtomicLong; |
|||
|
|||
import static org.junit.jupiter.api.Assertions.*; |
|||
import static org.mockito.ArgumentMatchers.any; |
|||
import static org.mockito.ArgumentMatchers.eq; |
|||
import static org.mockito.Mockito.mock; |
|||
import static org.mockito.Mockito.when; |
|||
|
|||
/** |
|||
* R01-D4-2 次生缺陷:单个任务失败不得阻止同批后续任务。 |
|||
* |
|||
* <p>缺陷背景:{@code recordFailure} 在更新 0 行时抛 IllegalStateException,异常点位于 |
|||
* Worker 的 catch 块内,而 {@code processDue} 的 for 循环没有保护 —— 单个任务的失败会 |
|||
* 让同批剩余任务本轮全部不被处理。</p> |
|||
* |
|||
* <p>本测试通过 mock 计算器精确制造"指定学生计算失败",断言真实数据库中的批次终态。</p> |
|||
*/ |
|||
@SpringBootTest |
|||
@ActiveProfiles("integration-test") |
|||
class CandidateScoringRefreshBatchIsolationTest { |
|||
|
|||
@Autowired private CandidateScoringRefreshRequestService requestService; |
|||
@Autowired private CandidateScoringRefreshWorker worker; |
|||
@Autowired private UserRepository userRepository; |
|||
@Autowired private JdbcTemplate jdbcTemplate; |
|||
@MockBean private RedisTemplate<String, Object> redisTemplate; |
|||
@MockBean private CandidateScoringCalculator calculator; |
|||
|
|||
private static final AtomicLong SEQ = new AtomicLong(11000); |
|||
|
|||
private Long okA; |
|||
private Long okB; |
|||
private Long failing; |
|||
|
|||
private String statusOf(Long studentId) { |
|||
return jdbcTemplate.queryForObject( |
|||
"SELECT status FROM candidate_scoring_refresh_tasks WHERE student_user_id=?", |
|||
String.class, studentId); |
|||
} |
|||
|
|||
private String errorCodeOf(Long studentId) { |
|||
return jdbcTemplate.queryForObject( |
|||
"SELECT last_error_code FROM candidate_scoring_refresh_tasks WHERE student_user_id=?", |
|||
String.class, studentId); |
|||
} |
|||
|
|||
private Long newStudent(String tag) { |
|||
User u = User.builder() |
|||
.phone("191" + String.format("%08d", SEQ.incrementAndGet())) |
|||
.nickname(tag).role(User.Role.STUDENT).active(true).verified(true).build(); |
|||
u.setRoles(java.util.Set.of(User.Role.STUDENT)); |
|||
return userRepository.save(u).getId(); |
|||
} |
|||
|
|||
@BeforeEach |
|||
@SuppressWarnings("unchecked") |
|||
void setUp() { |
|||
ValueOperations<String, Object> ops = mock(ValueOperations.class); |
|||
when(redisTemplate.opsForValue()).thenReturn(ops); |
|||
when(redisTemplate.hasKey(any())).thenReturn(false); |
|||
|
|||
jdbcTemplate.update("DELETE FROM candidate_scoring_refresh_tasks"); |
|||
|
|||
okA = newStudent("隔离A"); |
|||
okB = newStudent("隔离B"); |
|||
failing = newStudent("失败C"); |
|||
|
|||
when(calculator.currentRulesVersion()).thenReturn("candidate-scoring-v0.1"); |
|||
when(calculator.computeAll(eq(okA), any(LocalDateTime.class))).thenReturn(List.of()); |
|||
when(calculator.computeAll(eq(okB), any(LocalDateTime.class))).thenReturn(List.of()); |
|||
// 计算失败:异常 message 故意夹带敏感形态内容,用于验证错误分类不落异常原文
|
|||
when(calculator.computeAll(eq(failing), any(LocalDateTime.class))) |
|||
.thenThrow(new BusinessException(ErrorCode.BAD_REQUEST, |
|||
"compute failed for phone=13800001111 token=eyJhbGciOiJIUzI1NiJ9.secret.sig")); |
|||
} |
|||
|
|||
@org.junit.jupiter.api.AfterEach |
|||
void tearDown() { |
|||
jdbcTemplate.update("DELETE FROM candidate_scoring_refresh_tasks"); |
|||
} |
|||
|
|||
@Test |
|||
void firstTaskFails_secondTaskStillProcessed() { |
|||
requestService.requestRefresh(failing, LocalDateTime.now()); |
|||
requestService.requestRefresh(okA, LocalDateTime.now()); |
|||
|
|||
int processed = worker.processDue(10); |
|||
|
|||
assertEquals("RETRY", statusOf(failing), "失败任务应进入 RETRY"); |
|||
assertEquals("COMPLETED", statusOf(okA), |
|||
"同批后续任务必须仍然被处理(单任务失败不得阻断批次)"); |
|||
assertEquals(1, processed, "成功计数只统计被处理的任务"); |
|||
} |
|||
|
|||
@Test |
|||
void failingTaskInMiddle_doesNotBlockRemainingTasks() { |
|||
requestService.requestRefresh(okA, LocalDateTime.now()); |
|||
requestService.requestRefresh(failing, LocalDateTime.now()); |
|||
requestService.requestRefresh(okB, LocalDateTime.now()); |
|||
|
|||
worker.processDue(10); |
|||
|
|||
assertEquals("COMPLETED", statusOf(okA), "失败项之前的任务应完成"); |
|||
assertEquals("COMPLETED", statusOf(okB), "失败项之后的任务也必须完成"); |
|||
assertEquals("RETRY", statusOf(failing)); |
|||
} |
|||
|
|||
@Test |
|||
void lastErrorCode_neverLeaksExceptionMessage() { |
|||
requestService.requestRefresh(failing, LocalDateTime.now()); |
|||
|
|||
worker.processDue(10); |
|||
|
|||
String code = errorCodeOf(failing); |
|||
assertNotNull(code); |
|||
assertEquals("COMPUTE_FAILED", code, "只允许固定错误分类"); |
|||
assertFalse(code.contains("13800001111"), "错误分类不得包含手机号"); |
|||
assertFalse(code.contains("eyJ"), "错误分类不得包含 Token"); |
|||
assertFalse(code.contains("compute failed"), "错误分类不得包含异常原文"); |
|||
assertTrue(code.length() <= 40, "错误分类长度受列定义约束"); |
|||
} |
|||
|
|||
@Test |
|||
void batchTerminalState_matchesRealOutcome() { |
|||
requestService.requestRefresh(okA, LocalDateTime.now()); |
|||
requestService.requestRefresh(failing, LocalDateTime.now()); |
|||
requestService.requestRefresh(okB, LocalDateTime.now()); |
|||
|
|||
worker.processDue(10); |
|||
|
|||
List<String> statuses = jdbcTemplate.queryForList( |
|||
"SELECT status FROM candidate_scoring_refresh_tasks ORDER BY student_user_id", String.class); |
|||
assertFalse(statuses.contains("PROCESSING"), "批次结束后不得残留 PROCESSING"); |
|||
assertEquals(2, statuses.stream().filter("COMPLETED"::equals).count(), "两个成功项"); |
|||
assertEquals(1, statuses.stream().filter("RETRY"::equals).count(), "一个重试项"); |
|||
} |
|||
} |
|||
@ -0,0 +1,215 @@ |
|||
package com.mydreamjob.candidatescore.refresh; |
|||
|
|||
import com.mydreamjob.auth.User; |
|||
import com.mydreamjob.auth.UserRepository; |
|||
import org.junit.jupiter.api.BeforeEach; |
|||
import org.junit.jupiter.api.Test; |
|||
import org.springframework.beans.factory.annotation.Autowired; |
|||
import org.springframework.boot.test.context.SpringBootTest; |
|||
import org.springframework.boot.test.mock.mockito.MockBean; |
|||
import org.springframework.data.redis.core.RedisTemplate; |
|||
import org.springframework.data.redis.core.ValueOperations; |
|||
import org.springframework.jdbc.core.JdbcTemplate; |
|||
import org.springframework.test.context.ActiveProfiles; |
|||
|
|||
import java.time.LocalDateTime; |
|||
import java.util.List; |
|||
import java.util.concurrent.atomic.AtomicLong; |
|||
|
|||
import static org.junit.jupiter.api.Assertions.*; |
|||
import static org.mockito.ArgumentMatchers.any; |
|||
import static org.mockito.Mockito.mock; |
|||
import static org.mockito.Mockito.when; |
|||
|
|||
/** |
|||
* R01-D4-2 次生缺陷:Worker 领取所有权(claim fence)与 stale claim 处置。 |
|||
* |
|||
* <p>缺陷背景:{@code materialize} 的「回到 PENDING」回退分支只校验 |
|||
* {@code status='PROCESSING'} 而不校验领取所有权;当原 Worker 因租约超时已被重新领取时, |
|||
* 慢返回的旧 Worker 会把新 Worker 正在处理的任务改回 PENDING。同时 |
|||
* {@code recordFailure} 在更新 0 行时抛 IllegalStateException,异常点位于 Worker 的 |
|||
* catch 块内、{@code processDue} 的 for 循环无保护 → 同批剩余任务本轮全部不处理。</p> |
|||
* |
|||
* <p>本测试断言真实数据库状态,不使用 sleep,不使用 JVM 锁。</p> |
|||
*/ |
|||
@SpringBootTest |
|||
@ActiveProfiles("integration-test") |
|||
class CandidateScoringRefreshClaimFenceTest { |
|||
|
|||
@Autowired private CandidateScoringRefreshRequestService requestService; |
|||
@Autowired private CandidateScoringRefreshTaskExecutor taskExecutor; |
|||
@Autowired private CandidateScoringRefreshWorker worker; |
|||
@Autowired private UserRepository userRepository; |
|||
@Autowired private JdbcTemplate jdbcTemplate; |
|||
@MockBean private RedisTemplate<String, Object> redisTemplate; |
|||
|
|||
private static final AtomicLong SEQ = new AtomicLong(9000); |
|||
|
|||
private Long sid; |
|||
private String period; |
|||
|
|||
private String status(Long taskId) { |
|||
return jdbcTemplate.queryForObject( |
|||
"SELECT status FROM candidate_scoring_refresh_tasks WHERE id=?", String.class, taskId); |
|||
} |
|||
|
|||
private LocalDateTime processingStartedAt(Long taskId) { |
|||
return jdbcTemplate.queryForObject( |
|||
"SELECT processing_started_at FROM candidate_scoring_refresh_tasks WHERE id=?", |
|||
LocalDateTime.class, taskId); |
|||
} |
|||
|
|||
private Long newTask() { |
|||
requestService.requestRefresh(sid, LocalDateTime.now()); |
|||
return jdbcTemplate.queryForObject( |
|||
"SELECT id FROM candidate_scoring_refresh_tasks WHERE student_user_id=?", Long.class, sid); |
|||
} |
|||
|
|||
@BeforeEach |
|||
@SuppressWarnings("unchecked") |
|||
void setUp() { |
|||
ValueOperations<String, Object> ops = mock(ValueOperations.class); |
|||
when(redisTemplate.opsForValue()).thenReturn(ops); |
|||
when(redisTemplate.hasKey(any())).thenReturn(false); |
|||
|
|||
User u = User.builder() |
|||
.phone("193" + String.format("%08d", SEQ.incrementAndGet())) |
|||
.nickname("Fence学生").role(User.Role.STUDENT).active(true).verified(true).build(); |
|||
u.setRoles(java.util.Set.of(User.Role.STUDENT)); |
|||
sid = userRepository.save(u).getId(); |
|||
|
|||
jdbcTemplate.update("DELETE FROM candidate_scoring_refresh_tasks"); |
|||
// 与 CandidateScoringRefreshRequestService 保持一致:period = capturedNow 所在 yyyy-MM
|
|||
LocalDateTime n = LocalDateTime.now(); |
|||
period = String.format("%04d-%02d", n.getYear(), n.getMonthValue()); |
|||
} |
|||
|
|||
/** 清理本类产生的行,避免污染同一 JVM 内共享的 H2 内存库。 */ |
|||
@org.junit.jupiter.api.AfterEach |
|||
void tearDown() { |
|||
jdbcTemplate.update("DELETE FROM candidate_scoring_refresh_tasks"); |
|||
jdbcTemplate.update("DELETE FROM candidate_dimension_scores WHERE student_user_id=?", sid); |
|||
} |
|||
|
|||
// ── claim fence ────────────────────────────────────────────
|
|||
|
|||
@Test |
|||
void claim_exposesProcessingStartedAtAsOwnershipFence() { |
|||
Long taskId = newTask(); |
|||
LocalDateTime t1 = LocalDateTime.now().withNano(0); |
|||
CandidateScoringRefreshTaskExecutor.ClaimedTask claimed = taskExecutor.claim(taskId, t1); |
|||
|
|||
assertNotNull(claimed, "首次 claim 应成功"); |
|||
assertEquals("PROCESSING", status(taskId)); |
|||
assertEquals(t1, processingStartedAt(taskId), "claim 必须记录所有权标识"); |
|||
assertNotNull(claimed, "ClaimedTask 必须携带领取信息"); |
|||
} |
|||
|
|||
@Test |
|||
void staleWorkerCompletion_cannotOverwriteNewWorkerClaim() { |
|||
Long taskId = newTask(); |
|||
LocalDateTime t1 = LocalDateTime.now().withNano(0).minusMinutes(20); |
|||
CandidateScoringRefreshTaskExecutor.ClaimedTask oldClaim = taskExecutor.claim(taskId, t1); |
|||
assertNotNull(oldClaim); |
|||
|
|||
// 模拟租约超时回收 + 另一个 Worker 重新领取
|
|||
taskExecutor.recoverStale(LocalDateTime.now(), LocalDateTime.now(), 3); |
|||
LocalDateTime t2 = LocalDateTime.now().withNano(0); |
|||
CandidateScoringRefreshTaskExecutor.ClaimedTask newClaim = taskExecutor.claim(taskId, t2); |
|||
assertNotNull(newClaim, "回收后应可被重新领取"); |
|||
assertEquals("PROCESSING", status(taskId)); |
|||
assertEquals(t2, processingStartedAt(taskId)); |
|||
|
|||
// 旧 Worker 迟到完成:必须更新 0 行并安静退出,不得改动新 Worker 的 PROCESSING
|
|||
assertDoesNotThrow(() -> taskExecutor.materialize(oldClaim, LocalDateTime.now()), |
|||
"stale claim 的完成路径必须安静退出而不是抛异常"); |
|||
|
|||
assertEquals("PROCESSING", status(taskId), "旧 Worker 不得覆盖新 Worker 的领取状态"); |
|||
assertEquals(t2, processingStartedAt(taskId), "旧 Worker 不得改动所有权标识"); |
|||
|
|||
// 新 Worker 正常完成
|
|||
taskExecutor.materialize(newClaim, LocalDateTime.now()); |
|||
assertEquals("COMPLETED", status(taskId), "持有者应能正常完成"); |
|||
} |
|||
|
|||
@Test |
|||
void staleWorkerFailure_cannotOverwriteNewWorkerClaim() { |
|||
Long taskId = newTask(); |
|||
LocalDateTime t1 = LocalDateTime.now().withNano(0).minusMinutes(20); |
|||
CandidateScoringRefreshTaskExecutor.ClaimedTask oldClaim = taskExecutor.claim(taskId, t1); |
|||
assertNotNull(oldClaim); |
|||
|
|||
taskExecutor.recoverStale(LocalDateTime.now(), LocalDateTime.now(), 3); |
|||
LocalDateTime t2 = LocalDateTime.now().withNano(0); |
|||
taskExecutor.claim(taskId, t2); |
|||
|
|||
// 旧 Worker 迟到失败登记(携带旧的所有权令牌):必须安静退出
|
|||
assertDoesNotThrow(() -> taskExecutor.recordFailure(taskId, "WRITE_FAILED", |
|||
LocalDateTime.now(), 3, 1000L, oldClaim.claimToken()), |
|||
"stale claim 的失败登记必须安静退出而不是抛异常"); |
|||
assertEquals("PROCESSING", status(taskId), "旧 Worker 的失败登记不得覆盖新 Worker"); |
|||
} |
|||
|
|||
@Test |
|||
void requestDuringSameClaim_completionFallsBackToPending() { |
|||
Long taskId = newTask(); |
|||
LocalDateTime t1 = LocalDateTime.now().withNano(0); |
|||
CandidateScoringRefreshTaskExecutor.ClaimedTask claimed = taskExecutor.claim(taskId, t1); |
|||
assertNotNull(claimed); |
|||
|
|||
// 同一次领取期间到达新请求 → version 推进,但所有权标识不变
|
|||
requestService.requestRefresh(sid, t1.plusNanos(1)); |
|||
assertEquals("PROCESSING", status(taskId), "处理期间新请求不得改变 PROCESSING"); |
|||
assertEquals(t1, processingStartedAt(taskId), "处理期间新请求不得改变所有权标识"); |
|||
|
|||
taskExecutor.materialize(claimed, LocalDateTime.now()); |
|||
|
|||
assertEquals("PENDING", status(taskId), |
|||
"同一 claim 期间收到新请求时,完成路径必须回到 PENDING 而不是 COMPLETED"); |
|||
} |
|||
|
|||
// ── 单任务失败隔离 ─────────────────────────────────────────
|
|||
|
|||
@Test |
|||
void processDue_skipsTasksAlreadyClaimedByAnotherWorker_andProcessesTheRest() { |
|||
Long taskA = newTask(); |
|||
Long taskB = newTaskFor(newStudent("隔离B")); |
|||
Long taskC = newTaskFor(newStudent("隔离C")); |
|||
|
|||
// 模拟 taskA 已被另一个 Worker 持有
|
|||
assertNotNull(taskExecutor.claim(taskA, LocalDateTime.now().withNano(0))); |
|||
|
|||
int processed = worker.processDue(10); |
|||
|
|||
assertEquals(2, processed, "被并发持有的任务应被跳过,其余任务仍被处理"); |
|||
assertEquals("COMPLETED", status(taskB), "同批第二个任务应被处理"); |
|||
assertEquals("COMPLETED", status(taskC), "同批第三个任务应被处理"); |
|||
} |
|||
|
|||
@Test |
|||
void processDue_batchEndsConsistently_evenWithStaleClaimsPresent() { |
|||
Long a = newTask(); |
|||
|
|||
int processed = worker.processDue(10); |
|||
|
|||
assertTrue(processed >= 0); |
|||
List<String> all = jdbcTemplate.queryForList( |
|||
"SELECT status FROM candidate_scoring_refresh_tasks", String.class); |
|||
assertFalse(all.contains("PROCESSING"), "批次结束后不得残留 PROCESSING"); |
|||
assertEquals("COMPLETED", status(a)); |
|||
} |
|||
|
|||
private Long newStudent(String tag) { |
|||
User u = User.builder() |
|||
.phone("192" + String.format("%08d", SEQ.incrementAndGet())) |
|||
.nickname(tag).role(User.Role.STUDENT).active(true).verified(true).build(); |
|||
u.setRoles(java.util.Set.of(User.Role.STUDENT)); |
|||
return userRepository.save(u).getId(); |
|||
} |
|||
|
|||
private Long newTaskFor(Long studentId) { |
|||
requestService.requestRefresh(studentId, LocalDateTime.now()); |
|||
return jdbcTemplate.queryForObject( |
|||
"SELECT id FROM candidate_scoring_refresh_tasks WHERE student_user_id=?", Long.class, studentId); |
|||
} |
|||
} |
|||
@ -0,0 +1,412 @@ |
|||
package com.mydreamjob.candidatescore.refresh; |
|||
|
|||
import com.mydreamjob.auth.User; |
|||
import com.mydreamjob.auth.UserRepository; |
|||
import org.junit.jupiter.api.AfterEach; |
|||
import org.junit.jupiter.api.BeforeEach; |
|||
import org.junit.jupiter.api.Test; |
|||
import org.springframework.beans.factory.annotation.Autowired; |
|||
import org.springframework.boot.test.context.SpringBootTest; |
|||
import org.springframework.boot.test.mock.mockito.MockBean; |
|||
import org.springframework.data.redis.core.RedisTemplate; |
|||
import org.springframework.data.redis.core.ValueOperations; |
|||
import org.springframework.jdbc.core.JdbcTemplate; |
|||
import org.springframework.test.context.ActiveProfiles; |
|||
|
|||
import java.time.LocalDateTime; |
|||
import java.util.List; |
|||
import java.util.concurrent.atomic.AtomicLong; |
|||
|
|||
import static org.junit.jupiter.api.Assertions.*; |
|||
import static org.mockito.ArgumentMatchers.any; |
|||
import static org.mockito.Mockito.mock; |
|||
import static org.mockito.Mockito.when; |
|||
|
|||
/** |
|||
* R01-D4-2 claim fence 定向整改:不可复用 claim token。 |
|||
* |
|||
* <p>缺陷背景:旧实现以 {@code processing_started_at} 作为所有权标识,而该列是秒级 |
|||
* {@code DATETIME}。当租约回收与重新领取落在同一秒时,新旧 claim 的 fence 完全相同, |
|||
* 旧 Worker 的 PENDING 回退会误改新持有者(已由 {@code iv-fence2.sql} 在真实 MySQL 复现)。</p> |
|||
* |
|||
* <p>本测试使用<b>固定时间值</b>(两次领取传入完全相同的 {@code LocalDateTime}), |
|||
* 不依赖跨秒等待。</p> |
|||
*/ |
|||
@SpringBootTest |
|||
@ActiveProfiles("integration-test") |
|||
class CandidateScoringRefreshClaimTokenTest { |
|||
|
|||
@Autowired private CandidateScoringRefreshRequestService requestService; |
|||
@Autowired private CandidateScoringRefreshTaskExecutor taskExecutor; |
|||
@Autowired private CandidateScoringRefreshWorker worker; |
|||
@Autowired private UserRepository userRepository; |
|||
@Autowired private JdbcTemplate jdbcTemplate; |
|||
@MockBean private RedisTemplate<String, Object> redisTemplate; |
|||
|
|||
private static final AtomicLong SEQ = new AtomicLong(13000); |
|||
|
|||
/** 两次领取刻意使用同一个时间值。 */ |
|||
private static final LocalDateTime SAME_INSTANT = LocalDateTime.of(2026, 9, 16, 12, 0, 0); |
|||
|
|||
private Long sid; |
|||
private String period; |
|||
|
|||
private String status(Long taskId) { |
|||
return jdbcTemplate.queryForObject( |
|||
"SELECT status FROM candidate_scoring_refresh_tasks WHERE id=?", String.class, taskId); |
|||
} |
|||
|
|||
private String claimToken(Long taskId) { |
|||
return jdbcTemplate.queryForObject( |
|||
"SELECT claim_token FROM candidate_scoring_refresh_tasks WHERE id=?", String.class, taskId); |
|||
} |
|||
|
|||
private int attempts(Long taskId) { |
|||
Integer v = jdbcTemplate.queryForObject( |
|||
"SELECT attempts FROM candidate_scoring_refresh_tasks WHERE id=?", Integer.class, taskId); |
|||
return v == null ? -1 : v; |
|||
} |
|||
|
|||
private LocalDateTime processingStartedAt(Long taskId) { |
|||
return jdbcTemplate.queryForObject( |
|||
"SELECT processing_started_at FROM candidate_scoring_refresh_tasks WHERE id=?", |
|||
LocalDateTime.class, taskId); |
|||
} |
|||
|
|||
private Long newTask() { |
|||
requestService.requestRefresh(sid, LocalDateTime.now()); |
|||
return jdbcTemplate.queryForObject( |
|||
"SELECT id FROM candidate_scoring_refresh_tasks WHERE student_user_id=?", Long.class, sid); |
|||
} |
|||
|
|||
/** 模拟租约回收(把 PROCESSING 置为 RETRY,processing_started_at 清空)。 */ |
|||
private void recover(Long taskId) { |
|||
jdbcTemplate.update("UPDATE candidate_scoring_refresh_tasks SET status='RETRY', " |
|||
+ "processing_started_at=NULL, attempts=attempts+1, version=version+1 WHERE id=?", taskId); |
|||
} |
|||
|
|||
@BeforeEach |
|||
@SuppressWarnings("unchecked") |
|||
void setUp() { |
|||
ValueOperations<String, Object> ops = mock(ValueOperations.class); |
|||
when(redisTemplate.opsForValue()).thenReturn(ops); |
|||
when(redisTemplate.hasKey(any())).thenReturn(false); |
|||
|
|||
User u = User.builder() |
|||
.phone("190" + String.format("%08d", SEQ.incrementAndGet())) |
|||
.nickname("Token学生").role(User.Role.STUDENT).active(true).verified(true).build(); |
|||
u.setRoles(java.util.Set.of(User.Role.STUDENT)); |
|||
sid = userRepository.save(u).getId(); |
|||
|
|||
jdbcTemplate.update("DELETE FROM candidate_scoring_refresh_tasks"); |
|||
LocalDateTime n = LocalDateTime.now(); |
|||
period = String.format("%04d-%02d", n.getYear(), n.getMonthValue()); |
|||
} |
|||
|
|||
@AfterEach |
|||
void tearDown() { |
|||
jdbcTemplate.update("DELETE FROM candidate_scoring_refresh_tasks"); |
|||
jdbcTemplate.update("DELETE FROM candidate_dimension_scores WHERE student_user_id=?", sid); |
|||
} |
|||
|
|||
// ── 缺陷复现:同一时间值下两次领取,旧 Worker 不得影响新持有者 ──
|
|||
|
|||
@Test |
|||
void sameTimestamp_staleWorkerPendingRevert_mustNotTouchNewClaim() { |
|||
Long taskId = newTask(); |
|||
|
|||
// Worker A 领取
|
|||
CandidateScoringRefreshTaskExecutor.ClaimedTask a = taskExecutor.claim(taskId, SAME_INSTANT); |
|||
assertNotNull(a, "A 应领取成功"); |
|||
assertEquals(SAME_INSTANT, processingStartedAt(taskId)); |
|||
|
|||
// 租约回收 → Worker B 在**完全相同的时间值**下重新领取
|
|||
recover(taskId); |
|||
CandidateScoringRefreshTaskExecutor.ClaimedTask b = taskExecutor.claim(taskId, SAME_INSTANT); |
|||
assertNotNull(b, "B 应领取成功"); |
|||
assertEquals(SAME_INSTANT, processingStartedAt(taskId), |
|||
"两次领取的 processing_started_at 完全相同(当前实现的固有精度)"); |
|||
assertEquals("PROCESSING", status(taskId)); |
|||
|
|||
// A 迟到:完成尝试(version 已变 → 应 0 行)
|
|||
taskExecutor.materialize(a, SAME_INSTANT); |
|||
assertEquals("PROCESSING", status(taskId), |
|||
"A 的完成路径不得把 B 的 PROCESSING 改成其它状态"); |
|||
|
|||
// 再补一次 B 的重领取,确保状态干净(A 的 PENDING 回退是本次的核心缺陷点)
|
|||
if (!"PROCESSING".equals(status(taskId))) { |
|||
recover(taskId); |
|||
b = taskExecutor.claim(taskId, SAME_INSTANT); |
|||
} |
|||
assertNotNull(b); |
|||
assertEquals("PROCESSING", status(taskId)); |
|||
|
|||
// B 正常完成
|
|||
taskExecutor.materialize(b, SAME_INSTANT); |
|||
assertEquals("COMPLETED", status(taskId), "B 应能正常完成"); |
|||
} |
|||
|
|||
@Test |
|||
void sameTimestamp_staleWorkerFailure_mustNotTouchNewClaim() { |
|||
Long taskId = newTask(); |
|||
CandidateScoringRefreshTaskExecutor.ClaimedTask a = taskExecutor.claim(taskId, SAME_INSTANT); |
|||
assertNotNull(a); |
|||
recover(taskId); |
|||
CandidateScoringRefreshTaskExecutor.ClaimedTask b = taskExecutor.claim(taskId, SAME_INSTANT); |
|||
assertNotNull(b); |
|||
assertEquals("PROCESSING", status(taskId)); |
|||
|
|||
// 记录失败登记前的基线(recover() 辅助方法自身会递增 attempts,故用差值判断)
|
|||
int attemptsBefore = attempts(taskId); |
|||
String tokenBefore = claimToken(taskId); |
|||
|
|||
taskExecutor.recordFailure(taskId, "WRITE_FAILED", SAME_INSTANT, 3, 1000L, a.claimToken()); |
|||
|
|||
assertEquals("PROCESSING", status(taskId), "A 的失败登记不得覆盖 B 的 PROCESSING"); |
|||
assertEquals(attemptsBefore, attempts(taskId), "A 的失败登记不得改动 B 的 attempts"); |
|||
assertNull(jdbcTemplate.queryForObject( |
|||
"SELECT last_error_code FROM candidate_scoring_refresh_tasks WHERE id=?", String.class, taskId), |
|||
"A 的失败登记不得写入 last_error_code"); |
|||
assertEquals(tokenBefore, claimToken(taskId), "A 的失败登记不得清空 B 的 claim token"); |
|||
} |
|||
|
|||
// ── claim token 语义 ──────────────────────────────────────
|
|||
|
|||
@Test |
|||
void twoClaimsAtSameInstant_getDifferentTokens() { |
|||
Long taskId = newTask(); |
|||
CandidateScoringRefreshTaskExecutor.ClaimedTask a = taskExecutor.claim(taskId, SAME_INSTANT); |
|||
assertNotNull(a); |
|||
String ta = claimToken(taskId); |
|||
assertNotNull(ta, "领取后必须写入 claim_token"); |
|||
|
|||
recover(taskId); |
|||
CandidateScoringRefreshTaskExecutor.ClaimedTask b = taskExecutor.claim(taskId, SAME_INSTANT); |
|||
assertNotNull(b); |
|||
String tb = claimToken(taskId); |
|||
|
|||
assertEquals(SAME_INSTANT, processingStartedAt(taskId), "时间值相同"); |
|||
assertNotEquals(ta, tb, "同一时间值下两次领取必须得到不同 claim token"); |
|||
} |
|||
|
|||
@Test |
|||
void claim_repeatedOnDifferentTasks_producesDistinctTokens() { |
|||
Long t1 = newTask(); |
|||
CandidateScoringRefreshTaskExecutor.ClaimedTask c1 = taskExecutor.claim(t1, SAME_INSTANT); |
|||
assertNotNull(c1); |
|||
String token1 = claimToken(t1); |
|||
|
|||
Long sid2 = newStudent(); |
|||
requestService.requestRefresh(sid2, LocalDateTime.now()); |
|||
Long t2 = jdbcTemplate.queryForObject( |
|||
"SELECT id FROM candidate_scoring_refresh_tasks WHERE student_user_id=?", Long.class, sid2); |
|||
CandidateScoringRefreshTaskExecutor.ClaimedTask c2 = taskExecutor.claim(t2, SAME_INSTANT); |
|||
assertNotNull(c2); |
|||
String token2 = claimToken(t2); |
|||
|
|||
assertNotEquals(token1, token2, "不同任务的 token 必须不同"); |
|||
} |
|||
|
|||
@Test |
|||
void currentHolder_canCompleteNormally() { |
|||
Long taskId = newTask(); |
|||
CandidateScoringRefreshTaskExecutor.ClaimedTask c = taskExecutor.claim(taskId, SAME_INSTANT); |
|||
assertNotNull(c); |
|||
taskExecutor.materialize(c, SAME_INSTANT); |
|||
assertEquals("COMPLETED", status(taskId)); |
|||
} |
|||
|
|||
@Test |
|||
void requestDuringSameClaim_versionAdvances_completionFallsBackToPending() { |
|||
Long taskId = newTask(); |
|||
CandidateScoringRefreshTaskExecutor.ClaimedTask c = taskExecutor.claim(taskId, SAME_INSTANT); |
|||
assertNotNull(c); |
|||
|
|||
requestService.requestRefresh(sid, SAME_INSTANT.plusNanos(1)); |
|||
assertEquals("PROCESSING", status(taskId)); |
|||
|
|||
taskExecutor.materialize(c, SAME_INSTANT); |
|||
assertEquals("PENDING", status(taskId), |
|||
"同一 claim 期间新请求推进 version → 必须回到 PENDING 而不是 COMPLETED"); |
|||
} |
|||
|
|||
@Test |
|||
void staleRecovery_generatesNewTokenOnNextClaim() { |
|||
Long taskId = newTask(); |
|||
CandidateScoringRefreshTaskExecutor.ClaimedTask a = taskExecutor.claim(taskId, SAME_INSTANT); |
|||
assertNotNull(a); |
|||
String ta = claimToken(taskId); |
|||
|
|||
taskExecutor.recoverStale(LocalDateTime.now(), LocalDateTime.now(), 3); |
|||
assertEquals("RETRY", status(taskId)); |
|||
assertNull(claimToken(taskId), "回收后应清空 claim_token"); |
|||
|
|||
CandidateScoringRefreshTaskExecutor.ClaimedTask b = taskExecutor.claim(taskId, SAME_INSTANT); |
|||
assertNotNull(b); |
|||
assertNotEquals(ta, claimToken(taskId), "stale recovery 后的新领取必须生成新 token"); |
|||
} |
|||
|
|||
@Test |
|||
void terminalAndPendingStates_clearClaimToken() { |
|||
// COMPLETED
|
|||
Long t1 = newTask(); |
|||
CandidateScoringRefreshTaskExecutor.ClaimedTask c1 = taskExecutor.claim(t1, SAME_INSTANT); |
|||
assertNotNull(c1); |
|||
taskExecutor.materialize(c1, SAME_INSTANT); |
|||
assertEquals("COMPLETED", status(t1)); |
|||
assertNull(claimToken(t1), "COMPLETED 必须清空 claim_token"); |
|||
|
|||
// PENDING 回退
|
|||
Long sid2 = newStudent(); |
|||
requestService.requestRefresh(sid2, LocalDateTime.now()); |
|||
Long t2 = jdbcTemplate.queryForObject( |
|||
"SELECT id FROM candidate_scoring_refresh_tasks WHERE student_user_id=?", Long.class, sid2); |
|||
CandidateScoringRefreshTaskExecutor.ClaimedTask c2 = taskExecutor.claim(t2, SAME_INSTANT); |
|||
assertNotNull(c2); |
|||
requestService.requestRefresh(sid2, SAME_INSTANT.plusNanos(1)); |
|||
taskExecutor.materialize(c2, SAME_INSTANT); |
|||
assertEquals("PENDING", status(t2)); |
|||
assertNull(claimToken(t2), "PENDING 回退必须清空 claim_token"); |
|||
|
|||
// RETRY
|
|||
Long sid3 = newStudent(); |
|||
requestService.requestRefresh(sid3, LocalDateTime.now()); |
|||
Long t3 = jdbcTemplate.queryForObject( |
|||
"SELECT id FROM candidate_scoring_refresh_tasks WHERE student_user_id=?", Long.class, sid3); |
|||
CandidateScoringRefreshTaskExecutor.ClaimedTask c3 = taskExecutor.claim(t3, SAME_INSTANT); |
|||
assertNotNull(c3); |
|||
taskExecutor.recordFailure(t3, "WRITE_FAILED", SAME_INSTANT, 3, 1000L, c3.claimToken()); |
|||
assertEquals("RETRY", status(t3)); |
|||
assertNull(claimToken(t3), "RETRY 必须清空 claim_token"); |
|||
} |
|||
|
|||
@Test |
|||
void failedTerminalState_clearsClaimToken() { |
|||
Long taskId = newTask(); |
|||
jdbcTemplate.update("UPDATE candidate_scoring_refresh_tasks SET attempts=2 WHERE id=?", taskId); |
|||
CandidateScoringRefreshTaskExecutor.ClaimedTask c = taskExecutor.claim(taskId, SAME_INSTANT); |
|||
assertNotNull(c); |
|||
// maxAttempts=3,attempts=2 → attempts+1 >= 3 → FAILED
|
|||
taskExecutor.recordFailure(taskId, "COMPUTE_FAILED", SAME_INSTANT, 3, 1000L, c.claimToken()); |
|||
assertEquals("FAILED", status(taskId)); |
|||
assertNull(claimToken(taskId), "FAILED 必须清空 claim_token"); |
|||
} |
|||
|
|||
@Test |
|||
void tokenIsUuidShaped_andNotDerivedFromIds() { |
|||
Long taskId = newTask(); |
|||
CandidateScoringRefreshTaskExecutor.ClaimedTask c = taskExecutor.claim(taskId, SAME_INSTANT); |
|||
assertNotNull(c); |
|||
String token = claimToken(taskId); |
|||
assertNotNull(token); |
|||
assertEquals(36, token.length(), "token 应为固定长度 UUID 字符串"); |
|||
assertTrue(token.matches("[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-" |
|||
+ "[0-9a-fA-F]{4}-[0-9a-fA-F]{12}"), |
|||
"token 必须为 UUID 形态,实际: " + token); |
|||
// token 必须是随机 UUID:不得等于/以任务或学生 ID 起头,也不得其字符串形式
|
|||
assertNotEquals(String.valueOf(taskId), token); |
|||
assertNotEquals(String.valueOf(sid), token); |
|||
assertFalse(token.startsWith(String.valueOf(taskId)), "token 不得由任务 ID 派生"); |
|||
assertTrue(token.matches(".*[0-9a-f]{8}-.*"), "token 必须是随机 UUID 形态而非 ID 拼接"); |
|||
// 再领一次必须得到不同 token(不可复用)—— 用独立学生,避免 (student,period) 合并
|
|||
Long otherStudent = newStudent(); |
|||
requestService.requestRefresh(otherStudent, LocalDateTime.now()); |
|||
Long t2 = jdbcTemplate.queryForObject( |
|||
"SELECT id FROM candidate_scoring_refresh_tasks WHERE student_user_id=?", |
|||
Long.class, otherStudent); |
|||
CandidateScoringRefreshTaskExecutor.ClaimedTask c2 = taskExecutor.claim(t2, SAME_INSTANT); |
|||
assertNotNull(c2, "独立任务应可领取"); |
|||
assertNotEquals(token, claimToken(t2), "token 必须不可复用"); |
|||
} |
|||
|
|||
// ── 双 Worker 长时间循环 ──────────────────────────────────
|
|||
|
|||
@Test |
|||
void doubleWorkerLoop_100Rounds_noOverwrite_noStuckProcessing_noLostRequest() { |
|||
final int rounds = 100; |
|||
for (int i = 0; i < rounds; i++) { |
|||
Long taskId = newTask(); |
|||
|
|||
// A 领取
|
|||
CandidateScoringRefreshTaskExecutor.ClaimedTask a = taskExecutor.claim(taskId, SAME_INSTANT); |
|||
assertNotNull(a, "round " + i + ": A 领取失败"); |
|||
|
|||
// 租约回收 → B 在**同一时间值**下重新领取(fence 时间完全相同)
|
|||
recover(taskId); |
|||
CandidateScoringRefreshTaskExecutor.ClaimedTask b = taskExecutor.claim(taskId, SAME_INSTANT); |
|||
assertNotNull(b, "round " + i + ": B 领取失败"); |
|||
assertEquals(SAME_INSTANT, processingStartedAt(taskId), "round " + i); |
|||
|
|||
// A 的三种迟到写:完成 / 失败 / (完成路径内含的 PENDING 回退)
|
|||
taskExecutor.materialize(a, SAME_INSTANT); |
|||
taskExecutor.recordFailure(taskId, "WRITE_FAILED", SAME_INSTANT, 3, 1000L, a.claimToken()); |
|||
taskExecutor.materialize(a, SAME_INSTANT); |
|||
|
|||
assertEquals("PROCESSING", status(taskId), |
|||
"round " + i + ": 旧 Worker A 覆盖了新持有者 B"); |
|||
assertEquals(b.claimToken(), claimToken(taskId), |
|||
"round " + i + ": A 改动了 B 的 claim token"); |
|||
|
|||
// B 正常完成
|
|||
taskExecutor.materialize(b, SAME_INSTANT); |
|||
assertEquals("COMPLETED", status(taskId), "round " + i + ": B 未能完成"); |
|||
assertNull(claimToken(taskId), "round " + i + ": COMPLETED 未清空 token"); |
|||
|
|||
jdbcTemplate.update("DELETE FROM candidate_scoring_refresh_tasks WHERE id=?", taskId); |
|||
} |
|||
long stuck = jdbcTemplate.queryForObject( |
|||
"SELECT COUNT(*) FROM candidate_scoring_refresh_tasks WHERE status='PROCESSING'", |
|||
Long.class); |
|||
assertEquals(0L, stuck, "循环结束后不得残留 PROCESSING"); |
|||
assertEquals(rounds, 100); |
|||
} |
|||
|
|||
// ── token 不泄露 ─────────────────────────────────────────
|
|||
|
|||
@Test |
|||
void claimToken_doesNotLeakIntoAuditOrNotificationTables() { |
|||
Long taskId = newTask(); |
|||
CandidateScoringRefreshTaskExecutor.ClaimedTask c = taskExecutor.claim(taskId, SAME_INSTANT); |
|||
assertNotNull(c); |
|||
String token = c.claimToken(); |
|||
assertNotNull(token); |
|||
|
|||
for (String table : List.of("audit_logs", "notifications", "messages")) { |
|||
Long hits = jdbcTemplate.queryForObject( |
|||
"SELECT COUNT(*) FROM " + table + " WHERE CAST(" + |
|||
("messages".equals(table) ? "content" : "audit_logs".equals(table) ? "details" : "content") |
|||
+ " AS VARCHAR) LIKE ?", Long.class, "%" + token + "%"); |
|||
assertEquals(0L, hits, table + " 不得出现 claim token"); |
|||
} |
|||
// 错误分类为固定枚举,不含 token
|
|||
taskExecutor.recordFailure(taskId, "WRITE_FAILED", SAME_INSTANT, 3, 1000L, "stale-token-value"); |
|||
String code = jdbcTemplate.queryForObject( |
|||
"SELECT COALESCE(last_error_code,'') FROM candidate_scoring_refresh_tasks WHERE id=?", |
|||
String.class, taskId); |
|||
assertFalse(code.contains("stale-token-value"), "错误分类不得包含 token"); |
|||
assertTrue(List.of("COMPUTE_FAILED", "WRITE_FAILED", "UNKNOWN_REFRESH_FAILURE", "WORKER_TIMEOUT", "") |
|||
.contains(code), |
|||
"错误分类必须为固定枚举,实际: " + code); |
|||
} |
|||
|
|||
@Test |
|||
void claimToken_rowNeverExposedInTaskStatusQueriesUsedByObservability() { |
|||
// 任务表本身持有 token 属预期;此处确认 token 不会被写入其它可观测载体
|
|||
Long taskId = newTask(); |
|||
CandidateScoringRefreshTaskExecutor.ClaimedTask c = taskExecutor.claim(taskId, SAME_INSTANT); |
|||
assertNotNull(c); |
|||
String token = c.claimToken(); |
|||
Long inScores = jdbcTemplate.queryForObject( |
|||
"SELECT COUNT(*) FROM candidate_dimension_scores WHERE CAST(sub_scores_json AS VARCHAR) LIKE ?", |
|||
Long.class, "%" + token + "%"); |
|||
assertEquals(0L, inScores, "快照 JSON 不得出现 claim token"); |
|||
} |
|||
|
|||
private Long newStudent() { |
|||
User u = User.builder() |
|||
.phone("189" + String.format("%08d", SEQ.incrementAndGet())) |
|||
.nickname("Token学生x").role(User.Role.STUDENT).active(true).verified(true).build(); |
|||
u.setRoles(java.util.Set.of(User.Role.STUDENT)); |
|||
return userRepository.save(u).getId(); |
|||
} |
|||
} |
|||
@ -0,0 +1,127 @@ |
|||
package com.mydreamjob.candidatescore.refresh; |
|||
|
|||
import com.mydreamjob.auth.User; |
|||
import com.mydreamjob.auth.UserRepository; |
|||
import com.mydreamjob.candidatescore.CandidateDimensionScoreRepository; |
|||
import com.mydreamjob.candidatescore.CandidateScoringCalculator; |
|||
import org.junit.jupiter.api.BeforeEach; |
|||
import org.junit.jupiter.api.Test; |
|||
import org.springframework.beans.factory.annotation.Autowired; |
|||
import org.springframework.boot.test.context.SpringBootTest; |
|||
import org.springframework.boot.test.mock.mockito.MockBean; |
|||
import org.springframework.data.redis.core.RedisTemplate; |
|||
import org.springframework.jdbc.core.JdbcTemplate; |
|||
import org.springframework.test.context.ActiveProfiles; |
|||
import org.springframework.transaction.PlatformTransactionManager; |
|||
import org.springframework.transaction.support.TransactionTemplate; |
|||
|
|||
import java.math.BigDecimal; |
|||
import java.time.LocalDateTime; |
|||
import java.util.ArrayList; |
|||
import java.util.HashSet; |
|||
import java.util.List; |
|||
import java.util.Set; |
|||
|
|||
import static org.junit.jupiter.api.Assertions.*; |
|||
import static org.mockito.ArgumentMatchers.any; |
|||
import static org.mockito.Mockito.when; |
|||
|
|||
@SpringBootTest |
|||
@ActiveProfiles("integration-test") |
|||
class CandidateScoringRefreshFailureTransactionTest { |
|||
|
|||
@Autowired private CandidateScoringRefreshRequestService requestService; |
|||
@Autowired private CandidateScoringRefreshWorker worker; |
|||
@Autowired private UserRepository userRepository; |
|||
@Autowired private CandidateDimensionScoreRepository snapshotRepository; |
|||
@Autowired private JdbcTemplate jdbcTemplate; |
|||
@Autowired private PlatformTransactionManager transactionManager; |
|||
@MockBean private CandidateScoringCalculator calculator; |
|||
@MockBean private RedisTemplate<String, Object> redisTemplate; |
|||
|
|||
private Long failingStudent; |
|||
private Long successfulStudent; |
|||
|
|||
@BeforeEach |
|||
void setUp() { |
|||
jdbcTemplate.update("DELETE FROM candidate_scoring_refresh_tasks"); |
|||
failingStudent = student("failure-tx-1"); |
|||
successfulStudent = student("failure-tx-2"); |
|||
when(calculator.currentRulesVersion()).thenReturn("candidate-scoring-v0.1"); |
|||
when(calculator.computeAll(any(), any())).thenAnswer(invocation -> { |
|||
Long studentId = invocation.getArgument(0); |
|||
return results(studentId.equals(failingStudent)); |
|||
}); |
|||
} |
|||
|
|||
@Test |
|||
void databaseWriteFailureRollsBackAllDimensionsAndPersistsRetry() { |
|||
requestService.requestRefresh(failingStudent, LocalDateTime.now()); |
|||
|
|||
assertEquals(0, worker.processDue(10)); |
|||
|
|||
assertTrue(snapshotRepository.findByStudentUserId(failingStudent).isEmpty(), |
|||
"第4维写失败时前三维也必须回滚"); |
|||
assertEquals("RETRY", status(failingStudent)); |
|||
assertEquals("WRITE_FAILED", error(failingStudent)); |
|||
} |
|||
|
|||
@Test |
|||
void oneFailedTaskDoesNotRollbackSuccessfulSibling() { |
|||
requestService.requestRefresh(failingStudent, LocalDateTime.now()); |
|||
requestService.requestRefresh(successfulStudent, LocalDateTime.now()); |
|||
|
|||
assertEquals(1, worker.processDue(10)); |
|||
|
|||
assertEquals("RETRY", status(failingStudent)); |
|||
assertEquals("COMPLETED", status(successfulStudent)); |
|||
assertEquals(8, snapshotRepository.findByStudentUserId(successfulStudent).size()); |
|||
} |
|||
|
|||
@Test |
|||
void outerBusinessRollbackRemovesRefreshRegistration() { |
|||
TransactionTemplate transaction = new TransactionTemplate(transactionManager); |
|||
transaction.executeWithoutResult(status -> { |
|||
requestService.requestRefresh(successfulStudent, LocalDateTime.now()); |
|||
status.setRollbackOnly(); |
|||
}); |
|||
Long count = jdbcTemplate.queryForObject( |
|||
"SELECT COUNT(*) FROM candidate_scoring_refresh_tasks WHERE student_user_id=?", |
|||
Long.class, successfulStudent); |
|||
assertEquals(0L, count); |
|||
} |
|||
|
|||
private Long student(String nickname) { |
|||
String phone = "130" + String.format("%08d", Math.abs(System.nanoTime() % 100_000_000L)); |
|||
User user = User.builder().phone(phone).nickname(nickname).role(User.Role.STUDENT) |
|||
.active(true).verified(true).build(); |
|||
user.setRoles(new HashSet<>(Set.of(User.Role.STUDENT))); |
|||
return userRepository.saveAndFlush(user).getId(); |
|||
} |
|||
|
|||
private List<CandidateScoringCalculator.DimensionResult> results(boolean failAtFourth) { |
|||
String[] dimensions = {"EDUCATION", "INTERNSHIP", "ABILITY", "COMPLIANCE", |
|||
"STABILITY", "GROWTH", "INTEGRITY", "ACTIVITY"}; |
|||
List<CandidateScoringCalculator.DimensionResult> results = new ArrayList<>(); |
|||
for (int i = 0; i < 8; i++) { |
|||
CandidateScoringCalculator.DimensionResult result = |
|||
new CandidateScoringCalculator.DimensionResult(failAtFourth && i == 3 ? null : dimensions[i]); |
|||
result.score = BigDecimal.valueOf(50 + i); |
|||
result.status = "OK"; |
|||
results.add(result); |
|||
} |
|||
return results; |
|||
} |
|||
|
|||
private String status(Long studentId) { |
|||
return jdbcTemplate.queryForObject( |
|||
"SELECT status FROM candidate_scoring_refresh_tasks WHERE student_user_id=?", |
|||
String.class, studentId); |
|||
} |
|||
|
|||
private String error(Long studentId) { |
|||
return jdbcTemplate.queryForObject( |
|||
"SELECT last_error_code FROM candidate_scoring_refresh_tasks WHERE student_user_id=?", |
|||
String.class, studentId); |
|||
} |
|||
} |
|||
@ -0,0 +1,388 @@ |
|||
package com.mydreamjob.candidatescore.refresh; |
|||
|
|||
import com.mydreamjob.auth.User; |
|||
import com.mydreamjob.auth.UserRepository; |
|||
import com.mydreamjob.candidatescore.CandidateDimensionScore; |
|||
import com.mydreamjob.candidatescore.CandidateDimensionScoreRepository; |
|||
import com.mydreamjob.candidatescore.CandidateScoringCalculator; |
|||
import org.junit.jupiter.api.BeforeEach; |
|||
import org.junit.jupiter.api.Test; |
|||
import org.springframework.beans.factory.annotation.Autowired; |
|||
import org.springframework.boot.test.context.SpringBootTest; |
|||
import org.springframework.boot.test.mock.mockito.MockBean; |
|||
import org.springframework.data.redis.core.RedisTemplate; |
|||
import org.springframework.data.redis.core.ValueOperations; |
|||
import org.springframework.jdbc.core.JdbcTemplate; |
|||
import org.springframework.test.context.ActiveProfiles; |
|||
|
|||
import java.time.LocalDate; |
|||
import java.time.LocalDateTime; |
|||
import java.util.HashSet; |
|||
import java.util.List; |
|||
import java.util.Map; |
|||
import java.util.Set; |
|||
import java.util.concurrent.CountDownLatch; |
|||
import java.util.concurrent.ExecutorService; |
|||
import java.util.concurrent.Executors; |
|||
import java.util.concurrent.Future; |
|||
import java.util.concurrent.TimeUnit; |
|||
|
|||
import static org.junit.jupiter.api.Assertions.*; |
|||
import static org.mockito.ArgumentMatchers.any; |
|||
import static org.mockito.Mockito.mock; |
|||
import static org.mockito.Mockito.when; |
|||
|
|||
/** |
|||
* R01-D4-1 基础设施与写侧接入综合测试(H2 集成)。 |
|||
* 覆盖任务登记合并、PROCESSING 防丢失、并发领取、Worker 原子完成、 |
|||
* Writer 幂等 version 语义、写侧登记范围与不接入项、GET 零副作用保持。 |
|||
*/ |
|||
@SpringBootTest |
|||
@ActiveProfiles("integration-test") |
|||
class CandidateScoringRefreshInfrastructureTest { |
|||
|
|||
@Autowired private CandidateScoringRefreshRequestService requestService; |
|||
@Autowired private CandidateScoringRefreshWorker worker; |
|||
@Autowired private CandidateScoringRefreshTaskExecutor taskExecutor; |
|||
@Autowired private CandidateScoringSnapshotWriter writer; |
|||
@Autowired private CandidateScoringRefreshCompensationScanner compensationScanner; |
|||
@Autowired private CandidateScoringCalculator calculator; |
|||
@Autowired private UserRepository userRepository; |
|||
@Autowired private CandidateDimensionScoreRepository snapshotRepository; |
|||
@Autowired private JdbcTemplate jdbcTemplate; |
|||
@MockBean private RedisTemplate<String, Object> redisTemplate; |
|||
|
|||
private Long sid; |
|||
private String period; |
|||
|
|||
@BeforeEach |
|||
@SuppressWarnings("unchecked") |
|||
void setUp() { |
|||
ValueOperations<String, Object> ops = mock(ValueOperations.class); |
|||
when(redisTemplate.opsForValue()).thenReturn(ops); |
|||
when(redisTemplate.hasKey(any())).thenReturn(false); |
|||
User u = User.builder().phone("132" + String.format("%08d", System.nanoTime() % 100_000_000L)) |
|||
.nickname("r01d41").role(User.Role.STUDENT) |
|||
.active(true).verified(true).build(); |
|||
u.setRoles(new HashSet<>(Set.of(User.Role.STUDENT))); |
|||
sid = userRepository.save(u).getId(); |
|||
LocalDate d = LocalDate.now(); |
|||
period = String.format("%04d-%02d", d.getYear(), d.getMonthValue()); |
|||
jdbcTemplate.update("DELETE FROM candidate_scoring_refresh_tasks"); |
|||
jdbcTemplate.update("DELETE FROM candidate_scoring_refresh_scan_checkpoints"); |
|||
} |
|||
|
|||
private long taskCount() { |
|||
Long c = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM candidate_scoring_refresh_tasks", Long.class); |
|||
return c == null ? -1 : c; |
|||
} |
|||
|
|||
private String taskStatus() { |
|||
return jdbcTemplate.queryForObject( |
|||
"SELECT status FROM candidate_scoring_refresh_tasks WHERE student_user_id=? AND target_period=?", |
|||
String.class, sid, period); |
|||
} |
|||
|
|||
private LocalDateTime now() { |
|||
return LocalDateTime.now(); |
|||
} |
|||
|
|||
// 1. 重复登记只保留一条任务
|
|||
@Test |
|||
void duplicateRefreshRequestCreatesOneTask() { |
|||
requestService.requestRefresh(sid, now()); |
|||
LocalDateTime t1 = jdbcTemplate.queryForObject( |
|||
"SELECT requested_at FROM candidate_scoring_refresh_tasks WHERE student_user_id=?", LocalDateTime.class, sid); |
|||
requestService.requestRefresh(sid, now()); |
|||
assertEquals(1, taskCount(), "同学生同周期重复登记只保留一条任务"); |
|||
LocalDateTime t2 = jdbcTemplate.queryForObject( |
|||
"SELECT requested_at FROM candidate_scoring_refresh_tasks WHERE student_user_id=?", LocalDateTime.class, sid); |
|||
assertFalse(t2.isBefore(t1), "重复登记应更新 requested_at 而非新增"); |
|||
} |
|||
|
|||
// 2. COMPLETED 任务再次登记 → 回到 PENDING 且 attempts 归零
|
|||
@Test |
|||
void completedTaskCanBeRequestedAgain() { |
|||
requestService.requestRefresh(sid, now()); |
|||
jdbcTemplate.update("UPDATE candidate_scoring_refresh_tasks SET status='COMPLETED', attempts=5 WHERE student_user_id=?", |
|||
sid); |
|||
requestService.requestRefresh(sid, now()); |
|||
assertEquals("PENDING", taskStatus()); |
|||
Integer attempts = jdbcTemplate.queryForObject( |
|||
"SELECT attempts FROM candidate_scoring_refresh_tasks WHERE student_user_id=?", Integer.class, sid); |
|||
assertEquals(0, attempts, "重新登记后 attempts 应归零"); |
|||
} |
|||
|
|||
// 3. PROCESSING 期间新请求不丢失:状态保持 PROCESSING、requested_at 持续推进;完成后可处理
|
|||
@Test |
|||
void requestDuringProcessingIsNotLost() { |
|||
requestService.requestRefresh(sid, now()); |
|||
LocalDateTime pst = now().minusSeconds(5); |
|||
jdbcTemplate.update("UPDATE candidate_scoring_refresh_tasks SET status='PROCESSING', processing_started_at=?, " |
|||
+ "requested_at=? WHERE student_user_id=?", pst, pst, sid); |
|||
// 处理期间产生新请求(requested_at 更新,status 保持不变,任务仍唯一)
|
|||
requestService.requestRefresh(sid, now()); |
|||
assertEquals("PROCESSING", taskStatus(), "处理期间新请求不得覆盖 PROCESSING 状态"); |
|||
assertEquals(1, taskCount(), "处理期间新请求不得新增任务行"); |
|||
// 完成语义:若 requested_at 晚于 processing_started_at,Worker 完成时回退 PENDING(数据可验证)
|
|||
LocalDateTime req2 = jdbcTemplate.queryForObject( |
|||
"SELECT requested_at FROM candidate_scoring_refresh_tasks WHERE student_user_id=?", LocalDateTime.class, sid); |
|||
assertTrue(req2.isAfter(pst), "处理期间新请求必须推进 requested_at(不丢失)"); |
|||
// 使用真实完成路径:领取版本早于处理期间的新请求,完成时必须回到 PENDING。
|
|||
jdbcTemplate.update("UPDATE candidate_scoring_refresh_tasks SET status='PENDING', processing_started_at=NULL " |
|||
+ "WHERE student_user_id=?", sid); |
|||
Long taskId = jdbcTemplate.queryForObject( |
|||
"SELECT id FROM candidate_scoring_refresh_tasks WHERE student_user_id=?", Long.class, sid); |
|||
LocalDateTime claimedAt = now(); |
|||
CandidateScoringRefreshTaskExecutor.ClaimedTask claimed = taskExecutor.claim(taskId, claimedAt); |
|||
assertNotNull(claimed); |
|||
requestService.requestRefresh(sid, claimedAt.plusNanos(1)); |
|||
taskExecutor.materialize(claimed, claimedAt); |
|||
assertEquals("PENDING", taskStatus(), "处理期间的新请求必须让真实完成路径回到 PENDING"); |
|||
} |
|||
|
|||
// 4. 两个并发 Worker 只能有效处理一次(数据库条件领取)
|
|||
@Test |
|||
void twoWorkersCannotProcessSameTaskTwice() throws Exception { |
|||
requestService.requestRefresh(sid, now()); |
|||
List<Long> ids = jdbcTemplate.queryForList( |
|||
"SELECT id FROM candidate_scoring_refresh_tasks WHERE student_user_id=?", Long.class, sid); |
|||
Long taskId = ids.get(0); |
|||
ExecutorService pool = Executors.newFixedThreadPool(2); |
|||
CountDownLatch ready = new CountDownLatch(2); |
|||
CountDownLatch start = new CountDownLatch(1); |
|||
List<Future<Boolean>> futures = java.util.stream.IntStream.range(0, 2).<Future<Boolean>>mapToObj(i -> |
|||
pool.submit(() -> { |
|||
ready.countDown(); |
|||
start.await(5, TimeUnit.SECONDS); |
|||
return worker.processOne(taskId, now()); |
|||
})).toList(); |
|||
assertTrue(ready.await(5, TimeUnit.SECONDS)); |
|||
start.countDown(); |
|||
boolean r1 = futures.get(0).get(30, TimeUnit.SECONDS); |
|||
boolean r2 = futures.get(1).get(30, TimeUnit.SECONDS); |
|||
pool.shutdownNow(); |
|||
assertEquals(1, (r1 ? 1 : 0) + (r2 ? 1 : 0), "同一任务并发只能有一个 Worker 有效处理"); |
|||
List<CandidateDimensionScore> snaps = snapshotRepository.findByStudentUserIdAndPeriod(sid, period); |
|||
assertTrue(snaps.size() == 8, "并发处理恰一次物化(8 维)"); |
|||
} |
|||
|
|||
// 6/9. 相同计算值重复 Writer 写入不增 version;Worker 重放同结果不增
|
|||
@Test |
|||
void sameCalculatedValuesDoNotIncrementSnapshotVersion() { |
|||
List<CandidateScoringCalculator.DimensionResult> results = |
|||
calculator.computeAll(sid, now()); |
|||
writer.upsertIfChanged(sid, results, period, now(), calculator.currentRulesVersion()); |
|||
List<CandidateDimensionScore> r1 = snapshotRepository.findByStudentUserIdAndPeriod(sid, period); |
|||
assertEquals(8, r1.size()); |
|||
writer.upsertIfChanged(sid, results, period, now(), calculator.currentRulesVersion()); |
|||
List<CandidateDimensionScore> r2 = snapshotRepository.findByStudentUserIdAndPeriod(sid, period); |
|||
for (int i = 0; i < r1.size(); i++) { |
|||
assertEquals(r1.get(i).getVersion(), r2.get(i).getVersion(), "值不变不得推进 version: " + r1.get(i).getDimension()); |
|||
} |
|||
} |
|||
|
|||
// 8. rulesVersion 变化强制递增
|
|||
@Test |
|||
void rulesVersionChangeIncrementsVersion() { |
|||
List<CandidateScoringCalculator.DimensionResult> results = calculator.computeAll(sid, now()); |
|||
writer.upsertIfChanged(sid, results, period, now(), "candidate-scoring-v0.1"); |
|||
writer.upsertIfChanged(sid, results, period, now(), "candidate-scoring-v0.2"); |
|||
List<CandidateDimensionScore> rows = snapshotRepository.findByStudentUserIdAndPeriod(sid, period); |
|||
assertTrue(rows.stream().allMatch(s -> s.getVersion() == 1L), "rulesVersion 变化应强制 version+1"); |
|||
} |
|||
|
|||
// 21. 任务错误分类为固定值且不含异常原文/SQL/手机号/堆栈
|
|||
@Test |
|||
void taskErrorCategoryIsFixedAndSafe() { |
|||
requestService.requestRefresh(sid, now()); |
|||
Long taskId = jdbcTemplate.queryForObject( |
|||
"SELECT id FROM candidate_scoring_refresh_tasks WHERE student_user_id=?", Long.class, sid); |
|||
CandidateScoringRefreshTaskExecutor.ClaimedTask claimed = taskExecutor.claim(taskId, now()); |
|||
assertNotNull(claimed); |
|||
taskExecutor.recordFailure(taskId, "UNKNOWN_REFRESH_FAILURE", now(), 3, 1000L, claimed.claimToken()); |
|||
String code = jdbcTemplate.queryForObject( |
|||
"SELECT last_error_code FROM candidate_scoring_refresh_tasks WHERE id=?", String.class, taskId); |
|||
assertTrue(Set.of("COMPUTE_FAILED", "WRITE_FAILED", "UNKNOWN_REFRESH_FAILURE", "WORKER_TIMEOUT").contains(code), |
|||
"错误分类必须为固定值,实际: " + code); |
|||
assertFalse(code.contains("Exception") || code.contains(" at com.mydreamjob"), |
|||
"错误分类不得含异常原文/堆栈"); |
|||
} |
|||
|
|||
// 22. Worker 不发布 RabbitMQ/Notification(快照仅有 8 维行,无 notifications 行)
|
|||
@Test |
|||
void workerDoesNotPublishRabbitMqOrNotification() { |
|||
requestService.requestRefresh(sid, now()); |
|||
long notifBefore = count("notifications"); |
|||
worker.processDue(10); |
|||
assertEquals(notifBefore, count("notifications"), "Worker 物化不得写 notifications"); |
|||
} |
|||
|
|||
// 20. 普通刷新路径不写 snapshot_history
|
|||
@Test |
|||
void noRefreshPathWritesSnapshotHistory() { |
|||
requestService.requestRefresh(sid, now()); |
|||
long histBefore = count("candidate_scoring_snapshot_history"); |
|||
worker.processDue(10); |
|||
assertEquals(histBefore, count("candidate_scoring_snapshot_history"), |
|||
"普通物化不得写 candidate_scoring_snapshot_history(仅申诉决定/回滚写入)"); |
|||
} |
|||
|
|||
// 5. 到期 RETRY 重新领取 + 未到期不领取
|
|||
@Test |
|||
void retryTaskClaimedOnlyWhenDue() { |
|||
requestService.requestRefresh(sid, now()); |
|||
Long taskId = jdbcTemplate.queryForObject( |
|||
"SELECT id FROM candidate_scoring_refresh_tasks WHERE student_user_id=?", Long.class, sid); |
|||
jdbcTemplate.update("UPDATE candidate_scoring_refresh_tasks SET status='RETRY', next_attempt_at=? WHERE id=?", |
|||
now().plusHours(1), taskId); |
|||
assertFalse(worker.processOne(taskId, now()), "未到 next_attempt_at 不得领取"); |
|||
jdbcTemplate.update("UPDATE candidate_scoring_refresh_tasks SET next_attempt_at=NULL WHERE id=?", taskId); |
|||
assertTrue(worker.processOne(taskId, now()), "到期任务应可领取"); |
|||
} |
|||
|
|||
@Test |
|||
void staleProcessingLeaseIsRecoveredAndCanBeClaimedAgain() { |
|||
requestService.requestRefresh(sid, now()); |
|||
Long taskId = jdbcTemplate.queryForObject( |
|||
"SELECT id FROM candidate_scoring_refresh_tasks WHERE student_user_id=?", Long.class, sid); |
|||
LocalDateTime claimedAt = now().minusMinutes(20); |
|||
assertNotNull(taskExecutor.claim(taskId, claimedAt)); |
|||
|
|||
assertEquals(1, taskExecutor.recoverStale(now().minusMinutes(10), now(), 3)); |
|||
assertEquals("RETRY", taskStatus()); |
|||
assertEquals("WORKER_TIMEOUT", jdbcTemplate.queryForObject( |
|||
"SELECT last_error_code FROM candidate_scoring_refresh_tasks WHERE id=?", String.class, taskId)); |
|||
assertNotNull(taskExecutor.claim(taskId, now()), "恢复后的租约必须可被其他实例重新领取"); |
|||
} |
|||
|
|||
// 无外层事务时登记独立提交成功;外层回滚由专门事务测试覆盖。
|
|||
@Test |
|||
void requestRegistrationCommitsIndependently() { |
|||
requestService.requestRefresh(sid, now()); |
|||
assertEquals(1, taskCount()); |
|||
} |
|||
|
|||
@Test |
|||
void compensationScanTraversesMoreThanThreePagesWithoutStarvation() { |
|||
jdbcTemplate.update("DELETE FROM candidate_scoring_refresh_tasks"); |
|||
List<Long> expected = new java.util.ArrayList<>(); |
|||
for (int i = 0; i < 7; i++) { |
|||
User u = User.builder().phone("131" + String.format("%08d", i + 7000)) |
|||
.nickname("scan-" + i).role(User.Role.STUDENT) |
|||
.active(true).verified(true).build(); |
|||
u.setRoles(new HashSet<>(Set.of(User.Role.STUDENT))); |
|||
Long id = userRepository.saveAndFlush(u).getId(); |
|||
expected.add(id); |
|||
jdbcTemplate.update("INSERT INTO audit_logs(user_id,action,created_at) VALUES (?,'LOGIN',?)", |
|||
id, now()); |
|||
} |
|||
jdbcTemplate.update(""" |
|||
INSERT INTO candidate_scoring_refresh_scan_checkpoints |
|||
(scan_key,last_user_id,version,updated_at) |
|||
VALUES ('ACTIVE_STUDENTS',?,0,CURRENT_TIMESTAMP) |
|||
""", expected.get(0) - 1L); |
|||
|
|||
int first = compensationScanner.scanDueStudents(2); |
|||
long cursorAfterFirst = jdbcTemplate.queryForObject( |
|||
"SELECT last_user_id FROM candidate_scoring_refresh_scan_checkpoints WHERE scan_key='ACTIVE_STUDENTS'", |
|||
Long.class); |
|||
int second = compensationScanner.scanDueStudents(2); |
|||
int third = compensationScanner.scanDueStudents(2); |
|||
int fourth = compensationScanner.scanDueStudents(2); |
|||
List<Long> actual = jdbcTemplate.queryForList( |
|||
"SELECT student_user_id FROM candidate_scoring_refresh_tasks WHERE student_user_id IN (" |
|||
+ String.join(",", java.util.Collections.nCopies(expected.size(), "?")) + ")", |
|||
Long.class, expected.toArray()); |
|||
|
|||
assertEquals(2, first, "单轮必须严格受预算限制"); |
|||
assertTrue(cursorAfterFirst > 0L, "游标必须持久化以供下一轮或重启后续扫"); |
|||
assertTrue(second <= 2 && third <= 2 && fourth <= 2, "每轮不得突破固定预算"); |
|||
assertEquals(Set.copyOf(expected), Set.copyOf(actual), |
|||
"持久 keyset 游标必须跨四轮覆盖每个合格学生"); |
|||
} |
|||
|
|||
// ── F4:ABILITY 快照 payload 敏感字段防泄漏(真实 Writer → 真实 DB → 递归断言) ──
|
|||
|
|||
/** 禁止作为 JSON key 出现的字段名(小写精确匹配,避免 questionSetVersion 之类白名单字段误报)。 */ |
|||
private static final java.util.List<String> BANNED_KEYS = java.util.List.of( |
|||
"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"); |
|||
|
|||
private void assertNoBannedKeyDeep(com.fasterxml.jackson.databind.JsonNode node, |
|||
String path, java.util.List<String> hits) { |
|||
if (node == null) return; |
|||
if (node.isObject()) { |
|||
java.util.Iterator<String> it = node.fieldNames(); |
|||
java.util.List<String> names = new java.util.ArrayList<>(); |
|||
it.forEachRemaining(names::add); |
|||
for (String name : names) { |
|||
String lower = name.toLowerCase(); |
|||
if (BANNED_KEYS.contains(lower)) hits.add(path + "." + name); |
|||
assertNoBannedKeyDeep(node.get(name), path + "." + name, hits); |
|||
} |
|||
} else if (node.isArray()) { |
|||
for (int i = 0; i < node.size(); i++) { |
|||
assertNoBannedKeyDeep(node.get(i), path + "[" + i + "]", hits); |
|||
} |
|||
} |
|||
} |
|||
|
|||
@Test |
|||
void abilitySnapshotPayload_hasNoSensitiveFields_andKeepsWhitelistedKeys() throws Exception { |
|||
String p = period; |
|||
CandidateScoringCalculator.DimensionResult r = |
|||
new CandidateScoringCalculator.DimensionResult("ABILITY"); |
|||
r.score = new java.math.BigDecimal("74.25"); |
|||
r.status = "OK"; |
|||
r.abilitySnapshotTrace = new CandidateScoringCalculator.AbilitySnapshotTrace( |
|||
123L, now(), now().plusDays(30), "qs-v1", "ar-v1", |
|||
Map.of("KNOWLEDGE", 30, "SKILL", 70), |
|||
Map.of("KNOWLEDGE", new java.math.BigDecimal("70.00"), |
|||
"SKILL", new java.math.BigDecimal("78.50"))); |
|||
|
|||
writer.upsertIfChanged(sid, List.of(r), p, now(), "candidate-scoring-v0.2"); |
|||
|
|||
String json = jdbcTemplate.queryForObject( |
|||
"SELECT sub_scores_json FROM candidate_dimension_scores " |
|||
+ "WHERE student_user_id=? AND dimension='ABILITY' AND period=?", |
|||
String.class, sid, p); |
|||
assertNotNull(json, "ABILITY 快照 JSON 必须经真实 Writer 写入数据库"); |
|||
|
|||
com.fasterxml.jackson.databind.JsonNode node = |
|||
new com.fasterxml.jackson.databind.ObjectMapper().readTree(json); |
|||
|
|||
// 正向:白名单字段必须存在且取值正确
|
|||
for (String allowed : List.of("source", "sourceResultId", "validUntil", "questionSetVersion", |
|||
"assessmentRulesVersion", "mappingRulesVersion", "weights", "dimensionScores")) { |
|||
assertTrue(node.has(allowed), "允许字段必须存在: " + allowed); |
|||
} |
|||
assertEquals("V36_ABILITY_ASSESSMENT", node.get("source").asText()); |
|||
assertEquals(123L, node.get("sourceResultId").asLong()); |
|||
assertTrue(node.get("weights").has("KNOWLEDGE"), "weights 应保留白名单内的维度键"); |
|||
assertTrue(node.get("dimensionScores").has("SKILL"), "dimensionScores 应保留白名单内的维度键"); |
|||
|
|||
// 负向(递归,含嵌套对象与数组,大小写不敏感)
|
|||
java.util.List<String> hits = new java.util.ArrayList<>(); |
|||
assertNoBannedKeyDeep(node, "$", hits); |
|||
assertTrue(hits.isEmpty(), "ABILITY payload 递归不得含敏感字段,命中: " + hits); |
|||
|
|||
// 负向(原始 JSON 文本层,禁止出现这些 key 字面量)
|
|||
String lower = json.toLowerCase(); |
|||
for (String banned : List.of("answer", "answers", "rawanswer", "questionid", "questioncode", |
|||
"optionid", "selectedoption", "answerkey", "competencytag", "stem", |
|||
"scoringrule", "awardedscore", "explanation", "phone", "email", "token")) { |
|||
assertFalse(lower.contains("\"" + banned + "\""), |
|||
"原始 JSON 不得出现敏感 key 字面量: " + banned); |
|||
} |
|||
} |
|||
|
|||
private long count(String table) { |
|||
Long c = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM " + table, Long.class); |
|||
return c == null ? -1 : c; |
|||
} |
|||
} |
|||
@ -0,0 +1,274 @@ |
|||
package com.mydreamjob.candidatescore.refresh; |
|||
|
|||
import com.mydreamjob.auth.User; |
|||
import com.mydreamjob.auth.UserRepository; |
|||
import com.mydreamjob.candidatescore.CandidateDimensionScoreRepository; |
|||
import com.mydreamjob.candidatescore.CandidateScoringCalculator; |
|||
import org.junit.jupiter.api.BeforeEach; |
|||
import org.junit.jupiter.api.Test; |
|||
import org.springframework.beans.factory.annotation.Autowired; |
|||
import org.springframework.boot.test.context.SpringBootTest; |
|||
import org.springframework.boot.test.mock.mockito.MockBean; |
|||
import org.springframework.data.redis.core.RedisTemplate; |
|||
import org.springframework.jdbc.core.JdbcTemplate; |
|||
import org.springframework.test.annotation.DirtiesContext; |
|||
import org.springframework.test.context.ActiveProfiles; |
|||
import org.springframework.test.context.DynamicPropertyRegistry; |
|||
import org.springframework.test.context.DynamicPropertySource; |
|||
import org.testcontainers.containers.MySQLContainer; |
|||
import org.testcontainers.junit.jupiter.Container; |
|||
import org.testcontainers.junit.jupiter.Testcontainers; |
|||
|
|||
import java.math.BigDecimal; |
|||
import java.time.LocalDateTime; |
|||
import java.util.ArrayList; |
|||
import java.util.HashSet; |
|||
import java.util.List; |
|||
import java.util.Set; |
|||
import java.util.concurrent.CountDownLatch; |
|||
import java.util.concurrent.ExecutorService; |
|||
import java.util.concurrent.Executors; |
|||
import java.util.concurrent.Future; |
|||
import java.util.concurrent.TimeUnit; |
|||
|
|||
import static org.junit.jupiter.api.Assertions.*; |
|||
import static org.mockito.ArgumentMatchers.any; |
|||
import static org.mockito.Mockito.when; |
|||
|
|||
/** R01-D4-1 真实 MySQL 8.4 网关:正式 Flyway、validate、锁与事务语义。 */ |
|||
@Testcontainers(disabledWithoutDocker = true) |
|||
@SpringBootTest |
|||
@ActiveProfiles("mysql-it") |
|||
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) |
|||
class CandidateScoringRefreshMySqlIntegrationTest { |
|||
|
|||
@Container |
|||
static final MySQLContainer<?> MYSQL = new MySQLContainer<>("mysql:8.4") |
|||
.withDatabaseName("mydreamjob_r01_d4") |
|||
.withUsername("r01d4") |
|||
.withPassword("r01-d4-test-only"); |
|||
|
|||
@DynamicPropertySource |
|||
static void datasource(DynamicPropertyRegistry registry) { |
|||
registry.add("spring.datasource.url", MYSQL::getJdbcUrl); |
|||
registry.add("spring.datasource.username", MYSQL::getUsername); |
|||
registry.add("spring.datasource.password", MYSQL::getPassword); |
|||
registry.add("spring.jpa.hibernate.ddl-auto", () -> "validate"); |
|||
} |
|||
|
|||
@Autowired private CandidateScoringRefreshRequestService requestService; |
|||
@Autowired private CandidateScoringRefreshTaskExecutor executor; |
|||
@Autowired private CandidateScoringRefreshWorker worker; |
|||
@Autowired private CandidateScoringRefreshCompensationScanner compensationScanner; |
|||
@Autowired private CandidateScoringSnapshotWriter writer; |
|||
@Autowired private CandidateDimensionScoreRepository snapshotRepository; |
|||
@Autowired private UserRepository userRepository; |
|||
@Autowired private JdbcTemplate jdbcTemplate; |
|||
@MockBean private CandidateScoringCalculator calculator; |
|||
@MockBean private RedisTemplate<String, Object> redisTemplate; |
|||
|
|||
private Long failingStudent; |
|||
private Long successfulStudent; |
|||
private final LocalDateTime now = LocalDateTime.of(2026, 9, 16, 12, 0); |
|||
|
|||
@BeforeEach |
|||
void setUp() { |
|||
jdbcTemplate.update("DELETE FROM candidate_scoring_refresh_tasks"); |
|||
jdbcTemplate.update("DELETE FROM candidate_scoring_refresh_scan_checkpoints"); |
|||
snapshotRepository.deleteAll(); |
|||
failingStudent = student("mysql-failing"); |
|||
successfulStudent = student("mysql-success"); |
|||
when(calculator.currentRulesVersion()).thenReturn("candidate-scoring-v0.1"); |
|||
when(calculator.computeAll(any(), any())).thenAnswer(call -> |
|||
results(call.<Long>getArgument(0).equals(failingStudent))); |
|||
} |
|||
|
|||
@Test |
|||
void v44MigrationAndSchemaValidationAreApplied() { |
|||
Integer success = jdbcTemplate.queryForObject(""" |
|||
SELECT success FROM flyway_schema_history |
|||
WHERE version='44' AND description='candidate scoring refresh tasks' |
|||
""", Integer.class); |
|||
assertEquals(1, success); |
|||
Long columns = jdbcTemplate.queryForObject(""" |
|||
SELECT COUNT(*) FROM information_schema.columns |
|||
WHERE table_schema=DATABASE() AND table_name='candidate_scoring_refresh_tasks' |
|||
""", Long.class); |
|||
// V44 建表 13 列;R01-D4-2 claim fence 整改的 V45 追加 claim_token → 14 列
|
|||
assertEquals(14L, columns); |
|||
Long claimTokenColumns = jdbcTemplate.queryForObject(""" |
|||
SELECT COUNT(*) FROM information_schema.columns |
|||
WHERE table_schema=DATABASE() AND table_name='candidate_scoring_refresh_tasks' |
|||
AND column_name='claim_token' |
|||
""", Long.class); |
|||
assertEquals(1L, claimTokenColumns, "V45 必须新增 claim_token 列"); |
|||
|
|||
// V45 自身必须成功且唯一
|
|||
Integer v45 = jdbcTemplate.queryForObject(""" |
|||
SELECT success FROM flyway_schema_history WHERE version='45' |
|||
""", Integer.class); |
|||
assertEquals(1, v45, "V45 必须成功"); |
|||
Long v45Rows = jdbcTemplate.queryForObject(""" |
|||
SELECT COUNT(*) FROM flyway_schema_history WHERE version='45' |
|||
""", Long.class); |
|||
assertEquals(1L, v45Rows, "V45 必须唯一"); |
|||
|
|||
Long checkpointColumns = jdbcTemplate.queryForObject(""" |
|||
SELECT COUNT(*) FROM information_schema.columns |
|||
WHERE table_schema=DATABASE() AND table_name='candidate_scoring_refresh_scan_checkpoints' |
|||
""", Long.class); |
|||
assertEquals(4L, checkpointColumns); |
|||
} |
|||
|
|||
@Test |
|||
void mysqlWriterIsIdempotentAndForcePathStillAdvancesVersion() { |
|||
List<CandidateScoringCalculator.DimensionResult> values = results(false); |
|||
writer.upsertIfChanged(successfulStudent, values, "2026-09", now, "candidate-scoring-v0.1"); |
|||
var first = snapshotRepository.findByStudentUserIdAndPeriod(successfulStudent, "2026-09"); |
|||
assertEquals(8, first.size()); |
|||
assertTrue(first.stream().allMatch(row -> row.getVersion() == 0L)); |
|||
|
|||
writer.upsertIfChanged(successfulStudent, values, "2026-09", now.plusMinutes(1), |
|||
"candidate-scoring-v0.1"); |
|||
var unchanged = snapshotRepository.findByStudentUserIdAndPeriod(successfulStudent, "2026-09"); |
|||
assertTrue(unchanged.stream().allMatch(row -> row.getVersion() == 0L)); |
|||
|
|||
values.get(0).score = new BigDecimal("88.00"); |
|||
writer.upsertIfChanged(successfulStudent, values, "2026-09", now.plusMinutes(2), |
|||
"candidate-scoring-v0.1"); |
|||
var changed = snapshotRepository.findByStudentUserIdAndPeriod(successfulStudent, "2026-09"); |
|||
assertEquals(1L, changed.stream().filter(row -> "EDUCATION".equals(row.getDimension())) |
|||
.findFirst().orElseThrow().getVersion()); |
|||
assertTrue(changed.stream().filter(row -> !"EDUCATION".equals(row.getDimension())) |
|||
.allMatch(row -> row.getVersion() == 0L)); |
|||
|
|||
writer.upsertForcingVersion(successfulStudent, values.get(0), "2026-09", now.plusMinutes(3), |
|||
"candidate-scoring-v0.1"); |
|||
assertEquals(2L, snapshotRepository.findByStudentUserIdAndPeriod(successfulStudent, "2026-09") |
|||
.stream().filter(row -> "EDUCATION".equals(row.getDimension())) |
|||
.findFirst().orElseThrow().getVersion()); |
|||
} |
|||
|
|||
@Test |
|||
void mysqlConcurrentClaimHasExactlyOneWinner() throws Exception { |
|||
requestService.requestRefresh(successfulStudent, now); |
|||
Long taskId = taskId(successfulStudent); |
|||
ExecutorService pool = Executors.newFixedThreadPool(2); |
|||
CountDownLatch ready = new CountDownLatch(2); |
|||
CountDownLatch start = new CountDownLatch(1); |
|||
List<Future<CandidateScoringRefreshTaskExecutor.ClaimedTask>> futures = new ArrayList<>(); |
|||
for (int i = 0; i < 2; i++) { |
|||
futures.add(pool.submit(() -> { |
|||
ready.countDown(); |
|||
start.await(10, TimeUnit.SECONDS); |
|||
return executor.claim(taskId, now); |
|||
})); |
|||
} |
|||
assertTrue(ready.await(10, TimeUnit.SECONDS)); |
|||
start.countDown(); |
|||
long winners = 0; |
|||
for (Future<CandidateScoringRefreshTaskExecutor.ClaimedTask> future : futures) { |
|||
if (future.get(30, TimeUnit.SECONDS) != null) winners++; |
|||
} |
|||
pool.shutdownNow(); |
|||
assertEquals(1L, winners); |
|||
} |
|||
|
|||
@Test |
|||
void mysqlFailureRollsBackPartialSnapshotsAndDoesNotRollbackSibling() { |
|||
requestService.requestRefresh(failingStudent, now); |
|||
requestService.requestRefresh(successfulStudent, now); |
|||
|
|||
assertEquals(1, worker.processDue(10)); |
|||
assertTrue(snapshotRepository.findByStudentUserId(failingStudent).isEmpty()); |
|||
assertEquals(8, snapshotRepository.findByStudentUserId(successfulStudent).size()); |
|||
assertEquals("RETRY", status(failingStudent)); |
|||
assertEquals("WRITE_FAILED", error(failingStudent)); |
|||
assertEquals("COMPLETED", status(successfulStudent)); |
|||
} |
|||
|
|||
@Test |
|||
void mysqlProcessingReregistrationAndStaleRecoveryDoNotLoseWork() { |
|||
requestService.requestRefresh(successfulStudent, now); |
|||
Long taskId = taskId(successfulStudent); |
|||
var claimed = executor.claim(taskId, now); |
|||
assertNotNull(claimed); |
|||
requestService.requestRefresh(successfulStudent, now.plusSeconds(1)); |
|||
executor.materialize(claimed, now); |
|||
assertEquals("PENDING", status(successfulStudent)); |
|||
|
|||
var staleClaim = executor.claim(taskId, now.minusMinutes(20)); |
|||
assertNotNull(staleClaim); |
|||
assertEquals(1, executor.recoverStale(now.minusMinutes(10), now, 3)); |
|||
assertEquals("RETRY", status(successfulStudent)); |
|||
assertNotNull(executor.claim(taskId, now)); |
|||
} |
|||
|
|||
@Test |
|||
void mysqlCompensationCursorPersistsAndHonorsPerRunBudget() { |
|||
List<Long> students = new ArrayList<>(); |
|||
for (int i = 0; i < 5; i++) { |
|||
Long id = student("mysql-scan-" + i); |
|||
students.add(id); |
|||
jdbcTemplate.update("INSERT INTO audit_logs(user_id,action,created_at) VALUES (?,'LOGIN',?)", id, now); |
|||
} |
|||
jdbcTemplate.update(""" |
|||
INSERT INTO candidate_scoring_refresh_scan_checkpoints |
|||
(scan_key,last_user_id,version,updated_at) |
|||
VALUES ('ACTIVE_STUDENTS',?,0,CURRENT_TIMESTAMP) |
|||
""", students.get(0) - 1L); |
|||
|
|||
assertEquals(2, compensationScanner.scanDueStudents(2)); |
|||
long cursor = jdbcTemplate.queryForObject(""" |
|||
SELECT last_user_id FROM candidate_scoring_refresh_scan_checkpoints |
|||
WHERE scan_key='ACTIVE_STUDENTS' |
|||
""", Long.class); |
|||
assertEquals(students.get(1).longValue(), cursor); |
|||
assertEquals(2, compensationScanner.scanDueStudents(2)); |
|||
assertEquals(1, compensationScanner.scanDueStudents(2)); |
|||
Long registered = jdbcTemplate.queryForObject(""" |
|||
SELECT COUNT(*) FROM candidate_scoring_refresh_tasks |
|||
WHERE student_user_id IN (?,?,?,?,?) |
|||
""", Long.class, students.toArray()); |
|||
assertEquals(5L, registered); |
|||
} |
|||
|
|||
private Long student(String nickname) { |
|||
User user = User.builder() |
|||
.phone("131" + String.format("%08d", Math.abs(System.nanoTime() % 100_000_000L))) |
|||
.nickname(nickname).role(User.Role.STUDENT).active(true).verified(true).build(); |
|||
user.setRoles(new HashSet<>(Set.of(User.Role.STUDENT))); |
|||
return userRepository.saveAndFlush(user).getId(); |
|||
} |
|||
|
|||
private Long taskId(Long studentId) { |
|||
return jdbcTemplate.queryForObject( |
|||
"SELECT id FROM candidate_scoring_refresh_tasks WHERE student_user_id=?", |
|||
Long.class, studentId); |
|||
} |
|||
|
|||
private String status(Long studentId) { |
|||
return jdbcTemplate.queryForObject( |
|||
"SELECT status FROM candidate_scoring_refresh_tasks WHERE student_user_id=?", |
|||
String.class, studentId); |
|||
} |
|||
|
|||
private String error(Long studentId) { |
|||
return jdbcTemplate.queryForObject( |
|||
"SELECT last_error_code FROM candidate_scoring_refresh_tasks WHERE student_user_id=?", |
|||
String.class, studentId); |
|||
} |
|||
|
|||
private List<CandidateScoringCalculator.DimensionResult> results(boolean failAtFourth) { |
|||
String[] dimensions = {"EDUCATION", "INTERNSHIP", "ABILITY", "COMPLIANCE", |
|||
"STABILITY", "GROWTH", "INTEGRITY", "ACTIVITY"}; |
|||
List<CandidateScoringCalculator.DimensionResult> values = new ArrayList<>(); |
|||
for (int i = 0; i < dimensions.length; i++) { |
|||
var value = new CandidateScoringCalculator.DimensionResult(failAtFourth && i == 3 ? null : dimensions[i]); |
|||
value.score = BigDecimal.valueOf(60L + i); |
|||
value.status = "OK"; |
|||
values.add(value); |
|||
} |
|||
return values; |
|||
} |
|||
} |
|||
@ -0,0 +1,110 @@ |
|||
package com.mydreamjob.candidatescore.refresh; |
|||
|
|||
import org.junit.jupiter.api.Test; |
|||
import org.junit.jupiter.api.extension.ExtendWith; |
|||
import org.mockito.InjectMocks; |
|||
import org.mockito.Mock; |
|||
import org.mockito.junit.jupiter.MockitoExtension; |
|||
import org.mockito.junit.jupiter.MockitoSettings; |
|||
import org.mockito.quality.Strictness; |
|||
import org.springframework.jdbc.core.JdbcTemplate; |
|||
|
|||
import java.time.LocalDateTime; |
|||
import java.util.List; |
|||
|
|||
import static org.mockito.ArgumentMatchers.any; |
|||
import static org.mockito.ArgumentMatchers.anyInt; |
|||
import static org.mockito.ArgumentMatchers.anyLong; |
|||
import static org.mockito.ArgumentMatchers.anyString; |
|||
import static org.mockito.ArgumentMatchers.eq; |
|||
import static org.mockito.Mockito.doThrow; |
|||
import static org.mockito.Mockito.never; |
|||
import static org.mockito.Mockito.times; |
|||
import static org.mockito.Mockito.verify; |
|||
import static org.mockito.Mockito.when; |
|||
|
|||
/** |
|||
* R01-D4-2 次生缺陷:{@code processDue} 的批次循环必须逐任务隔离。 |
|||
* |
|||
* <p>缺陷背景:{@code recordFailure} 更新 0 行时抛 IllegalStateException,该异常在 |
|||
* Worker 的 catch 块内抛出,而 {@code processDue} 的 for 循环没有保护 —— 会导致同批 |
|||
* 剩余任务本轮全部不被处理。</p> |
|||
* |
|||
* <p>纯单元测试:精确控制"第一项失败且失败登记也抛异常"的时序,不依赖 sleep 或 JVM 锁。</p> |
|||
*/ |
|||
@ExtendWith(MockitoExtension.class) |
|||
@MockitoSettings(strictness = Strictness.LENIENT) |
|||
class CandidateScoringRefreshWorkerLoopIsolationTest { |
|||
|
|||
@Mock private JdbcTemplate jdbcTemplate; |
|||
@Mock private CandidateScoringRefreshTaskExecutor taskExecutor; |
|||
|
|||
@InjectMocks private CandidateScoringRefreshWorker worker; |
|||
|
|||
/** 固定时间,保证 record 的 equals 在参数匹配时可稳定比较。 */ |
|||
private static final LocalDateTime CLAIMED_AT = LocalDateTime.of(2026, 9, 16, 12, 0); |
|||
|
|||
private CandidateScoringRefreshTaskExecutor.ClaimedTask claim(long id) { |
|||
return new CandidateScoringRefreshTaskExecutor.ClaimedTask(id, id * 10, "2026-09", |
|||
CLAIMED_AT, "token-" + id, 0L); |
|||
} |
|||
|
|||
@Test |
|||
void recordFailureThrowing_doesNotAbortRemainingTasksInBatch() { |
|||
when(jdbcTemplate.queryForList(anyString(), eq(Long.class), any(), anyInt())) |
|||
.thenReturn(List.of(1L, 2L, 3L)); |
|||
when(taskExecutor.claim(anyLong(), any(LocalDateTime.class))) |
|||
.thenAnswer(inv -> claim(inv.getArgument(0))); |
|||
when(taskExecutor.recoverStale(any(), any(), anyInt())).thenReturn(0); |
|||
|
|||
// 第一个任务计算失败
|
|||
doThrow(new RuntimeException("compute blew up")) |
|||
.when(taskExecutor).materialize(eq(claim(1L)), any(LocalDateTime.class)); |
|||
// 且其失败登记也抛异常(旧实现会抛出 IllegalStateException)
|
|||
doThrow(new IllegalStateException("refresh task failure state conflict")) |
|||
.when(taskExecutor).recordFailure(eq(1L), anyString(), any(LocalDateTime.class), |
|||
anyInt(), anyLong(), anyString()); |
|||
|
|||
worker.processDue(10); |
|||
|
|||
// 关键:后续两个任务必须仍被处理
|
|||
verify(taskExecutor, times(1)).materialize(eq(claim(2L)), any(LocalDateTime.class)); |
|||
verify(taskExecutor, times(1)).materialize(eq(claim(3L)), any(LocalDateTime.class)); |
|||
} |
|||
|
|||
@Test |
|||
void recordFailureThrowingInMiddle_doesNotAbortRemainingTasks() { |
|||
when(jdbcTemplate.queryForList(anyString(), eq(Long.class), any(), anyInt())) |
|||
.thenReturn(List.of(11L, 12L, 13L)); |
|||
when(taskExecutor.claim(anyLong(), any(LocalDateTime.class))) |
|||
.thenAnswer(inv -> claim(inv.getArgument(0))); |
|||
when(taskExecutor.recoverStale(any(), any(), anyInt())).thenReturn(0); |
|||
|
|||
doThrow(new RuntimeException("boom")).when(taskExecutor) |
|||
.materialize(eq(claim(12L)), any(LocalDateTime.class)); |
|||
doThrow(new IllegalStateException("state conflict")).when(taskExecutor) |
|||
.recordFailure(eq(12L), anyString(), any(LocalDateTime.class), anyInt(), anyLong(), |
|||
anyString()); |
|||
|
|||
worker.processDue(10); |
|||
|
|||
verify(taskExecutor, times(1)).materialize(eq(claim(11L)), any(LocalDateTime.class)); |
|||
verify(taskExecutor, times(1)).materialize(eq(claim(13L)), any(LocalDateTime.class)); |
|||
} |
|||
|
|||
@Test |
|||
void claimLosing_skipsTaskWithoutRecordingFailure() { |
|||
when(jdbcTemplate.queryForList(anyString(), eq(Long.class), any(), anyInt())) |
|||
.thenReturn(List.of(21L, 22L)); |
|||
when(taskExecutor.recoverStale(any(), any(), anyInt())).thenReturn(0); |
|||
// 21 被并发 Worker 抢走(claim 返回 null)
|
|||
when(taskExecutor.claim(eq(21L), any(LocalDateTime.class))).thenReturn(null); |
|||
when(taskExecutor.claim(eq(22L), any(LocalDateTime.class))).thenReturn(claim(22L)); |
|||
|
|||
worker.processDue(10); |
|||
|
|||
verify(taskExecutor, never()).recordFailure(eq(21L), anyString(), any(LocalDateTime.class), |
|||
anyInt(), anyLong(), anyString()); |
|||
verify(taskExecutor, times(1)).materialize(eq(claim(22L)), any(LocalDateTime.class)); |
|||
} |
|||
} |
|||
@ -0,0 +1,200 @@ |
|||
package com.mydreamjob.feedback; |
|||
|
|||
import com.mydreamjob.application.Application; |
|||
import com.mydreamjob.application.ApplicationRepository; |
|||
import com.mydreamjob.auth.User; |
|||
import com.mydreamjob.auth.UserRepository; |
|||
import com.mydreamjob.company.Company; |
|||
import com.mydreamjob.company.CompanyRepository; |
|||
import com.mydreamjob.common.exception.BusinessException; |
|||
import com.mydreamjob.feedback.dto.CandidateFeedbackRequest; |
|||
import com.mydreamjob.job.Job; |
|||
import com.mydreamjob.job.JobRepository; |
|||
import org.junit.jupiter.api.BeforeEach; |
|||
import org.junit.jupiter.api.Test; |
|||
import org.springframework.beans.factory.annotation.Autowired; |
|||
import org.springframework.boot.test.context.SpringBootTest; |
|||
import org.springframework.boot.test.mock.mockito.MockBean; |
|||
import org.springframework.data.redis.core.RedisTemplate; |
|||
import org.springframework.data.redis.core.ValueOperations; |
|||
import org.springframework.jdbc.core.JdbcTemplate; |
|||
import org.springframework.test.context.ActiveProfiles; |
|||
|
|||
import java.util.List; |
|||
import java.util.concurrent.atomic.AtomicLong; |
|||
|
|||
import static org.junit.jupiter.api.Assertions.*; |
|||
import static org.mockito.ArgumentMatchers.any; |
|||
import static org.mockito.Mockito.mock; |
|||
import static org.mockito.Mockito.when; |
|||
|
|||
/** |
|||
* R01-D4-2 阻断缺陷 F2:候选人反馈的创建/修改/删除都必须登记评分刷新任务。 |
|||
* |
|||
* <p>缺陷背景:{@code CandidateScoringCalculator#queryFeedback} 以 |
|||
* {@code COUNT(*) / AVG(rating)} 参与 INTEGRITY 维度,但只有 createFeedback 登记了刷新, |
|||
* updateFeedback(改 rating 会改变 AVG)与 deleteFeedback(改变 COUNT 与 AVG)此前不登记。</p> |
|||
*/ |
|||
@SpringBootTest |
|||
@ActiveProfiles("integration-test") |
|||
class CandidateFeedbackRefreshRegistrationTest { |
|||
|
|||
@Autowired private CandidateFeedbackService service; |
|||
@Autowired private CandidateFeedbackRepository feedbackRepository; |
|||
@Autowired private ApplicationRepository applicationRepository; |
|||
@Autowired private JobRepository jobRepository; |
|||
@Autowired private CompanyRepository companyRepository; |
|||
@Autowired private UserRepository userRepository; |
|||
@Autowired private JdbcTemplate jdbcTemplate; |
|||
@MockBean private RedisTemplate<String, Object> redisTemplate; |
|||
|
|||
private static final AtomicLong SEQ = new AtomicLong(5000); |
|||
|
|||
private Long companyUserId; |
|||
private Long studentId; |
|||
private Long applicationId; |
|||
|
|||
private long taskCount(Long studentUserId) { |
|||
Long c = jdbcTemplate.queryForObject( |
|||
"SELECT COUNT(*) FROM candidate_scoring_refresh_tasks WHERE student_user_id=?", |
|||
Long.class, studentUserId); |
|||
return c == null ? -1 : c; |
|||
} |
|||
|
|||
private CandidateFeedbackRequest req(int rating, String content) { |
|||
CandidateFeedbackRequest r = new CandidateFeedbackRequest(); |
|||
r.setRating(rating); |
|||
r.setContent(content); |
|||
r.setTags(List.of("t")); |
|||
return r; |
|||
} |
|||
|
|||
@BeforeEach |
|||
@SuppressWarnings("unchecked") |
|||
void setUp() { |
|||
ValueOperations<String, Object> ops = mock(ValueOperations.class); |
|||
when(redisTemplate.opsForValue()).thenReturn(ops); |
|||
when(redisTemplate.hasKey(any())).thenReturn(false); |
|||
|
|||
jdbcTemplate.update("DELETE FROM candidate_scoring_refresh_tasks"); |
|||
feedbackRepository.deleteAll(); |
|||
applicationRepository.deleteAll(); |
|||
jobRepository.deleteAll(); |
|||
companyRepository.deleteAll(); |
|||
|
|||
User student = userRepository.save(User.builder() |
|||
.phone("196" + String.format("%08d", SEQ.incrementAndGet())) |
|||
.nickname("F2学生").role(User.Role.STUDENT).active(true).verified(true).build()); |
|||
student.setRoles(java.util.Set.of(User.Role.STUDENT)); |
|||
studentId = student.getId(); |
|||
|
|||
User companyUser = userRepository.save(User.builder() |
|||
.phone("195" + String.format("%08d", SEQ.incrementAndGet())) |
|||
.nickname("F2企业").role(User.Role.COMPANY).active(true).verified(true).build()); |
|||
companyUser.setRoles(java.util.Set.of(User.Role.COMPANY)); |
|||
companyUserId = companyUser.getId(); |
|||
|
|||
Company company = companyRepository.save(Company.builder() |
|||
.ownerUserId(companyUserId).name("F2公司").build()); |
|||
Job job = jobRepository.save(Job.builder() |
|||
.companyId(company.getId()).title("F2岗位").description("d").city("上海") |
|||
.employmentType("FULL_TIME").status("PUBLISHED").build()); |
|||
Application app = applicationRepository.save(Application.builder() |
|||
.jobId(job.getId()).studentUserId(studentId).status("SUBMITTED").build()); |
|||
applicationId = app.getId(); |
|||
} |
|||
|
|||
private void clearTasks() { |
|||
jdbcTemplate.update("DELETE FROM candidate_scoring_refresh_tasks"); |
|||
} |
|||
|
|||
/** 清理本类产生的全部行,避免污染同一 JVM 内共享的 H2 内存库。 */ |
|||
@org.junit.jupiter.api.AfterEach |
|||
void tearDown() { |
|||
jdbcTemplate.update("DELETE FROM candidate_scoring_refresh_tasks"); |
|||
jdbcTemplate.update("DELETE FROM candidate_feedback"); |
|||
jdbcTemplate.update("DELETE FROM offers"); |
|||
jdbcTemplate.update("DELETE FROM application_status_history"); |
|||
jdbcTemplate.update("DELETE FROM applications"); |
|||
jdbcTemplate.update("DELETE FROM jobs"); |
|||
jdbcTemplate.update("DELETE FROM companies"); |
|||
} |
|||
|
|||
@Test |
|||
void createFeedback_registersRefreshTask() { |
|||
clearTasks(); |
|||
service.createFeedback(companyUserId, applicationId, req(5, "优秀")); |
|||
assertEquals(1L, taskCount(studentId), "createFeedback 必须登记刷新任务"); |
|||
} |
|||
|
|||
@Test |
|||
void updateFeedback_registersRefreshTask() { |
|||
service.createFeedback(companyUserId, applicationId, req(5, "优秀")); |
|||
clearTasks(); // 只观察 update 自身是否登记
|
|||
|
|||
service.updateFeedback(companyUserId, applicationId, req(2, "较差")); |
|||
|
|||
assertEquals(1L, taskCount(studentId), |
|||
"updateFeedback 改变 rating 会影响 AVG(rating),必须登记刷新任务"); |
|||
} |
|||
|
|||
@Test |
|||
void deleteFeedback_registersRefreshTask_afterRowRemoved() { |
|||
service.createFeedback(companyUserId, applicationId, req(5, "优秀")); |
|||
clearTasks(); |
|||
|
|||
service.deleteFeedback(companyUserId, applicationId); |
|||
|
|||
assertEquals(0L, feedbackRepository.count(), "反馈行应已删除"); |
|||
assertEquals(1L, taskCount(studentId), |
|||
"deleteFeedback 改变 COUNT(*) 与 AVG(rating),必须登记刷新任务"); |
|||
} |
|||
|
|||
@Test |
|||
void updateFeedback_ratingUnchanged_stillRegistersButConvergesToOneTask() { |
|||
service.createFeedback(companyUserId, applicationId, req(4, "一般")); |
|||
clearTasks(); |
|||
|
|||
service.updateFeedback(companyUserId, applicationId, req(4, "一般-改文案")); |
|||
service.updateFeedback(companyUserId, applicationId, req(4, "一般-再改")); |
|||
|
|||
assertEquals(1L, taskCount(studentId), |
|||
"重复登记必须收敛为同一学生同一周期的一条任务"); |
|||
} |
|||
|
|||
@Test |
|||
void updateFeedback_forbiddenCaller_leavesNoOrphanTask() { |
|||
service.createFeedback(companyUserId, applicationId, req(5, "优秀")); |
|||
clearTasks(); |
|||
|
|||
assertThrows(BusinessException.class, |
|||
() -> service.updateFeedback(999999L, applicationId, req(1, "越权"))); |
|||
|
|||
assertEquals(0L, taskCount(studentId), "失败路径不得留下孤立刷新任务"); |
|||
} |
|||
|
|||
@Test |
|||
void deleteFeedback_missingFeedback_leavesNoOrphanTask() { |
|||
clearTasks(); |
|||
|
|||
assertThrows(BusinessException.class, |
|||
() -> service.deleteFeedback(companyUserId, applicationId)); |
|||
|
|||
assertEquals(0L, taskCount(studentId), "失败路径不得留下孤立刷新任务"); |
|||
} |
|||
|
|||
@Test |
|||
void feedbackLifecycle_createUpdateDelete_allRegisteredAndSnapshotRefreshable() { |
|||
clearTasks(); |
|||
service.createFeedback(companyUserId, applicationId, req(5, "优秀")); |
|||
assertEquals(1L, taskCount(studentId), "create 登记"); |
|||
|
|||
clearTasks(); |
|||
service.updateFeedback(companyUserId, applicationId, req(1, "很差")); |
|||
assertEquals(1L, taskCount(studentId), "update 登记"); |
|||
|
|||
clearTasks(); |
|||
service.deleteFeedback(companyUserId, applicationId); |
|||
assertEquals(1L, taskCount(studentId), "delete 登记"); |
|||
} |
|||
} |
|||
@ -0,0 +1,278 @@ |
|||
package com.mydreamjob.offer; |
|||
|
|||
import com.mydreamjob.application.Application; |
|||
import com.mydreamjob.application.ApplicationRepository; |
|||
import com.mydreamjob.auth.User; |
|||
import com.mydreamjob.auth.UserRepository; |
|||
import com.mydreamjob.company.Company; |
|||
import com.mydreamjob.company.CompanyRepository; |
|||
import com.mydreamjob.job.Job; |
|||
import com.mydreamjob.job.JobRepository; |
|||
import org.junit.jupiter.api.BeforeEach; |
|||
import org.junit.jupiter.api.Test; |
|||
import org.springframework.beans.factory.annotation.Autowired; |
|||
import org.springframework.boot.test.context.SpringBootTest; |
|||
import org.springframework.boot.test.mock.mockito.MockBean; |
|||
import org.springframework.data.redis.core.RedisTemplate; |
|||
import org.springframework.data.redis.core.ValueOperations; |
|||
import org.springframework.jdbc.core.JdbcTemplate; |
|||
import org.springframework.test.context.ActiveProfiles; |
|||
|
|||
import java.time.LocalDateTime; |
|||
import java.util.List; |
|||
import java.util.concurrent.atomic.AtomicLong; |
|||
|
|||
import static org.junit.jupiter.api.Assertions.*; |
|||
import static org.mockito.ArgumentMatchers.any; |
|||
import static org.mockito.ArgumentMatchers.anyLong; |
|||
import static org.mockito.Mockito.doThrow; |
|||
import static org.mockito.Mockito.mock; |
|||
import static org.mockito.Mockito.when; |
|||
|
|||
/** |
|||
* R01-D4-2 阻断缺陷 F1:Offer 自动过期必须登记评分刷新任务。 |
|||
* |
|||
* <p>缺陷背景:{@code OfferExpiryWorker#expireOne} 直接把 Offer 置为 EXPIRED, |
|||
* 而 {@code CandidateScoringCalculator#queryOffers} 以 {@code status='EXPIRED'} 计数 |
|||
* 参与 INTEGRITY 维度评分 —— 该写侧事件此前不登记刷新任务。</p> |
|||
* |
|||
* <p>本测试断言真实数据库状态(offers / candidate_scoring_refresh_tasks / notifications), |
|||
* 不依赖对 requestRefresh 的 mock 验证。</p> |
|||
*/ |
|||
@SpringBootTest |
|||
@ActiveProfiles("integration-test") |
|||
class OfferExpiryRefreshRegistrationTest { |
|||
|
|||
@Autowired private OfferExpiryWorker worker; |
|||
@Autowired private com.mydreamjob.candidatescore.refresh.CandidateScoringRefreshRequestService requestService; |
|||
@Autowired private OfferRepository offerRepository; |
|||
@Autowired private OfferStatusHistoryRepository historyRepository; |
|||
@Autowired private ApplicationRepository applicationRepository; |
|||
@Autowired private JobRepository jobRepository; |
|||
@Autowired private CompanyRepository companyRepository; |
|||
@Autowired private UserRepository userRepository; |
|||
@Autowired private JdbcTemplate jdbcTemplate; |
|||
@MockBean private RedisTemplate<String, Object> redisTemplate; |
|||
/** 仅用于制造"事务内后续步骤失败"以验证原子性;正常路径行为不变。 */ |
|||
@MockBean private OfferCounterOfferRepository counterOfferRepository; |
|||
|
|||
private static final AtomicLong SEQ = new AtomicLong(3000); |
|||
|
|||
private Long companyId; |
|||
private Long companyUserId; |
|||
|
|||
/** 与 CandidateScoringRefreshRequestService 保持一致:period = capturedNow 所在 yyyy-MM。 */ |
|||
private String period() { |
|||
LocalDateTime n = LocalDateTime.now(); |
|||
return String.format("%04d-%02d", n.getYear(), n.getMonthValue()); |
|||
} |
|||
|
|||
private long taskCount(Long studentId) { |
|||
Long c = jdbcTemplate.queryForObject( |
|||
"SELECT COUNT(*) FROM candidate_scoring_refresh_tasks WHERE student_user_id=?", |
|||
Long.class, studentId); |
|||
return c == null ? -1 : c; |
|||
} |
|||
|
|||
/** userId 为 null 时统计全部通知行。 */ |
|||
private long notificationCount(Long userId) { |
|||
Long c = (userId == null) |
|||
? jdbcTemplate.queryForObject("SELECT COUNT(*) FROM notifications", Long.class) |
|||
: jdbcTemplate.queryForObject( |
|||
"SELECT COUNT(*) FROM notifications WHERE user_id=?", Long.class, userId); |
|||
return c == null ? -1 : c; |
|||
} |
|||
|
|||
@BeforeEach |
|||
@SuppressWarnings("unchecked") |
|||
void setUp() { |
|||
ValueOperations<String, Object> ops = mock(ValueOperations.class); |
|||
when(redisTemplate.opsForValue()).thenReturn(ops); |
|||
when(redisTemplate.hasKey(any())).thenReturn(false); |
|||
|
|||
jdbcTemplate.update("DELETE FROM candidate_scoring_refresh_tasks"); |
|||
jdbcTemplate.update("DELETE FROM candidate_feedback"); |
|||
historyRepository.deleteAll(); // offer_status_history 引用 offers,必须先删
|
|||
jdbcTemplate.update("DELETE FROM offer_reminders"); |
|||
jdbcTemplate.update("DELETE FROM offer_counter_offers"); |
|||
offerRepository.deleteAll(); |
|||
applicationRepository.deleteAll(); |
|||
jobRepository.deleteAll(); |
|||
companyRepository.deleteAll(); |
|||
|
|||
User companyUser = userRepository.save(User.builder() |
|||
.phone("198" + String.format("%08d", SEQ.incrementAndGet())) |
|||
.nickname("F1企业").role(User.Role.COMPANY).active(true).verified(true).build()); |
|||
companyUser.setRoles(java.util.Set.of(User.Role.COMPANY)); |
|||
companyUserId = companyUser.getId(); |
|||
Company company = companyRepository.save(Company.builder() |
|||
.ownerUserId(companyUserId).name("F1公司").build()); |
|||
companyId = company.getId(); |
|||
Job job = jobRepository.save(Job.builder() |
|||
.companyId(companyId).title("F1岗位").description("d").city("上海") |
|||
.employmentType("FULL_TIME").status("PUBLISHED").build()); |
|||
|
|||
// 原状态非 ACTIVE,避免 activeKey 唯一约束在多 Offer 场景下互相冲突
|
|||
jdbcTemplate.update("UPDATE jobs SET status='PUBLISHED' WHERE id=?", job.getId()); |
|||
} |
|||
|
|||
/** 清理本类产生的全部行,避免污染同一 JVM 内共享的 H2 内存库。 */ |
|||
@org.junit.jupiter.api.AfterEach |
|||
void tearDown() { |
|||
jdbcTemplate.update("DELETE FROM candidate_scoring_refresh_tasks"); |
|||
jdbcTemplate.update("DELETE FROM candidate_feedback"); |
|||
historyRepository.deleteAll(); |
|||
jdbcTemplate.update("DELETE FROM offer_reminders"); |
|||
jdbcTemplate.update("DELETE FROM offer_counter_offers"); |
|||
offerRepository.deleteAll(); |
|||
jdbcTemplate.update("DELETE FROM application_status_history"); |
|||
applicationRepository.deleteAll(); |
|||
jobRepository.deleteAll(); |
|||
companyRepository.deleteAll(); |
|||
} |
|||
|
|||
/** 建一个属于指定学生的 SENT Offer(activeKey 置空以便同库多 Offer 共存)。 */ |
|||
private Offer newSentOffer(Long studentId) { |
|||
Job job = jobRepository.findAll().get(0); |
|||
Application app = applicationRepository.save(Application.builder() |
|||
.jobId(job.getId()).studentUserId(studentId).status("SUBMITTED").build()); |
|||
return offerRepository.saveAndFlush(Offer.builder() |
|||
.applicationId(app.getId()).jobId(job.getId()).companyId(companyId) |
|||
.candidateId(studentId) |
|||
.title("F1 Offer").description("d").expireAt(LocalDateTime.now().minusDays(1)) |
|||
.status(OfferStatus.SENT.name()).activeKey(null) |
|||
.createdBy(companyUserId).updatedBy(companyUserId) |
|||
.build()); |
|||
} |
|||
|
|||
private Long newStudent(String tag) { |
|||
User u = userRepository.save(User.builder() |
|||
.phone("197" + String.format("%08d", SEQ.incrementAndGet())) |
|||
.nickname("F1学生" + tag).role(User.Role.STUDENT).active(true).verified(true).build()); |
|||
u.setRoles(java.util.Set.of(User.Role.STUDENT)); |
|||
return u.getId(); |
|||
} |
|||
|
|||
@Test |
|||
void expireOne_marksOfferExpired_andRegistersRefreshTaskForCandidate() { |
|||
Long studentId = newStudent("A"); |
|||
Offer offer = newSentOffer(studentId); |
|||
|
|||
worker.expireOne(offer.getId()); |
|||
|
|||
// 1) Offer 状态确实被改为 EXPIRED(真实数据库)
|
|||
assertEquals(OfferStatus.EXPIRED.name(), |
|||
offerRepository.findById(offer.getId()).orElseThrow().getStatus()); |
|||
// 2) 同一业务事务内登记了该学生的刷新任务(真实数据库)
|
|||
assertEquals(1L, taskCount(studentId), |
|||
"Offer 自动过期必须为该候选人登记恰好一条评分刷新任务"); |
|||
String taskPeriod = jdbcTemplate.queryForObject( |
|||
"SELECT target_period FROM candidate_scoring_refresh_tasks WHERE student_user_id=?", |
|||
String.class, studentId); |
|||
assertEquals(period(), taskPeriod); |
|||
} |
|||
|
|||
@Test |
|||
void expireOne_repeatedExecution_keepsSingleTask() { |
|||
Long studentId = newStudent("B"); |
|||
Offer offer = newSentOffer(studentId); |
|||
|
|||
worker.expireOne(offer.getId()); |
|||
worker.expireOne(offer.getId()); // 第二次:状态已是 EXPIRED,幂等返回
|
|||
worker.expireOne(offer.getId()); |
|||
|
|||
assertEquals(1L, taskCount(studentId), "重复执行不得产生多条刷新任务"); |
|||
} |
|||
|
|||
@Test |
|||
void expireOne_multipleOffersOfDifferentStudents_registerSeparately() { |
|||
Long studentA = newStudent("C"); |
|||
Long studentB = newStudent("D"); |
|||
Offer a = newSentOffer(studentA); |
|||
Offer b = newSentOffer(studentB); |
|||
|
|||
worker.expireOne(a.getId()); |
|||
worker.expireOne(b.getId()); |
|||
|
|||
assertEquals(1L, taskCount(studentA), "学生 A 应各自登记一条"); |
|||
assertEquals(1L, taskCount(studentB), "学生 B 应各自登记一条"); |
|||
Long total = jdbcTemplate.queryForObject( |
|||
"SELECT COUNT(*) FROM candidate_scoring_refresh_tasks", Long.class); |
|||
assertEquals(2L, total, "两个不同学生应产生两条独立任务"); |
|||
} |
|||
|
|||
@Test |
|||
void expireOne_rollback_rollsBackOfferStatusAndRefreshTaskTogether() { |
|||
Long studentId = newStudent("E"); |
|||
Offer offer = newSentOffer(studentId); |
|||
|
|||
// 制造"同一事务内后续步骤失败" → 整个 expireOne 事务回滚
|
|||
doThrow(new org.springframework.dao.DataAccessException("boom") {}) |
|||
.when(counterOfferRepository).expirePendingByOfferId(anyLong()); |
|||
|
|||
assertThrows(RuntimeException.class, () -> worker.expireOne(offer.getId())); |
|||
|
|||
assertEquals(OfferStatus.SENT.name(), |
|||
offerRepository.findById(offer.getId()).orElseThrow().getStatus(), |
|||
"事务回滚后 Offer 状态必须保持原值"); |
|||
assertEquals(0L, taskCount(studentId), |
|||
"事务回滚后不得留下孤立的刷新任务"); |
|||
} |
|||
|
|||
@Test |
|||
void expireOne_refreshRegistrationAddsNoNotificationRows() { |
|||
Long studentId = newStudent("F"); |
|||
Offer offer = newSentOffer(studentId); |
|||
|
|||
long globalBefore = notificationCount(null); |
|||
|
|||
worker.expireOne(offer.getId()); |
|||
|
|||
long globalAfter = notificationCount(null); |
|||
// Offer 过期通知由既有 notifyExpired 走通知通道(测试 profile 下经 MQ 开关关闭的路径),
|
|||
// 本断言只要求:新增的刷新登记不得额外插入通知行。
|
|||
assertEquals(globalBefore, globalAfter, |
|||
"刷新登记不得引入任何 notifications 行"); |
|||
} |
|||
|
|||
@Test |
|||
void requestRefresh_alone_createsNoNotificationsAndNoMessages() { |
|||
Long studentId = newStudent("H"); |
|||
long nBefore = notificationCount(null); |
|||
long mBefore = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM messages", Long.class); |
|||
|
|||
requestService.requestRefresh(studentId, LocalDateTime.now()); |
|||
|
|||
assertEquals(nBefore, notificationCount(null), "纯登记不得写通知"); |
|||
assertEquals(mBefore, jdbcTemplate.queryForObject("SELECT COUNT(*) FROM messages", Long.class), |
|||
"纯登记不得写消息"); |
|||
assertEquals(1L, taskCount(studentId), "纯登记只登记任务"); |
|||
} |
|||
|
|||
@Test |
|||
void expireOne_refreshTaskCarriesNoSensitiveFields() { |
|||
Long studentId = newStudent("G"); |
|||
Offer offer = newSentOffer(studentId); |
|||
|
|||
worker.expireOne(offer.getId()); |
|||
|
|||
List<String> cols = jdbcTemplate.queryForList( |
|||
"SELECT column_name FROM information_schema.columns " |
|||
+ "WHERE table_name='CANDIDATE_SCORING_REFRESH_TASKS' AND table_schema='PUBLIC'", |
|||
String.class); |
|||
if (cols.isEmpty()) { // H2 大小写差异兜底
|
|||
cols = List.of("id", "student_user_id", "target_period", "status", "requested_at", |
|||
"processing_started_at", "processed_at", "next_attempt_at", "attempts", |
|||
"last_error_code", "version", "created_at", "updated_at"); |
|||
} |
|||
// claim_token 是 V45 引入的所有权令牌(随机 UUID),不是用户凭据,属允许列;
|
|||
// 其余用户凭据/隐私列一律禁止。
|
|||
for (String banned : List.of("PHONE", "EMAIL", "REASON", "CONTENT", "SALARY", |
|||
"ACCESS_TOKEN", "REFRESH_TOKEN", "PASSWORD", "SECRET")) { |
|||
assertTrue(cols.stream().noneMatch(c -> c.toUpperCase().contains(banned)), |
|||
"刷新任务表不得含敏感列: " + banned); |
|||
} |
|||
assertTrue(cols.stream().anyMatch(c -> "CLAIM_TOKEN".equalsIgnoreCase(c)), |
|||
"V45 必须提供 claim_token 所有权令牌列"); |
|||
} |
|||
} |
|||
@ -0,0 +1,139 @@ |
|||
package com.mydreamjob.resume; |
|||
|
|||
import com.mydreamjob.auth.User; |
|||
import com.mydreamjob.auth.UserRepository; |
|||
import com.mydreamjob.profile.storage.ResumeStorageService; |
|||
import org.junit.jupiter.api.BeforeEach; |
|||
import org.junit.jupiter.api.Test; |
|||
import org.springframework.beans.factory.annotation.Autowired; |
|||
import org.springframework.boot.test.context.SpringBootTest; |
|||
import org.springframework.boot.test.mock.mockito.MockBean; |
|||
import org.springframework.data.redis.core.RedisTemplate; |
|||
import org.springframework.data.redis.core.ValueOperations; |
|||
import org.springframework.jdbc.core.JdbcTemplate; |
|||
import org.springframework.mock.web.MockMultipartFile; |
|||
import org.springframework.test.context.ActiveProfiles; |
|||
import org.springframework.transaction.support.TransactionTemplate; |
|||
|
|||
import java.util.concurrent.atomic.AtomicLong; |
|||
|
|||
import static org.junit.jupiter.api.Assertions.*; |
|||
import static org.mockito.ArgumentMatchers.any; |
|||
import static org.mockito.ArgumentMatchers.anyLong; |
|||
import static org.mockito.Mockito.mock; |
|||
import static org.mockito.Mockito.when; |
|||
|
|||
/** |
|||
* R01-D4-2 阻断缺陷 F3:简历版本行与评分刷新任务必须同一提交边界。 |
|||
* |
|||
* <p>缺陷背景:{@code ResumeVersionWriter#saveNewVersion} 以 REQUIRES_NEW 独立提交版本行 |
|||
* (这是既有的一致性问题防护设计,不能简单移除),而 {@code createVersion} 在外层事务中 |
|||
* 登记刷新任务 —— 外层事务在提交阶段失败时,版本行已落库而刷新任务丢失。</p> |
|||
* |
|||
* <p>本测试断言真实数据库状态:外层事务回滚后,版本行存在则刷新任务必须同样存在。</p> |
|||
* |
|||
* <p>文件系统操作被 mock:本测试只验证数据库原子性,不把文件存储纳入数据库事务承诺。</p> |
|||
*/ |
|||
@SpringBootTest |
|||
@ActiveProfiles("integration-test") |
|||
class ResumeVersionRefreshAtomicityTest { |
|||
|
|||
@Autowired private ResumeVersionService service; |
|||
@Autowired private ResumeVersionRepository resumeVersionRepository; |
|||
@Autowired private UserRepository userRepository; |
|||
@Autowired private JdbcTemplate jdbcTemplate; |
|||
@Autowired private TransactionTemplate txTemplate; |
|||
@MockBean private ResumeStorageService resumeStorageService; |
|||
@MockBean private RedisTemplate<String, Object> redisTemplate; |
|||
|
|||
private static final AtomicLong SEQ = new AtomicLong(7000); |
|||
|
|||
private Long userId; |
|||
|
|||
private long taskCount(Long uid) { |
|||
Long c = jdbcTemplate.queryForObject( |
|||
"SELECT COUNT(*) FROM candidate_scoring_refresh_tasks WHERE student_user_id=?", |
|||
Long.class, uid); |
|||
return c == null ? -1 : c; |
|||
} |
|||
|
|||
/** 必须是带真实 PDF 签名的内容,否则 ResumeFileValidator 会拒绝。 */ |
|||
private MockMultipartFile file() { |
|||
return new MockMultipartFile("file", "resume.pdf", "application/pdf", |
|||
"%PDF-1.4\n1 0 obj\n<<>>\nendobj\ntrailer\n<<>>\n%%EOF\n".getBytes()); |
|||
} |
|||
|
|||
@BeforeEach |
|||
@SuppressWarnings("unchecked") |
|||
void setUp() { |
|||
ValueOperations<String, Object> ops = mock(ValueOperations.class); |
|||
when(redisTemplate.opsForValue()).thenReturn(ops); |
|||
when(redisTemplate.hasKey(any())).thenReturn(false); |
|||
when(resumeStorageService.store(anyLong(), any())).thenReturn("test/resume.pdf"); |
|||
|
|||
jdbcTemplate.update("DELETE FROM candidate_scoring_refresh_tasks"); |
|||
resumeVersionRepository.deleteAll(); |
|||
|
|||
User student = userRepository.save(User.builder() |
|||
.phone("194" + String.format("%08d", SEQ.incrementAndGet())) |
|||
.nickname("F3学生").role(User.Role.STUDENT).active(true).verified(true).build()); |
|||
student.setRoles(java.util.Set.of(User.Role.STUDENT)); |
|||
userId = student.getId(); |
|||
} |
|||
|
|||
/** 清理本类产生的行,避免污染同一 JVM 内共享的 H2 内存库。 */ |
|||
@org.junit.jupiter.api.AfterEach |
|||
void tearDown() { |
|||
jdbcTemplate.update("DELETE FROM candidate_scoring_refresh_tasks"); |
|||
jdbcTemplate.update("DELETE FROM resume_export_tasks"); |
|||
resumeVersionRepository.deleteAll(); |
|||
} |
|||
|
|||
@Test |
|||
void createVersion_success_registersRefreshTask() { |
|||
service.createVersion(userId, "我的简历", file()); |
|||
|
|||
assertEquals(1L, resumeVersionRepository.count(), "版本行已创建"); |
|||
assertEquals(1L, taskCount(userId), "createVersion 必须登记刷新任务"); |
|||
} |
|||
|
|||
@Test |
|||
void createVersion_outerTransactionRollsBack_versionRowAndTaskCommitTogether() { |
|||
// 外层事务包裹 createVersion,并在返回后抛出 → 外层回滚
|
|||
assertThrows(RuntimeException.class, () -> txTemplate.execute(status -> { |
|||
service.createVersion(userId, "外层回滚简历", file()); |
|||
throw new RuntimeException("outer transaction failed after createVersion"); |
|||
})); |
|||
|
|||
long versions = resumeVersionRepository.count(); |
|||
long tasks = taskCount(userId); |
|||
|
|||
// 既有设计:版本行由 REQUIRES_NEW 独立提交 → 外层回滚后仍然存在
|
|||
assertEquals(1L, versions, "版本行经独立事务提交,外层回滚不应使其消失"); |
|||
|
|||
// F3 核心断言:版本行存在则刷新任务必须同样存在(同一提交边界)
|
|||
assertEquals(versions > 0 ? 1L : 0L, tasks, |
|||
"版本行已提交时刷新任务不得丢失(必须与版本行同一提交边界)"); |
|||
} |
|||
|
|||
@Test |
|||
void createVersion_neverLeavesTaskWithoutVersionRow() { |
|||
// 反向:不允许出现"有任务但没有版本行"
|
|||
service.createVersion(userId, "正常简历", file()); |
|||
long versions = resumeVersionRepository.count(); |
|||
long tasks = taskCount(userId); |
|||
assertTrue(versions >= tasks, |
|||
"不得出现任务多于版本行的不一致状态 (versions=" + versions + ", tasks=" + tasks + ")"); |
|||
} |
|||
|
|||
@Test |
|||
void createVersion_repeatedRegistrations_convergeToOneTaskPerPeriod() { |
|||
service.createVersion(userId, "简历一", file()); |
|||
service.createVersion(userId, "简历二", file()); |
|||
service.createVersion(userId, "简历三", file()); |
|||
|
|||
assertEquals(3L, resumeVersionRepository.count(), "三个版本行"); |
|||
assertEquals(1L, taskCount(userId), |
|||
"同学生同周期重复登记必须收敛为一条任务"); |
|||
} |
|||
} |
|||
Loading…
Reference in new issue