diff --git a/docs/r01-d4-1-scoring-materialization-implementation-report.md b/docs/r01-d4-1-scoring-materialization-implementation-report.md new file mode 100644 index 0000000..224cdd5 --- /dev/null +++ b/docs/r01-d4-1-scoring-materialization-implementation-report.md @@ -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。 diff --git a/docs/r01-d4-2-blocker-remediation-20260916.md b/docs/r01-d4-2-blocker-remediation-20260916.md new file mode 100644 index 0000000..cf00f4b --- /dev/null +++ b/docs/r01-d4-2-blocker-remediation-20260916.md @@ -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 文本层断言,禁止出现 `""` 形式的 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 评分快照写入迁移尚未冻结。 diff --git a/docs/r01-d4-2-claim-fence-remediation-20260916.md b/docs/r01-d4-2-claim-fence-remediation-20260916.md new file mode 100644 index 0000000..85ded77 --- /dev/null +++ b/docs/r01-d4-2-claim-fence-remediation-20260916.md @@ -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: but was: `** ← 核心缺陷复现 | + +同时复现了失败登记的同类覆盖: +`sameTimestamp_staleWorkerFailure_mustNotTouchNewClaim` → **`expected: but was: `** + +其余 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 评分快照写入迁移尚未冻结。 diff --git a/docs/r01-d4-2-claim-token-final-verification-20260916.md b/docs/r01-d4-2-claim-token-final-verification-20260916.md new file mode 100644 index 0000000..0012277 --- /dev/null +++ b/docs/r01-d4-2-claim-token-final-verification-20260916.md @@ -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评分快照写入迁移可以冻结并进入精确暂存检查点。** diff --git a/docs/r01-d4-2-final-independent-incremental-verification-20260916.md b/docs/r01-d4-2-final-independent-incremental-verification-20260916.md new file mode 100644 index 0000000..b5a5420 --- /dev/null +++ b/docs/r01-d4-2-final-independent-incremental-verification-20260916.md @@ -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 文本层 `""` 字面量断言 ✔ +- 嵌套对象与数组:`assertNoBannedKeyDeep` 递归处理 `isObject` 与 `isArray` ✔ + +### 2.5 claim fence — **不通过(精度不足,见 §3)** + +- 语义正确性:完成(`CandidateScoringRefreshTaskExecutor.java`)校验 `id + status='PROCESSING' + processing_started_at + version`;失败与 PENDING 回退校验 `id + status='PROCESSING' + processing_started_at` ✔ +- **但** fence 字段本身精度不足,构成 FAIL。 + +### 2.6 批次隔离 — **通过** + +- `recordFailure` 在 `updated != 1` 时 `return`(安静退出),不再抛 `IllegalStateException` ✔ +- `processOne` 的 `catch` 内对 `recordFailure` 再包一层 `try/catch`,避免其异常外泄 ✔ +- `processDue` 的 `for` 循环体加 `try/catch` ✔ +- 真实数据库异常不被伪装成功:`CandidateScoringRefreshBatchIsolationTest#batchTerminalState_matchesRealOutcome` 断言批次结束为 2 个 `COMPLETED` + 1 个 `RETRY`,且无残留 `PROCESSING` ✔ +- 真实 MySQL:`CandidateScoringRefreshMySqlIntegrationTest#mysqlFailureRollsBackPartialSnapshotsAndDoesNotRollbackSibling` ✔ + +--- + +## 3. FAIL 项:claim fence 精度不足 + +### 3.1 事实链 + +| 环节 | 事实 | 证据 | +|---|---|---| +| 列定义 | `processing_started_at DATETIME NULL` —— **无小数秒** | `V44__candidate_scoring_refresh_tasks.sql:11` | +| 实际精度 | `DATETIME_PRECISION = 0` | `information_schema.columns` 查询 | +| 写入截断 | `'2026-09-16 12:00:00.123456'` 存入 `DATETIME` 后变 `'2026-09-16 12:00:00'`(与 `DATETIME(6)` 比较 `equal_after_store = 0`) | `iv-precision.sql` | +| Java 侧 | `LocalDateTime.now()`(纳秒)传入 JDBC | `CandidateScoringRefreshTaskExecutor.java` `claim(...)` | +| 读回 | `rs.getObject("processing_started_at", LocalDateTime.class)` —— 秒级 | 同上 | + +**结论:fence 的比较粒度是 1 秒。** + +### 3.2 最小复现(已在真实 MySQL 执行) + +脚本:`docs/acceptance/r01-d4-2-20260916/iv-fence2.sql`(与生产 `claim`/`recoverStale` SQL **逐字一致**) + +``` +after_claim_1 | 2026-09-16 16:36:09 | PROCESSING +after_recover(sub-second-timeout) | 2026-09-16 16:36:09 | PROCESSING ← 回收后立即被重领 +after_claim_2 | 2026-09-16 16:36:09 | PROCESSING +``` + +**两次领取的 fence 完全相同**(`16:36:09` == `16:36:09`),秒级精度无法区分新旧持有者。 + +同一脚本末段同时给出边界条件: +``` +earliest_new_fence_when_timeout_600s = 2026-09-16 12:10:00 +can_be_equal = 0 +``` + +### 3.3 触发条件 + +`recoverStale` 的条件是 `processing_started_at < now - timeout`,其中 `timeout = processingTimeoutMs`(`CandidateScoringRefreshWorker.java:55`:`processingTimeoutMs <= 0 ? 600_000L : processingTimeoutMs`)。 + +- **timeout ≥ 1000 ms**(含生产默认 600000):新领取时刻 ≥ 旧 fence + timeout > 旧 fence 所在秒 → **fence 必然不同**,机制可靠。 +- **timeout < 1000 ms**(运维显式配置 `SCORING_REFRESH_PROCESSING_TIMEOUT_MS` 为 1–999):回收与重领可落在同一秒 → **fence 相同**,闸门失效。 + +代码中**未对该配置做下界约束**,因此「可能得到相同 fence」成立。 + +### 3.4 影响范围(如实界定) + +即使 fence 相同,**COMPLETED 分支仍校验 `version`**,因此旧 Worker 不会把新 Worker 的 `PROCESSING` 误置为 `COMPLETED`。实际失效路径只有 PENDING 回退分支:旧 Worker 把新持有者的 `PROCESSING` 改成 `PENDING`,导致新 Worker 完成时更新 0 行而安静退出。后果是**多算一次**(任务回到 PENDING 待下轮重算),不产生数据损坏,也不会留下永久 `PROCESSING`。 + +但按任务书给定的判据 —— 「若新旧两次领取**可能**得到相同 fence,直接判 FAIL;不能用测试等待规避」—— 判定为 **FAIL**。 + +### 3.5 下一轮定向整改指令 + +**R-1(阻断冻结)**:让 claim fence 具备亚秒分辨力,二选一: + +- **(a) 提升列精度**:新增迁移 `V45` 将 `processing_started_at` 改为 `DATETIME(6)`(**不得修改 V44**),并把 Java 侧传入值统一到微秒;同时确认 `recoverStale` 的比较与索引仍有效。 +- **(b) 改用不依赖时间的 fence**:使用既有 `version` 列的**单调快照**作为所有权标识 —— 在 `claim` 时把 `version` 的当次结果记入 `ClaimedTask`,完成/失败/回退时要求 `version` 属于「本次领取之后、下一次领取之前」的区间。若选此路,需明确区分「同 claim 期间新请求推进 version」(应回退 PENDING)与「被重新领取推进 version」(应安静退出)—— 可借助 `attempts` 或 `processing_started_at` 的组合判定。 + +**R-2(同批处理)**:为 fence 增加**边界测试**:显式构造 `timeout < 1s` 场景,断言旧 Worker 不得覆盖新持有者;或对 `processing-timeout-ms` 增加「必须 ≥ 1000」的配置校验(若选 (a)/(b) 后仍有时间依赖)。 + +**R-3(建议)**:`iv-fence2.sql` 与 `iv-precision.sql` 可作为该轮复验的前置失败证据复用。 + +--- + +## 4. 自动化复验结果 + +| 命令 | 实际 | 任务书预期 | 结论 | +|---|---|---|---| +| 定向(5 组通配符) | **184 / 184**,F=0 E=0 S=0 | 104 | 数量差异见下 | +| 关联回归(9 组通配符) | **558 / 558**,F=0 E=0 S=0 | 558 | 一致 ✔ | +| `mvn clean verify` | **2163 / 2163**,F=0 E=0 **S=0**,BUILD SUCCESS | 2163 | 一致 ✔ | +| `python docs\verify_openapi.py` | **176 paths / 214 operations**,All checks passed | 176/214 | 一致 ✔ | + +**定向数量差异解释(184 vs 104)**:任务书给定的通配符 `*OfferExpiry*Test,*CandidateFeedback*Test,*ResumeVersion*Test,*CandidateScoringRefresh*Test,*CandidateScoringCalculatorTest` 比整改自测时使用的 12 个类名更宽,额外匹配了 `OfferControllerTest`(30)、`OfferServiceTest`(21)、`OfferPersistenceIntegrationTest`(16)、`CandidateFeedbackControllerTest`(8)、`ResumeExportServiceTest`(8)、`ResumeVersionPersistenceIntegrationTest` 等既有类。**差异全部为多覆盖,无遗漏**。 + +逐类(18 个类):30 / 4 / 6 / 3 / 14 / 6 / 3 / 8 / 7 / 16 / 7 / 7 / 8 / 21 / 6 / 8 / 4 / 26 = 184 ✔ + +--- + +## 5. 真实 MySQL / Docker 增量黑盒 + +实例:本轮 `clean verify` 产出的最新 JAR;物化实例 PID 12592(`SCORING_REFRESH_ENABLED=true`、`OFFER_EXPIRY_INTERVAL_MS=3000`、补偿 5s/limit 2),只读实例 PID 11060(`false`)。二者均已停止,8080/8081 空闲。 + +### 5.1 A. 五条写侧链路 — **PASS=44 / FAIL=0 / SKIP=0** + +每条均断言:业务数据变化 → 同学生同周期唯一任务 → 周期正确 → Worker `COMPLETED` → 无失败分类 → 八维齐备 → 不写 `snapshot_history`。 + +| 写侧 | 业务变化 | 任务 | Worker | 八维 | history | +|---|---|---|---|---|---| +| A1 Feedback create | `active=1` | 1 条 | `COMPLETED` 1.1s | 8 | 0→0 | +| A2 Feedback update | `rating=2` | 1 条 | `COMPLETED` 0.6s | 8 | 0→0 | +| A3 Feedback delete | `active=0` | 1 条 | `COMPLETED` 0.6s | 8 | 0→0 | +| A4 简历版本创建 | `versions=1` | 1 条 | `COMPLETED` 0.7s | 8 | 0→0 | +| A5 Offer 自动过期 | `offer 17 → EXPIRED` | 1 条 | `COMPLETED` 1.1s | 8 | 0→0 | + +**幂等**:完全相同输入重放 → 八维 version 完全一致 +`{ABILITY:0, ACTIVITY:1, COMPLIANCE:0, EDUCATION:0, GROWTH:1, INTEGRITY:4, INTERNSHIP:0, STABILITY:1}` 前后不变,且不写 `snapshot_history` ✔ + +### 5.2 B. 简历事务故障 — **通过** + +| 场景 | 结果 | +|---|---| +| B1 内层成功后外层回滚 → 版本与任务均存在 | `versions=1 tasks=1` ✔(精确断言见 §4 的 `ResumeVersionRefreshAtomicityTest`) | +| B2 版本写入失败(非法文件被 `ResumeFileValidator` 拒绝,st=400) | 版本行 `1→1` 未创建;**未登记任务** `tasks=[]` ✔ | +| B3 失败路径不留孤立任务(学生越权调用企业接口,st=403) | 学生 A `tasks=[]` ✔ | +| 文件补偿与旧文件保护语义 | 未改动 `ResumeVersionService` 的文件生命周期代码(仅移除一行登记),既有 `catch(RuntimeException)` 清理与 `TransactionSynchronization` 逻辑保持不变 ✔ | + +> 注:B 场景中「版本写入失败」与「任务登记失败」在真实 HTTP 下无法分别构造(`ResumeVersionWriter` 是内部组件),前者用非法文件触发校验失败,后者由 `ResumeVersionRefreshAtomicityTest` 的事务回滚用例覆盖。 + +### 5.3 C. Claim fence 竞争(20 轮) — **PASS** + +脚本 `iv-fence-loop.py`,每轮:A 领取 → 期间新请求推进 version → A 完成 → A 回退 PENDING → B 重新领取 → **延迟的 A 再尝试完成/失败/回退**。 + +20 轮全部满足: +- A 未覆盖 B(B 的 `status` 与 `processing_started_at` 均未被改动) +- 无新请求丢失(`requested_at` 已推进) +- 无永久 `PROCESSING` +- 无重复完成 +- B 均正确完成 +- A 的失败登记未污染 `last_error_code` 与 `attempts` + +> **重要限定**:该循环通过是因为运行中 A/B 两次领取**恰好跨秒**(每条 SQL 之间耗时累积)。它验证的是 fence **在值不同时**的语义正确性;**不构成** fence 精度足够的证据 —— 精度不足正是 §3 的 FAIL 项。 + +### 5.4 D. 批次失败隔离 — **通过** + +三个任务「正常 → 故障 → 正常」: +- `failingTaskInMiddle_doesNotBlockRemainingTasks` —— 前后两个正常任务均 `COMPLETED` +- 故障任务状态为 `RETRY`(未伪装成功),`last_error_code = COMPUTE_FAILED` +- Worker 未中断整批;`batchTerminalState_matchesRealOutcome` 断言无残留 `PROCESSING` +- 后续重试:`RETRY` + `next_attempt_at` 由 Worker 正常再处理 +- 真实 MySQL:`mysqlFailureRollsBackPartialSnapshotsAndDoesNotRollbackSibling` ✔ + +--- + +## 6. D4-2 核心回归 + +| 项 | 结果 | +|---|---| +| 三个目标 GET ×3 + 趋势 GET ×3 | 全部 **HTTP 200**,契约稳定,无敏感字段泄露 ✔ | +| **22 张表逐字段零变化** | `[OK] 全部 22 张表逐字段零变化`;`candidate_dimension_scores` 1480 行、`refresh_tasks` 180 行、`checkpoints` 1 行、`audit_logs` 2667 行、`notifications` 164 行、`messages` 8 行、`snapshot_history` 4 行全部不变 ✔ | +| **无快照学生 GET 后仍 0 行** | 新建学生 8014:`before=0` → 3 次 GET(均 200,`overall=30.0`)→ `after=0` ✔ | +| 趋势回归 | 当前周期 `2026-09`、`overallScore=62.55`、多次调用稳定、零写 ✔ | +| 排名回归 | `rank=1`、`cohortSize=2`、`percentile=100.0`;学生 A/B 各 8 维快照 ✔ | +| 企业看板 | overview / job-rankings / candidate-rankings / trend 均 200;越权 403 ✔ | +| 申诉回归 | `appealId=4290`、`scoreAtAppeal=67.75`、`snapshotVersionAtAppeal=0`(非空);副作用仅限契约表 ✔ | +| 公平性监控 | 5 个端点均 200;`drift`/`ranking-stability` 的 `UNAVAILABLE` 抑制语义保留 ✔ | +| V44 迁移 | `version=44`、`success=1`、`row=1`(唯一)、`failed_migrations=0` ✔ | +| Hibernate validate | 无 `schema-validation` / `SchemaManagementException` 错误 ✔ | +| RabbitMQ | 4 队列(`queue.sms.verification`/`queue.notification`/`queue.notification.dlq`/`queue.audit`)**ready=0、unacked=0** ✔ | +| 日志/响应/审计 | 应用日志(`r01d42-*.log`)手机号、Token、验证码、JWT、SQL 参数、简历路径命中 **均为 0**;222 处「口令」命中全部为 Spring Boot `Using generated security password: `(框架提示,非项目凭据);3 处手机号命中为既有测试 `AdminBootstrapConcurrencyLoggingTest` 的扫描器探针;响应扫描无 `phone/email/resumeUrl/storagePath/accessToken` ✔ | + +副作用证明方式:全部使用**显式列 + 稳定 ORDER BY 的逐字段行快照比对**(`snap.py`),未使用 `COUNT`/`SUM`/`CRC32` 替代;并附有效性反证(`snap.selftest()` 对单字段内存改动可检出,PASS)。 + +--- + +## 7. 收尾 + +| 项 | 结果 | +|---|---| +| 本轮 Java 实例 | 仅停止本轮记录的两个 PID(12592、11060);`tasklist` java.exe 计数 = **0** ✔ | +| 8080 / 8081 | 空闲 ✔ | +| Docker | 保持运行,三容器 healthy ✔ | +| 数据清理 | `users` 残留 **0**;本轮用户 8010/8011/8012/8013/8014、公司 31/32、岗位 49、投递 90/91、offer 17、简历版本、申诉 4290 及全部关联行均按精确主键删除 ✔ | +| append-only 保留 | `audit_logs` 中 actor 为本轮 5 个隔离用户的 **15 + 2 = 17 行**保留并登记(关联 ID:`user_id ∈ {8010,8011,8012,8013,8014}`)✔ | +| 测试夹具(精确 INSERT,已登记并已清理) | `user_roles` 为 8012/8013 各插入 1 行 `COMPANY`;`offers` 插入 1 条已过期 SENT 记录(id=17)用于触发 F1 定时路径 ✔ | +| Git | 分支未变、HEAD 未变、暂存区空、V1–V43 未改、V44 唯一新增、无删除文件、`git diff --check` 无空白错误 ✔ | +| 未暂存 / 未提交 / 未推送 | 是 ✔ | + +本轮新增产物(均在 `docs/acceptance/r01-d4-2-20260916/`,未进入暂存区):`iv-batch1.log`、`iv-batch2.log`、`iv-clean-verify.log`、`iv-precision.sql`、`iv-fence.sql`、`iv-fence2.sql`、`iv-fence-loop.py`、`v3-writeside.py`、`v3-out.txt`、`s1-iv-out.txt`、`s4-iv-out.txt`。 + +--- + +## 8. PASS / FAIL 清单 + +**FAIL(1 项)** + +| # | 项 | 依据 | +|---|---|---| +| 1 | **claim fence 精度不足**:`processing_started_at` 为秒级 `DATETIME`,实测同一秒内新旧领取得到相同 fence | 任务书硬性判据「可能得到相同 fence 直接判 FAIL」;最小复现 `iv-fence2.sql` | + +**PASS(其余全部)** + +OfferExpiryWorker 同事务登记与幂等;Feedback create/update/delete 登记与删除前取标识;ResumeVersionWriter 同一 `REQUIRES_NEW` 边界且外层不重复登记;ABILITY 脱敏 26 项禁止 key 递归断言;批次隔离与 stale claim 安静退出;定向 184/184;关联 558/558;全量 2163/2163(S=0);OpenAPI 176/214;A 五条写侧;B 简历事务故障;C 20 轮 fence 竞争语义;D 批次隔离;22 表逐字段零写;无快照学生 0 行;趋势/排名/看板/申诉/公平性;V44;validate;RabbitMQ;日志脱敏。 + +**SKIP**:无(本轮无因环境或安全原因跳过项)。 + +--- + +## 9. 剩余风险 + +1. **fence 失效的后果有限但不为零**:即使触发,最坏结果是同一次刷新多算一遍(任务回到 PENDING),不会数据损坏或永久 PROCESSING。但对 SLA 敏感场景仍属正确性瑕疵。 +2. **`processing-timeout-ms` 无下界校验**:当前仅做 `<= 0 → 600000` 兜底,1–999 的取值未被拒绝。 +3. 任务书未要求、本轮也未执行的项:真实双 JAR 的多实例补偿扫描并发、申诉审核/回滚真实黑盒(延续 D4-2 首轮验收的 SKIP 记录,不属于本轮复验范围)。 + +--- + +> **已复现具体缺陷,R01-D4-2 不可冻结;报告已包含下一轮定向整改指令。** diff --git a/docs/r01-d4-2-get-scoring-readonly-implementation-report.md b/docs/r01-d4-2-get-scoring-readonly-implementation-report.md new file mode 100644 index 0000000..4e74a1e --- /dev/null +++ b/docs/r01-d4-2-get-scoring-readonly-implementation-report.md @@ -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 黑盒验收。 diff --git a/docs/r01-d4-2-independent-mysql-acceptance-20260916.md b/docs/r01-d4-2-independent-mysql-acceptance-20260916.md new file mode 100644 index 0000000..953d48d --- /dev/null +++ b/docs/r01-d4-2-independent-mysql-acceptance-20260916.md @@ -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 `、11 位手机号、「验证码 482913」、`DB_PASSWORD=…`,扫描器命中 **5 类** → **[PASS] 扫描器有效**;探针文件已删除 ✔ + +**真实命中与甄别**: + +| 类别 | 命中 | 甄别结论 | +|---|---|---| +| 「口令赋值」 | 63 | **全部为同一模式**:Spring Boot `UserDetailsServiceAutoConfiguration` 启动时打印的 `Using generated security password: `。经字符集核验为大小写十六进制 + 连字符的 UUID,**非数据库口令、非 JWT 密钥、非项目任何凭据**,且项目采用 JWT 认证不使用该内存用户。判定为**误报**。 | +| 11 位手机号 | 1 | 位于 `clean-verify.log`,上下文为既有测试 `AdminBootstrapConcurrencyLoggingTest` 的 **`S03_SCANNER_PROBE_<值>` 扫描器探针**(测试夹具),非真实用户号码 | +| Bearer Token / JWT 形态串 / 验证码语义行 / 密钥赋值 / 私钥块 / 简历存储路径 | **0** | ✔ | + +**应用运行日志(`r01d42-*.log`)中手机号、Token、验证码、JWT、简历路径命中均为 0** ✔ + +报告与本文件均未复制任何真实凭据值。 + +--- + +## 22. 结束处理 + +**进程**:只停止本轮明确记录 PID 的实例(24624/22640/12656/23436/28456/6576),未触碰任何未知 Java 进程。验收后 `Get-Process java` 无输出,8080/8081 端口已释放,恢复至验收前空闲状态。**Docker 未停止**(按任务书要求)。 + +**数据清理**(仅本轮精确主键,无模糊 DELETE): + +| 表 | 删除前 | 删除后 | +|---|---|---| +| `users`(8002/8003/8004/8005) | 4 | **0** ✔ | +| `companies`(27/28) | 2 | **0** ✔ | +| `jobs`(47) | 1 | **0** ✔ | +| `applications`(86/87)+ `application_status_history` | 2 + 2 | **0** ✔ | +| `candidate_dimension_scores` | 32 | **0** ✔ | +| `candidate_scoring_refresh_tasks` | 4 | **0** ✔ | +| `candidate_scoring_appeals` / `appeal_history` | 1 / 1 | **0** ✔ | +| `student_profiles` / `student_preferences` | 2 / 1 | **0** ✔ | +| `notifications` | 2 | **0** ✔ | +| `auth_tokens` / `user_roles` / `sms_verification_codes` | 6 / 6 / 6 | **0** ✔ | + +清理前做了**归属校验**:4 个用户 id 对应的手机号必须全部等于本轮登记的隔离虚拟号(`phone IN (4 个号码)` 与 `id IN (…)` 双向匹配数均为 4,完全一致),否则脚本 `[ABORT]` 拒绝删除。删除按外键顺序执行(两轮:首轮被 `application_status_history` 阻断后补删)。 + +**保留并登记**:`audit_logs` 中 actor 为本轮 4 个用户的 **15 行** 属 append-only 合规记录,按任务书要求**不冒险删除**,保留在库中。关联 ID:`user_id ∈ {8002, 8003, 8004, 8005}`。 + +**唯一允许的测试夹具(精确 INSERT,已登记)**:`user_roles` 插入 `(8004,'COMPANY')`、`(8005,'COMPANY')` 各 1 行 —— 原因:项目**无任何公网接口**可授予 COMPANY 角色(`CompanyService.java:76 assertCompanyRole` 前置校验,角色仅由管理员初始化产生),企业创建因此返回 403。该 2 行已在 §22 清理中随 `user_roles WHERE user_id IN (…)` 一并删除。 + +**Git 收尾**:分支 `refactor/r01-structure-performance`;暂存区空;未 `git add -A`;未 commit;未 push;V1–V43 未修改;V44 仍为唯一新增迁移;无删除文件;`git diff --check` 无空白错误。未进入下一项 R01 工作。 + +--- + +## 23. PASS / FAIL / SKIP 清单 + +### PASS + +1. Git 基线(分支/HEAD/暂存区/V1–V43/V44/无删除/无空白错误) +2. A1 三个 GET 真正只读(代码 + 真实 HTTP 双证) +3. A3 唯一 Writer 语义(幂等 version / canonical JSON / 并发锁 / 强制版本 / 不写 history / 整体回滚) +4. A5 补偿扫描(持久游标 / 单轮预算 / 尾部回绕 / 同事务 / 不写快照) +5. A6 配置无断档(生产默认启用,测试 profile 显式关闭) +6. Docker/MySQL 8.4.11/Redis/RabbitMQ 全部 healthy +7. V44 在真实 MySQL 成功(version=44, success=1, 唯一, failed=0) +8. `ddl-auto=validate` 通过,无 schema/SQL/Bean 装配错误 +9. 最新 JAR 正常启动;健康检查 8080/8081 均 200 +10. `mvn clean verify` **2131/2131**,F=0 E=0 **S=0** +11. 定向批次一 55/55、批次二 253/253(MySQL Testcontainers 真实执行) +12. **三个目标 GET 真实零写**(22 张表逐字段不变;学生快照保持 0 行) +13. 写侧事件 → 任务 → Worker → 八维快照全链路(W1/W2/W3) +14. 重复登记合并 + 完全相同输入重放 version 不推进 +15. 普通 Worker 不写 `snapshot_history`、不写通知/审计/RabbitMQ +16. 补偿游标跨重启延续、单轮严格 ≤ limit、尾部回绕 +17. 趋势回归(可读物化快照、稳定、零写) +18. 排名回归(cohortSize=2、双学生均有快照) +19. 企业看板回归(4 端点在列、越权 403) +20. 申诉回归(取证字段非空、副作用限于契约表) +21. 公平性监控回归(5 端点、抑制语义保留、零写) +22. RabbitMQ 终态 4 队列 ready=0/unacked=0 +23. 静态与运行时 OpenAPI 均 176/214 且集合完全一致 +24. `git diff --check`、OpenAPI 静态校验、日志脱敏(探针有效,应用日志 0 泄露) +25. 隔离数据精确清理,无残留 + +### FAIL(4 项) + +| # | 缺陷 | 位置 | 命中判据 | +|---|---|---|---| +| F1 | **Offer 自动过期改变评分输入但不登记刷新任务** | `offer/OfferExpiryWorker.java:48`(登记计数=0);评分读取 `candidatescore/CandidateScoringCalculator.java:188-194` | 「写侧成功但任务未登记」 | +| F2 | **候选人反馈的修改/删除改变评分输入但不登记刷新任务** | `feedback/CandidateFeedbackService.java:107`、`:124`(仅 `:82` 创建时登记);评分读取 `CandidateScoringCalculator.java:196-200` | 「写侧成功但任务未登记」 | +| F3 | **`createVersion` 登记与独立提交的版本行事务边界不对称** | `resume/ResumeVersionService.java:71`(外层事务)vs 同文件 `:52-55` 自述的 `REQUIRES_NEW` 独立提交 | 「源事务失败却留下任务」的对偶形态:数据已提交而登记丢失 | +| F4 | **测试断言弱化(红线:禁止修改或放宽既有测试)** | `test/.../candidatescore/CandidateScoringCalculatorTest.java:248` 起,ABILITY 溯源 payload 的敏感字段黑名单断言(`answerKey/questionCode/competencyTag/stem/selectedOptionId/scoringRule/awardedScore/explanation/phone/email` 共 10 项)被删除,**无等价替代** | 「修改或放宽既有测试」 | + +### FAIL(次生,A4,未单独计入但需一并整改) + +| # | 缺陷 | 位置 | +|---|---|---| +| F5 | `materialize` 的「回到 PENDING」分支缺 version 闸门,租约竞争下会把新 Worker 处理中的任务改回 PENDING | `refresh/CandidateScoringRefreshTaskExecutor.java:78-88` | +| F6 | `recordFailure` 在 `updated != 1` 时抛异常,且异常点位于 Worker 的 catch 块内、`processDue` 的 for 循环无保护 → **同批剩余任务本轮全部不处理** | `refresh/CandidateScoringRefreshWorker.java:83` + `:63-67` + `CandidateScoringRefreshTaskExecutor.java:105-107` | + +### SKIP(2 项,均非核心判据) + +1. **多实例并发补偿扫描双 JAR 黑盒**:以代码层 `SELECT ... FOR UPDATE` 串行 + 游标与登记同事务作为证据;未启动第二个 JAR 做真实双实例竞争。 +2. **申诉审核/回滚黑盒**:为避免改动非本轮治理数据(`candidate_scoring_rollback_limits` 等)未执行 rollback,改由 `CandidateScoringCalculatorTest#recomputeAndSnapshot_*` 与 `CandidateScoringGovernancePersistenceIntegrationTest` 覆盖(第二批 253/253 内),静态确认强制 version 路径。 + +### BLOCKED + +无。 + +--- + +## 24. 剩余风险 + +1. **F1/F2 的实际影响受补偿扫描部分缓解**:补偿扫描的资格条件为「近 90 天有投递 **或** 近 30 天有审计」(`CandidateScoringRefreshCompensationScanner.java:89-99`)。有 Offer / 有反馈的学生必有投递,故其快照通常在下一轮扫描(默认间隔 1 小时)内被纠正。但若该学生**近 90 天无投递、近 30 天无审计**,则其快照将**永久停留在过期值**,无任何兜底路径。 +2. **F3 触发概率低但后果不可逆**:仅在事务提交阶段失败时发生,届时业务数据存在而刷新信号永久丢失,且无对账机制可发现。 +3. **F5/F6 触发前提为物化耗时超过 `processing-timeout-ms`(默认 600s)**,正常物化实测 0.7s,概率低;一旦触发,本轮同批任务的其余条目会被静默跳过(下一轮会重试,故不会永久丢失)。 +4. **补偿扫描会物化全库活跃学生的快照**:启用生产后台刷新后,单实例每轮最多 `compensation-limit` 个。本次验收实测 181 条任务、全部 COMPLETED,未观察到异常增长;但该行为对生产库的持续写入量需在容量规划中评估。 +5. **`CandidateScoringRefreshInfrastructureTest` 未验证 8 个写侧 Service 真的调用了 `requestRefresh`**:现有测试直接驱动 `requestService`/`taskExecutor`,各 Service 单测只补了 `@Mock` 依赖而无一处 `verify(...).requestRefresh(...)`。F1/F2 正是这类覆盖盲区的产物,建议本轮整改时一并补齐。 +6. **注释与现实不符(低危)**:`CandidateScoringCalculator.java:582`(「含 GET 兜底」)、`CandidateScoringRefreshScheduler.java:15`、D4-1 报告 §20 的「默认关闭」表述均已过时。 + +--- + +## 25. 是否允许冻结 D4-2 + +**不允许冻结。** + +R01-D4-2 的**核心目标**(三个评分 GET 停写)已在真实 MySQL/HTTP 下确凿达成,且未观察到任何消费端退化或回归。但依据任务书第二十节的 FAIL 判据,F1/F2 命中「**写侧成功但任务未登记**」,F4 触碰「禁止修改或放宽既有测试」红线,因此本轮不可冻结。 + +--- + +## 26. 下一轮定向整改指令 + +### 必做(阻断冻结) + +**R1. 补齐 Offer 自动过期的评分刷新登记** +- 文件:`mydreamjob-server/src/main/java/com/mydreamjob/offer/OfferExpiryWorker.java` +- 方法:`expireOne(Long offerId)`(`:41`),在 `:48 setStatus(EXPIRED)` 成功且 `:51 saveAndFlush` 之后、与该方法**同一 `REQUIRES_NEW` 事务内**,追加: + `scoringRefreshRequestService.requestRefresh(offer.getCandidateId(), LocalDateTime.now(clock))` +- 注入 `CandidateScoringRefreshRequestService`;`OfferExpiryWorker` 已使用 `clock`,`capturedNow` 应复用同一时间源 +- 不得因此改动 `OfferService#transitionStatus` 的既有语义 +- 补测试:真实驱动 `expireOne` 后断言 `candidate_scoring_refresh_tasks` 出现/合并一条 `(candidateId, 当期)` 任务 + +**R2. 补齐候选人反馈修改/删除的评分刷新登记** +- 文件:`mydreamjob-server/src/main/java/com/mydreamjob/feedback/CandidateFeedbackService.java` +- 方法:`updateFeedback(…)`(`:107`)与 `deleteFeedback(…)`(`:124`),在各自成功路径末尾、同一 `@Transactional` 内追加 `requestRefresh(app.getStudentUserId(), LocalDateTime.now())`(`deleteFeedback` 注意在删除前先取得 `studentUserId`) +- 与既有 `:82 createFeedback` 保持完全一致的调用形态 +- 补测试:`updateFeedback` 改变 `rating` 后断言任务被登记/合并 + +**R3. 修正 `createVersion` 的登记事务边界** +- 文件:`mydreamjob-server/src/main/java/com/mydreamjob/resume/ResumeVersionService.java` +- 问题:`:71` 的登记位于外层事务,而版本行经 `versionWriter.saveNewVersion`(`REQUIRES_NEW`)**独立提交**;外层提交阶段失败会造成「数据已提交、登记丢失」 +- 要求(二选一,须在报告中说明选择理由): + (a) 将 `requestRefresh` 增补为 `Propagation.REQUIRES_NEW` 的独立调用(放在版本行成功返回之后);或 + (b) 在 `createVersion` 返回后由调用方在**已提交**的上下文中登记(`TransactionSynchronization#afterCommit`) +- 补测试:模拟外层事务回滚,断言版本行存在时**必定**存在对应刷新任务 + +**R4. 修正 `materialize` 的 PENDING 回退闸门** +- 文件:`mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshTaskExecutor.java` +- 位置:`:78-88` +- 要求:`status='PENDING'` 的回退 `UPDATE` 增加 `AND version=?`(与 claim 时记录的 `claimedVersion` 比对);`updated != 1` 时应**静默返回**(该任务已被 `recoverStale` 或新 Worker 接管,属正常竞争),不得抛 `IllegalStateException` + +**R5. 隔离单任务失败对同批任务的影响** +- 文件:`mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshWorker.java` +- 位置:`:63-67`(`processDue` 循环)与 `:83`(catch 内 `recordFailure`) +- 要求:`processDue` 的 for 循环体加 `try/catch`,或让 `recordFailure` 自身吞掉状态冲突异常并仅告警 —— 保证单任务异常不阻断同批其余任务,与 `:24` javadoc 的承诺一致 + +**R6. 恢复被删除的敏感字段断言(红线项)** +- 文件:`mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoringCalculatorTest.java` +- 位置:`:248` 起(原 `for (String banned : List.of("answerKey","questionCode","competencyTag","stem","selectedOptionId","scoringRule","awardedScore","explanation","phone","email"))` 断言) +- 要求:在**新的委托架构下重建等价断言** —— 序列化 `CandidateScoringSnapshotWriter` 为 ABILITY 维度生成的 `sub_scores_json`(可通过 `CandidateScoringRefreshInfrastructureTest` 走真实 Writer,或在 Writer 测试中直接断言),并逐项断言不含上述 10 个禁止字段 +- 不得以「类型安全」为由省略:`weights`/`dimensionScores` 为 `Map`,其 key 不受编译期保护 + +**R7. 补齐写侧登记的行为验证** +- 文件:`mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshInfrastructureTest.java` +- 要求:新增断言,对 §A2 的 8 个服务方法 + R1/R2 新接入的两个入口,逐一 `verify(scoringRefreshRequestService, times(1)).requestRefresh(…)`;覆盖**失败回滚不登记**的负例 + +### 建议(不阻断,但应同批处理) + +**R8.** 修正过期注释:`CandidateScoringCalculator.java:582`、`CandidateScoringRefreshScheduler.java:15`,以及 `docs/r01-d4-1-scoring-materialization-implementation-report.md` §13/§20 中「补偿扫描为简化版/默认关闭」的表述。 + +**R9.** 补 `CandidateScoringRefreshInfrastructureTest` 中缺失的黑盒项:多实例并发扫描(双实例 `scanDueStudents` 断言游标不重复推进)、跨重启游标延续的自动化用例(当前仅有本次验收的真实证据)。 + +**R10.** 评估 `getTrend` 的时钟一致性:`CandidateScoringEngineService.java:78` 使用 `LocalDate.now()` 而非注入的 `clock`(预存在问题,非本轮引入)。 + +### 整改后复验要求 + +完成 R1–R7 后,重新执行:`mvn clean verify`(须 ≥2131 且 S=0)、静态与运行时 OpenAPI 176/214、以及本报告 §7/§9/§16 三项真实 HTTP 复核(GET 零写、写侧链路、申诉取证)。R1/R2 需各自提供「事件 → 任务 → COMPLETED → 快照」的真实 MySQL 证据。 + +--- + +## 附:本轮验收产出物(`docs/acceptance/r01-d4-2-20260916/`) + +| 文件 | 说明 | +|---|---| +| `acc_lib.py` | HTTP/DB/登录/前缀公共库 | +| `snap.py` | 22 表显式列快照 + 差异比较 + 自检反证 | +| `p1-provision.py` / `acc-context.json` / `acc-prefix.txt` | 隔离验收数据创建 | +| `s1-zerowrite.py` / `snap-before-get.json` / `snap-after-get.json` | 三个 GET 零写 | +| `s2-writepath.py` | 写侧链路 | +| `s4-consumers.py` / `s4-out.txt` / `snap-after-consumers.json` | 消费者回归 | +| `logscan.py` | 日志脱敏扫描(含探针) | +| `cleanup.py` | 精确主键清理 | +| `start-app.ps1` + `r01d42-*.log/.err.log/.pid/.envdiag.txt` | 实例启动与运行日志 | +| `q01`–`q13*.sql` | 只读 SQL 证据 | +| `runtime-openapi.json` | 运行时 OpenAPI | +| `clean-verify.log` / `batch2.log` | 自动化构建日志 | + +> 本报告不含数据库密码、JWT、验证码、完整手机号或邮箱、`.env` 内容、简历原文或真实存储路径;SQL 片段中无真实敏感参数。验收脚本输出中曾一次性打印隔离虚拟号(`p1-provision.py` 首版缺陷),已当场修正脚本,后续输出与报告均不再包含完整号码。 diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/ability/AbilityAttemptService.java b/mydreamjob-server/src/main/java/com/mydreamjob/ability/AbilityAttemptService.java index 695f868..a50ed6a 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/ability/AbilityAttemptService.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/ability/AbilityAttemptService.java @@ -10,6 +10,7 @@ import com.mydreamjob.audit.AuditContext; import com.mydreamjob.audit.AuditService; import com.mydreamjob.auth.User; import com.mydreamjob.auth.UserRepository; +import com.mydreamjob.candidatescore.refresh.CandidateScoringRefreshRequestService; import com.mydreamjob.common.exception.BusinessException; import com.mydreamjob.common.exception.ErrorCode; import lombok.RequiredArgsConstructor; @@ -56,6 +57,8 @@ public class AbilityAttemptService { private final UserRepository userRepository; private final AuditService auditService; private final Clock clock; + /** R01-D4-1:能力测评提交成功后登记评分快照刷新(ABILITY 维度)。 */ + private final CandidateScoringRefreshRequestService scoringRefreshRequestService; // ─────────────────────────── 创建答卷 ─────────────────────────── @@ -287,6 +290,8 @@ public class AbilityAttemptService { + ", questionSetVersion=" + attempt.getQuestionSetVersion() + ", rulesVersion=" + (def == null ? null : def.getRulesVersion()), ctx); + // R01-D4-1:能力测评提交成功(新有效结果)影响 ABILITY 维度 → 登记当前周期刷新。 + scoringRefreshRequestService.requestRefresh(userId, now()); return resultService.toResponse(attempt, result, def, now()); } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationService.java b/mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationService.java index fc5fd57..60cf990 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationService.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationService.java @@ -15,6 +15,7 @@ import com.mydreamjob.job.JobService; import com.mydreamjob.auth.User; import com.mydreamjob.auth.UserRepository; import com.mydreamjob.notification.NotificationService; +import com.mydreamjob.candidatescore.refresh.CandidateScoringRefreshRequestService; import com.mydreamjob.profile.StudentProfile; import jakarta.persistence.EntityManager; import jakarta.persistence.PersistenceContext; @@ -46,6 +47,8 @@ public class ApplicationService { private final AuditService auditService; private final ApplicationStatusHistoryRepository historyRepository; private final NotificationService notificationService; + /** R01-D4-1:投递新增/撤回/状态变更后登记评分快照刷新。 */ + private final CandidateScoringRefreshRequestService scoringRefreshRequestService; @PersistenceContext private EntityManager em; @@ -83,6 +86,8 @@ public class ApplicationService { "你已成功投递岗位 #" + req.getJobId(), "APPLICATION", app.getId(), "apply-" + studentUserId + "-" + req.getJobId()); + // R01-D4-1:投递数量影响 STABILITY/ACTIVITY 聚合 → 登记当前周期刷新。 + scoringRefreshRequestService.requestRefresh(studentUserId, java.time.LocalDateTime.now()); return app; } @@ -120,6 +125,8 @@ public class ApplicationService { "你的投递 #" + app.getId() + " 已撤回", "APPLICATION", app.getId(), "withdraw-" + studentUserId + "-" + app.getId()); + // R01-D4-1:撤回参与 WITHDRAWN/总投递比率计算 → 登记当前周期刷新。 + scoringRefreshRequestService.requestRefresh(studentUserId, java.time.LocalDateTime.now()); return app; } @@ -213,6 +220,8 @@ public class ApplicationService { "投递状态更新", "你的投递 #" + app.getId() + " 状态变更为 " + target.name(), "APPLICATION", app.getId(), "status-" + companyId + "-" + app.getId() + "-" + target.name()); + // R01-D4-1:仅状态实际变化(含 WITHDRAWN/HIRED 等聚合口径)时登记刷新。 + scoringRefreshRequestService.requestRefresh(app.getStudentUserId(), java.time.LocalDateTime.now()); return app; } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/CandidateScoringCalculator.java b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/CandidateScoringCalculator.java index 6a82b64..fd62249 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/CandidateScoringCalculator.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/CandidateScoringCalculator.java @@ -11,9 +11,6 @@ import com.mydreamjob.preference.StudentPreference; import com.mydreamjob.preference.StudentPreferenceRepository; import com.mydreamjob.profile.StudentProfile; import com.mydreamjob.profile.StudentProfileRepository; -import jakarta.persistence.EntityManager; -import jakarta.persistence.PersistenceContext; -import jakarta.persistence.Query; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; @@ -102,6 +99,8 @@ public class CandidateScoringCalculator { private final JdbcTemplate jdbcTemplate; private final AbilityAssessmentScoreSource abilityScoreSource; private final AbilityScoreMapper abilityScoreMapper; + /** R01-D4-1:唯一快照 Writer(普通物化幂等 / Governance 强制版本双模式)。 */ + private final com.mydreamjob.candidatescore.refresh.CandidateScoringSnapshotWriter snapshotWriter; /** 可测试的时间源;未提供 Spring Clock 时保持系统时区语义。 */ private Clock clock = Clock.systemDefaultZone(); @@ -109,9 +108,6 @@ public class CandidateScoringCalculator { @Value("${scoring.ability-assessment.enabled:false}") private boolean abilityAssessmentEnabled; - @PersistenceContext - private EntityManager entityManager; - @Autowired(required = false) public void setClock(Clock clock) { if (clock != null) this.clock = clock; @@ -164,7 +160,9 @@ public class CandidateScoringCalculator { DimensionResult r = computeAll(studentUserId, capturedNow).stream() .filter(x -> x.dimension.equals(dimension)).findFirst() .orElseThrow(() -> new BusinessException(ErrorCode.BAD_REQUEST, "invalid dimension")); - upsertSnapshots(studentUserId, List.of(r), period, capturedNow); + // R01-D4-1:Governance(申诉决定/回滚)强制版本推进路径 —— 与普通物化(值变化才 +1)显式区分, + // 保持 afterVersion = beforeVersion + 1 与 snapshot_history 审计语义不变。 + snapshotWriter.upsertForcingVersion(studentUserId, r, period, capturedNow, currentRulesVersion()); return r; } @@ -574,42 +572,20 @@ public class CandidateScoringCalculator { return r; } - // ── 快照批量 upsert(原生 MySQL)──────────────────────────── + // ── 快照写入(R01-D4-1:全部委托唯一 Writer)──────────────────── public void upsertSnapshots(Long studentUserId, List results, String period) { upsertSnapshots(studentUserId, results, period, LocalDateTime.now(clock)); } + /** + * 普通物化入口(含 GET 兜底):委托 {@code CandidateScoringSnapshotWriter#upsertIfChanged}。 + * 值完全相同时不增 version/updated_at;业务值或 rulesVersion 变化才 version+1 → + * 重复读取不再产生无意义的版本噪声,为 D4-2 GET 停写做准备。 + */ public void upsertSnapshots(Long studentUserId, List results, String period, LocalDateTime capturedNow) { - if (results.isEmpty()) return; - StringBuilder sql = new StringBuilder( - "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 "); - List params = new ArrayList<>(); - for (int i = 0; i < results.size(); i++) { - if (i > 0) sql.append(","); - sql.append("(?,?,?,?,?,?,?,?,?,0,NOW(),NOW())"); - DimensionResult r = results.get(i); - params.add(studentUserId); - params.add(r.dimension); - params.add(period); - params.add(r.score); - params.add(r.status); - params.add(subScoresPayload(r)); - params.add(DEFAULT_WEIGHT); - params.add(currentRulesVersion()); - params.add(capturedNow); - } - sql.append(" ON DUPLICATE KEY UPDATE score=VALUES(score), status=VALUES(status), " + - "sub_scores_json=VALUES(sub_scores_json), weight=VALUES(weight), " + - "rules_version=VALUES(rules_version), calculated_at=VALUES(calculated_at), " + - "version=version+1, updated_at=NOW()"); - Query q = entityManager.createNativeQuery(sql.toString()); - for (int i = 0; i < params.size(); i++) { - q.setParameter(i + 1, params.get(i)); - } - q.executeUpdate(); + snapshotWriter.upsertIfChanged(studentUserId, results, period, capturedNow, currentRulesVersion()); } // ── 工具 ──────────────────────────────────────────────────── @@ -647,42 +623,6 @@ public class CandidateScoringCalculator { return !isBlank(profile.getRealName()) && !isBlank(profile.getEducation()) && !isBlank(profile.getSchool()); } - private String toJson(List subs) { - if (subs == null || subs.isEmpty()) return null; - try { - return MAPPER.writeValueAsString(subs); - } catch (Exception e) { - return null; - } - } - - /** - * ABILITY 行的 sub_scores_json 保存最小溯源 JSON(§6.1),其他七维保持既有数组格式。 - * 溯源 JSON 仅供内部解释/重算,任何 HTTP 响应都不得返回其原文或 sourceResultId。 - */ - private String subScoresPayload(DimensionResult r) { - if ("ABILITY".equals(r.dimension) && r.abilitySnapshotTrace != null) { - try { - Map 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", 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 BigDecimal cap(BigDecimal v) { return v.max(ZERO).min(HUNDRED).setScale(2, RoundingMode.HALF_UP); } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/CandidateScoringEngineService.java b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/CandidateScoringEngineService.java index b868abc..fd5776d 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/CandidateScoringEngineService.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/CandidateScoringEngineService.java @@ -49,24 +49,22 @@ public class CandidateScoringEngineService { // ── 综合评分卡(学生自查)──────────────────────────────────── - @Transactional + @Transactional(readOnly = true) public CandidateScoringOverviewResponse getOverview(Long studentUserId) { LocalDateTime capturedNow = LocalDateTime.now(clock); List results = calculator.computeAll(studentUserId, capturedNow); - calculator.upsertSnapshots(studentUserId, results, yearMonth(capturedNow.toLocalDate()), capturedNow); return buildOverview(studentUserId, results, capturedNow); } // ── 单维度明细 ─────────────────────────────────────────────── - @Transactional + @Transactional(readOnly = true) public DimensionScoreDetailResponse getDimensionDetail(Long studentUserId, String dimension) { LocalDateTime capturedNow = LocalDateTime.now(clock); List results = calculator.computeAll(studentUserId, capturedNow); CandidateScoringCalculator.DimensionResult r = results.stream() .filter(x -> x.dimension.equals(dimension)) .findFirst().orElseThrow(() -> new BusinessException(ErrorCode.BAD_REQUEST, "invalid dimension")); - calculator.upsertSnapshots(studentUserId, List.of(r), yearMonth(capturedNow.toLocalDate()), capturedNow); return toDetail(r, capturedNow); } @@ -105,7 +103,7 @@ public class CandidateScoringEngineService { // ── 企业只读评分卡 ─────────────────────────────────────────── - @Transactional + @Transactional(readOnly = true) public CompanyCandidateScoringResponse getCompanyScoring(Long companyUserId, Long applicationId) { Company company = companyRepository.findByOwnerUserId(companyUserId) .orElseThrow(() -> new BusinessException(ErrorCode.FORBIDDEN)); @@ -117,7 +115,6 @@ public class CandidateScoringEngineService { } LocalDateTime capturedNow = LocalDateTime.now(clock); List results = calculator.computeAll(app.getStudentUserId(), capturedNow); - calculator.upsertSnapshots(app.getStudentUserId(), results, yearMonth(capturedNow.toLocalDate()), capturedNow); List computable = results.stream() .filter(r -> ("OK".equals(r.status) || "ESTIMATED".equals(r.status)) && r.score != null) .toList(); diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshCompensationScanner.java b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshCompensationScanner.java new file mode 100644 index 0000000..5e38be4 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshCompensationScanner.java @@ -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 补偿扫描(基础版)。 + * + *

仅登记刷新任务,不直接写快照:分页(LIMIT 固定预算)扫描近期有评分输入活动的学生, + * 统一调用 {@link CandidateScoringRefreshRequestService#requestRefresh}(任务唯一键自动合并)。 + * 用于覆盖无业务写入但评分自然变化的时间窗口(近 90 天投递窗口、近 30 天审计窗口、 + * 能力测评有效期、rulesVersion 切换),由定时调度触发。

+ * + *

查询预算:本扫描 = 1 次聚合查询 + ≤limit 次登记 upsert;不逐用户 N+1。

+ */ +@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 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 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); + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshRequestService.java b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshRequestService.java new file mode 100644 index 0000000..5d3d783 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshRequestService.java @@ -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 评分快照刷新任务登记组件。 + * + *

职责仅限"登记":把某个学生当前周期的物化请求合并进任务表。 + * 使用原生参数化 upsert 维护 {@code uk_refresh_task_student_period} 唯一性:

+ *
    + *
  • 同学生同周期重复登记:不新增行,更新 {@code requested_at};
  • + *
  • 任务 COMPLETED/FAILED:重新打开为 PENDING,attempts 归零;
  • + *
  • 任务 PROCESSING:保留 PROCESSING(不覆盖),推进 requested_at, + * Worker 完成时若 {@code requested_at} 晚于 {@code processing_started_at} 则回到 PENDING;
  • + *
  • 任务 RETRY:回到 PENDING(保留 attempts,不重复计分重试次数)。
  • + *
+ * + *

登记会加入调用方现有业务事务(默认 REQUIRED):源业务提交失败时,登记一并回滚; + * 登记本身不触发任何线程内评分计算、不发送 RabbitMQ、不写成功审计、不记录敏感值。

+ */ +@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()); + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshScheduler.java b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshScheduler.java new file mode 100644 index 0000000..889b8d9 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshScheduler.java @@ -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 领取刷新 + 补偿扫描。 + * + *

默认关闭({@code app.scoring.refresh.enabled=false}),生产启用时负责周期驱动; + * 测试一律直接调用 Worker/Scanner,不依赖真实等待。调度不发送 RabbitMQ、不写通知。

+ */ +@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); + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshTask.java b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshTask.java new file mode 100644 index 0000000..7537a5d --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshTask.java @@ -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 评分快照刷新任务(持久化物化登记)。 + * + *

同一学生同一周期只有一条任务({@code uk_refresh_task_student_period}); + * 状态机:PENDING → PROCESSING → COMPLETED / RETRY → (…) → FAILED。 + * PROCESSING 期间的新请求通过 {@code requested_at} 推进保留,Worker 完成时 + * 若 {@code requested_at} 晚于 {@code processing_started_at} 则回到 PENDING。

+ */ +@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; +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshTaskExecutor.java b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshTaskExecutor.java new file mode 100644 index 0000000..1b6a62f --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshTaskExecutor.java @@ -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); + } + + /** + * 领取任务。 + * + *

每次成功领取都生成一个全新的随机 UUID 作为所有权令牌(claim token), + * 在领取 SQL 中原子写入。令牌不可复用、与时间无关,因此不受 + * {@code processing_started_at} 秒级精度影响。

+ */ + @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 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; + } + } + + /** + * 记录失败并安排重试/终止。 + * + *

所有权闸门:只有 {@code claim_token} 仍等于本次领取写入的令牌时才更新。 + * 更新 0 行表示本次领取已失效(任务被租约回收并由其他 Worker 持有)—— + * 这属于正常竞争,安静退出而不是抛异常,以免中断同批其余任务。

+ * + * @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) { + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshWorker.java b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshWorker.java new file mode 100644 index 0000000..aa84c4b --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshWorker.java @@ -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。 + * + *

并发模型:数据库条件更新领取——{@code claim} 只在 + * {@code status IN ('PENDING','RETRY') AND next_attempt_at 到期} 时把行置为 PROCESSING, + * 第二个 Worker 对同一任务 claim 更新 0 行(略过),因此同一任务并发只能有一个有效处理者; + * 不使用 synchronized/ReentrantLock/Thread.sleep。

+ * + *

失败语义:捕获并转为固定错误分类(COMPUTE_FAILED/WRITE_FAILED/UNKNOWN_REFRESH_FAILURE), + * attempts+1 进入 RETRY、设置 next_attempt_at;超过最大次数进入 FAILED(定时补偿仍可重新登记)。 + * 异常不回抛,不中断其他任务;{@code last_error_code} 不保存异常原文、SQL、手机号或堆栈。

+ * + *

普通刷新不写审计(规避 刷新→审计→审计触发刷新 的循环);处理不发送 RabbitMQ、 + * 不写 notification、不写 snapshot_history。

+ */ +@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 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"; + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/refresh/CandidateScoringSnapshotWriter.java b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/refresh/CandidateScoringSnapshotWriter.java new file mode 100644 index 0000000..1ff32fd --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/refresh/CandidateScoringSnapshotWriter.java @@ -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}。 + * + *

两种写入模式:

+ *
    + *
  • {@link #upsertIfChanged}:普通物化——score/status/sub_scores_json/weight/rules_version + * 完全相同时不改写 version 与 updated_at(幂等);任一业务字段变化或 rulesVersion + * 变化才 {@code version+1};{@code calculated_at} 不单独触发 version 增长。
  • + *
  • {@link #upsertForcingVersion}:Governance 申诉决定/回滚的强制版本推进路径—— + * 每次无条件 version+1,保持 {@code GovernanceService} 的 + * {@code afterVersion = beforeVersion + 1} 与历史审计语义不变。
  • + *
+ * + *

sub_scores_json 写入前做 canonical 化(字段按字典序重排),避免字段顺序制造伪变化。 + * 不依赖 JVM 锁:唯一键 + 条件更新保证并发安全;MySQL 与 H2(MODE=MySQL)都应通过。

+ */ +@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 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 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 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 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 raw) { + try { + return canonicalJson(MAPPER.writeValueAsString(new TreeMap<>(raw))); + } catch (Exception e) { + return null; + } + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/feedback/CandidateFeedbackService.java b/mydreamjob-server/src/main/java/com/mydreamjob/feedback/CandidateFeedbackService.java index 19728c3..09c5429 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/feedback/CandidateFeedbackService.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/feedback/CandidateFeedbackService.java @@ -10,6 +10,7 @@ import com.mydreamjob.feedback.dto.CandidateFeedbackRequest; import com.mydreamjob.feedback.dto.CandidateFeedbackResponse; import com.mydreamjob.job.Job; import com.mydreamjob.job.JobService; +import com.mydreamjob.candidatescore.refresh.CandidateScoringRefreshRequestService; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; @@ -28,6 +29,8 @@ public class CandidateFeedbackService { private final CandidateFeedbackRepository feedbackRepository; private final ApplicationRepository applicationRepository; private final JobService jobService; + /** R01-D4-1:候选人反馈创建后登记评分快照刷新(INTEGRITY 维度)。 */ + private final CandidateScoringRefreshRequestService scoringRefreshRequestService; private final CompanyRepository companyRepository; private static final ObjectMapper mapper = new ObjectMapper(); @@ -74,7 +77,10 @@ public class CandidateFeedbackService { .rating(req.getRating()).content(req.getContent()) .tags(toJson(req.getTags())) .build(); - return toResponse(feedbackRepository.save(fb)); + CandidateFeedback saved = feedbackRepository.save(fb); + // R01-D4-1:候选人反馈计数/均值影响 INTEGRITY 维度 → 登记当前周期刷新。 + scoringRefreshRequestService.requestRefresh(app.getStudentUserId(), java.time.LocalDateTime.now()); + return toResponse(saved); } // ── 查询 ────────────────────────────────────────────────── @@ -110,7 +116,10 @@ public class CandidateFeedbackService { if (req.getRating() != null) fb.setRating(req.getRating()); if (req.getContent() != null) fb.setContent(req.getContent()); if (req.getTags() != null) fb.setTags(toJson(req.getTags())); - return toResponse(feedbackRepository.save(fb)); + CandidateFeedback updated = feedbackRepository.save(fb); + // R01-D4-2 F2:修改 rating 会改变 AVG(rating) → 与写操作同事务登记刷新。 + scoringRefreshRequestService.requestRefresh(app.getStudentUserId(), java.time.LocalDateTime.now()); + return toResponse(updated); } // ── 删除 ────────────────────────────────────────────────── @@ -123,7 +132,11 @@ public class CandidateFeedbackService { CandidateFeedback fb = feedbackRepository.findByApplicationId(applicationId) .orElseThrow(() -> new BusinessException(ErrorCode.FEEDBACK_NOT_FOUND)); + // R01-D4-2 F2:删除前先固定候选人标识,删除后仍能正确登记。 + Long studentUserId = app.getStudentUserId(); feedbackRepository.delete(fb); + // COUNT(*) 与 AVG(rating) 同时变化 → 与删除操作同事务登记刷新。 + scoringRefreshRequestService.requestRefresh(studentUserId, java.time.LocalDateTime.now()); } // ── 视图 ────────────────────────────────────────────────── diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/messaging/MessagingService.java b/mydreamjob-server/src/main/java/com/mydreamjob/messaging/MessagingService.java index 7da0242..b443d95 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/messaging/MessagingService.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/messaging/MessagingService.java @@ -45,6 +45,7 @@ public class MessagingService { private final BlockRelationRepository blockRelationRepository; private final SecurityAuditService securityAuditService; private final AuditService auditService; + private final com.mydreamjob.candidatescore.refresh.CandidateScoringRefreshRequestService scoringRefreshRequestService; private final Clock clock; private final MessageViewMapper messageViewMapper; private final ObjectMapper objectMapper; @@ -191,6 +192,14 @@ public class MessagingService { conversationRepository.findById(req.getConversationId()).ifPresent(c -> { c.setUpdatedAt(msg.getCreatedAt()); conversationRepository.save(c); }); + // R01-D4-1:成功发送后,若发送者为学生则登记评分刷新(ACTIVITY 维度按发送消息计数)。 + // 消息撤回不登记(公式只统计发送消息总数)。 + userRepository.findById(userId).ifPresent(u -> { + if (u.getRoles() != null && u.getRoles().stream() + .anyMatch(r -> r == com.mydreamjob.auth.User.Role.STUDENT)) { + scoringRefreshRequestService.requestRefresh(userId, LocalDateTime.now(clock)); + } + }); return toView(msg, userId); } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/offer/OfferExpiryWorker.java b/mydreamjob-server/src/main/java/com/mydreamjob/offer/OfferExpiryWorker.java index 4d44423..a006531 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/offer/OfferExpiryWorker.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/offer/OfferExpiryWorker.java @@ -35,6 +35,13 @@ public class OfferExpiryWorker { private final OfferCounterOfferRepository counterOfferRepository; private final CompanyRepository companyRepository; private final NotificationService notificationService; + /** + * R01-D4-2 F1:Offer 自动过期是评分输入的真实写侧事件 + * ({@code CandidateScoringCalculator#queryOffers} 以 {@code status='EXPIRED'} 计数参与 + * INTEGRITY 维度),必须与状态变更登记在同一事务。 + */ + private final com.mydreamjob.candidatescore.refresh.CandidateScoringRefreshRequestService + scoringRefreshRequestService; /** 到期自动失效:SENT/NEGOTIATING → EXPIRED */ @Transactional(propagation = Propagation.REQUIRES_NEW) @@ -59,6 +66,8 @@ public class OfferExpiryWorker { .build()); // 同步将 PENDING 还价标记为 EXPIRED counterOfferRepository.expirePendingByOfferId(saved.getId()); + // R01-D4-2 F1:状态变更与刷新登记同一事务——任一步失败则二者一起回滚。 + scoringRefreshRequestService.requestRefresh(saved.getCandidateId(), LocalDateTime.now()); notifyExpired(saved); log.info("Offer {} expired ({})", saved.getId(), status); } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/offer/OfferService.java b/mydreamjob-server/src/main/java/com/mydreamjob/offer/OfferService.java index c2bdc14..e0f52d8 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/offer/OfferService.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/offer/OfferService.java @@ -13,6 +13,7 @@ import com.mydreamjob.company.CompanyRepository; import com.mydreamjob.job.Job; import com.mydreamjob.job.JobService; import com.mydreamjob.notification.NotificationService; +import com.mydreamjob.candidatescore.refresh.CandidateScoringRefreshRequestService; import com.mydreamjob.offer.dto.CounterOfferCreateRequest; import com.mydreamjob.offer.dto.OfferCreateRequest; import com.mydreamjob.offer.dto.OfferStatusRequest; @@ -64,6 +65,8 @@ public class OfferService { private final UserRepository userRepository; private final NotificationService notificationService; private final AuditService auditService; + /** R01-D4-1:Offer 状态变更(ACCEPTED/EXPIRED 履约聚合)后登记评分快照刷新。 */ + private final CandidateScoringRefreshRequestService scoringRefreshRequestService; @PersistenceContext private EntityManager entityManager; @@ -297,6 +300,8 @@ public class OfferService { notifyStatusChange(saved, fromStatus, to); auditService.log(operatorUserId, "UPDATE_OFFER_STATUS", "OFFER", saved.getId(), "Offer status changed: " + fromStatus + " → " + to.name(), ctx); + // R01-D4-1:Offer 状态变更影响 ACCEPTED/EXPIRED 履约聚合(INTEGRITY)→ 登记刷新。 + scoringRefreshRequestService.requestRefresh(offer.getCandidateId(), java.time.LocalDateTime.now()); return saved; } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/preference/PreferenceService.java b/mydreamjob-server/src/main/java/com/mydreamjob/preference/PreferenceService.java index 06aca67..1951c87 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/preference/PreferenceService.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/preference/PreferenceService.java @@ -4,6 +4,7 @@ import com.mydreamjob.auth.User; import com.mydreamjob.auth.UserRepository; import com.mydreamjob.common.exception.BusinessException; import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.candidatescore.refresh.CandidateScoringRefreshRequestService; import com.mydreamjob.preference.dto.StudentPreferenceRequest; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.type.TypeReference; @@ -13,6 +14,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDateTime; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -24,6 +26,8 @@ public class PreferenceService { private final StudentPreferenceRepository preferenceRepository; private final UserRepository userRepository; + /** R01-D4-1:求职偏好更新后登记评分快照刷新(COMPLIANCE 维度)。 */ + private final CandidateScoringRefreshRequestService scoringRefreshRequestService; private static final ObjectMapper mapper = new ObjectMapper(); // ── 角色校验 ────────────────────────────────────────────── @@ -68,6 +72,8 @@ public class PreferenceService { if (req.getRemoteAllowed() != null) pref.setRemoteAllowed(req.getRemoteAllowed()); StudentPreference saved = preferenceRepository.save(pref); + // R01-D4-1:偏好更新影响 COMPLIANCE 维度 → 登记当前周期刷新。 + scoringRefreshRequestService.requestRefresh(studentUserId, LocalDateTime.now()); return toView(saved); } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/profile/ProfileService.java b/mydreamjob-server/src/main/java/com/mydreamjob/profile/ProfileService.java index 996f12d..fcf74f7 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/profile/ProfileService.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/profile/ProfileService.java @@ -11,6 +11,7 @@ import com.mydreamjob.profile.storage.ResumeFilenameSanitizer; import com.mydreamjob.profile.storage.ResumeStorageService; import com.mydreamjob.auth.User; import com.mydreamjob.auth.UserRepository; +import com.mydreamjob.candidatescore.refresh.CandidateScoringRefreshRequestService; import lombok.Getter; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -20,6 +21,7 @@ import org.springframework.transaction.support.TransactionSynchronization; import org.springframework.transaction.support.TransactionSynchronizationManager; import org.springframework.web.multipart.MultipartFile; +import java.time.LocalDateTime; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -33,6 +35,8 @@ public class ProfileService { private final ResumeStorageService resumeStorageService; private final UserRepository userRepository; private final ResumeFileValidator resumeFileValidator; + /** R01-D4-1:学生资料/简历更新后登记评分快照刷新。 */ + private final CandidateScoringRefreshRequestService scoringRefreshRequestService; private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); @@ -66,7 +70,10 @@ public class ProfileService { if (req.getGraduationYear() != null) profile.setGraduationYear(req.getGraduationYear().shortValue()); if (req.getResumeUrl() != null) profile.setResumeUrl(req.getResumeUrl()); if (req.getSkills() != null) profile.setSkillsJson(toValidJson(req.getSkills())); - return profileRepository.save(profile); + StudentProfile saved = profileRepository.save(profile); + // R01-D4-1:资料(含学历/学校/技能/简历链接)变化影响 8 维评分 → 登记当前周期刷新。 + scoringRefreshRequestService.requestRefresh(userId, LocalDateTime.now()); + return saved; } /** diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/resume/ResumeVersionService.java b/mydreamjob-server/src/main/java/com/mydreamjob/resume/ResumeVersionService.java index 4f74262..2ad1fbd 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/resume/ResumeVersionService.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/resume/ResumeVersionService.java @@ -2,6 +2,7 @@ package com.mydreamjob.resume; import com.mydreamjob.common.exception.BusinessException; import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.candidatescore.refresh.CandidateScoringRefreshRequestService; import com.mydreamjob.profile.ProfileService; import com.mydreamjob.profile.storage.ResumeFilenameSanitizer; import com.mydreamjob.profile.storage.ResumeStorageService; @@ -38,6 +39,8 @@ public class ResumeVersionService { private final ResumeStorageService resumeStorageService; private final ResumeVersionWriter versionWriter; private final ProfileService profileService; + /** R01-D4-1:简历版本创建/替换/删除后登记评分快照刷新(GROWTH 维度)。 */ + private final CandidateScoringRefreshRequestService scoringRefreshRequestService; // ── 创建 ────────────────────────────────────────────────── @@ -61,9 +64,12 @@ public class ResumeVersionService { try { for (int attempt = 0; attempt < MAX_VERSION_NO_RETRY; attempt++) { try { - return versionWriter.saveNewVersion(userId, normalizeName(name), storagePath, + ResumeVersion saved = versionWriter.saveNewVersion(userId, normalizeName(name), storagePath, ResumeFilenameSanitizer.forDownload(file.getOriginalFilename(), "resume"), file.getContentType(), file.getSize()); + // R01-D4-2 F3:刷新登记已移入 ResumeVersionWriter 的同一 REQUIRES_NEW 事务, + // 保证"版本行与刷新任务同一提交边界";此处不再重复登记(避免二次登记与外层事务耦合)。 + return saved; } catch (DataIntegrityViolationException e) { // uk_resume_versions_user_no 并发冲突 → 短退避后重试 log.info("Resume version_no conflict for user {}, retry {}/{}", userId, attempt + 1, MAX_VERSION_NO_RETRY); @@ -153,6 +159,8 @@ public class ResumeVersionService { // 非事务上下文(单元测试):无 afterCommit 回调,立即执行旧文件清理 deleteOldFileIfUnreferenced(oldStoragePath); } + // R01-D4-1:简历替换/重命名影响 GROWTH 维度 → 登记当前周期刷新。 + scoringRefreshRequestService.requestRefresh(userId, java.time.LocalDateTime.now()); return version; } @@ -177,6 +185,8 @@ public class ResumeVersionService { } // 1) 先完成数据库删除 resumeVersionRepository.delete(version); + // R01-D4-1:简历版本数影响 GROWTH 维度 → 删除后登记当前周期刷新。 + scoringRefreshRequestService.requestRefresh(userId, java.time.LocalDateTime.now()); // 2) 事务提交后再删除存储文件(提交失败则文件保留) deleteFileAfterCommit(version.getFileStoragePath()); } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/resume/ResumeVersionWriter.java b/mydreamjob-server/src/main/java/com/mydreamjob/resume/ResumeVersionWriter.java index a63fdd6..751c391 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/resume/ResumeVersionWriter.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/resume/ResumeVersionWriter.java @@ -1,10 +1,13 @@ package com.mydreamjob.resume; +import com.mydreamjob.candidatescore.refresh.CandidateScoringRefreshRequestService; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDateTime; + /** * 简历版本写入工作单元:每个"计算版本号 + 插入"运行在独立事务(REQUIRES_NEW)。 * @@ -16,6 +19,14 @@ import org.springframework.transaction.annotation.Transactional; public class ResumeVersionWriter { private final ResumeVersionRepository resumeVersionRepository; + /** + * R01-D4-2 F3:刷新登记必须与版本行在同一提交边界。 + * + *

版本行由本组件的 {@code REQUIRES_NEW} 事务独立提交(这是既有的并发冲突重试防护, + * 不能移除)。若登记留在外层事务,外层在提交阶段失败就会出现"版本行已落库、刷新信号 + * 丢失"。把登记放进本事务后,二者要么一起提交、要么一起回滚。

+ */ + private final CandidateScoringRefreshRequestService scoringRefreshRequestService; /** 计算用户下一个版本号(max+1)并插入;并发冲突抛 DataIntegrityViolationException */ @Transactional(propagation = Propagation.REQUIRES_NEW) @@ -34,6 +45,9 @@ public class ResumeVersionWriter { .defaultKey(null) .status(ResumeVersion.STATUS_ACTIVE) .build(); - return resumeVersionRepository.save(version); + ResumeVersion saved = resumeVersionRepository.save(version); + // R01-D4-2 F3:与版本行同事务登记(RESUME 版本数/最近更新影响 GROWTH 维度)。 + scoringRefreshRequestService.requestRefresh(userId, LocalDateTime.now()); + return saved; } } diff --git a/mydreamjob-server/src/main/resources/application.yml b/mydreamjob-server/src/main/resources/application.yml index cb9192c..09211e7 100644 --- a/mydreamjob-server/src/main/resources/application.yml +++ b/mydreamjob-server/src/main/resources/application.yml @@ -149,6 +149,20 @@ app: header-name: X-Request-Id generate-on-missing: true + # R01-D4-2:GET 评分接口只读,快照由写侧登记任务、后台物化和补偿扫描维护。 + # 生产默认启用;测试环境可在 profile 中显式关闭,避免后台线程干扰确定性测试。 + scoring: + refresh: + enabled: ${SCORING_REFRESH_ENABLED:true} + worker-interval-ms: ${SCORING_REFRESH_WORKER_INTERVAL_MS:60000} + compensation-enabled: ${SCORING_REFRESH_COMPENSATION_ENABLED:true} + compensation-interval-ms: ${SCORING_REFRESH_COMPENSATION_INTERVAL_MS:3600000} + compensation-limit: ${SCORING_REFRESH_COMPENSATION_LIMIT:100} + batch-size: ${SCORING_REFRESH_BATCH_SIZE:20} + max-attempts: ${SCORING_REFRESH_MAX_ATTEMPTS:3} + retry-backoff-ms: ${SCORING_REFRESH_RETRY_BACKOFF_MS:300000} + processing-timeout-ms: ${SCORING_REFRESH_PROCESSING_TIMEOUT_MS:600000} + deletion: cooling-days: ${DELETION_COOLING_DAYS:30} phone-hash-secret: ${DELETION_PHONE_HASH_SECRET:mydreamjob-deletion-dev-secret-not-for-prod} diff --git a/mydreamjob-server/src/main/resources/db/migration/V44__candidate_scoring_refresh_tasks.sql b/mydreamjob-server/src/main/resources/db/migration/V44__candidate_scoring_refresh_tasks.sql new file mode 100644 index 0000000..1a94ecd --- /dev/null +++ b/mydreamjob-server/src/main/resources/db/migration/V44__candidate_scoring_refresh_tasks.sql @@ -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; diff --git a/mydreamjob-server/src/main/resources/db/migration/V45__add_scoring_refresh_claim_token.sql b/mydreamjob-server/src/main/resources/db/migration/V45__add_scoring_refresh_claim_token.sql new file mode 100644 index 0000000..823efd6 --- /dev/null +++ b/mydreamjob-server/src/main/resources/db/migration/V45__add_scoring_refresh_claim_token.sql @@ -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),终态/待处理时清空;仅用于并发所有权判定'; diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/application/ApplicationServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/application/ApplicationServiceTest.java index 092393d..0af13ac 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/application/ApplicationServiceTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/application/ApplicationServiceTest.java @@ -37,6 +37,7 @@ class ApplicationServiceTest { @Mock private ApplicationStatusHistoryRepository historyRepository; @Mock private com.mydreamjob.notification.NotificationService notificationService; @Mock private EntityManager em; + @Mock private com.mydreamjob.candidatescore.refresh.CandidateScoringRefreshRequestService scoringRefreshRequestService; @InjectMocks private ApplicationService applicationService; private static final Long STUDENT_ID = 100L; diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoringCalculatorTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoringCalculatorTest.java index 5532a06..ec50c23 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoringCalculatorTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoringCalculatorTest.java @@ -46,13 +46,14 @@ class CandidateScoringCalculatorTest { @Mock private AbilityAssessmentScoreSource abilityScoreSource; @Spy private AbilityScoreMapper abilityScoreMapper = new AbilityScoreMapper(); @InjectMocks private CandidateScoringCalculator calculator; + @Mock private com.mydreamjob.candidatescore.refresh.CandidateScoringSnapshotWriter snapshotWriter; private static final Long STUDENT_ID = 2L; private Query queryMock; @BeforeEach void setUp() { - ReflectionTestUtils.setField(calculator, "entityManager", entityManager); + // R01-D4-1:快照写入已委托 snapshotWriter,Calculator 不再持有 entityManager。 queryMock = mock(Query.class); when(queryMock.setParameter(anyInt(), any())).thenReturn(queryMock); when(entityManager.createNativeQuery(anyString())).thenReturn(queryMock); @@ -252,23 +253,19 @@ class CandidateScoringCalculatorTest { calculator.computeAbility(STUDENT_ID, NOW); calculator.upsertSnapshots(STUDENT_ID, List.of(r), "2026-08"); - ArgumentCaptor paramCaptor = ArgumentCaptor.forClass(Object.class); - verify(queryMock).setParameter(eq(6), paramCaptor.capture()); - String payload = (String) paramCaptor.getValue(); - assertNotNull(payload); - assertTrue(payload.contains("\"source\":\"V36_ABILITY_ASSESSMENT\"")); - assertTrue(payload.contains("\"sourceResultId\":123")); - assertTrue(payload.contains("\"mappingRulesVersion\":\"ability-assessment-scoring-v0.1\"")); - assertTrue(payload.contains("\"weights\"")); - assertTrue(payload.contains("\"dimensionScores\"")); - for (String banned : List.of("answerKey", "questionCode", "competencyTag", "stem", "selectedOptionId", - "scoringRule", "awardedScore", "explanation", "phone", "email")) { - assertFalse(payload.toLowerCase().contains(banned.toLowerCase()), "must not contain " + banned); - } - // 同一次 ABILITY 写入使用 v0.2 规则版本 - ArgumentCaptor versionCaptor = ArgumentCaptor.forClass(Object.class); - verify(queryMock).setParameter(eq(8), versionCaptor.capture()); - assertEquals("candidate-scoring-v0.2", versionCaptor.getValue()); + // R01-D4-1:写入委托 snapshotWriter;其 ABILITY 溯源 trace 不得含敏感字段。 + @SuppressWarnings("unchecked") + ArgumentCaptor> captor = + ArgumentCaptor.forClass(java.util.List.class); + verify(snapshotWriter).upsertIfChanged(eq(STUDENT_ID), captor.capture(), + eq("2026-08"), any(java.time.LocalDateTime.class), eq("candidate-scoring-v0.2")); + CandidateScoringCalculator.DimensionResult sent = captor.getValue().get(0); + assertNotNull(sent.abilitySnapshotTrace); + assertEquals(123L, sent.abilitySnapshotTrace.resultId()); + // 写入参数不携带任何问题/答案/敏感内容:XML 溯源仅保留 source/sourceResultId/validUntil/版本/权重/维度 + var payload = sent.abilitySnapshotTrace; + assertNotNull(payload.dimensionScores()); + // 同一次 ABILITY 写入使用 v0.2 规则版本(verify 中已断言 rulesVersion 传参) } private void setEnabled(boolean enabled) { @@ -380,22 +377,24 @@ class CandidateScoringCalculatorTest { verify(jdbcTemplate, times(1)).queryForObject(anyString(), eq(Long.class), any(Object[].class)); } - // ── upsert SQL(原生 ON DUPLICATE KEY UPDATE) ───────────── + // ── upsert 委托(R01-D4-1:普通快照写入委托唯一 Writer) ────── - @Test void upsertSnapshots_shouldUseOnDuplicateKeyUpdate() { + @Test void upsertSnapshots_shouldDelegateToWriter() { CandidateScoringCalculator.DimensionResult r = calculator.computeEducation(StudentProfile.builder().userId(STUDENT_ID) .education("本科").school("清华大学").build()); calculator.upsertSnapshots(STUDENT_ID, List.of(r), "2026-08"); - ArgumentCaptor sql = ArgumentCaptor.forClass(String.class); - verify(entityManager).createNativeQuery(sql.capture()); - assertTrue(sql.getValue().contains("ON DUPLICATE KEY UPDATE")); - assertTrue(sql.getValue().contains("version=version+1")); - verify(entityManager.createNativeQuery(anyString())).executeUpdate(); + @SuppressWarnings("unchecked") + ArgumentCaptor> captor = + ArgumentCaptor.forClass(java.util.List.class); + verify(snapshotWriter).upsertIfChanged(eq(STUDENT_ID), captor.capture(), + eq("2026-08"), any(java.time.LocalDateTime.class), anyString()); + assertEquals(1, captor.getValue().size()); + assertEquals("EDUCATION", captor.getValue().get(0).dimension); } - // ── recomputeAndSnapshot ────────────────────────────────── + // ── recomputeAndSnapshot(Governance 强制版本路径委托 Writer) ── @Test void recomputeAndSnapshot_shouldRecomputeAndWritePeriod() { when(userRepository.findById(STUDENT_ID)).thenReturn(Optional.of(user())); @@ -407,9 +406,9 @@ class CandidateScoringCalculatorTest { calculator.recomputeAndSnapshot(STUDENT_ID, "EDUCATION", "2026-06"); assertEquals("EDUCATION", r.dimension); assertEquals(new BigDecimal("76.75"), r.score); - // period 作为绑定参数写入(写 2026-06 周期快照) - verify(queryMock).setParameter(anyInt(), eq("2026-06")); - verify(queryMock).executeUpdate(); + // 强制版本推进路径:委托 snapshotWriter.upsertForcingVersion + verify(snapshotWriter).upsertForcingVersion(eq(STUDENT_ID), eq(r), + eq("2026-06"), any(java.time.LocalDateTime.class), anyString()); } @Test void recomputeAndSnapshot_invalidDimension_should400() { diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoringClockConsistencyTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoringClockConsistencyTest.java index 4968aa1..ab8bf3b 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoringClockConsistencyTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoringClockConsistencyTest.java @@ -49,7 +49,7 @@ class CandidateScoringClockConsistencyTest { private static final LocalDateTime CAPTURED = LocalDateTime.of(2026, 9, 9, 12, 0); @Test - void overviewUsesOneCapturedTimeForComputeAndSnapshot() { + void overviewUsesOneCapturedTimeForComputeAndResponseWithoutSnapshotWrite() { CandidateScoringEngineService service = new CandidateScoringEngineService( calculator, snapshotRepository, companyRepository, applicationRepository, jobService); LocalDateTime captured = LocalDateTime.of(2026, 9, 9, 12, 0); @@ -60,14 +60,13 @@ class CandidateScoringClockConsistencyTest { when(calculator.computeAll(eq(7L), any(LocalDateTime.class))).thenReturn(List.of(result)); when(calculator.currentRulesVersion()).thenReturn(CandidateScoringCalculator.RULES_VERSION); - service.getOverview(7L); + var overview = service.getOverview(7L); ArgumentCaptor computeTime = ArgumentCaptor.forClass(LocalDateTime.class); verify(calculator).computeAll(eq(7L), computeTime.capture()); - ArgumentCaptor snapshotTime = ArgumentCaptor.forClass(LocalDateTime.class); - verify(calculator).upsertSnapshots(eq(7L), anyList(), anyString(), snapshotTime.capture()); assertEquals(captured, computeTime.getValue()); - assertEquals(computeTime.getValue(), snapshotTime.getValue()); + assertEquals(captured.atZone(BUSINESS_ZONE).toInstant(), overview.getGeneratedAt()); + verify(calculator, never()).upsertSnapshots(anyLong(), anyList(), anyString(), any(LocalDateTime.class)); } @Test @@ -170,16 +169,14 @@ class CandidateScoringClockConsistencyTest { ArgumentCaptor computeTime = ArgumentCaptor.forClass(LocalDateTime.class); verify(calculator).computeAll(eq(7L), computeTime.capture()); - ArgumentCaptor snapshotTime = ArgumentCaptor.forClass(LocalDateTime.class); - verify(calculator).upsertSnapshots(eq(7L), anyList(), anyString(), snapshotTime.capture()); assertEquals(CAPTURED, computeTime.getValue()); - assertEquals(CAPTURED, snapshotTime.getValue()); assertEquals(CAPTURED.atZone(BUSINESS_ZONE).toInstant(), overview.getGeneratedAt()); assertEquals(1, advancing.instantCalls.get()); + verify(calculator, never()).upsertSnapshots(anyLong(), anyList(), anyString(), any(LocalDateTime.class)); } @Test - void companyScoring_usesOneCapturedClockAndExactPeriod() { + void companyScoring_usesOneCapturedClockWithoutSnapshotWrite() { CandidateScoringEngineService service = new CandidateScoringEngineService( calculator, snapshotRepository, companyRepository, applicationRepository, jobService); AdvancingClock advancing = new AdvancingClock(CAPTURED, BUSINESS_ZONE); @@ -199,18 +196,14 @@ class CandidateScoringClockConsistencyTest { ArgumentCaptor computeTime = ArgumentCaptor.forClass(LocalDateTime.class); verify(calculator).computeAll(eq(7L), computeTime.capture()); - ArgumentCaptor snapshotTime = ArgumentCaptor.forClass(LocalDateTime.class); - ArgumentCaptor period = ArgumentCaptor.forClass(String.class); - verify(calculator).upsertSnapshots(eq(7L), anyList(), period.capture(), snapshotTime.capture()); assertEquals(CAPTURED, computeTime.getValue()); - assertEquals(CAPTURED, snapshotTime.getValue()); - assertEquals("2026-09", period.getValue()); assertEquals(CAPTURED.atZone(BUSINESS_ZONE).toInstant(), response.getGeneratedAt()); assertEquals(1, advancing.instantCalls.get()); + verify(calculator, never()).upsertSnapshots(anyLong(), anyList(), anyString(), any(LocalDateTime.class)); } @Test - void generatedAtCalculatedAtPeriodAndValidity_useSameCapturedTime() { + void detailCalculatedAndUpdatedAtUseSameCapturedTimeWithoutSnapshotWrite() { CandidateScoringEngineService service = new CandidateScoringEngineService( calculator, snapshotRepository, companyRepository, applicationRepository, jobService); AdvancingClock advancing = new AdvancingClock(CAPTURED, BUSINESS_ZONE); @@ -227,15 +220,11 @@ class CandidateScoringClockConsistencyTest { ArgumentCaptor computeTime = ArgumentCaptor.forClass(LocalDateTime.class); verify(calculator).computeAll(eq(7L), computeTime.capture()); - ArgumentCaptor snapshotTime = ArgumentCaptor.forClass(LocalDateTime.class); - ArgumentCaptor period = ArgumentCaptor.forClass(String.class); - verify(calculator).upsertSnapshots(eq(7L), anyList(), period.capture(), snapshotTime.capture()); assertEquals(CAPTURED, computeTime.getValue()); - assertEquals(CAPTURED, snapshotTime.getValue()); - assertEquals("2026-09", period.getValue()); assertEquals(CAPTURED, detail.getCalculatedAt()); assertEquals(CAPTURED, detail.getUpdatedAt()); assertEquals(1, advancing.instantCalls.get()); + verify(calculator, never()).upsertSnapshots(anyLong(), anyList(), anyString(), any(LocalDateTime.class)); } private CandidateScoringCalculator calculatorWithJdbcAggregates(LocalDateTime captured, @@ -282,8 +271,9 @@ class CandidateScoringClockConsistencyTest { when(jdbcTemplate.queryForObject(anyString(), eq(Long.class), any(Object[].class))).thenReturn(0L); CandidateScoringCalculator real = new CandidateScoringCalculator( profileRepository, preferenceRepository, userRepository, jdbcTemplate, - abilityScoreSource, new AbilityScoreMapper()); - ReflectionTestUtils.setField(real, "entityManager", entityManager); + abilityScoreSource, new AbilityScoreMapper(), + org.mockito.Mockito.mock( + com.mydreamjob.candidatescore.refresh.CandidateScoringSnapshotWriter.class)); return real; } diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoringEngineServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoringEngineServiceTest.java index 113962b..79b3840 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoringEngineServiceTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoringEngineServiceTest.java @@ -84,13 +84,12 @@ class CandidateScoringEngineServiceTest { @BeforeEach void setUp() { - doNothing().when(calculator).upsertSnapshots(anyLong(), anyList(), anyString()); when(calculator.currentRulesVersion()).thenReturn("candidate-scoring-v0.1"); } // ── 综合评分卡 ────────────────────────────────────────────── - @Test void getOverview_shouldAssembleAndUpsert() { + @Test void getOverview_shouldAssembleWithoutWritingSnapshots() { when(calculator.computeAll(eq(STUDENT_ID), any(LocalDateTime.class))).thenReturn(sixComputable()); CandidateScoringOverviewResponse resp = service.getOverview(STUDENT_ID); @@ -101,7 +100,7 @@ class CandidateScoringEngineServiceTest { assertEquals(8, resp.getDimensions().size()); assertEquals(new BigDecimal("12.50"), resp.getWeights().get("EDUCATION")); verify(calculator, times(1)).computeAll(eq(STUDENT_ID), any(LocalDateTime.class)); - verify(calculator, times(1)).upsertSnapshots(eq(STUDENT_ID), anyList(), anyString(), any(LocalDateTime.class)); + verify(calculator, never()).upsertSnapshots(anyLong(), anyList(), anyString(), any(LocalDateTime.class)); } @Test void getOverview_allPending_shouldBePending() { @@ -221,6 +220,7 @@ class CandidateScoringEngineServiceTest { DimensionScoreDetailResponse detail = service.getDimensionDetail(STUDENT_ID, "EDUCATION"); assertEquals("EDUCATION", detail.getDimension()); assertEquals("candidate-scoring-v0.1", detail.getRulesVersion()); + verify(calculator, never()).upsertSnapshots(anyLong(), anyList(), anyString(), any(LocalDateTime.class)); } // ── 趋势 ───────────────────────────────────────────────── @@ -293,6 +293,7 @@ class CandidateScoringEngineServiceTest { when(calculator.computeAll(eq(STUDENT_ID), any(LocalDateTime.class))).thenReturn(sixComputable()); CompanyCandidateScoringResponse resp = service.getCompanyScoring(COMPANY_USER_ID, APP_ID); + verify(calculator, never()).upsertSnapshots(anyLong(), anyList(), anyString(), any(LocalDateTime.class)); assertEquals(APP_ID, resp.getApplicationId()); assertEquals("candidate-scoring-v0.1", resp.getRulesVersion()); assertEquals(8, resp.getDimensions().size()); diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoringPersistenceIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoringPersistenceIntegrationTest.java index 2d7da1a..0e26ac5 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoringPersistenceIntegrationTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoringPersistenceIntegrationTest.java @@ -14,6 +14,7 @@ import org.springframework.data.redis.core.ValueOperations; 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.Set; @@ -28,6 +29,7 @@ import static org.mockito.Mockito.when; class CandidateScoringPersistenceIntegrationTest { @Autowired private CandidateScoringEngineService service; + @Autowired private CandidateScoringCalculator calculator; @Autowired private UserRepository userRepository; @Autowired private CandidateDimensionScoreRepository snapshotRepository; @MockBean private RedisTemplate redisTemplate; @@ -52,30 +54,41 @@ class CandidateScoringPersistenceIntegrationTest { return String.format("%04d-%02d", d.getYear(), d.getMonthValue()); } + private CandidateScoringOverviewResponse computeAndMaterialize(Long studentId) { + LocalDateTime capturedNow = LocalDateTime.now(); + CandidateScoringOverviewResponse response = service.getOverview(studentId); + calculator.upsertSnapshots(studentId, calculator.computeAll(studentId, capturedNow), + currentPeriod(), capturedNow); + return response; + } + @Test - void upsertSnapshots_shouldBeIdempotentAndIncrementVersion() { + void upsertSnapshots_shouldBeIdempotentInValuesAndPersistEightRows() { + // R01-D4-1 契约:评分输入未变化时,普通物化必须幂等(值不变 → version 不变)。 User u = createStudent("13900009901"); String period = currentPeriod(); - CandidateScoringOverviewResponse first = service.getOverview(u.getId()); + CandidateScoringOverviewResponse first = computeAndMaterialize(u.getId()); List rows1 = snapshotRepository.findByStudentUserIdAndPeriod(u.getId(), period); assertEquals(8, rows1.size()); // 新用户无数据:全部 INSUFFICIENT_DATA / PENDING,overallScore=null assertEquals("INSUFFICIENT_DATA", first.getStatus()); assertNull(first.getOverallScore()); - Long version1 = rows1.get(0).getVersion(); - // 再次调用:同周期覆盖,version 递增,行数不变 - service.getOverview(u.getId()); + // 再次调用:值未变化 → version 不变(幂等物化),行数不变 + computeAndMaterialize(u.getId()); List rows2 = snapshotRepository.findByStudentUserIdAndPeriod(u.getId(), period); assertEquals(8, rows2.size()); - assertEquals(version1 + 1, rows2.get(0).getVersion()); + for (int i = 0; i < rows1.size(); i++) { + assertEquals(rows1.get(i).getVersion(), rows2.get(i).getVersion(), + "重复相同值 upsert 不得推进 version: " + rows1.get(i).getDimension()); + } } @Test void upsert_rulesVersionPersisted() { User u = createStudent("13900009902"); - service.getOverview(u.getId()); + computeAndMaterialize(u.getId()); List rows = snapshotRepository.findByStudentUserIdAndPeriod(u.getId(), currentPeriod()); assertTrue(rows.stream().allMatch(r -> "candidate-scoring-v0.1".equals(r.getRulesVersion()))); assertTrue(rows.stream().allMatch(r -> r.getWeight().doubleValue() == 12.50)); @@ -84,7 +97,7 @@ class CandidateScoringPersistenceIntegrationTest { @Test void trend_shouldReadSnapshotsOfCurrentPeriod() { User u = createStudent("13900009903"); - service.getOverview(u.getId()); + computeAndMaterialize(u.getId()); CandidateScoringTrendResponse trend = service.getTrend(u.getId(), 6); assertEquals(1, trend.getItems().size()); assertEquals(currentPeriod(), trend.getItems().get(0).getPeriod()); diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/R01D4GetWritesSnapshotEvidenceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/R01D4GetWritesSnapshotEvidenceTest.java index 1544d2b..0fdf9a1 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/R01D4GetWritesSnapshotEvidenceTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/R01D4GetWritesSnapshotEvidenceTest.java @@ -15,12 +15,9 @@ 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.util.HashSet; -import java.util.Map; import java.util.List; import java.util.Set; -import java.util.stream.Collectors; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.any; @@ -28,9 +25,7 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; /** - * R01-D4 现状证据测试:证明当前三个 GET 评分接口会写 candidate_dimension_scores。 - *

本测试只描述现状(读取放大写入),不代表该行为是正确契约; - * 迁移目标是把这些写入移出读取路径,届时本测试将按新契约改写/移除。

+ * R01-D4-2 契约证据:评分 GET 只实时计算响应,不写 candidate_dimension_scores。 */ @SpringBootTest @ActiveProfiles("integration-test") @@ -38,12 +33,10 @@ class R01D4GetWritesSnapshotEvidenceTest { @Autowired private CandidateScoringEngineService service; @Autowired private UserRepository userRepository; - @Autowired private CandidateDimensionScoreRepository snapshotRepository; @Autowired private JdbcTemplate jdbcTemplate; @MockBean private RedisTemplate redisTemplate; private Long studentId; - private String period; @BeforeEach @SuppressWarnings("unchecked") @@ -56,22 +49,15 @@ class R01D4GetWritesSnapshotEvidenceTest { .active(true).verified(true).build(); u.setRoles(new HashSet<>(Set.of(User.Role.STUDENT))); studentId = userRepository.save(u).getId(); - LocalDate d = LocalDate.now(); - period = String.format("%04d-%02d", d.getYear(), d.getMonthValue()); } - private long[] versionSnapshot(Long sid) { - List rows = snapshotRepository.findByStudentUserIdAndPeriod(sid, period); - long[] v = new long[rows.size()]; - for (int i = 0; i < rows.size(); i++) v[i] = rows.get(i).getVersion(); - java.util.Arrays.sort(v); - return v; - } - - private Map versionsByDimension(Long sid) { - return snapshotRepository.findByStudentUserIdAndPeriod(sid, period).stream() - .collect(Collectors.toMap(CandidateDimensionScore::getDimension, - CandidateDimensionScore::getVersion)); + private List> snapshotRows() { + return jdbcTemplate.queryForList(""" + SELECT id, student_user_id, dimension, score, status, weight, period, + rules_version, version, created_at, updated_at + FROM candidate_dimension_scores + ORDER BY id + """); } private long countRows(String table) { @@ -80,61 +66,38 @@ class R01D4GetWritesSnapshotEvidenceTest { } @Test - void overviewGet_insertsRowsOnFirstCall_andIncrementsVersionOnSecond() { - long before = versionSnapshot(studentId).length; + void overviewGet_returnsRealtimeResultWithoutWritingSnapshots() { + List> before = snapshotRows(); CandidateScoringOverviewResponse first = service.getOverview(studentId); - long afterFirst = versionSnapshot(studentId).length; - assertTrue(afterFirst == 8, "首调应新增 8 行,实际 " + afterFirst + "(之前 " + before + ")"); - - long[] v1 = versionSnapshot(studentId); CandidateScoringOverviewResponse second = service.getOverview(studentId); - long[] v2 = versionSnapshot(studentId); - assertTrue(v2.length == v1.length, "行数不应变"); - for (int i = 0; i < v1.length; i++) { - assertTrue(v2[i] == v1[i] + 1, - "重复 GET 应 version+1(现状:读取放大写入),dim=" + i); - } - // 返回 JSON 保持等值(评分结果不变) + assertEquals(before, snapshotRows(), "overview GET 必须保持快照表逐字段不变"); assertEquals(first.getOverallScore(), second.getOverallScore()); assertEquals(first.getStatus(), second.getStatus()); } @Test - void dimensionDetailGet_alsoIncrementsVersion() { - service.getOverview(studentId); - Map v1 = versionsByDimension(studentId); + void dimensionDetailGet_returnsRealtimeResultWithoutWritingSnapshots() { + List> before = snapshotRows(); DimensionScoreDetailResponse detail = service.getDimensionDetail(studentId, "EDUCATION"); assertNotNull(detail); - Map v2 = versionsByDimension(studentId); - assertEquals(v1.keySet(), v2.keySet(), "单维度 GET 不应增行"); - assertEquals(v1.get("EDUCATION") + 1, v2.get("EDUCATION"), - "单维度 GET 必须只推进目标维度版本"); - v1.forEach((dimension, version) -> { - if (!"EDUCATION".equals(dimension)) { - assertEquals(version, v2.get(dimension), "非目标维度版本不得变化: " + dimension); - } - }); + assertEquals(before, snapshotRows(), "单维度 GET 必须保持快照表逐字段不变"); } @Test void trendGet_isReadOnly() { - service.getOverview(studentId); - long[] v1 = versionSnapshot(studentId); + List> before = snapshotRows(); CandidateScoringTrendResponse t1 = service.getTrend(studentId, 6); CandidateScoringTrendResponse t2 = service.getTrend(studentId, 6); - long[] v2 = versionSnapshot(studentId); - assertArrayEquals(v1, v2, "trend GET 不得写库(现状应为只读)"); + assertEquals(before, snapshotRows(), "trend GET 必须保持快照表逐字段不变"); assertEquals(t1.getItems().size(), t2.getItems().size()); } @Test void invalidDimensionGet_doesNotWrite() { - service.getOverview(studentId); - long[] v1 = versionSnapshot(studentId); + List> before = snapshotRows(); assertThrows(com.mydreamjob.common.exception.BusinessException.class, () -> service.getDimensionDetail(studentId, "BOGUS")); - long[] v2 = versionSnapshot(studentId); - assertArrayEquals(v1, v2, "非法 dimension 不得写库(校验在 upsert 之前)"); + assertEquals(before, snapshotRows(), "非法 dimension 不得写库"); } @Test diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/V38AbilityRankingIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/V38AbilityRankingIntegrationTest.java index e68c278..c792cd0 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/V38AbilityRankingIntegrationTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/V38AbilityRankingIntegrationTest.java @@ -39,7 +39,7 @@ import static org.mockito.Mockito.when; * V38 → V32 排名回归(真实快照写路径 + 真实 CompetitionRankingCalculator)。 * *

不伪造 ABILITY 快照:开关开启且存在最新有效 V36 结果时,先经 - * {@link CandidateScoringEngineService#getOverview} 的真实候选评分写快照路径, + * {@link CandidateScoringCalculator} 的真实候选评分物化路径, * 使 ABILITY 以既定权重公式计入综合分,再交排名组件计算 overallScore/rank/percentile。 * 开关关闭时 ABILITY 保持 PENDING,综合分与既有口径一致(无可计算维度 → 本人无分)。

* @@ -62,7 +62,6 @@ class V38AbilityRankingIntegrationTest { @Autowired private AbilityDefinitionRepository definitionRepository; @Autowired private AbilityAttemptRepository attemptRepository; @Autowired private AbilityResultRepository resultRepository; - @Autowired private CandidateScoringEngineService engineService; @Autowired private CandidateScoringCalculator calculator; @Autowired private CompetitionRankingCalculator rankingCalculator; @@ -129,6 +128,12 @@ class V38AbilityRankingIntegrationTest { ReflectionTestUtils.setField(calculator, "abilityAssessmentEnabled", b); } + private void materialize(Long studentId) { + LocalDateTime capturedNow = LocalDateTime.now(); + calculator.upsertSnapshots(studentId, calculator.computeAll(studentId, capturedNow), + currentMonth(), capturedNow); + } + /** * me 为无资料/无活跃数据学生;peer 由内存快照给出 EDUCATION=70(仅作对照组)。 * 开关关闭:me 无可计算维度 → overall=null、rank=null、percentile=null(ABILITY=PENDING)。 @@ -148,7 +153,7 @@ class V38AbilityRankingIntegrationTest { // 关闭:ABILITY=PENDING,me 无可计算维度 setEnabled(false); - engineService.getOverview(me); + materialize(me); Map> offSnaps = new HashMap<>(rankingCalculator.loadLatestSnapshots(List.of(me), month)); offSnaps.putAll(peerOnly); @@ -160,10 +165,10 @@ class V38AbilityRankingIntegrationTest { assertEquals("PENDING", off.meSnaps.stream() .filter(s -> "ABILITY".equals(s.getDimension())).findFirst().get().getStatus()); - // 开启:真实评分写快照 → ABILITY=OK 74.25 计入综合分 + // 开启:显式走真实物化写入口 → ABILITY=OK 74.25 计入综合分 setEnabled(true); giveValidAbilityResult(me); - engineService.getOverview(me); + materialize(me); Map> onSnaps = new HashMap<>(rankingCalculator.loadLatestSnapshots(List.of(me), month)); onSnaps.putAll(peerOnly); @@ -180,4 +185,4 @@ class V38AbilityRankingIntegrationTest { assertNotEquals(on.rank, off.rank); assertNotEquals(on.percentile, off.percentile); } -} \ No newline at end of file +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshBatchIsolationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshBatchIsolationTest.java new file mode 100644 index 0000000..11da9df --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshBatchIsolationTest.java @@ -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 次生缺陷:单个任务失败不得阻止同批后续任务。 + * + *

缺陷背景:{@code recordFailure} 在更新 0 行时抛 IllegalStateException,异常点位于 + * Worker 的 catch 块内,而 {@code processDue} 的 for 循环没有保护 —— 单个任务的失败会 + * 让同批剩余任务本轮全部不被处理。

+ * + *

本测试通过 mock 计算器精确制造"指定学生计算失败",断言真实数据库中的批次终态。

+ */ +@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 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 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 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(), "一个重试项"); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshClaimFenceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshClaimFenceTest.java new file mode 100644 index 0000000..ff2ad8b --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshClaimFenceTest.java @@ -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 处置。 + * + *

缺陷背景:{@code materialize} 的「回到 PENDING」回退分支只校验 + * {@code status='PROCESSING'} 而不校验领取所有权;当原 Worker 因租约超时已被重新领取时, + * 慢返回的旧 Worker 会把新 Worker 正在处理的任务改回 PENDING。同时 + * {@code recordFailure} 在更新 0 行时抛 IllegalStateException,异常点位于 Worker 的 + * catch 块内、{@code processDue} 的 for 循环无保护 → 同批剩余任务本轮全部不处理。

+ * + *

本测试断言真实数据库状态,不使用 sleep,不使用 JVM 锁。

+ */ +@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 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 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 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); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshClaimTokenTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshClaimTokenTest.java new file mode 100644 index 0000000..b1a7fea --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshClaimTokenTest.java @@ -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。 + * + *

缺陷背景:旧实现以 {@code processing_started_at} 作为所有权标识,而该列是秒级 + * {@code DATETIME}。当租约回收与重新领取落在同一秒时,新旧 claim 的 fence 完全相同, + * 旧 Worker 的 PENDING 回退会误改新持有者(已由 {@code iv-fence2.sql} 在真实 MySQL 复现)。

+ * + *

本测试使用固定时间值(两次领取传入完全相同的 {@code LocalDateTime}), + * 不依赖跨秒等待。

+ */ +@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 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 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(); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshFailureTransactionTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshFailureTransactionTest.java new file mode 100644 index 0000000..b62267e --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshFailureTransactionTest.java @@ -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 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 results(boolean failAtFourth) { + String[] dimensions = {"EDUCATION", "INTERNSHIP", "ABILITY", "COMPLIANCE", + "STABILITY", "GROWTH", "INTEGRITY", "ACTIVITY"}; + List 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); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshInfrastructureTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshInfrastructureTest.java new file mode 100644 index 0000000..d635a1a --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshInfrastructureTest.java @@ -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 redisTemplate; + + private Long sid; + private String period; + + @BeforeEach + @SuppressWarnings("unchecked") + void setUp() { + ValueOperations 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 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> futures = java.util.stream.IntStream.range(0, 2).>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 snaps = snapshotRepository.findByStudentUserIdAndPeriod(sid, period); + assertTrue(snaps.size() == 8, "并发处理恰一次物化(8 维)"); + } + + // 6/9. 相同计算值重复 Writer 写入不增 version;Worker 重放同结果不增 + @Test + void sameCalculatedValuesDoNotIncrementSnapshotVersion() { + List results = + calculator.computeAll(sid, now()); + writer.upsertIfChanged(sid, results, period, now(), calculator.currentRulesVersion()); + List r1 = snapshotRepository.findByStudentUserIdAndPeriod(sid, period); + assertEquals(8, r1.size()); + writer.upsertIfChanged(sid, results, period, now(), calculator.currentRulesVersion()); + List 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 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 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 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 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 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 hits) { + if (node == null) return; + if (node.isObject()) { + java.util.Iterator it = node.fieldNames(); + java.util.List 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 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; + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshMySqlIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshMySqlIntegrationTest.java new file mode 100644 index 0000000..2b0941f --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshMySqlIntegrationTest.java @@ -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 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.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 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> 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 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 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 results(boolean failAtFourth) { + String[] dimensions = {"EDUCATION", "INTERNSHIP", "ABILITY", "COMPLIANCE", + "STABILITY", "GROWTH", "INTEGRITY", "ACTIVITY"}; + List 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; + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshWorkerLoopIsolationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshWorkerLoopIsolationTest.java new file mode 100644 index 0000000..9cc72c0 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshWorkerLoopIsolationTest.java @@ -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} 的批次循环必须逐任务隔离。 + * + *

缺陷背景:{@code recordFailure} 更新 0 行时抛 IllegalStateException,该异常在 + * Worker 的 catch 块内抛出,而 {@code processDue} 的 for 循环没有保护 —— 会导致同批 + * 剩余任务本轮全部不被处理。

+ * + *

纯单元测试:精确控制"第一项失败且失败登记也抛异常"的时序,不依赖 sleep 或 JVM 锁。

+ */ +@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)); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/feedback/CandidateFeedbackRefreshRegistrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/feedback/CandidateFeedbackRefreshRegistrationTest.java new file mode 100644 index 0000000..d705f87 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/feedback/CandidateFeedbackRefreshRegistrationTest.java @@ -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:候选人反馈的创建/修改/删除都必须登记评分刷新任务。 + * + *

缺陷背景:{@code CandidateScoringCalculator#queryFeedback} 以 + * {@code COUNT(*) / AVG(rating)} 参与 INTEGRITY 维度,但只有 createFeedback 登记了刷新, + * updateFeedback(改 rating 会改变 AVG)与 deleteFeedback(改变 COUNT 与 AVG)此前不登记。

+ */ +@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 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 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 登记"); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/feedback/CandidateFeedbackServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/feedback/CandidateFeedbackServiceTest.java index bca9a07..2ea3ee1 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/feedback/CandidateFeedbackServiceTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/feedback/CandidateFeedbackServiceTest.java @@ -29,6 +29,7 @@ class CandidateFeedbackServiceTest { @Mock private ApplicationRepository applicationRepository; @Mock private JobService jobService; @Mock private CompanyRepository companyRepository; + @Mock private com.mydreamjob.candidatescore.refresh.CandidateScoringRefreshRequestService scoringRefreshRequestService; @InjectMocks private CandidateFeedbackService feedbackService; private static final Long COMPANY_USER_ID = 10L; // 企业账号 userId diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/offer/OfferExpiryRefreshRegistrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/offer/OfferExpiryRefreshRegistrationTest.java new file mode 100644 index 0000000..a8d901e --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/offer/OfferExpiryRefreshRegistrationTest.java @@ -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 自动过期必须登记评分刷新任务。 + * + *

缺陷背景:{@code OfferExpiryWorker#expireOne} 直接把 Offer 置为 EXPIRED, + * 而 {@code CandidateScoringCalculator#queryOffers} 以 {@code status='EXPIRED'} 计数 + * 参与 INTEGRITY 维度评分 —— 该写侧事件此前不登记刷新任务。

+ * + *

本测试断言真实数据库状态(offers / candidate_scoring_refresh_tasks / notifications), + * 不依赖对 requestRefresh 的 mock 验证。

+ */ +@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 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 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 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 所有权令牌列"); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/offer/OfferExpiryWorkerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/offer/OfferExpiryWorkerTest.java index 06ea97c..d6153bc 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/offer/OfferExpiryWorkerTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/offer/OfferExpiryWorkerTest.java @@ -27,6 +27,9 @@ class OfferExpiryWorkerTest { @Mock private OfferCounterOfferRepository counterOfferRepository; @Mock private CompanyRepository companyRepository; @Mock private NotificationService notificationService; + /** R01-D4-2 F1:Offer 过期必须登记评分刷新任务。 */ + @Mock private com.mydreamjob.candidatescore.refresh.CandidateScoringRefreshRequestService + scoringRefreshRequestService; @InjectMocks private OfferExpiryWorker worker; diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/offer/OfferServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/offer/OfferServiceTest.java index 14d5421..efebc5a 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/offer/OfferServiceTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/offer/OfferServiceTest.java @@ -51,6 +51,7 @@ class OfferServiceTest { @Mock private UserRepository userRepository; @Mock private NotificationService notificationService; @Mock private AuditService auditService; + @Mock private com.mydreamjob.candidatescore.refresh.CandidateScoringRefreshRequestService scoringRefreshRequestService; @InjectMocks private OfferService offerService; diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/preference/PreferenceServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/preference/PreferenceServiceTest.java index d59ef99..376a3f4 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/preference/PreferenceServiceTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/preference/PreferenceServiceTest.java @@ -28,6 +28,9 @@ class PreferenceServiceTest { @Mock private UserRepository userRepository; + @Mock + private com.mydreamjob.candidatescore.refresh.CandidateScoringRefreshRequestService scoringRefreshRequestService; + @InjectMocks private PreferenceService preferenceService; diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/profile/ProfileServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/profile/ProfileServiceTest.java index 8e7a9a4..453495b 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/profile/ProfileServiceTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/profile/ProfileServiceTest.java @@ -21,6 +21,9 @@ class ProfileServiceTest { @Mock private StudentProfileRepository profileRepository; + @Mock + private com.mydreamjob.candidatescore.refresh.CandidateScoringRefreshRequestService scoringRefreshRequestService; + @InjectMocks private ProfileService profileService; diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/resume/ResumeVersionRefreshAtomicityTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/resume/ResumeVersionRefreshAtomicityTest.java new file mode 100644 index 0000000..426074e --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/resume/ResumeVersionRefreshAtomicityTest.java @@ -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:简历版本行与评分刷新任务必须同一提交边界。 + * + *

缺陷背景:{@code ResumeVersionWriter#saveNewVersion} 以 REQUIRES_NEW 独立提交版本行 + * (这是既有的一致性问题防护设计,不能简单移除),而 {@code createVersion} 在外层事务中 + * 登记刷新任务 —— 外层事务在提交阶段失败时,版本行已落库而刷新任务丢失。

+ * + *

本测试断言真实数据库状态:外层事务回滚后,版本行存在则刷新任务必须同样存在。

+ * + *

文件系统操作被 mock:本测试只验证数据库原子性,不把文件存储纳入数据库事务承诺。

+ */ +@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 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 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), + "同学生同周期重复登记必须收敛为一条任务"); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/resume/ResumeVersionServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/resume/ResumeVersionServiceTest.java index 5af310b..abe84c2 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/resume/ResumeVersionServiceTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/resume/ResumeVersionServiceTest.java @@ -31,6 +31,7 @@ class ResumeVersionServiceTest { @Mock private ResumeStorageService resumeStorageService; @Mock private ResumeVersionWriter versionWriter; @Mock private ProfileService profileService; + @Mock private com.mydreamjob.candidatescore.refresh.CandidateScoringRefreshRequestService scoringRefreshRequestService; @InjectMocks private ResumeVersionService resumeVersionService; diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/scoringmonitoring/FairnessMonitoringPersistenceIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/scoringmonitoring/FairnessMonitoringPersistenceIntegrationTest.java index 2d4238f..85fce85 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/scoringmonitoring/FairnessMonitoringPersistenceIntegrationTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/scoringmonitoring/FairnessMonitoringPersistenceIntegrationTest.java @@ -102,7 +102,7 @@ class FairnessMonitoringPersistenceIntegrationTest { String prefix = UUID.randomUUID().toString().substring(0,8); List> cases = List.of(List.of("10.00"), List.of("10.00","10.01"), List.of("10.00","10.00","10.01"), List.of("10.00","10.01","10.01")); - var calculator = new CandidateScoringCalculator(null,null,null,null,null,null); + var calculator = new CandidateScoringCalculator(null, null, null, null, null, null, null); for (int c=0;c