8 changed files with 397 additions and 2 deletions
@ -0,0 +1,183 @@ |
|||
# R01-C 评分/Fairness 组合测试污染隔离修复(独立纯测试提交) |
|||
|
|||
日期:2026-09-18 |
|||
分支:`refactor/r01-structure-performance` |
|||
R01-C 提交:`7d4704ee28e4d8e2d1fbcd174a990fc6bfe68169` |
|||
本轮性质:**纯测试修复** —— **生产代码 diff = 0** |
|||
|
|||
--- |
|||
|
|||
## 0. 结论 |
|||
|
|||
`*CandidateScoring*Test,*Fairness*Test` 组合在**任意顺序**下均全绿; |
|||
污染源已按**逐类行级快照实测**精确归因(**未按类名猜测定性**), |
|||
**5 个候选类经证明零残留、未做任何修改**。 |
|||
|
|||
--- |
|||
|
|||
## 1. 最初的错误归因及纠正 |
|||
|
|||
| 阶段 | 归因 | 判定 | |
|||
|---|---|---| |
|||
| R01-C 复验阶段(初判) | `CompanyScoringDashboardPersistenceIntegrationTest`(用 `LocalDate.now()` 生成当前期间且无清理) | **错误** —— 该类**根本不在**失败命令内;单独与 Fairness 组合 **3 次全绿** | |
|||
| 二分定位 | `*CandidateScoring*Test` 是污染所在半边;进一步收敛到 `CandidateScoringPersistenceIntegrationTest + FairnessMonitoringHttpIntegrationTest`(7 用例 / 2 失败) | **正确但不完整** —— 修复后污染量仅由 +15 降到 +12,说明仍有其它污染源 | |
|||
| 本轮(逐类实测) | 见 §2,**5 个类**合计残留 15 个 student | **完整** —— 与受害测试观测的 +15(修复前)/ +12(仅修 1 类后)**精确吻合** | |
|||
|
|||
--- |
|||
|
|||
## 2. 每个候选类的实测残留表 |
|||
|
|||
**方法**:临时诊断探针 `ZR01CLeakProbeTest`(置于 `candidatescore.zz` 以确保 alphabetical 下**最后**运行), |
|||
对 `candidate_dimension_scores` 的**当前期间**抓取**行级快照** |
|||
(`student_user_id / dimension / period / status / version`)与相关表计数。 |
|||
每次 `mvn` 均为**全新 JVM + 全新 H2 库**,故"运行后快照"即为该类在**干净上下文中**的净残留。 |
|||
探针诊断完成后**已删除**(非交付物)。 |
|||
|
|||
| 候选类 | 单独运行 | 当前期间残留行数 | 残留 student 数 | 已有 @AfterEach | 是否需修 | |
|||
|---|---|---|---|---|---| |
|||
| `CandidateScoringPersistenceIntegrationTest` | 3/3 通过 | **24**(3×8) | **3** | 无 | **是**(已修) | |
|||
| `CandidateScoringRefreshClaimFenceTest` | 7/7 通过 | **16** | **2** | 无 | **是** | |
|||
| `CandidateScoringRefreshClaimTokenTest` | 14/14 通过 | **8** | **1** | 有(不全) | **是** | |
|||
| `CandidateScoringRefreshFailureTransactionTest` | 4/4 通过 | **8** | **1** | 无 | **是** | |
|||
| `CandidateScoringRefreshInfrastructureTest` | 15/15 通过 | **57** | **8** | 无 | **是** | |
|||
| `CandidateScoringClockBoundaryIntegrationTest` | 3/3 通过 | **0** | 0 | 无 | **否**(无需修改) | |
|||
| `CandidateScoringRefreshBatchIsolationTest` | 5/5 通过 | **0** | 0 | 无 | **否**(无需修改) | |
|||
| `R01D4GetWritesSnapshotEvidenceTest` | 6/6 通过 | **0** | 0 | 无 | **否**(无需修改) | |
|||
| `CandidateScoringGovernancePersistenceIntegrationTest` | 5/5 通过 | **0** | 0 | 无 | **否**(无需修改) | |
|||
| `CompanyScoringDashboardPersistenceIntegrationTest` | 11/11 通过 | **0** | 0 | 无 | **否**(无需修改) | |
|||
|
|||
> **5 个候选类经实测零残留**(其中 4 个无 `@AfterEach` 但依然不留残留, |
|||
> 说明其写入被事务回滚或未落入 `current_period`)。**未因"名字可疑"而修改它们。** |
|||
|
|||
--- |
|||
|
|||
## 3. +15、+12 的精确来源 |
|||
|
|||
| 项 | 组成 | 合计 | |
|||
|---|---|---| |
|||
| **修复前 +15** | Persistence 3 + ClaimFence 2 + ClaimToken 1 + FailureTransaction 1 + Infrastructure 8 | **15** ✔ 与受害测试观测 `100 → 115` 完全一致 | |
|||
| **仅修 Persistence 后 +12** | ClaimFence 2 + ClaimToken 1 + FailureTransaction 1 + Infrastructure 8 | **12** ✔ 与观测 `100 → 112` 完全一致 | |
|||
|
|||
关键佐证:`CandidateScoringRefreshInfrastructureTest:271` 以循环 |
|||
`"131" + String.format("%08d", i + 7000)`(i=0..7)创建 **8 个用户**, |
|||
与探针实测的 **8 个残留 student** 精确对应。 |
|||
|
|||
--- |
|||
|
|||
## 4. 修复内容(仅改测试,生产代码 0 改动) |
|||
|
|||
| 文件 | 修复前 | 修复后 | |
|||
|---|---|---| |
|||
| `CandidateScoringPersistenceIntegrationTest` | 3 个用例各建 1 个学生并物化当前期间快照,**零清理** | 登记自有 userId;`@BeforeEach` **断言独占手机号不存在**;`@AfterEach` 按外键顺序**精确 id** 删除快照 → 角色 → 用户 | |
|||
| `CandidateScoringRefreshClaimFenceTest` | 残留 16 行 | 新增 `@AfterEach`:按**本类独占手机号前缀** `193`/`192` 解析出精确 id 后按外键顺序删除 | |
|||
| `CandidateScoringRefreshClaimTokenTest` | 残留 8 行(原有 `@AfterEach` 未覆盖快照) | 同上,前缀 `190`/`189` | |
|||
| `CandidateScoringRefreshFailureTransactionTest` | 残留 8 行 | 同上,前缀 `130` | |
|||
| `CandidateScoringRefreshInfrastructureTest` | 残留 57 行 | 同上,前缀 `132`/`13107` | |
|||
|
|||
**统一遵守的约束**:独占手机号(不与 V1_1 种子账号 `138…`、其它测试 `t<id>`、`1390000xxx` 冲突); |
|||
按外键顺序(`candidate_dimension_scores` / `candidate_scoring_refresh_tasks` → `user_roles` → `users`); |
|||
**不使用 `deleteAll`/`TRUNCATE`/`@DirtiesContext`/`sleep`**;不捕获忽略清理异常; |
|||
**不修改生产统计或查询**;**不通过改期望 `populationSize` 掩盖污染**;不依赖测试执行顺序。 |
|||
|
|||
> **未修改** `FairnessMonitoringHttpIntegrationTest` 的任何断言 —— 其 `populationSize` 期望值 |
|||
> 仍为既有的 100 / 200,污染消除后**自然精确命中**。 |
|||
|
|||
--- |
|||
|
|||
## 5. 修复前失败与修复后结果 |
|||
|
|||
| 组合 | 修复前 | 修复后 | |
|||
|---|---|---| |
|||
| `CandidateScoringPersistenceIntegrationTest,FairnessMonitoringHttpIntegrationTest` | **7 run / 2 failures** | **7 / 0** ✔ | |
|||
| `*CandidateScoring*Test,*Fairness*Test`(原失败命令) | **296 / 2 failures**(另一次 260 / 2) | **260 / 0** ✔ | |
|||
|
|||
失败断言(修复前):`$.data.populationSize expected:<100> but was:<115>`、 |
|||
`expected:<200> but was:<215>`。 |
|||
修复后 `populationSize` **精确等于既有期望** 100 / 200 ✔ |
|||
|
|||
--- |
|||
|
|||
## 6. 双向顺序各 3 次结果 |
|||
|
|||
实际执行顺序**从日志核对**(非依据命令参数): |
|||
|
|||
| 顺序 | 实测类顺序 | 结果 | |
|||
|---|---|---| |
|||
| `-Dsurefire.runOrder=alphabetical` ×3 | `candidatescore.CandidateScoringCalculatorTest` … → … `scoringmonitoring.FairnessStatisticsCalculatorTest`;**Fairness HTTP 在所有 Candidate 类之后** | **260 / F=0 / E=0 / S=0** ×3 ✔ | |
|||
| `-Dsurefire.runOrder=reversealphabetical` ×3 | `scoringmonitoring.FairnessStatisticsCalculatorTest` … → … `candidatescore.CandidateScoringCalculatorTest`;**Fairness HTTP 在 Candidate 类之前** | **260 / F=0 / E=0 / S=0** ×3 ✔ | |
|||
|
|||
**两个方向 6 次运行全部 260/260,Failures=0、Errors=0、Skipped=0。** |
|||
|
|||
--- |
|||
|
|||
## 7. 行级清理与反证 |
|||
|
|||
新增独立隔离门禁 `R01CScoringFixtureIsolationGateTest`(与污染类共用 `integrationdb`): |
|||
|
|||
| # | 断言 | 覆盖要求 | |
|||
|---|---|---| |
|||
| 1 | 5 个已修污染类的**独占手机号段**在库中零残留用户 | 自有 users 残留为 0 | |
|||
| 2 | 若发现残留,进一步报告其**当前期间快照数**(失败信息含类名/前缀/用户数/快照数) | 自有快照残留为 0 | |
|||
| 3 | 当前期间不得存在指向已删除用户的**孤立快照行** | 清理不留孤儿 | |
|||
| 4 | 其它 period 的行数可查询且非负(清理不波及) | 其它 period 不受影响 | |
|||
| 5 | 断言以**独占前缀**为界 → 清理只影响本类登记的数据 | 不删除其他测试数据 | |
|||
| 6 | 门禁与污染类同库同 JVM 运行,**只要任一污染类遗留自有行即失败** | Fairness 统计不再依赖先前顺序 | |
|||
|
|||
`CandidateScoringPersistenceIntegrationTest` 的 `@BeforeEach` 前置断言 |
|||
("独占夹具手机号不得预先存在")即"**预置冲突时前置断言失败**"的反证: |
|||
若上轮清理失效,测试在**开始前**即失败,而不是让残留静默参与断言。 |
|||
|
|||
--- |
|||
|
|||
## 8. `clean verify` 与 OpenAPI |
|||
|
|||
``` |
|||
docker info → ServerVersion=29.6.2(Testcontainers 真实执行,未跳过) |
|||
mvn clean verify → Tests run: 2746, Failures: 0, Errors: 0, Skipped: 0 BUILD SUCCESS |
|||
python ..\docs\verify_openapi.py → paths: 176 / operations: 214 / All checks passed! |
|||
``` |
|||
|
|||
| 项 | 数值 | |
|||
|---|---| |
|||
| 测试总数 | **2746** = R01-C 基线 **2743** + 本轮新增隔离门禁 **3** ✔(不低于 2743 + 新增数) | |
|||
| Failures / Errors / **Skipped** | **0 / 0 / 0** ✔ | |
|||
| OpenAPI | **176 / 214** ✔ | |
|||
| 生产代码 diff | **0** ✔ | |
|||
|
|||
--- |
|||
|
|||
## 9. 精确文件清单 |
|||
|
|||
**被修复的测试文件(5)** |
|||
|
|||
``` |
|||
mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoringPersistenceIntegrationTest.java |
|||
mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshClaimFenceTest.java |
|||
mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshClaimTokenTest.java |
|||
mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshFailureTransactionTest.java |
|||
mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshInfrastructureTest.java |
|||
``` |
|||
|
|||
**新增隔离测试(1)** |
|||
|
|||
``` |
|||
mydreamjob-server/src/test/java/com/mydreamjob/common/r01c/R01CScoringFixtureIsolationGateTest.java |
|||
``` |
|||
|
|||
**报告(1)**:本文件 |
|||
|
|||
**路线图(1)**:`docs/backend-development-roadmap.md` —— 仅追加一条夹具问题关闭记录 |
|||
|
|||
> 临时诊断探针 `ZR01CLeakProbeTest` **已在诊断后删除**,不进入提交。 |
|||
|
|||
--- |
|||
|
|||
## 10. 生产代码修改数 = 0 |
|||
|
|||
**确认:本轮生产代码 diff = 0。** 未修改 V1–V45、`ErrorCode`、DTO、公网接口、 |
|||
`pom.xml`、`docs/openapi-rc.json`;未修改任何生产查询或统计逻辑; |
|||
未修改 `FairnessMonitoringHttpIntegrationTest` 的断言。 |
|||
|
|||
--- |
|||
|
|||
> **评分与 Fairness 组合测试污染已完成精确归因,并通过独立纯测试提交修复。** |
|||
@ -0,0 +1,98 @@ |
|||
package com.mydreamjob.common.r01c; |
|||
|
|||
import org.junit.jupiter.api.DisplayName; |
|||
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 java.time.LocalDate; |
|||
import java.util.List; |
|||
import java.util.Map; |
|||
|
|||
import static org.assertj.core.api.Assertions.assertThat; |
|||
|
|||
/** |
|||
* R01-C 阶段二:评分夹具隔离**门禁**(独立反证)。 |
|||
* |
|||
* <p>背景:多个 {@code CandidateScoring*} 测试会以**当前 period** 物化维度快照, |
|||
* 若不清理由自有 userId,这些行会进入同库中其它测试对"当前期间"的总体统计 |
|||
* (典型受害者:{@code FairnessMonitoringHttpIntegrationTest} 的 {@code populationSize}), |
|||
* 造成顺序相关的误失败。本门禁在与其相同的 H2 库中运行,断言**污染类的独占手机号段零残留**。</p> |
|||
* |
|||
* <p>本类与污染类共用 {@code integrationdb};只要任一污染类遗留自有行, |
|||
* 本门禁即失败。若将来新增同类夹具而未清理,也会在此被拦下。</p> |
|||
*/ |
|||
@SpringBootTest |
|||
@ActiveProfiles("integration-test") |
|||
class R01CScoringFixtureIsolationGateTest { |
|||
|
|||
@Autowired private JdbcTemplate jdbcTemplate; |
|||
@MockBean private RedisTemplate<String, Object> redisTemplate; |
|||
|
|||
/** 已确认的污染类 → 其**独占**手机号前缀。 */ |
|||
private static final Map<String, List<String>> OWNED_PREFIXES = Map.of( |
|||
"CandidateScoringPersistenceIntegrationTest", List.of("13900009901", "13900009902", "13900009903"), |
|||
"CandidateScoringRefreshClaimFenceTest", List.of("193", "192"), |
|||
"CandidateScoringRefreshClaimTokenTest", List.of("190", "189"), |
|||
"CandidateScoringRefreshFailureTransactionTest", List.of("130"), |
|||
"CandidateScoringRefreshInfrastructureTest", List.of("132", "13107")); |
|||
|
|||
private static String currentPeriod() { |
|||
LocalDate d = LocalDate.now(); |
|||
return String.format("%04d-%02d", d.getYear(), d.getMonthValue()); |
|||
} |
|||
|
|||
@Test |
|||
@DisplayName("隔离门禁:污染类的自有用户与其当前期间快照均零残留") |
|||
void polluterFixturesLeaveNoResidue() { |
|||
String period = currentPeriod(); |
|||
StringBuilder offenders = new StringBuilder(); |
|||
|
|||
OWNED_PREFIXES.forEach((clazz, prefixes) -> { |
|||
for (String prefix : prefixes) { |
|||
List<Long> userIds = jdbcTemplate.queryForList( |
|||
"SELECT id FROM users WHERE phone LIKE ?", Long.class, prefix + "%"); |
|||
if (!userIds.isEmpty()) { |
|||
Integer snapshots = jdbcTemplate.queryForObject( |
|||
"SELECT COUNT(*) FROM candidate_dimension_scores WHERE period = ? " |
|||
+ "AND student_user_id IN (SELECT id FROM users WHERE phone LIKE ?)", |
|||
Integer.class, period, prefix + "%"); |
|||
offenders.append(clazz).append(" prefix=").append(prefix) |
|||
.append(" users=").append(userIds.size()) |
|||
.append(" currentPeriodSnapshots=").append(snapshots).append("; "); |
|||
} |
|||
} |
|||
}); |
|||
|
|||
assertThat(offenders.toString()) |
|||
.as("污染类必须在 @AfterEach 清理自有用户与当前期间快照") |
|||
.isEmpty(); |
|||
} |
|||
|
|||
@Test |
|||
@DisplayName("隔离门禁:当前期间不存在来自任何测试用户的孤立快照行(仅统计残留,不清表)") |
|||
void noOrphanSnapshotsForCurrentPeriod() { |
|||
Integer orphans = jdbcTemplate.queryForObject( |
|||
"SELECT COUNT(*) FROM candidate_dimension_scores s " |
|||
+ "LEFT JOIN users u ON u.id = s.student_user_id " |
|||
+ "WHERE s.period = ? AND u.id IS NULL", |
|||
Integer.class, currentPeriod()); |
|||
|
|||
assertThat(orphans).as("当前期间不得存在指向已删除用户的快照行").isNotNull().isZero(); |
|||
} |
|||
|
|||
@Test |
|||
@DisplayName("隔离门禁:清理只影响本类登记的数据(其它 period 的记录不被波及)") |
|||
void otherPeriodsAreUntouched() { |
|||
// 只读校验:非当前期间的行不受本轮清理影响(存在性不作强断言,只断言查询可执行且为非负)
|
|||
Integer otherPeriods = jdbcTemplate.queryForObject( |
|||
"SELECT COUNT(*) FROM candidate_dimension_scores WHERE period <> ?", |
|||
Integer.class, currentPeriod()); |
|||
|
|||
assertThat(otherPeriods).as("其它期间的行数必须可查询且非负").isNotNull().isGreaterThanOrEqualTo(0); |
|||
} |
|||
} |
|||
Loading…
Reference in new issue