Browse Source

refactor: centralize scoring and recommendation rules

master
Zhang Shun 1 month ago
parent
commit
7d4704ee28
  1. 2
      docs/backend-development-roadmap.md
  2. 347
      docs/r01-c-independent-verification-20260918.md
  3. 437
      docs/r01-c-scoring-recommendation-rules-refactor-report.md
  4. 68
      mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/CandidateScoringCalculator.java
  5. 137
      mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/rules/CandidateScoringRuleSet.java
  6. 29
      mydreamjob-server/src/main/java/com/mydreamjob/talentdiscovery/TalentDiscoveryService.java
  7. 159
      mydreamjob-server/src/test/java/com/mydreamjob/common/r01c/CandidateScoringRuleSetCharacterizationTest.java
  8. 141
      mydreamjob-server/src/test/java/com/mydreamjob/common/r01c/OverviewStatusPolicyCharacterizationTest.java
  9. 211
      mydreamjob-server/src/test/java/com/mydreamjob/common/r01c/PreferenceScoreCharacterizationTest.java
  10. 407
      mydreamjob-server/src/test/java/com/mydreamjob/common/r01c/R01CIndependentVerificationTest.java
  11. 107
      mydreamjob-server/src/test/java/com/mydreamjob/common/r01c/RecommendationOrderingCharacterizationTest.java

2
docs/backend-development-roadmap.md

@ -15,7 +15,7 @@
| S02 Access/Refresh Token 隔离 | 已实现并完成自动化验证与干净构建,待独立代码复验及真实 MySQL/Docker 黑盒验收 | 类型隔离、独立密钥、轮换、重放处置、会话吊销已锁定;OpenAPI 175/213 | | S02 Access/Refresh Token 隔离 | 已实现并完成自动化验证与干净构建,待独立代码复验及真实 MySQL/Docker 黑盒验收 | 类型隔离、独立密钥、轮换、重放处置、会话吊销已锁定;OpenAPI 175/213 |
| S03 生产配置与简历隐私 | 已实现并完成自动化验证与干净构建,待独立代码复验及真实 MySQL/Docker 黑盒验收 | V43 禁用原始指纹种子账号;管理员初始化默认关闭且幂等;企业简历下载按隐私决策表授权;Actuator 隔离到管理端口;OpenAPI 176/214 | | S03 生产配置与简历隐私 | 已实现并完成自动化验证与干净构建,待独立代码复验及真实 MySQL/Docker 黑盒验收 | V43 禁用原始指纹种子账号;管理员初始化默认关闭且幂等;企业简历下载按隐私决策表授权;Actuator 隔离到管理端口;OpenAPI 176/214 |
| Q01 岗位搜索真实查询覆盖(C02 证据补充) | 已完成实现、跨库一致性整改、独立复验及 Git 检查点(`77dc624`)。新增 54 条真实库测试:H2 Repository 21、H2 Service 9、H2 副作用 4、MySQL 8.4 Specification 15、MySQL 副作用 4、MySQL EXPLAIN 1;全量 1890/1890 | OpenAPI 保持 176/214;前导通配符、filesort 与真实规模执行计划移交 R01 | | Q01 岗位搜索真实查询覆盖(C02 证据补充) | 已完成实现、跨库一致性整改、独立复验及 Git 检查点(`77dc624`)。新增 54 条真实库测试:H2 Repository 21、H2 Service 9、H2 副作用 4、MySQL 8.4 Specification 15、MySQL 副作用 4、MySQL EXPLAIN 1;全量 1890/1890 | OpenAPI 保持 176/214;前导通配符、filesort 与真实规模执行计划移交 R01 |
| R01 结构与性能收束 | R01-A `0b5b849`、R01-B `15bd98b`、R01-C `b68519e`、R01-D1 `cf0c71e`、R01-D2 `8c40fd4` 已建本地检查点;**R01-D3 已完成**会话列表未读数 N+1 收束;**R01-D4 已冻结并推送**(提交 `52c8026`,远端 `origin/refactor/r01-structure-performance`);**R01-A2 请求身份与 requestId 收束(第二批 8 个 Controller)已完成独立复验、提交并推送**(提交 `72b82a3`);**R01-A3(第三批 8 个 Controller)已完成独立复验、提交并推送**(提交 `d5c3712`);**账号注销测试夹具隔离修复已提交并推送**(提交 `35baa84`);**R01-A4(第四批 8 个 Controller)已完成实施、独立复验、提交并推送**(提交 `0226977`);**R01-A5(第五批 8 个 Controller)已完成独立复验、提交并推送**(提交 `534afe68`);**R01-A6(最终批 5 个 Controller)已完成实施与独立复验**(实施报告 `docs/r01-a6-final-request-context-migration-report.md`;独立复验报告 `docs/r01-a6-independent-verification-20260917.md`)。**请求上下文收束 A 系列到此结束,不再拆分 A7/A8**;下一阶段为 R01-B。历史 R01-D4 迁移方案见 `docs/r01-d4-get-scoring-snapshot-migration-design.md` | R01-D4:三 GET 评分接口已停写快照,改为写侧登记 + 后台 Worker + 补偿扫描物化,V44/V45 已并入真实 MySQL;claim fence 采用不可复用 claim token(V45)。**R01-A2 至 A6 共迁移 37 个 Controller**(A2–A5 各 8 个 + A6 5 个),身份与 requestId 统一到 CurrentUserAccessor / RequestIdAccessor,非法主体由「400/500」统一为 401;角色、归属与 403/404 契约未变。A2–A5 各删除 **16 个**私有解析方法,A6 删除 **6 个**。**请求上下文收束终态:业务 Controller 身份解析 0、requestId 重复解析 0**;剩余命中仅为基础设施(身份 3 文件:CurrentUserAccessor / JwtAuthenticationFilter / DeletionGuardInterceptor;requestId 5 文件:AuditContext / GlobalExceptionHandler / RequestIdAccessor / RequestLoggingFilter / DeletionGuardInterceptor),**A7/A8 不再设置**。**A6 后夹具问题已关闭**:`FairnessMonitoringControllerTest` 曾用 `userId = 1/2` 与 V1_1 种子账号(被 V43 置为 inactive)冲突而在单跑时全部 401,已改为独占用户(`9_310_001/2`)并按精确 ID 清理,单跑稳定通过;新增反证测试 `FairnessMonitoringFixtureIsolationTest`(**已提交并推送,提交 `84fe9f5`**;报告 `docs/r01-a6-fairness-fixture-isolation-fix-20260917.md`)。**R01-B 角色授权与异常映射收束已实施完成,待独立代码复验**(报告 `docs/r01-b-authorization-exception-mapping-report.md`):新增 `security/UserRoleGuard` 与 `common/exception/ErrorHttpStatusRegistry`,52 个调用点改走公共 guard,GlobalExceptionHandler 不再持有映射表;下一阶段为 R01-C | | R01 结构与性能收束 | R01-A `0b5b849`、R01-B `15bd98b`、R01-C `b68519e`、R01-D1 `cf0c71e`、R01-D2 `8c40fd4` 已建本地检查点;**R01-D3 已完成**会话列表未读数 N+1 收束;**R01-D4 已冻结并推送**(提交 `52c8026`,远端 `origin/refactor/r01-structure-performance`);**R01-A2 请求身份与 requestId 收束(第二批 8 个 Controller)已完成独立复验、提交并推送**(提交 `72b82a3`);**R01-A3(第三批 8 个 Controller)已完成独立复验、提交并推送**(提交 `d5c3712`);**账号注销测试夹具隔离修复已提交并推送**(提交 `35baa84`);**R01-A4(第四批 8 个 Controller)已完成实施、独立复验、提交并推送**(提交 `0226977`);**R01-A5(第五批 8 个 Controller)已完成独立复验、提交并推送**(提交 `534afe68`);**R01-A6(最终批 5 个 Controller)已完成实施与独立复验**(实施报告 `docs/r01-a6-final-request-context-migration-report.md`;独立复验报告 `docs/r01-a6-independent-verification-20260917.md`)。**请求上下文收束 A 系列到此结束,不再拆分 A7/A8**;下一阶段为 R01-B。历史 R01-D4 迁移方案见 `docs/r01-d4-get-scoring-snapshot-migration-design.md` | R01-D4:三 GET 评分接口已停写快照,改为写侧登记 + 后台 Worker + 补偿扫描物化,V44/V45 已并入真实 MySQL;claim fence 采用不可复用 claim token(V45)。**R01-A2 至 A6 共迁移 37 个 Controller**(A2–A5 各 8 个 + A6 5 个),身份与 requestId 统一到 CurrentUserAccessor / RequestIdAccessor,非法主体由「400/500」统一为 401;角色、归属与 403/404 契约未变。A2–A5 各删除 **16 个**私有解析方法,A6 删除 **6 个**。**请求上下文收束终态:业务 Controller 身份解析 0、requestId 重复解析 0**;剩余命中仅为基础设施(身份 3 文件:CurrentUserAccessor / JwtAuthenticationFilter / DeletionGuardInterceptor;requestId 5 文件:AuditContext / GlobalExceptionHandler / RequestIdAccessor / RequestLoggingFilter / DeletionGuardInterceptor),**A7/A8 不再设置**。**A6 后夹具问题已关闭**:`FairnessMonitoringControllerTest` 曾用 `userId = 1/2` 与 V1_1 种子账号(被 V43 置为 inactive)冲突而在单跑时全部 401,已改为独占用户(`9_310_001/2`)并按精确 ID 清理,单跑稳定通过;新增反证测试 `FairnessMonitoringFixtureIsolationTest`(**已提交并推送,提交 `84fe9f5`**;报告 `docs/r01-a6-fairness-fixture-isolation-fix-20260917.md`)。**R01-B 角色授权与异常映射收束已完成独立复验、提交并推送**(提交 `70b9e10`;两个 Controller 测试夹具缺陷已由独立纯测试提交 `9954600` 修复,报告 `docs/r01-b-authorization-exception-mapping-report.md`、`docs/r01-b-independent-verification-20260918.md`):新增 `security/UserRoleGuard` 与 `common/exception/ErrorHttpStatusRegistry`,54 个调用点改走公共 guard,GlobalExceptionHandler 不再持有映射表。**R01-C 评分与推荐规则结构收束已实施完成,待独立代码复验**(报告 `docs/r01-c-scoring-recommendation-rules-refactor-report.md`):overviewStatus 经核验已是唯一实现(由 `b68519e` 完成);`JobRecommendationService.preferenceScore()` 已是编排方法;本轮新增 `candidatescore/rules/CandidateScoringRuleSet`(版本化强类型规则源,高校名单/阈值/权重),并把 `TalentDiscoveryService` 内联的第二套 30/20/20/15/15 偏好规则收束到 `JobPreferenceScoreCalculator`(规则实现处 2 → 1);下一阶段为 R01-E |
| 其余五个功能组 | 技能词云、入职清单/公司知识、薪资计算器/参考、诚信积分/激励、简历查重 | N03–N06、N08仍属一期,细节设计后编码 | | 其余五个功能组 | 技能词云、入职清单/公司知识、薪资计算器/参考、诚信积分/激励、简历查重 | N03–N06、N08仍属一期,细节设计后编码 |
| 上线保障 | 现有资产待逐项核验 | G01–G04,一期交付门槛 | | 上线保障 | 现有资产待逐项核验 | G01–G04,一期交付门槛 |
| 后续候选池 | AI简历、AI模拟面试/预测、SHAP高级解释等 | 以清单第6节为准,移出一期 | | 后续候选池 | AI简历、AI模拟面试/预测、SHAP高级解释等 | 以清单第6节为准,移出一期 |

347
docs/r01-c-independent-verification-20260918.md

@ -0,0 +1,347 @@
# R01-C 独立代码复验报告(评分与推荐规则结构收束)
日期:2026-09-18
分支:`refactor/r01-structure-performance`
HEAD:`9954600650c3e7cd40a5f4fa55bc38595973f214`(复验全程未变)
复验对象:`docs/r01-c-scoring-recommendation-rules-refactor-report.md`
性质:**仅复验** —— 未修改生产代码、未放宽既有断言、未暂存、未提交、未推送、未进入 R01-E
---
## 0. 结论
> **R01-C独立代码复验通过,可以进入精确暂存与独立提交检查点。**
- `overviewStatus` **确为唯一产出点**;三处调用方全部委托;
- 偏好公式实现点 **2 → 1**(HEAD 的 `TalentDiscoveryService` 确实内联了第二套等价公式);
- 985/211 名单定义点 **唯一**;规则集**不可变、无框架依赖、无 setter、集合只读**;
- 生产变更 **新增 1 + 修改 2 = 3 个生产文件**;
- 三类变异均使对应测试失败,恢复后**无残留**;
- Docker 已按给定路径启动,**Testcontainers 26 项真实执行、Skipped=0**;
- 一处**既有测试隔离脆弱性**被独立发现并定性(见 §10.3),**与 R01-C 无关**。
---
## 1. 独立库存结果
不采信实施报告数字,独立搜索证据:
| # | 核查项 | 独立结论 | 证据 |
|---|---|---|---|
| 1 | `overviewStatus` 是否只有 `CandidateOverviewStatusPolicy` 一个规则产出点 | **是** | 全 `src` 中 `"PARTIAL"` 仅在该类**主代码**出现一次(`CandidateOverviewStatusPolicy.java:43`);其余命中全部在测试的断言/测试数据中,非产出点 |
| 2 | 三个调用方是否全部委托该策略 | **是** | `CandidateScoringEngineService:123`、`:161`(`fromComputedResults`);`CompetitionRankingCalculator:103`(`fromSnapshots`)—— 均为直接委托调用 |
| 3 | `JobRecommendationService` 是否已经只是编排 | **是** | `preferenceScore` 方法体 13 行:构造 `Criteria`/`Target` → `JobPreferenceScoreCalculator.calculate(...)`,无任何内联权重 |
| 4 | `TalentDiscoveryService` 修复前是否确实维护了第二套等价偏好公式 | **是(已由 HEAD 证实)** | `git show HEAD:…TalentDiscoveryService.java` 的 `preferenceScore` 为内联 `total += 30/20/20/15/15` + `BigDecimal.valueOf(got*100L).divide(...,2,HALF_UP)` —— 与计算器**逐字等价** |
| 5 | 本轮是否只新增一个规则集并修改两个文件 | **是** | 新增 `CandidateScoringRuleSet.java`;修改 `CandidateScoringCalculator.java`、`TalentDiscoveryService.java` |
| 6 | **生产代码变更总数** | **新增 1 + 修改 2 = 3 个生产文件** | 与 `git diff --name-status` + 未跟踪文件清单一致 |
---
## 2. overviewStatus 唯一产出点证据
```
main/java/com/mydreamjob/candidatescore/CandidateOverviewStatusPolicy.java:43: return "PARTIAL";
```
`"PARTIAL"` 在**主代码**中的唯一出现点即为该策略类(第 43 行)。
状态聚合器**必须**能够产出 `PARTIAL`,因此该字符串的出现位置即为产出点的充要证据。
调用方 3 处(`CandidateScoringEngineService` ×2、`CompetitionRankingCalculator` ×1)全部为委托。
**结构门禁已加入独立复验测试**:扫描 `src/main/java/com/mydreamjob` 全部 `.java`,
断言含 `"PARTIAL"` 的文件**恰为** `CandidateOverviewStatusPolicy.java`。
---
## 3. 偏好公式修复前后实现点数量
| 时点 | 30/20/20/15/15 规则实现处 | 证据 |
|---|---|---|
| 修复前(HEAD) | **2** —— `recommendation/JobPreferenceScoreCalculator.java` + `talentdiscovery/TalentDiscoveryService.java`(内联) | `git grep -l "total += 30\|CITY_WEIGHT" HEAD` → 2 个文件 |
| 修复后(工作区) | **1** —— 仅 `recommendation/JobPreferenceScoreCalculator.java` | 同 grep → 1 个文件 |
`preferenceScore` 方法定义仍为 2 处(两个 Service 各一),但**两处都只是编排**,均调用
`JobPreferenceScoreCalculator.calculate(...)`;规则**实现**唯一。
---
## 4. 985/211 名单定义点数量
| 时点 | 定义点 | 证据 |
|---|---|---|
| HEAD | **2**(`C985`、`C211`)**均在** `CandidateScoringCalculator.java` | `git grep "Set<String> C985\|Set<String> C211" HEAD` |
| 工作区 | **2**(`C985`、`C211`)**均在** `candidatescore/rules/CandidateScoringRuleSet.java` | `grep` 主代码 → 唯一文件 |
判定为"唯一权威定义点":名单**只在规范集内定义一次**,计算器内**已无**名单定义
(独立测试断言 `CandidateScoringCalculator.java` 不含 `Set<String> C985` / `C211`)。
| 项 | HEAD | 工作区 | 一致 |
|---|---|---|---|
| C985 唯一项 | 38 | **38** ✔ | |
| C211 字面量条数 | 62 | **62** ✔ | |
| C211 唯一项 | 61 | **61** ✔ | |
| `北京科技大学` 重复项 | 存在 | **原样保留** ✔ | |
---
## 5. 规则集不可变性和依赖边界
逐项核验 `CandidateScoringRuleSet.java`:
| 要求 | 结果 |
|---|---|
| `final` | ✔ `public final class CandidateScoringRuleSet`(私有构造) |
| 无 setter | ✔ 主代码 `grep "public void set"` 命中 0 |
| 无可变集合泄漏 | ✔ 集合经 `Collections.unmodifiableSet(new HashSet<>(...))` 包装后返回 |
| 返回集合不可修改 | ✔ 独立测试断言 `add`/`remove`/`clear` 均抛 `UnsupportedOperationException` |
| 无 Spring 注解 | ✔ `@Component`/`@Service`/`@Autowired`/`@Value` 命中 0 |
| 无数据库/Repository | ✔ `Repository`/`JdbcTemplate`/`EntityManager` 命中 0 |
| 无配置中心/环境变量 | ✔ `System.getenv`/`Properties` 命中 0 |
| 不接运行时可变配置 | ✔ 全部规则为静态常量,只能随代码发布变更 |
| BigDecimal 字符串构造 | ✔ 17 个常量全部 `new BigDecimal("…")`;`new BigDecimal(<数字>)` 命中 **0** |
| 同值不同义不合并 | ✔ `EDUCATION_LEVEL_MASTER(85)` 与 `SCHOOL_TIER_985(85)` 为不同常量;`EDUCATION_LEVEL_UNKNOWN(50)` 与 `SCHOOL_TIER_MISSING(50)` 同理 |
| 规则版本单一定义点 | ✔ `CandidateScoringRuleSet.VERSION` 为权威;`CandidateScoringCalculator.RULES_VERSION` 现**指向该常量**(赋值而非字面量) |
---
## 6. BigDecimal 精确结果与边界表(独立测试推导)
独立复验测试 `R01CIndependentVerificationTest`(18 个用例)全部通过,
期望值**手工推算**,断言 `isEqualByComparingTo` + `scale` + `toPlainString()`(不使用近似比较):
| 输入 | 期望 | 说明 |
|---|---|---|
| 五维全命中(可用权重 100) | **100.00** | 上界 |
| 五维全缺失 / 零分母 | **null** | 非 0.00 |
| 城市(30) 命中、其余缺失 | **100.00** | 单维独立 |
| 用工类型(20) 命中、其余缺失 | **100.00** | |
| 薪资(20) 命中(边界相等 20000 == 20000) | **100.00** | 相等视为命中 |
| 行业(15) 命中、其余缺失 | **100.00** | |
| 岗位族(15) 命中、其余缺失 | **100.00** | |
| 城市命中(30) + 行业未命中(15) → 30/45 | **66.67** | HALF_UP 舍入 |
| 城市未命中(30) + 行业命中(15) → 15/45 | **33.33** | |
| 四方存在命中两项 → 15/65 | **23.08** | |
| 薪资低一单位(19999 < 20000) | **0.00** | 下界 |
| 薪资高一单位(20001) | **100.00** | |
| 岗位 salaryMax 为 null | **0.00** | |
**跨服务逐值一致**:以反射调用两个 Service 的私有 `preferenceScore`,同一组输入下
`JobRecommendationService` 与 `TalentDiscoveryService` 的返回值
**`isEqualByComparingTo` 相等且 `scale` 相同**(三组输入:全命中 / 部分命中 / 全不命中)。
**overviewStatus 边界**(独立推导):全可用→`OK`;部分可用→`PARTIAL`;全不可用(全 `PENDING`)→`PENDING`;
含 `INSUFFICIENT_DATA`→`INSUFFICIENT_DATA`;空列表(计算结果入口)→`PENDING`、(快照入口)→`INSUFFICIENT_DATA`;
`null`(计算结果入口)→`NPE`;未知状态字符串→`INSUFFICIENT_DATA`;**输入顺序反转结果不变**。
---
## 7. 三类变异测试的失败证据
| # | 变异 | 文件 | 结果 |
|---|---|---|---|
| 1 | `if (computableCount > 0)` → `> 1`(overviewStatus 边界) | `CandidateOverviewStatusPolicy` | **18 run / 1 failure** ✔ |
| 2 | `INDUSTRY_WEIGHT = 15` → `10`(偏好权重) | `JobPreferenceScoreCalculator` | **18 run / 1 failure** ✔ |
| 3 | 985 名单删除 `"北京大学", `(被测试覆盖的学校) | `CandidateScoringRuleSet` | **18 run / 1 failure** ✔ |
**恢复核验**:
- 变异前的模式唯一性检查:三个替换串在目标文件中的出现次数均为 **1**;
- 恢复后 `git diff --quiet` 对两个**已跟踪**目标文件均为**逐字节一致**;
其中 `JobPreferenceScoreCalculator.java` 的 SHA-256 恢复为 `5771647b…`,
与**首次诊断时**测得的哈希完全相同;
- 未跟踪的 `CandidateScoringRuleSet.java` 经内容检查确认变异条目已还原
(`"北京大学", "清华大学", "中国人民大学"` 出现次数为 1);
- `git diff --name-status` 仅显示 3 个预期文件,**无变异残留**;`git diff --check` exit 0;
- **未使用 `git restore` / `git checkout`**。
> 透明说明:脚本首次报告的三次 `restored_ok=NO` 经逐层排查确认是**我方脚本的变量取值缺陷**,
> 而非文件损坏 —— 用同一 Python 读写惯用法对未变异文件做往返实验,SHA-256 保持不变;
> 再对目标文件执行"变异→还原"全程,A/B 哈希一致(`5771647b…`)。
> 上述三项内容级核验(git 逐字节一致 + 哈希比对 + 变异条目还原)均已独立完成。
---
## 8. Docker 启动路径与 `docker info` 结果
| 项 | 结果 |
|---|---|
| 复验开始时 `docker info` | **失败**(`failed to connect … npipe:////./pipe/dockerDesktopLinuxEngine`) |
| 指定的已知路径 | `D:\DevApps\DockerDesktop\Docker Desktop.exe` —— **存在**(15,142,832 字节,2025-07-23) |
| 启动命令 | `Start-Process -FilePath 'D:\DevApps\DockerDesktop\Docker Desktop.exe' -WindowStyle Hidden` |
| 轮询 | 每 15 秒一次,上限 180 秒 |
| **结果** | **约 15 秒后成功**:`docker info` → `ServerVersion=29.6.2` ✔ |
未重建、删除或清理任何容器、volume 或 image。
---
## 9. Testcontainers 实际执行数
```
mvn -Dtest="JobSearchSpecificationMySqlIntegrationTest,CandidateScoringRefreshMySqlIntegrationTest,JobSearchQuerySideEffectMySqlIntegrationTest,JobSearchQueryExplainMySqlIntegrationTest" test
→ Tests run: 26, Failures: 0, Errors: 0, Skipped: 0 BUILD SUCCESS
```
| 测试类 | Tests | Skipped |
|---|---|---|
| `JobSearchSpecificationMySqlIntegrationTest` | 15 | **0** |
| `CandidateScoringRefreshMySqlIntegrationTest` | 6 | **0** |
| `JobSearchQuerySideEffectMySqlIntegrationTest` | 4 | **0** |
| `JobSearchQueryExplainMySqlIntegrationTest` | 1 | **0** |
| **合计** | **26** | **0** ✔ |
这 26 项正是此前因 Docker 不可用而跳过的全部用例,现已**真实执行并通过**。
---
## 10. 定向、全量、clean verify 的 Tests/Failures/Errors/Skipped
| 命令 | Tests | Failures | Errors | Skipped | 结论 |
|---|---|---|---|---|---|
| 独立复验测试 `R01CIndependentVerificationTest` | **18** | 0 | 0 | 0 | ✔ |
| 定向 `*CandidateScoring*Test,*OverviewStatus*Test,*JobPreference*Test,*JobRecommendation*Test,*TalentDiscovery*Test,*Fairness*Test` | 296 | **2** | 0 | 0 | 见 §10.3(既有隔离脆弱性) |
| `mvn test` | 见 §10.1 | | | | |
| `mvn clean verify` | 见 §10.1 | | | | |
### 10.1 全量与 clean verify
```
mvn test → Tests run: 2743, Failures: 0, Errors: 0, Skipped: 0 BUILD SUCCESS
mvn clean verify → Tests run: 2743, Failures: 0, Errors: 0, Skipped: 0 BUILD SUCCESS
```
| 项 | 数值 |
|---|---|
| 测试总数 | **2743** |
| 下限校验 | 实施基线 **2725** + 本轮独立新增 **18** = **2743** ✔ **恰好等于下限** |
| Failures / Errors / **Skipped** | **0 / 0 / 0** ✔ |
| BUILD | **SUCCESS** ✔ |
**Skipped=0 的达成路径**:实施阶段因 Docker 不可用而跳过的 26 个 Testcontainers 用例,
在本轮 Docker 启动后**全部真实执行**(见 §9),因此不再计入 Skipped;
`2743 = 2725(实施阶段总数,含 26 项跳过)+ 18(本轮独立新增)= 2699 执行 + 26 执行 + 18`。
**该结果同时反证 §10.3 的定性**:`FairnessMonitoringHttpIntegrationTest` 在**全量顺序下通过**
(`mvn test` 与 `mvn clean verify` 均为 0 失败),说明其失败只在我方**自选的定向类组合**下触发,
是组合相关的既有隔离脆弱性,而非 R01-C 的行为差异。
### 10.2 Testcontainers 已执行
见 §9(26 项,Skipped=0)。
### 10.3 定向命令中的 2 个失败:既有测试隔离脆弱性(与 R01-C 无关)
**现象**:定向命令中 `FairnessMonitoringHttpIntegrationTest` 有 2 项失败:
```
$.data.populationSize expected:<100> but was:<115>
$.data.populationSize expected:<200> but was:<215>
```
**定性证据链**:
1. **单独运行该类:4/4 全部通过**(`mvn -Dtest="FairnessMonitoringHttpIntegrationTest" test` → `Tests run: 4, Failures: 0`);
2. 失败仅在**与其他类同批**时出现,且两次都恰好多 **15** 行 → 典型的共享 H2 库数据泄漏;
3. **泄漏源已定位**:`candidatescore/dashboard/CompanyScoringDashboardPersistenceIntegrationTest`
以 `LocalDate.now()` 生成 **当前期间**(`period = String.format("%04d-%02d", …)`),
并向 `candidate_dimension_scores` 插入该期间的快照;
`FairnessMonitoringHttpIntegrationTest` 同样使用 `YearMonth.now()`,且其 `populationSize`
**未按自身前缀限定范围**,因此把同批类的行一并计入;
4. **该泄漏源不在 R01-C 改动范围内**(`git diff --name-only` 中无该文件);
5. R01-C 的生产改动仅为"教育规则常量改读规则集"与"偏好分改委托共用计算器",
**不涉及任何持久化、清理或计数逻辑**。
**判定**:属**既有测试隔离脆弱性**(与 A6/R01-B 定位的同类问题同源),**非 R01-C 行为差异**,
不构成 R01-C 阻断项。按任务要求**如实记录、不写成通过**,也不修改该类测试。
**明确结论**:
1. **R01-C 单独测试通过** —— `FairnessMonitoringHttpIntegrationTest` 单独运行 4/4 全绿;
全量 `mvn test` / `mvn clean verify`(2743/2743)中该类亦通过;
2. **泄漏源(`CompanyScoringDashboardPersistenceIntegrationTest`)与受害测试
(`FairnessMonitoringHttpIntegrationTest`)均不在 R01-C 生产改动内**(3 个生产文件中无它们);
3. **该问题将由下一独立纯测试提交修复**(修污染源:独占 period/studentId + 精确清理),
**不并入 R01-C 提交**;
4. **该次定向组合命令(296/2 失败)不得写成全绿** —— 上表如实保留 `Failures=2`。
---
## 11. OpenAPI 数量
```
python ..\docs\verify_openapi.py
→ paths: 176 / operations: 214
→ All checks passed!
```
`git diff -- docs/openapi-rc.json` → **空**,`openapi-rc.json` **未被修改** ✔
---
## 12. 精确文件清单
**本轮 R01-C 生产改动(新增 1 + 修改 2 = 3 个生产文件)**
```
新增: mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/rules/CandidateScoringRuleSet.java
修改: mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/CandidateScoringCalculator.java
修改: mydreamjob-server/src/main/java/com/mydreamjob/talentdiscovery/TalentDiscoveryService.java
```
**实施者新增测试(4)**:`mydreamjob-server/src/test/java/com/mydreamjob/common/r01c/` 下
`OverviewStatusPolicyCharacterizationTest`、`PreferenceScoreCharacterizationTest`、
`CandidateScoringRuleSetCharacterizationTest`、`RecommendationOrderingCharacterizationTest`
**本轮独立复验新增(2)**
```
mydreamjob-server/src/test/java/com/mydreamjob/common/r01c/R01CIndependentVerificationTest.java
docs/r01-c-independent-verification-20260918.md
```
**文档(1 修改)**:`docs/backend-development-roadmap.md`
**本轮未修改任何生产代码** —— 复验前后 `git diff --name-only -- …/src/main` 恒为上述 2 个文件。
---
## 13. 是否修改迁移、ErrorCode、接口、依赖
| 项 | 结果 |
|---|---|
| V1–V45 / Flyway | **未修改**(`git diff --name-status -- …/db/migration` 为空) |
| `ErrorCode` | **未修改** |
| DTO | **未修改** |
| 公网接口 / OpenAPI 契约 | **未修改**(176/214,`openapi-rc.json` 未变) |
| `pom.xml` / 依赖 | **未修改** |
| Security / Token / 角色 / requestId | **未修改** |
| D4 快照物化语义 | **未修改** |
| 排名、申诉、公平性抑制规则 | **未修改** |
**未为了测试通过修改生产公式或放宽既有断言**(独立测试的期望值均独立推导;
实施者的 40 个特征测试断言未被改动)。
---
## 14. Git 状态
| 项 | 结果 |
|---|---|
| 分支 | `refactor/r01-structure-performance` ✔ |
| HEAD | `9954600650c3e7cd40a5f4fa55bc38595973f214`(未变)✔ |
| `git diff --cached --name-only` | **空** ✔ |
| `git diff --check` | **exit 0** ✔ |
| `git diff --name-status` | 仅 `docs/backend-development-roadmap.md`、`CandidateScoringCalculator.java`、`TalentDiscoveryService.java`(**无变异残留**)✔ |
| HEAD 与 upstream | `0 0` ✔ |
| 历史未跟踪文件 | 完整保留 ✔ |
| 残留 Java/Maven 进程 | 无 ✔ |
---
## 15. 未暂存、未提交、未推送、未进入 R01-E
**确认:** 未执行 `git add`;未创建提交;未推送;未执行 `reset`/`restore`/`checkout`/`clean`;
未进入 R01-E;未继续开发。
---
> **R01-C独立代码复验通过,可以进入精确暂存与独立提交检查点。**

437
docs/r01-c-scoring-recommendation-rules-refactor-report.md

@ -0,0 +1,437 @@
# R01-C 评分与推荐规则结构收束报告
日期:2026-09-18
分支:`refactor/r01-structure-performance`
HEAD:`9954600650c3e7cd40a5f4fa55bc38595973f214`(R01-B 提交 `70b9e10` + 夹具修复 `9954600`)
本轮性质:**结构重构** —— 未暂存、未提交、未推送,待独立代码复验
铁律:**所有输入对应的评分、状态、排序、推荐结果、解释内容、规则版本与舍入结果必须与重构前逐项一致**
---
## 0. 结论与一处必须先说明的口径差异
> **R01-C评分与推荐规则结构收束完成,待独立代码复验。**
**权威库存(§2)推翻了指令的两项前提,已按你的裁决实施:**
| 指令主题 | 库存实际 | 本轮处置 |
|---|---|---|
| 1. 消除**重复的 overviewStatus** 规则 | **不存在重复**:全仓 `"PARTIAL"` 只出现在 `CandidateOverviewStatusPolicy`(状态聚合器必须能产出 PARTIAL),3 个调用点已全部委托它。该策略由既有提交 `b68519e` 创建 | **无可合并项,不做任何改动**;以结构门禁测试锁定"唯一产出点" |
| 2. 拆分 `JobRecommendationService.preferenceScore()` | 该目标方法**已是 13 行编排方法**,重逻辑已在 `JobPreferenceScoreCalculator`(同为 `b68519e` 产物)。**但** `TalentDiscoveryService.preferenceScore` 内联了**第二套**完全相同的 30/20/20/15/15 规则 | 按裁决**收束该真实重复**:改委托共用计算器,**规则实现处 2 → 1** |
| 3. 高校名单/阈值/权重强类型化 | **真实待办**:`CandidateScoringCalculator` 内为裸常量,无规则集类 | **已完成**:新增 `candidatescore/rules/CandidateScoringRuleSet` |
**未为了"grep 归零"制造改动**;主题 1 的"已唯一"结论附结构门禁测试可证。
---
## 1. 分支、HEAD、Git 状态
| 项 | 结果 |
|---|---|
| `git branch --show-current` | `refactor/r01-structure-performance` |
| `git rev-parse HEAD` | `9954600650c3e7cd40a5f4fa55bc38595973f214` ✔ |
| `git rev-list --left-right --count HEAD...@{upstream}` | `0 0` ✔ |
| `git diff --cached --name-only` | **空** ✔ |
| `git diff --check` | exit 0,无空白错误 ✔ |
| 开始前未提交的已跟踪修改 | **无**(工作区干净)✔ |
| 历史未跟踪文件 | 完整保留 ✔ |
---
## 2. 上游: 三份权威库存
### 2.1 overviewStatus 库存
| 项 | 事实 |
|---|---|
| 实现处 | **唯一**:`candidatescore/CandidateOverviewStatusPolicy`(`final`、纯静态、无 Spring 依赖、无 DB、无系统时间、无副作用) |
| 入口 | `fromComputedResults(List<DimensionResult>, int expectedDimensions)`、`fromSnapshots(List<CandidateDimensionScore>)` |
| 调用方 | `CandidateScoringEngineService`(2 处)、`CompetitionRankingCalculator`(1 处)—— 全部为委托 |
| 判断顺序 | ① 空/null → `emptyStatus`(计算结果入口为 `PENDING`,快照入口为 `INSUFFICIENT_DATA`);② 可计算数 == 期望数 → `OK`;③ 可计算数 > 0 → `PARTIAL`;④ 全为 `PENDING` → `PENDING`;⑤ 否则 `INSUFFICIENT_DATA` |
| 可计算定义 | `valueAvailable && ("OK".equals(status) || "ESTIMATED".equals(status))` |
| 阈值 | 无数值阈值(纯计数比较,边界为"相等"与"大于 0") |
| null 行为 | 计算结果入口:`results == null` → **NPE**(`Objects.requireNonNull`);快照入口:`null` → `INSUFFICIENT_DATA` |
| 边界相等 | `computableCount == completeCount` 判 OK(相等即 OK,不是严格大于) |
| 返回值 | 字符串状态(沿用既有字符串契约,**未新建语义重复的枚举**) |
| 是否参与响应/快照/解释/排名/监控 | 参与:候选评分概览响应、竞争力排名卡状态;不参与快照版本推进 |
| 两份实现是否等价 | **不适用 —— 只有一份实现** |
### 2.2 preferenceScore 库存(重构前,两套)
| 项 | `JobPreferenceScoreCalculator`(共用) | `TalentDiscoveryService`(内联,已收束) |
|---|---|---|
| 输入 | `Criteria(cities, employmentTypes, expectedSalaryMin, industries, jobFamilies)` + `Target(city, employmentType, salaryMax, industryCode, jobFamilyCode)` | `PreferenceSignal(cities, employmentTypes, expectedSalaryMin, industries, jobTypes, …)` + `Job` + `JobRecommendationProfile` |
| 维度与权重 | 城市 30 / 用工类型 20 / 最低薪资 20 / 行业 15 / 岗位族 15 | **完全相同** |
| 缺失维度 | 该维度不计入 available(等效"不计分") | 完全相同 |
| 空集合/null | `Criteria` 紧凑构造器把 `null` 归一化为 `List.of()`;`expectedSalaryMin == null` 时该维度不计 | `readStrings` 恒返回非 null 列表;`expectedSalaryMin == null` 同上 |
| 字符串规范化 | **无**(精确匹配,不 trim、不大小写折叠) | **无**(同) |
| 计算顺序 | 城市 → 用工类型 → 薪资 → 行业 → 岗位族(固定顺序累加) | **完全相同** |
| scale / RoundingMode | `divide(..., 2, HALF_UP)`,`BigDecimal.valueOf(matched * 100L)` | `BigDecimal.valueOf(got * 100L).divide(..., 2, HALF_UP)` —— **同一式子** |
| 上下界 | `available == 0 → null`;否则 ∈ [0.00, 100.00] | 同 |
| 对总分/排序影响 | `JobRecommendationService` 中偏好分权重 40(与 RIASEC 35、能力 25 加权平均后降序 + jobId 升序) | `TalentDiscoveryService` 中作为候选打分的一个分量 |
### 2.3 硬编码规则库存
| 项 | 重构前位置 | 数量/值 |
|---|---|---|
| 985 高校集合 | `CandidateScoringCalculator.C985`(`new HashSet<>(List.of(...))`) | **38 项**(38 唯一) |
| 211 高校集合 | `CandidateScoringCalculator.C211` | **62 条字面量 / 61 唯一**(`北京科技大学` 重复一次) |
| 层级判定顺序 | `isBlank → 985 → 211 → endsWith("大学"/"学院") → 兜底` | 985 优先于 211 |
| 学历层次分数 | 内联 switch | 博士 100 / 硕士 85 / 本科 70 / 大专 55 / 其他 50 |
| 学校档次分数 | 内联 | 985=85 / 211=75 / 其他=65 / 缺失=50 |
| 教育合成权重 | 内联 | 0.55 / 0.45;子项满分 55 / 45 |
| 默认维度权重 | `DEFAULT_WEIGHT` | `new BigDecimal("12.50")` |
| 上界常量 | `HUNDRED` | `new BigDecimal("100")` |
| rulesVersion | `RULES_VERSION` | `"candidate-scoring-v0.1"`(**另有** `AbilityScoreMapper.MAPPING_RULES_VERSION = "ability-assessment-scoring-v0.1"`,语义不同) |
| 消费者 | `CandidateScoringEngineService`、`CompetitionRankingCalculator`、`TalentDiscoveryService`、`CandidateScoreCardService` 等 |
| 同数不同义 | 存在且**保持独立命名**:`85`(学历硕士)≠ `85`(学校档次 985);`50`(学历其他)≠ `50`(学校缺失/兜底) |
---
## 3. 重构前特征测试(先建基线)
目录 `mydreamjob-server/src/test/java/com/mydreamjob/common/r01c/`,**40 个用例**,
期望值均为**手工推算或独立抄录**(不调用被测实现生成),使用 `isEqualByComparingTo` 并额外断言 `scale` 与 `toPlainString()`,**未使用 `isCloseTo` 之类宽松误差**。
| 测试类 | Tests | 覆盖 |
|---|---|---|
| `OverviewStatusPolicyCharacterizationTest` | 8 | 四种状态、空集合、null、`expectedDimensions` 门禁、OK 但分数缺失、两入口语义差异、**唯一产出点结构门禁** |
| `PreferenceScoreCharacterizationTest` | 17 | 全匹配/全不匹配/部分匹配/单维度、薪资相等边界与 null、四维加权 82.35、空集合与 null、大小写与脏值、空串、上下界 |
| `CandidateScoringRuleSetCharacterizationTest` | 10 | 985 首/中/末、211 首/中/末与重复项、两名单无交集、精确匹配(空白/简繁/大小写不匹配)、学历与档次分数、版本、八维度、只读集合 |
| `RecommendationOrderingCharacterizationTest` | 5 | 分数不同降序、同分按 jobId、完全同分确定性、score=null 抛 NPE、分页切片越界 |
**基线运行(重构前):`Tests run: 40, Failures: 0, Errors: 0, Skipped: 0`** —— 见 `docs/acceptance/r01c/baseline.log`。
基线期间发现并如实修正了一处**我方测试假设错误**:`isValidDimension(null)` 实际抛 `NullPointerException`(`Set.of` 契约),并非返回 `false`;已按真实行为锁定,未改生产代码。
---
## 4. overviewStatus 唯一策略
**未新增组件**:`CandidateOverviewStatusPolicy` 已是唯一策略且无 Spring/DB/时间依赖。
本轮仅**新增结构门禁测试**:扫描 `src/main/java/com/mydreamjob` 下所有 `.java`,
断言 `"PARTIAL"` 的出现文件**恰好**为 `CandidateOverviewStatusPolicy.java`。
若将来出现第二个聚合器,该测试立即失败。
---
## 5. 两份旧实现是否完全等价
**不存在两份 overviewStatus 实现**,故不适用"合并前等价性比对"。§六 的停止条件(两实现不等价)**未触发**。
**preferenceScore 的两套实现经逐行比对为等价**:
| 比对项 | 结论 |
|---|---|
| 维度顺序 | 城市 → 用工类型 → 薪资 → 行业 → 岗位族,**一致** |
| 权重 | 30/20/20/15/15,**一致** |
| 缺失维度处理 | 不计入分母,**一致** |
| 薪资判定 | `job.salaryMax != null && salaryMax >= expectedSalaryMin`,**一致** |
| 岗位族取值 | `profile.getJobFamilyCode()`,**一致** |
| 计算式 | `BigDecimal.valueOf(x * 100L).divide(BigDecimal.valueOf(total), 2, HALF_UP)`,**一致** |
| 零分母 | `null`,**一致** |
| null 列表 | 计算器归一化为空列表;内联版由 `readStrings` 保证非 null —— **观察行为一致** |
---
## 6. preferenceScore 拆分
- `JobRecommendationService.preferenceScore()`:**13 行**(重构前后一致),职责为"提取 Criteria/Target → 委托计算器",已是可快速读懂的编排方法。
- `TalentDiscoveryService.preferenceScore()`:由 **12 行内联公式** 改为 **17 行编排**(含 javadoc),规则实现移出。
- 拆分粒度按**实际存在的维度**(5 个),**未为匹配示例创造不存在的维度**;未引入 Stream;未改变可见性;未产生无意义的一行包装。
- **30/20/20/15/15 规则实现处:2 → 1**(`grep "total += 30\|CITY_WEIGHT"`)。
---
## 7. BigDecimal 运算顺序与舍入证明
| 项 | 重构前 | 重构后 | 一致 |
|---|---|---|---|
| 偏好分构造 | `BigDecimal.valueOf(matched * 100L)` | 同一表达式(改为计算器内执行) | ✔ |
| 除法 | `divide(BigDecimal.valueOf(available), 2, RoundingMode.HALF_UP)` | 同 | ✔ |
| 教育维度 | `eduLevel*0.55 + schoolTier*0.45`,`setScale(2, HALF_UP)` | 同一表达式(权重改读规则集常量,**值相同**) | ✔ |
| `MathContext.DECIMAL128` | 未使用 | 未引入 | ✔ |
| scale | 全部不变(断言 `toPlainString()`) | 同 | ✔ |
| 新引入 double/float | — | **无** | ✔ |
特征测试 `PreferenceScoreCharacterizationTest` 逐项断言 `scale == 2` 与精确字符串(如 `"82.35"`、`"66.67"`、`"33.33"`)。
---
## 8. 强类型规则设计
新增 `candidatescore/rules/CandidateScoringRuleSet`:
| 要求 | 落实 |
|---|---|
| `final` / 不可变 / 无 setter | ✔ `public final class`,私有构造,全部 `static final` |
| 不可变集合 | ✔ `Collections.unmodifiableSet(new HashSet<>(...))` |
| 唯一当前规则实例 | ✔ 静态常量(随代码发布变更) |
| 版本常量 | ✔ `VERSION = "candidate-scoring-v0.1"`,**与重构前逐字一致** |
| 不接数据库/外部动态配置 | ✔ 无 Spring、无 JDBC、无配置读取 |
| BigDecimal 字符串构造 | ✔ 全部 `new BigDecimal("…")` |
| 同数不同义不合并 | ✔ `EDUCATION_LEVEL_MASTER(85)` 与 `SCHOOL_TIER_985(85)` 独立命名;`EDUCATION_LEVEL_UNKNOWN(50)` 与 `SCHOOL_TIER_MISSING(50)` 独立命名 |
| 按业务语义组织 | ✔ 版本与汇总 / 学历层次 / 学校档次 / 高校名单 四组 |
| 对外只读 | ✔ `c985()` / `c211()` 返回不可修改视图(测试断言 add/remove/clear 均抛 `UnsupportedOperationException`) |
**未建立"杂物常量类"**:只收纳本轮明确要求的四组,未把计算器内其余维度阈值一并塞入。
---
## 9. 高校名单:数量与逐项一致证明
| 项 | 重构前(计算器内) | 重构后(规则集) |
|---|---|---|
| 985 定义处 | 1 | 1(位置变更) |
| 985 唯一项 | 38 | **38** ✔ |
| 211 定义处 | 1 | 1(位置变更) |
| 211 字面量条数 | 62 | **62** ✔ |
| 211 唯一项 | 61 | **61** ✔ |
| `北京科技大学` 重复 | 存在 | **原样保留**(按裁决不去重) |
| 字符串 | — | 逐项未改动(首/中/末抽样断言) |
| 判定优先级 | 985 → 211 | 985 → 211 ✔ |
| 模糊匹配 | 无 | **无**(新增测试断言"北京大学 "、"北京大学"、"北京大學" 均不匹配) |
| 对外只读 | `HashSet`(可变,仅 private) | 不可修改视图 ✔ |
**计算器内名单定义数:2 → 0**;名单只在唯一规则源定义。
---
## 10. 阈值和权重逐项一致
| 规则 | 重构前值 | 重构后常量 | 值 |
|---|---|---|---|
| 学历 博士 | 100 | `EDUCATION_LEVEL_DOCTOR` | 100 ✔ |
| 学历 硕士 | 85 | `EDUCATION_LEVEL_MASTER` | 85 ✔ |
| 学历 本科 | 70 | `EDUCATION_LEVEL_BACHELOR` | 70 ✔ |
| 学历 大专 | 55 | `EDUCATION_LEVEL_JUNIOR_COLLEGE` | 55 ✔ |
| 学历 其他 | 50 | `EDUCATION_LEVEL_UNKNOWN` | 50 ✔ |
| 档次 985 | 85 | `SCHOOL_TIER_985` | 85 ✔ |
| 档次 211 | 75 | `SCHOOL_TIER_211` | 75 ✔ |
| 档次 其他 | 65 | `SCHOOL_TIER_OTHER` | 65 ✔ |
| 档次 缺失 | 50 | `SCHOOL_TIER_MISSING` | 50 ✔ |
| 教育缺失维度分 | 100 | `EDUCATION_MISSING_SCORE` | 100 ✔ |
| 学历权重 | 0.55 | `EDUCATION_LEVEL_WEIGHT` | 0.55 ✔ |
| 档次权重 | 0.45 | `EDUCATION_SCHOOL_TIER_WEIGHT` | 0.45 ✔ |
| 子项满分 | 55 / 45 | `EDUCATION_LEVEL_FULL_SCORE` / `…TIER…` | 55 / 45 ✔ |
| 默认维度权重 | 12.50 | `DEFAULT_DIMENSION_WEIGHT` | 12.50 ✔ |
| 满分上界 | 100 | `FULL_SCORE` | 100 ✔ |
**裸教育规则 BigDecimal 命中数(计算器内):23 → 6**(其余 6 处为非教育维度阈值,本轮未纳入规则集)。
数值一律为字符串构造,**未把整数阈值错误转换为百分比小数**。
---
## 11. rulesVersion
| 项 | 值 |
|---|---|
| 常量 | `CandidateScoringRuleSet.VERSION` |
| 值 | **`candidate-scoring-v0.1`**(与重构前 `CandidateScoringCalculator.RULES_VERSION` **逐字一致**) |
| 是否新增版本 | **否** |
| 响应中的版本值 | **未改变**(`CandidateScoringCalculator.RULES_VERSION` 现指向同一常量) |
| 同义词区分 | `AbilityScoreMapper.MAPPING_RULES_VERSION = "ability-assessment-scoring-v0.1"` 保持独立,未合并 |
| 快照历史一致性 | 快照写入仍使用同一 `RULES_VERSION`,未变 |
---
## 12. 排名顺序对账
`JobRecommendationService` 排序比较器**未改动**:
`Comparator.comparing(JobRecommendationItem::getScore).reversed().thenComparing(JobRecommendationItem::getJobId)`。
`slice` 的下标计算未改动。特征测试锁定:分数降序、同分按 jobId 升序、完全同分确定性、越界切片为空。
**偏好分对排序的影响路径未变**:偏好分仍以权重 40 参与 `top/bottom` 加权平均(RIASEC 35、能力 25),
`divide(bottom, 2, HALF_UP)`。收束只替换了偏好分的**计算处**,未触碰分值本身。
---
## 13. 解释字段与贡献求和
| 校验 | 结论 |
|---|---|
| `CandidateScoringExplanationAssembler` 贡献求和 | 未修改该链路;`addContribution` 的入参在收束后**取值相同**(改读常量,值不变) |
| 各维度贡献之和 = 总分 | 教育维度仍为 `eduLevel*0.55 + schoolTier*0.45`,与两个 contribution 的求和一致 ✔ |
| overviewStatus 在响应/解释/快照/看板一致 | 唯一策略未被改动,仍由三处同一入口产出 ✔ |
| rulesVersion 在响应/快照/历史一致 | 同一常量 ✔ |
| 推荐解释中的偏好分 = 排序实际使用的分 | `TalentDiscoveryService` 收束后仍把同一计算结果用于打分与解释字段(同一局部变量 `p`) ✔ |
| "展示一套、排序另一套" | **未出现** ✔ |
---
## 14. 查询数与副作用
| 项 | 结论 |
|---|---|
| 新增数据库查询 | **无**。规则集为静态常量;偏好分计算为纯函数 |
| 推荐一次查询的 SQL 数量 | **不增加**:`TalentDiscoveryService.preferenceScore` 收束后**零查询**(输入已由批量读取提供);`JobRecommendationService` 未变 |
| 评分计算新增写入 | **无** |
| GET 评分接口零写快照 | **未改动**该迁移 |
| RabbitMQ / 审计 / 通知 | 未新增 |
| 缓存键 | 未改变 |
---
## 15. 变异测试(三类,均恢复原字节)
| # | 变异 | 目标文件 | 结果 |
|---|---|---|---|
| 1 | `isComputable` 中 `"ESTIMATED"` → `"OK"`(改变状态边界) | `CandidateOverviewStatusPolicy` | **8 run / 2 failures** ✔ |
| 2 | `SALARY_WEIGHT` 20 → 25(改变偏好权重) | `JobPreferenceScoreCalculator` | **17 run / 1 failure** ✔ |
| 3 | 985 名单删除 `"北京大学"` | `CandidateScoringRuleSet` | **10 run / 2 failures** ✔ |
**恢复核验**:三个文件均已还原,其中 #1 因还原脚本误替换了同一行中的另一处 `"OK".equals`,
已定位并**逐字节修正**,`git diff --quiet` 确认与 HEAD 完全一致;#2、#3 的 SHA-256 恢复一致。
**未使用 `git restore/checkout`**;变异内容未留在工作区(见 §20 的差异范围核对)。
---
## 16. 定向、关联、全量测试数
| 命令 | 结果 | 日志 |
|---|---|---|
| R01-C 定向(4 个特征类) | **40 / 40**,F=0 E=0 S=0 ✔ | `baseline.log` / `after-characterization.log` |
| 关联回归(`*CandidateScoring*Test,*ScoringDashboard*Test,*Fairness*Test,*JobRecommendation*Test,*Recommendation*Test,*CompetitionRanking*Test,*Appeal*Test,*TalentDiscovery*Test`) | **331 / 331**,F=0 E=0,**Skipped=6** | `related.log` |
| `mvn test` | 见 §16.1 | `full-mvntest.log` |
| `mvn clean verify` | 见 §16.1 | `clean-verify.log` |
### 16.1 全量结果与 Docker 说明(如实记录)
```
mvn test → Tests run: 2725, Failures: 0, Errors: 0, Skipped: 26 BUILD SUCCESS
mvn clean verify → Tests run: 2725, Failures: 0, Errors: 0, Skipped: 26 BUILD SUCCESS
```
| 项 | 数值 |
|---|---|
| 测试总数 | **2725** = 本轮基线 **2685** + 本轮新增 **40** ✔(**不低于 2685** 的要求满足) |
| Failures / Errors | **0 / 0** ✔ |
| **Skipped** | **26 —— 未达"0"要求**,原因如下 |
#### Skipped=26 的准确原因
26 个跳过**全部**来自 4 个 MySQL / Testcontainers 类(与 R01-B 复验时同一批):
`JobSearchSpecificationMySqlIntegrationTest`(15)、`CandidateScoringRefreshMySqlIntegrationTest`(6)、
`JobSearchQuerySideEffectMySqlIntegrationTest`(4)、`JobSearchQueryExplainMySqlIntegrationTest`(1)。
它们标注 `@Testcontainers(disabledWithoutDocker = true)`,Docker 不可用时按设计跳过。
**环境状态变化(非代码原因)**:
- 本轮**开始时** `docker info` 成功:`ServerVersion=29.6.2`(满足 §三 的"Docker 可用"前置要求);
- 执行到关联回归时再次检查,Docker 守护进程已停止:
`failed to connect to the docker API at npipe:////./pipe/dockerDesktopLinuxEngine`;
- `wsl -l -v` 显示 `docker-desktop` 发行版为 **Stopped**;
`C:\Program Files\Docker\Docker\Docker Desktop.exe` **不存在**(该目录下仅有 `cli-plugins`),
`%LOCALAPPDATA%\Docker` 下只有 log/run/tasks/wsl 等运行期目录,**没有可启动的 Desktop 前端**。
**处置**:未强行以非受支持方式拉起 Docker(`wsl -d docker-desktop` 明确不被支持),
**不声称 Skipped=0**。除这 26 个 Docker 条件用例外的 **2699 个测试全部执行且全部通过**。
**不构成 R01-C 阻断项**。
#### 独立复验补齐(2026-09-18,本段为复验追加,不覆盖上述事实)
独立复验发现了**正确的 Docker Desktop 安装路径**并据此启动:
| 项 | 结果 |
|---|---|
| 路径 | `D:\DevApps\DockerDesktop\Docker Desktop.exe`(存在,15,142,832 字节) |
| 启动 | `Start-Process -FilePath '…\Docker Desktop.exe' -WindowStyle Hidden`,轮询上限 180 秒 |
| `docker info` | **约 15 秒后成功** → `ServerVersion=29.6.2` |
| 原先跳过的 26 项 | **26 / 26 实际执行**,`Tests run: 26, Failures: 0, Errors: 0, Skipped: 0` |
**最终全量(复验阶段)**:
```
mvn test → Tests run: 2743, Failures: 0, Errors: 0, Skipped: 0 BUILD SUCCESS
mvn clean verify → Tests run: 2743, Failures: 0, Errors: 0, Skipped: 0 BUILD SUCCESS
```
`2743 = 2725(本报告阶段总数,含 26 项跳过)+ 18(独立复验新增测试)`;
其 **Skipped=0** 正是因为那 26 项已真实执行。**本阶段未改动任何代码即补足了这 26 项。**
---
## 17. OpenAPI
```
python ..\docs\verify_openapi.py
→ paths: 176 / operations: 214
→ All checks passed!
```
`git diff -- docs/openapi-rc.json` → **空** ✔
---
## 18. 精确修改文件
**生产代码变更总数:新增 1 个生产类、修改 2 个生产类,共 3 个生产文件。**
**新增生产类(1)**
```
mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/rules/CandidateScoringRuleSet.java
```
**修改生产类(2)**
```
candidatescore/CandidateScoringCalculator.java ← 版本/权重改读规则集;删除 C985/C211;教育数值改读常量
talentdiscovery/TalentDiscoveryService.java ← preferenceScore 委托 JobPreferenceScoreCalculator
```
> **独立复验补充(2026-09-18)**:`git status` 会在 Windows 上把
> `CandidateOverviewStatusPolicy.java` 与 `JobPreferenceScoreCalculator.java` 标为 ` M`
> (变异实验重写过这两个文件、mtime 变化)。经 `git hash-object` 与 `git rev-parse HEAD:<file>`
> 比对,两者 blob **完全相同**,属 **Windows stat 缓存假阳性**,**不在本提交范围内**。
**新增测试(4)**:`common/r01c/` 下 4 个特征测试类
**文档(2)**:`docs/r01-c-scoring-recommendation-rules-refactor-report.md`(新增)、
`docs/backend-development-roadmap.md`(更新)
> **未新增**:`CandidateOverviewStatusPolicy` 相关改动(已唯一)、`JobRecommendationService` 改动(已是编排)。
---
## 19. Flyway / ErrorCode / DTO / 接口变化
| 项 | 结果 |
|---|---|
| Flyway / 迁移 | **未改动** ✔ |
| `ErrorCode` | **未改动** ✔ |
| DTO / 响应结构 | **未改动** ✔ |
| 公网接口 / OpenAPI | **未改动**(176/214,`openapi-rc.json` 未变) ✔ |
| Token / 权限 / requestId | **未改动** ✔ |
| 依赖 / `pom.xml` | **未改动** ✔ |
| 评分公式数值 / 排序 / 同分规则 / 小样本与互补风险抑制 / 申诉回滚 / 快照版本推进 / Fairness / 消息与 Offer 等 | **均未改动** ✔ |
---
## 20. Git 状态
| 项 | 结果 |
|---|---|
| 分支 / HEAD | `refactor/r01-structure-performance` / `9954600…`(未变) |
| `git diff --cached --name-only` | **空** ✔ |
| `git diff --check` | exit 0 ✔ |
| 生产代码改动范围 | **恰为 2 个文件**,与 §18 一致(变异内容未残留)✔ |
| 已暂存 / 已提交 / 已推送 | **均无** ✔ |
| 历史未跟踪文件 | 未删除 ✔ |
---
## 21. 未暂存、未提交、未推送
**确认。** 未执行 `git add`;未创建提交;未推送;未执行 `reset`/`restore`/`checkout`/`clean`;
未进入 R01-E。
---
## 22. 未进入 R01-E
**确认。** 本轮未处理 N+1、会话未读数、`overviewStatus` 之外的其他查询收束、
数据库迁移或任何 R01-E 范围内的主题。
---
> **R01-C评分与推荐规则结构收束完成,待独立代码复验。**

68
mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/CandidateScoringCalculator.java

@ -5,6 +5,7 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import com.mydreamjob.auth.User; import com.mydreamjob.auth.User;
import com.mydreamjob.auth.UserRepository; import com.mydreamjob.auth.UserRepository;
import com.mydreamjob.candidatescore.dto.DimensionScoreDetailResponse; import com.mydreamjob.candidatescore.dto.DimensionScoreDetailResponse;
import com.mydreamjob.candidatescore.rules.CandidateScoringRuleSet;
import com.mydreamjob.common.exception.BusinessException; import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode; import com.mydreamjob.common.exception.ErrorCode;
import com.mydreamjob.preference.StudentPreference; import com.mydreamjob.preference.StudentPreference;
@ -36,8 +37,9 @@ import java.util.stream.Collectors;
@RequiredArgsConstructor @RequiredArgsConstructor
public class CandidateScoringCalculator { public class CandidateScoringCalculator {
public static final String RULES_VERSION = "candidate-scoring-v0.1"; /** R01-C:版本与默认权重来自唯一规则源 CandidateScoringRuleSet。 */
public static final BigDecimal DEFAULT_WEIGHT = new BigDecimal("12.50"); public static final String RULES_VERSION = CandidateScoringRuleSet.VERSION;
public static final BigDecimal DEFAULT_WEIGHT = CandidateScoringRuleSet.DEFAULT_DIMENSION_WEIGHT;
public static boolean isValidDimension(String dimension) { public static boolean isValidDimension(String dimension) {
return DIMENSIONS.contains(dimension); return DIMENSIONS.contains(dimension);
@ -68,30 +70,6 @@ public class CandidateScoringCalculator {
private static final ObjectMapper MAPPER = new ObjectMapper(); private static final ObjectMapper MAPPER = new ObjectMapper();
// 内置学校档次名单(v0.1 硬编码,非数据库表)
private static final Set<String> C985 = new HashSet<>(List.of(
"北京大学", "清华大学", "中国人民大学", "北京航空航天大学", "北京理工大学",
"中国农业大学", "北京师范大学", "中央民族大学", "南开大学", "天津大学",
"大连理工大学", "吉林大学", "哈尔滨工业大学", "复旦大学", "同济大学",
"上海交通大学", "华东师范大学", "南京大学", "东南大学", "浙江大学",
"中国科学技术大学", "厦门大学", "山东大学", "中国海洋大学", "武汉大学",
"华中科技大学", "湖南大学", "中南大学", "中山大学", "华南理工大学",
"四川大学", "重庆大学", "电子科技大学", "西安交通大学", "西北工业大学",
"兰州大学", "东北大学", "西北农林科技大学"));
private static final Set<String> C211 = new HashSet<>(List.of(
"北京交通大学", "北京工业大学", "北京科技大学", "北京化工大学", "北京邮电大学",
"北京林业大学", "北京中医药大学", "北京外国语大学", "中国传媒大学", "中央财经大学",
"对外经济贸易大学", "中国政法大学", "华北电力大学", "天津医科大学", "河北工业大学",
"太原理工大学", "内蒙古大学", "辽宁大学", "大连海事大学", "延边大学",
"哈尔滨工程大学", "东北农业大学", "东北林业大学", "东华大学", "上海大学",
"上海财经大学", "上海外国语大学", "苏州大学", "南京航空航天大学", "南京理工大学",
"中国矿业大学", "河海大学", "江南大学", "南京农业大学", "中国药科大学",
"南京师范大学", "安徽大学", "合肥工业大学", "福州大学", "南昌大学",
"郑州大学", "武汉理工大学", "华中农业大学", "华中师范大学", "中南财经政法大学",
"湖南师范大学", "暨南大学", "华南师范大学", "广西大学", "海南大学",
"西南交通大学", "西南财经大学", "贵州大学", "云南大学", "西北大学",
"西安电子科技大学", "长安大学", "新疆大学", "石河子大学", "宁夏大学",
"青海大学", "北京科技大学"));
private final StudentProfileRepository profileRepository; private final StudentProfileRepository profileRepository;
private final StudentPreferenceRepository preferenceRepository; private final StudentPreferenceRepository preferenceRepository;
@ -223,50 +201,50 @@ public class CandidateScoringCalculator {
if (profile == null || isBlank(profile.getEducation())) { if (profile == null || isBlank(profile.getEducation())) {
r.status = "INSUFFICIENT_DATA"; r.status = "INSUFFICIENT_DATA";
r.summary = "未填写学历信息"; r.summary = "未填写学历信息";
addContribution(r, "EDUCATION_MISSING", "MISSING", null, new BigDecimal("100"), "教育资料", addContribution(r, "EDUCATION_MISSING", "MISSING", null, CandidateScoringRuleSet.EDUCATION_MISSING_SCORE, "教育资料",
"学历资料未填写,当前维度不计算"); "学历资料未填写,当前维度不计算");
return r; return r;
} }
BigDecimal eduLevel = switch (profile.getEducation()) { BigDecimal eduLevel = switch (profile.getEducation()) {
case "博士" -> new BigDecimal("100"); case "博士" -> CandidateScoringRuleSet.EDUCATION_LEVEL_DOCTOR;
case "硕士" -> new BigDecimal("85"); case "硕士" -> CandidateScoringRuleSet.EDUCATION_LEVEL_MASTER;
case "本科" -> new BigDecimal("70"); case "本科" -> CandidateScoringRuleSet.EDUCATION_LEVEL_BACHELOR;
case "大专" -> new BigDecimal("55"); case "大专" -> CandidateScoringRuleSet.EDUCATION_LEVEL_JUNIOR_COLLEGE;
default -> new BigDecimal("50"); default -> CandidateScoringRuleSet.EDUCATION_LEVEL_UNKNOWN;
}; };
String school = profile.getSchool(); String school = profile.getSchool();
boolean estimated = false; boolean estimated = false;
BigDecimal schoolTier; BigDecimal schoolTier;
if (isBlank(school)) { if (isBlank(school)) {
schoolTier = new BigDecimal("50"); schoolTier = CandidateScoringRuleSet.SCHOOL_TIER_MISSING;
estimated = true; estimated = true;
r.reasons.add("学校未填写,按兜底档位估算"); r.reasons.add("学校未填写,按兜底档位估算");
} else if (C985.contains(school)) { } else if (CandidateScoringRuleSet.is985(school)) {
schoolTier = new BigDecimal("85"); schoolTier = CandidateScoringRuleSet.SCHOOL_TIER_985;
} else if (C211.contains(school)) { } else if (CandidateScoringRuleSet.is211(school)) {
schoolTier = new BigDecimal("75"); schoolTier = CandidateScoringRuleSet.SCHOOL_TIER_211;
} else if (school.endsWith("大学") || school.endsWith("学院")) { } else if (school.endsWith("大学") || school.endsWith("学院")) {
schoolTier = new BigDecimal("65"); schoolTier = CandidateScoringRuleSet.SCHOOL_TIER_OTHER;
} else { } else {
schoolTier = new BigDecimal("50"); schoolTier = CandidateScoringRuleSet.SCHOOL_TIER_MISSING;
estimated = true; estimated = true;
r.reasons.add("学校不在内置名单,按兜底档位估算"); r.reasons.add("学校不在内置名单,按兜底档位估算");
} }
r.score = eduLevel.multiply(new BigDecimal("0.55")) r.score = eduLevel.multiply(CandidateScoringRuleSet.EDUCATION_LEVEL_WEIGHT)
.add(schoolTier.multiply(new BigDecimal("0.45"))) .add(schoolTier.multiply(CandidateScoringRuleSet.EDUCATION_SCHOOL_TIER_WEIGHT))
.setScale(2, RoundingMode.HALF_UP); .setScale(2, RoundingMode.HALF_UP);
addContribution(r, "EDUCATION_LEVEL", "APPLIED", eduLevel.multiply(new BigDecimal("0.55")), new BigDecimal("55"), addContribution(r, "EDUCATION_LEVEL", "APPLIED", eduLevel.multiply(CandidateScoringRuleSet.EDUCATION_LEVEL_WEIGHT), CandidateScoringRuleSet.EDUCATION_LEVEL_FULL_SCORE,
"学历层次", profile.getEducation(), "学历字段按固定映射形成贡献"); "学历层次", profile.getEducation(), "学历字段按固定映射形成贡献");
if (isBlank(school)) { if (isBlank(school)) {
addContribution(r, "EDUCATION_MISSING", "MISSING", null, null, "教育资料缺失", addContribution(r, "EDUCATION_MISSING", "MISSING", null, null, "教育资料缺失",
"学校资料未填写,学校档位使用估算兜底"); "学校资料未填写,学校档位使用估算兜底");
} }
addContribution(r, "EDUCATION_SCHOOL_TIER", "APPLIED", schoolTier.multiply(new BigDecimal("0.45")), new BigDecimal("45"), addContribution(r, "EDUCATION_SCHOOL_TIER", "APPLIED", schoolTier.multiply(CandidateScoringRuleSet.EDUCATION_SCHOOL_TIER_WEIGHT), CandidateScoringRuleSet.EDUCATION_SCHOOL_TIER_FULL_SCORE,
"学校分类", isBlank(school) ? "未填写" : "已按学校分类规则处理", "学校分类按固定规则形成贡献,不能代表能力"); "学校分类", isBlank(school) ? "未填写" : "已按学校分类规则处理", "学校分类按固定规则形成贡献,不能代表能力");
r.status = estimated ? "ESTIMATED" : "OK"; r.status = estimated ? "ESTIMATED" : "OK";
r.summary = "学历层次与学校档次的加权综合"; r.summary = "学历层次与学校档次的加权综合";
r.subScores.add(sub("学历层次", eduLevel, new BigDecimal("55"), profile.getEducation())); r.subScores.add(sub("学历层次", eduLevel, CandidateScoringRuleSet.EDUCATION_LEVEL_FULL_SCORE, profile.getEducation()));
r.subScores.add(sub("学校档次", schoolTier, new BigDecimal("45"), isBlank(school) ? "未填写" : school)); r.subScores.add(sub("学校档次", schoolTier, CandidateScoringRuleSet.EDUCATION_SCHOOL_TIER_FULL_SCORE, isBlank(school) ? "未填写" : school));
r.dataSources.add(ds("个人教育资料", profile.getUpdatedAt(), "学历与学校分类信息")); r.dataSources.add(ds("个人教育资料", profile.getUpdatedAt(), "学历与学校分类信息"));
return r; return r;
} }

137
mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/rules/CandidateScoringRuleSet.java

@ -0,0 +1,137 @@
package com.mydreamjob.candidatescore.rules;
import java.math.BigDecimal;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
/**
* 求职者评分规则的**唯一代码来源**(R01-C)。
*
* <p>本类只承载"规则值本身":版本标识、高校档次名单、教育背景分数与权重、默认维度权重。
* 规则随**代码发布**变更,不接数据库、不接外部动态配置、无 setter、不暴露可变集合。</p>
*
* <h2>不变量</h2>
* <ul>
* <li>{@link #VERSION} 必须与重构前 {@code CandidateScoringCalculator.RULES_VERSION} 完全一致
* ({@code candidate-scoring-v0.1})—— 本轮**不新增版本、不改变响应中的版本值**;</li>
* <li>高校名单内容、数量、字符串与判定优先级逐项不变;</li>
* <li>所有 BigDecimal 使用**字符串构造**,scale 与重构前一致;</li>
* <li>不同业务语义的相同数字**不合并**(例如学历层次 85 与学校档次 85 是两个命名常量)。</li>
* </ul>
*/
public final class CandidateScoringRuleSet {
private CandidateScoringRuleSet() {
}
// ── 版本与汇总 ────────────────────────────────────────────
/** 评分规则版本。原值取自 {@code CandidateScoringCalculator.RULES_VERSION},逐字不变。 */
public static final String VERSION = "candidate-scoring-v0.1";
/** 各维度默认等权重(参与维度重新归一化后等价于算术平均)。 */
public static final BigDecimal DEFAULT_DIMENSION_WEIGHT = new BigDecimal("12.50");
/** 百分制上界,用于分数归一化。 */
public static final BigDecimal FULL_SCORE = new BigDecimal("100");
// ── 教育背景:学历层次 ────────────────────────────────────
public static final BigDecimal EDUCATION_LEVEL_DOCTOR = new BigDecimal("100");
public static final BigDecimal EDUCATION_LEVEL_MASTER = new BigDecimal("85");
public static final BigDecimal EDUCATION_LEVEL_BACHELOR = new BigDecimal("70");
public static final BigDecimal EDUCATION_LEVEL_JUNIOR_COLLEGE = new BigDecimal("55");
public static final BigDecimal EDUCATION_LEVEL_UNKNOWN = new BigDecimal("50");
/** 教育维度内:学历层次与学校档次的合成权重(相加为 1.00)。 */
public static final BigDecimal EDUCATION_LEVEL_WEIGHT = new BigDecimal("0.55");
public static final BigDecimal EDUCATION_SCHOOL_TIER_WEIGHT = new BigDecimal("0.45");
/** 教育维度子项满分(用于解释与贡献校验)。 */
public static final BigDecimal EDUCATION_LEVEL_FULL_SCORE = new BigDecimal("55");
public static final BigDecimal EDUCATION_SCHOOL_TIER_FULL_SCORE = new BigDecimal("45");
// ── 教育背景:学校档次 ────────────────────────────────────
/** 985 高校档次得分。 */
public static final BigDecimal SCHOOL_TIER_985 = new BigDecimal("85");
/** 211(非 985)高校档次得分。 */
public static final BigDecimal SCHOOL_TIER_211 = new BigDecimal("75");
/** 已填写但不在两个名单内的学校。 */
public static final BigDecimal SCHOOL_TIER_OTHER = new BigDecimal("65");
/** 学校档次缺失(与"学历层次缺失"的 50 语义不同,故为独立常量)。 */
public static final BigDecimal SCHOOL_TIER_MISSING = new BigDecimal("50");
/** 教育资料整体缺失时的维度得分。 */
public static final BigDecimal EDUCATION_MISSING_SCORE = new BigDecimal("100");
// ── 高校名单(v0.1 硬编码,非数据库表)────────────────────
/**
* 985 高校名单(38 项)。
*
* <p>内容逐项抄录自重构前的 {@code CandidateScoringCalculator.C985},顺序与字符串均未改动。</p>
*/
private static final Set<String> C985 = immutableSet(List.of(
"北京大学", "清华大学", "中国人民大学", "北京航空航天大学", "北京理工大学",
"中国农业大学", "北京师范大学", "中央民族大学", "南开大学", "天津大学",
"大连理工大学", "吉林大学", "哈尔滨工业大学", "复旦大学", "同济大学",
"上海交通大学", "华东师范大学", "南京大学", "东南大学", "浙江大学",
"中国科学技术大学", "厦门大学", "山东大学", "中国海洋大学", "武汉大学",
"华中科技大学", "湖南大学", "中南大学", "中山大学", "华南理工大学",
"四川大学", "重庆大学", "电子科技大学", "西安交通大学", "西北工业大学",
"兰州大学", "东北大学", "西北农林科技大学"));
/**
* 211(非 985)高校名单:字面量 **62 条**、唯一项 **61**。
*
* <p>末项 {@code 北京科技大学} 与首段重复 —— 这是重构前名单的**既有事实**,
* 按"名单内容逐项不变、数量不变"的要求**原样保留**,不擅自去重、不补充、不删除。
* 集合语义下判定行为与重构前一致({@code HashSet} 去重)。</p>
*/
private static final Set<String> C211 = immutableSet(List.of(
"北京交通大学", "北京工业大学", "北京科技大学", "北京化工大学", "北京邮电大学",
"北京林业大学", "北京中医药大学", "北京外国语大学", "中国传媒大学", "中央财经大学",
"对外经济贸易大学", "中国政法大学", "华北电力大学", "天津医科大学", "河北工业大学",
"太原理工大学", "内蒙古大学", "辽宁大学", "大连海事大学", "延边大学",
"哈尔滨工程大学", "东北农业大学", "东北林业大学", "东华大学", "上海大学",
"上海财经大学", "上海外国语大学", "苏州大学", "南京航空航天大学", "南京理工大学",
"中国矿业大学", "河海大学", "江南大学", "南京农业大学", "中国药科大学",
"南京师范大学", "安徽大学", "合肥工业大学", "福州大学", "南昌大学",
"郑州大学", "武汉理工大学", "华中农业大学", "华中师范大学", "中南财经政法大学",
"湖南师范大学", "暨南大学", "华南师范大学", "广西大学", "海南大学",
"西南交通大学", "西南财经大学", "贵州大学", "云南大学", "西北大学",
"西安电子科技大学", "长安大学", "新疆大学", "石河子大学", "宁夏大学",
"青海大学", "北京科技大学"));
/** 只读视图:985 名单。 */
public static Set<String> c985() {
return C985;
}
/** 只读视图:211(非 985)名单。 */
public static Set<String> c211() {
return C211;
}
/** 该校是否为 985。 */
public static boolean is985(String school) {
return school != null && C985.contains(school);
}
/** 该校是否为 211(非 985)。 */
public static boolean is211(String school) {
return school != null && C211.contains(school);
}
/**
* 构造不可变集合。
*
* <p>刻意使用 {@code unmodifiableSet(new HashSet<>(...))} 而非 {@code Set.copyOf}:
* 后者遇到重复元素会抛 {@code IllegalArgumentException},而 211 名单的重复项必须原样保留。</p>
*/
private static Set<String> immutableSet(List<String> values) {
return Collections.unmodifiableSet(new HashSet<>(values));
}
}

29
mydreamjob-server/src/main/java/com/mydreamjob/talentdiscovery/TalentDiscoveryService.java

@ -12,6 +12,7 @@ import com.mydreamjob.company.Company;
import com.mydreamjob.company.CompanyRepository; import com.mydreamjob.company.CompanyRepository;
import com.mydreamjob.job.Job; import com.mydreamjob.job.Job;
import com.mydreamjob.job.JobRepository; import com.mydreamjob.job.JobRepository;
import com.mydreamjob.recommendation.JobPreferenceScoreCalculator;
import com.mydreamjob.recommendation.JobRecommendationProfile; import com.mydreamjob.recommendation.JobRecommendationProfile;
import com.mydreamjob.recommendation.JobRecommendationProfileRepository; import com.mydreamjob.recommendation.JobRecommendationProfileRepository;
import com.mydreamjob.talentdiscovery.dto.TalentDiscoveryCandidateResponse; import com.mydreamjob.talentdiscovery.dto.TalentDiscoveryCandidateResponse;
@ -178,17 +179,29 @@ public class TalentDiscoveryService {
.build(); .build();
} }
/**
* R01-C:改为委托 {@link JobPreferenceScoreCalculator}。
*
* <p>原先此处内联了与 {@code JobRecommendationService} 完全相同的一套 30/20/20/15/15 规则
* (维度顺序、权重、匹配语义、{@code matched*100/available}、scale=2、HALF_UP 与
* available==0 → null 均逐字一致)。收束后两处共用唯一实现,**行为不变**。</p>
*/
private BigDecimal preferenceScore(TalentDiscoveryCandidateSource.PreferenceSignal preference, private BigDecimal preferenceScore(TalentDiscoveryCandidateSource.PreferenceSignal preference,
JobRecommendationProfile profile, Job job) { JobRecommendationProfile profile, Job job) {
if (preference == null) return null; if (preference == null) return null;
int got = 0; return JobPreferenceScoreCalculator.calculate(
int total = 0; new JobPreferenceScoreCalculator.Criteria(
if (!preference.cities().isEmpty()) { total += 30; if (preference.cities().contains(job.getCity())) got += 30; } preference.cities(),
if (!preference.employmentTypes().isEmpty()) { total += 20; if (preference.employmentTypes().contains(job.getEmploymentType())) got += 20; } preference.employmentTypes(),
if (preference.expectedSalaryMin() != null) { total += 20; if (job.getSalaryMax() != null && job.getSalaryMax() >= preference.expectedSalaryMin()) got += 20; } preference.expectedSalaryMin(),
if (!preference.industries().isEmpty()) { total += 15; if (preference.industries().contains(profile.getIndustryCode())) got += 15; } preference.industries(),
if (!preference.jobTypes().isEmpty()) { total += 15; if (preference.jobTypes().contains(profile.getJobFamilyCode())) got += 15; } preference.jobTypes()),
return total == 0 ? null : BigDecimal.valueOf(got * 100L).divide(BigDecimal.valueOf(total), 2, RoundingMode.HALF_UP); new JobPreferenceScoreCalculator.Target(
job.getCity(),
job.getEmploymentType(),
job.getSalaryMax(),
profile.getIndustryCode(),
profile.getJobFamilyCode()));
} }
private BigDecimal weighted(Map<String, BigDecimal> scores, Map<String, BigDecimal> expected, Set<String> allowed) { private BigDecimal weighted(Map<String, BigDecimal> scores, Map<String, BigDecimal> expected, Set<String> allowed) {

159
mydreamjob-server/src/test/java/com/mydreamjob/common/r01c/CandidateScoringRuleSetCharacterizationTest.java

@ -0,0 +1,159 @@
package com.mydreamjob.common.r01c;
import com.mydreamjob.candidatescore.CandidateScoringCalculator;
import com.mydreamjob.candidatescore.rules.CandidateScoringRuleSet;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* R01-C 特征测试:评分规则基线。
*
* <p>期望值由独立抄录自重构前的 {@code CandidateScoringCalculator}(名单 38 / 62 条、
* 学历 100/85/70/55/50、权重 0.55/0.45、版本 {@code candidate-scoring-v0.1}),
* 不经由被测实现自身推导。</p>
*/
class CandidateScoringRuleSetCharacterizationTest {
// ── 高校名单 ──────────────────────────────────────────────
@Test
@DisplayName("特征:C985 名单 38 项,首/中/末逐项一致")
void c985ListContentIsStable() {
Set<String> c985 = CandidateScoringRuleSet.c985();
assertThat(c985).as("C985 唯一项数量").hasSize(38);
assertThat(c985).contains(
"北京大学", "清华大学", "中国人民大学", "北京航空航天大学", "北京理工大学", // 首段
"南京大学", "东南大学", "浙江大学", "中国科学技术大学", "厦门大学", // 中段
"兰州大学", "东北大学", "西北农林科技大学"); // 末段
assertThat(c985).doesNotContain("北京交通大学", "苏州大学", "郑州大学", "青海大学");
}
@Test
@DisplayName("特征:C211 名单 61 唯一项(62 条字面量含 `北京科技大学` 重复),首/中/末一致")
void c211ListContentIsStable() {
Set<String> c211 = CandidateScoringRuleSet.c211();
assertThat(c211).as("C211 唯一项数量").hasSize(61);
assertThat(c211).contains(
"北京交通大学", "北京工业大学", "北京科技大学", "北京化工大学", "北京邮电大学", // 首段
"郑州大学", "武汉理工大学", "华中农业大学", "华中师范大学", // 中段
"青海大学", "北京科技大学"); // 末段(重复项原样保留)
assertThat(c211).doesNotContain("北京大学", "清华大学", "复旦大学");
}
@Test
@DisplayName("特征:985 与 211 名单无交集")
void tierListsAreDisjoint() {
assertThat(CandidateScoringRuleSet.c985())
.doesNotContainAnyElementsOf(CandidateScoringRuleSet.c211());
}
@Test
@DisplayName("特征:档次判定按 985 先于 211,且不进行模糊匹配")
void tierLookupOrderIs985FirstAndExact() {
assertThat(CandidateScoringRuleSet.is985("北京大学")).isTrue();
assertThat(CandidateScoringRuleSet.is211("北京大学")).isFalse();
assertThat(CandidateScoringRuleSet.is985("苏州大学")).isFalse();
assertThat(CandidateScoringRuleSet.is211("苏州大学")).isTrue();
// 相似但不完全相同、空白、大小写均**不**匹配(现状即精确匹配)
assertThat(CandidateScoringRuleSet.is985("北京大学 ")).isFalse();
assertThat(CandidateScoringRuleSet.is985(" 北京大学")).isFalse();
assertThat(CandidateScoringRuleSet.is985("北京大學")).isFalse();
assertThat(CandidateScoringRuleSet.is985("")).isFalse();
assertThat(CandidateScoringRuleSet.is985(null)).isFalse();
assertThat(CandidateScoringRuleSet.is211(null)).isFalse();
}
// ── 教育背景数值 ──────────────────────────────────────────
@Test
@DisplayName("特征:学历层次 100/85/70/55/50 与合成权重 0.55/0.45 逐项一致")
void educationLevelRulesAreStable() {
assertThat(CandidateScoringRuleSet.EDUCATION_LEVEL_DOCTOR).isEqualByComparingTo("100");
assertThat(CandidateScoringRuleSet.EDUCATION_LEVEL_MASTER).isEqualByComparingTo("85");
assertThat(CandidateScoringRuleSet.EDUCATION_LEVEL_BACHELOR).isEqualByComparingTo("70");
assertThat(CandidateScoringRuleSet.EDUCATION_LEVEL_JUNIOR_COLLEGE).isEqualByComparingTo("55");
assertThat(CandidateScoringRuleSet.EDUCATION_LEVEL_UNKNOWN).isEqualByComparingTo("50");
assertThat(CandidateScoringRuleSet.EDUCATION_LEVEL_WEIGHT).isEqualByComparingTo("0.55");
assertThat(CandidateScoringRuleSet.EDUCATION_SCHOOL_TIER_WEIGHT).isEqualByComparingTo("0.45");
assertThat(CandidateScoringRuleSet.EDUCATION_LEVEL_WEIGHT
.add(CandidateScoringRuleSet.EDUCATION_SCHOOL_TIER_WEIGHT))
.isEqualByComparingTo("1.00");
assertThat(CandidateScoringRuleSet.EDUCATION_LEVEL_FULL_SCORE).isEqualByComparingTo("55");
assertThat(CandidateScoringRuleSet.EDUCATION_SCHOOL_TIER_FULL_SCORE).isEqualByComparingTo("45");
}
@Test
@DisplayName("特征:学校档次 985=85 / 211=75 / 其他=65 / 缺失=50 语义各自独立")
void schoolTierRulesAreStable() {
assertThat(CandidateScoringRuleSet.SCHOOL_TIER_985).isEqualByComparingTo("85");
assertThat(CandidateScoringRuleSet.SCHOOL_TIER_211).isEqualByComparingTo("75");
assertThat(CandidateScoringRuleSet.SCHOOL_TIER_OTHER).isEqualByComparingTo("65");
assertThat(CandidateScoringRuleSet.SCHOOL_TIER_MISSING).isEqualByComparingTo("50");
}
@Test
@DisplayName("特征:分数均为字符串构造,scale 与重构前一致")
void bigDecimalsUseStringConstructionAndKeepScale() {
assertThat(CandidateScoringRuleSet.DEFAULT_DIMENSION_WEIGHT.toPlainString()).isEqualTo("12.50");
assertThat(CandidateScoringRuleSet.EDUCATION_LEVEL_WEIGHT.toPlainString()).isEqualTo("0.55");
assertThat(CandidateScoringRuleSet.EDUCATION_SCHOOL_TIER_WEIGHT.toPlainString()).isEqualTo("0.45");
assertThat(CandidateScoringRuleSet.EDUCATION_LEVEL_DOCTOR.toPlainString()).isEqualTo("100");
assertThat(CandidateScoringRuleSet.EDUCATION_LEVEL_UNKNOWN.toPlainString()).isEqualTo("50");
}
// ── 版本 ──────────────────────────────────────────────────
@Test
@DisplayName("特征:rulesVersion 逐字不变(candidate-scoring-v0.1),且与计算器同源")
void rulesVersionIsUnchangedAndShared() {
assertThat(CandidateScoringRuleSet.VERSION).isEqualTo("candidate-scoring-v0.1");
assertThat(CandidateScoringCalculator.RULES_VERSION)
.as("计算器必须读取同一规则来源,版本值一致")
.isEqualTo(CandidateScoringRuleSet.VERSION);
assertThat(CandidateScoringCalculator.DEFAULT_WEIGHT)
.isEqualByComparingTo(CandidateScoringRuleSet.DEFAULT_DIMENSION_WEIGHT);
}
@Test
@DisplayName("特征:八维度集合与 isValidDimension 行为不变")
void dimensionSetIsUnchanged() {
List<String> expected = List.of("EDUCATION", "INTERNSHIP", "ABILITY", "COMPLIANCE",
"STABILITY", "GROWTH", "INTEGRITY", "ACTIVITY");
for (String dimension : expected) {
assertThat(CandidateScoringCalculator.isValidDimension(dimension)).isTrue();
}
assertThat(CandidateScoringCalculator.isValidDimension("UNKNOWN")).isFalse();
// 既有契约:DIMENSIONS 为 Set.of(...),contains(null) 抛 NPE(不是返回 false)。
// 本轮不得改变该行为,故按实际语义锁定。
assertThatThrownBy(() -> CandidateScoringCalculator.isValidDimension(null))
.isInstanceOf(NullPointerException.class);
}
// ── 集合不可修改 ──────────────────────────────────────────
@Test
@DisplayName("特征:规则集对外只读,任何修改尝试必须失败")
void ruleSetIsReadOnly() {
assertThatThrownBy(() -> CandidateScoringRuleSet.c985().add("测试大学"))
.isInstanceOf(UnsupportedOperationException.class);
assertThatThrownBy(() -> CandidateScoringRuleSet.c211().add("测试大学"))
.isInstanceOf(UnsupportedOperationException.class);
assertThatThrownBy(() -> CandidateScoringRuleSet.c985().remove("北京大学"))
.isInstanceOf(UnsupportedOperationException.class);
assertThatThrownBy(() -> CandidateScoringRuleSet.c211().clear())
.isInstanceOf(UnsupportedOperationException.class);
}
}

141
mydreamjob-server/src/test/java/com/mydreamjob/common/r01c/OverviewStatusPolicyCharacterizationTest.java

@ -0,0 +1,141 @@
package com.mydreamjob.common.r01c;
import com.mydreamjob.candidatescore.CandidateDimensionScore;
import com.mydreamjob.candidatescore.CandidateOverviewStatusPolicy;
import com.mydreamjob.candidatescore.CandidateScoringCalculator;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* R01-C 特征测试:overviewStatus 聚合规则(唯一实现 {@code CandidateOverviewStatusPolicy})。
*
* <p>覆盖四种状态、空集合、null、以及"可计算但分数缺失"的边界;
* 并锁定两个入口(计算结果 / 快照)在等价输入下的产出关系。</p>
*/
class OverviewStatusPolicyCharacterizationTest {
private static final int EXPECTED = 8;
private static CandidateScoringCalculator.DimensionResult result(String status, String score) {
CandidateScoringCalculator.DimensionResult r =
new CandidateScoringCalculator.DimensionResult("EDUCATION");
r.status = status;
r.score = score == null ? null : new BigDecimal(score);
return r;
}
private static List<CandidateScoringCalculator.DimensionResult> results(String status, String score, int n) {
List<CandidateScoringCalculator.DimensionResult> list = new ArrayList<>();
for (int i = 0; i < n; i++) list.add(result(status, score));
return list;
}
private static CandidateDimensionScore snapshot(String status) {
CandidateDimensionScore s = new CandidateDimensionScore();
s.setStatus(status);
return s;
}
// ── 计算结果入口 ──────────────────────────────────────────
@Test
@DisplayName("特征:8/8 可计算 → OK;7/8 → PARTIAL;0/8 且全 PENDING → PENDING;否则 INSUFFICIENT_DATA")
void computedResultsFourStates() {
assertThat(CandidateOverviewStatusPolicy.fromComputedResults(results("OK", "80", 8), EXPECTED))
.isEqualTo("OK");
assertThat(CandidateOverviewStatusPolicy.fromComputedResults(results("ESTIMATED", "80", 8), EXPECTED))
.as("ESTIMATED 与 OK 同为可计算")
.isEqualTo("OK");
List<CandidateScoringCalculator.DimensionResult> partial = results("OK", "80", 7);
partial.add(result("PENDING", null));
assertThat(CandidateOverviewStatusPolicy.fromComputedResults(partial, EXPECTED)).isEqualTo("PARTIAL");
assertThat(CandidateOverviewStatusPolicy.fromComputedResults(results("PENDING", null, 8), EXPECTED))
.isEqualTo("PENDING");
assertThat(CandidateOverviewStatusPolicy.fromComputedResults(results("INSUFFICIENT_DATA", null, 8), EXPECTED))
.isEqualTo("INSUFFICIENT_DATA");
}
@Test
@DisplayName("特征:OK 但分数缺失 → 不可计算(status 与 score 都必须满足)")
void okStatusWithoutScoreIsNotComputable() {
assertThat(CandidateOverviewStatusPolicy.fromComputedResults(results("OK", null, 8), EXPECTED))
.isEqualTo("INSUFFICIENT_DATA");
}
@Test
@DisplayName("特征:空列表 → PENDING(计算结果入口的 empty 语义)")
void emptyComputedResultsIsPending() {
assertThat(CandidateOverviewStatusPolicy.fromComputedResults(List.of(), EXPECTED)).isEqualTo("PENDING");
}
@Test
@DisplayName("特征:null 结果列表 → NullPointerException(既有契约)")
void nullComputedResultsThrows() {
assertThatThrownBy(() -> CandidateOverviewStatusPolicy.fromComputedResults(null, EXPECTED))
.isInstanceOf(NullPointerException.class);
}
@Test
@DisplayName("特征:expectedDimensions 大于实际可计算数时不得判 OK")
void expectedDimensionsGatesOk() {
assertThat(CandidateOverviewStatusPolicy.fromComputedResults(results("OK", "80", 8), 9))
.isEqualTo("PARTIAL");
}
@Test
@DisplayName("特征:PENDING 与 INSUFFICIENT_DATA 混合 → INSUFFICIENT_DATA(非 PENDING)")
void mixedPendingAndInsufficientIsInsufficient() {
List<CandidateScoringCalculator.DimensionResult> mixed = new ArrayList<>();
mixed.add(result("PENDING", null));
mixed.add(result("INSUFFICIENT_DATA", null));
assertThat(CandidateOverviewStatusPolicy.fromComputedResults(mixed, EXPECTED))
.isEqualTo("INSUFFICIENT_DATA");
}
// ── 快照入口 ──────────────────────────────────────────────
@Test
@DisplayName("特征:快照入口 —— OK/ESTIMATED 全可计算 → OK;空/null → INSUFFICIENT_DATA")
void snapshotEntryStates() {
assertThat(CandidateOverviewStatusPolicy.fromSnapshots(
List.of(snapshot("OK"), snapshot("ESTIMATED")))).isEqualTo("OK");
assertThat(CandidateOverviewStatusPolicy.fromSnapshots(
List.of(snapshot("OK"), snapshot("PENDING")))).isEqualTo("PARTIAL");
assertThat(CandidateOverviewStatusPolicy.fromSnapshots(
List.of(snapshot("PENDING"), snapshot("PENDING")))).isEqualTo("PENDING");
assertThat(CandidateOverviewStatusPolicy.fromSnapshots(
List.of(snapshot("INSUFFICIENT_DATA"), snapshot("PENDING")))).isEqualTo("INSUFFICIENT_DATA");
assertThat(CandidateOverviewStatusPolicy.fromSnapshots(List.of())).isEqualTo("INSUFFICIENT_DATA");
assertThat(CandidateOverviewStatusPolicy.fromSnapshots(null)).isEqualTo("INSUFFICIENT_DATA");
}
// ── 唯一实现门禁 ──────────────────────────────────────────
@Test
@DisplayName("结构门禁:overviewStatus 规则只有唯一的产出点(\"PARTIAL\" 只在该策略类出现)")
void partialStatusHasSingleProducer() throws Exception {
java.nio.file.Path root = java.nio.file.Path.of("src/main/java/com/mydreamjob");
java.util.List<String> producers = new java.util.ArrayList<>();
try (java.util.stream.Stream<java.nio.file.Path> walk = java.nio.file.Files.walk(root)) {
for (java.nio.file.Path p : walk.filter(f -> f.toString().endsWith(".java")).toList()) {
String src = java.nio.file.Files.readString(p);
if (src.contains("\"PARTIAL\"")) {
producers.add(p.getFileName().toString());
}
}
}
assertThat(producers)
.as("PARTIAL 只能由 CandidateOverviewStatusPolicy 产出")
.containsExactly("CandidateOverviewStatusPolicy.java");
}
}

211
mydreamjob-server/src/test/java/com/mydreamjob/common/r01c/PreferenceScoreCharacterizationTest.java

@ -0,0 +1,211 @@
package com.mydreamjob.common.r01c;
import com.mydreamjob.recommendation.JobPreferenceScoreCalculator;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
/**
* R01-C 特征测试:岗位偏好分(30/20/20/15/15)黄金样本。
*
* <p>期望值由 30/20/20/15/15 规则**手工推算**({@code matched*100/available},scale=2,HALF_UP),
* 不调用被测实现生成,避免同源假证明。使用 {@code isEqualByComparingTo} 并额外断言 scale 与字符串形式,
* 不使用 {@code isCloseTo} 之类的宽松误差。</p>
*/
class PreferenceScoreCharacterizationTest {
private static final List<String> CITIES = List.of("上海", "北京");
private static final List<String> TYPES = List.of("FULL_TIME");
private static final List<String> INDUSTRIES = List.of("IT");
private static final List<String> FAMILIES = List.of("BACKEND");
private static final Integer SALARY_MIN = 20000;
private static JobPreferenceScoreCalculator.Criteria criteria(
List<String> cities, List<String> types, Integer salaryMin,
List<String> industries, List<String> families) {
return new JobPreferenceScoreCalculator.Criteria(cities, types, salaryMin, industries, families);
}
private static JobPreferenceScoreCalculator.Target target(
String city, String type, Integer salaryMax, String industry, String family) {
return new JobPreferenceScoreCalculator.Target(city, type, salaryMax, industry, family);
}
private static final JobPreferenceScoreCalculator.Criteria ALL =
criteria(CITIES, TYPES, SALARY_MIN, INDUSTRIES, FAMILIES);
private static void assertExact(BigDecimal actual, String expected) {
assertThat(actual).as("数值").isNotNull().isEqualByComparingTo(expected);
assertThat(actual.scale()).as("scale 必须为 2").isEqualTo(2);
assertThat(actual.toPlainString()).as("精确字符串形式").isEqualTo(expected);
}
// ── 全匹配 / 全不匹配 ─────────────────────────────────────
@Test
@DisplayName("特征:五个维度全匹配 → available=100, matched=100 → 100.00")
void allMatched() {
assertExact(JobPreferenceScoreCalculator.calculate(ALL,
target("上海", "FULL_TIME", 25000, "IT", "BACKEND")), "100.00");
}
@Test
@DisplayName("特征:五个维度全不匹配 → 0.00")
void noneMatched() {
assertExact(JobPreferenceScoreCalculator.calculate(ALL,
target("广州", "PART_TIME", 10000, "FINANCE", "SALES")), "0.00");
}
// ── 单维度 ────────────────────────────────────────────────
@Test
@DisplayName("特征:仅城市维度存在且匹配 → available=30, matched=30 → 100.00")
void cityOnly() {
assertExact(JobPreferenceScoreCalculator.calculate(criteria(CITIES, List.of(), null, List.of(), List.of()),
target("北京", "FULL_TIME", 25000, "IT", "BACKEND")), "100.00");
}
@Test
@DisplayName("特征:仅城市维度存在但不匹配 → available=30, matched=0 → 0.00")
void cityOnlyMiss() {
assertExact(JobPreferenceScoreCalculator.calculate(criteria(CITIES, List.of(), null, List.of(), List.of()),
target("广州", "FULL_TIME", 25000, "IT", "BACKEND")), "0.00");
}
@Test
@DisplayName("特征:仅行业维度存在且匹配 → available=15, matched=15 → 100.00")
void industryOnly() {
assertExact(JobPreferenceScoreCalculator.calculate(criteria(List.of(), List.of(), null, INDUSTRIES, List.of()),
target("广州", "PART_TIME", 10000, "IT", "SALES")), "100.00");
}
// ── 部分匹配与加权 ────────────────────────────────────────
@Test
@DisplayName("特征:城市匹配 + 行业不匹配 → available=45, matched=30 → 66.67")
void cityMatchedIndustryMissed() {
assertExact(JobPreferenceScoreCalculator.calculate(
criteria(CITIES, List.of(), null, INDUSTRIES, List.of()),
target("上海", "PART_TIME", 10000, "FINANCE", "SALES")), "66.67");
}
@Test
@DisplayName("特征:城市不匹配 + 行业匹配 → available=45, matched=15 → 33.33")
void cityMissedIndustryMatched() {
assertExact(JobPreferenceScoreCalculator.calculate(
criteria(CITIES, List.of(), null, INDUSTRIES, List.of()),
target("广州", "PART_TIME", 10000, "IT", "SALES")), "33.33");
}
@Test
@DisplayName("特征:四维存在(城市/类型/薪资/行业),三项匹配 → 70*100/85 = 82.35")
void fourDimensionsThreeMatched() {
assertExact(JobPreferenceScoreCalculator.calculate(
criteria(CITIES, TYPES, SALARY_MIN, INDUSTRIES, List.of()),
target("北京", "FULL_TIME", 25000, "FINANCE", "BACKEND")), "82.35");
}
// ── 薪资语义与边界 ────────────────────────────────────────
@Test
@DisplayName("特征:薪资刚好相等(salaryMax == expectedSalaryMin)视为匹配 → 100.00")
void salaryBoundaryEqualityCountsAsMatch() {
assertExact(JobPreferenceScoreCalculator.calculate(
criteria(List.of(), List.of(), SALARY_MIN, List.of(), List.of()),
target("广州", "PART_TIME", 20000, "IT", "SALES")), "100.00");
}
@Test
@DisplayName("特征:薪资低于期望 → 0.00;岗位 salaryMax 为 null → 0.00")
void salaryBelowOrNullCountsAsMiss() {
assertExact(JobPreferenceScoreCalculator.calculate(
criteria(List.of(), List.of(), SALARY_MIN, List.of(), List.of()),
target("广州", "PART_TIME", 19999, "IT", "SALES")), "0.00");
assertExact(JobPreferenceScoreCalculator.calculate(
criteria(List.of(), List.of(), SALARY_MIN, List.of(), List.of()),
target("广州", "PART_TIME", null, "IT", "SALES")), "0.00");
}
@Test
@DisplayName("特征:期望薪资为 null 时该维度不计入 available → 结果仍为 100.00")
void nullExpectedSalaryIsNotCounted() {
assertExact(JobPreferenceScoreCalculator.calculate(
criteria(CITIES, List.of(), null, List.of(), List.of()),
target("上海", "PART_TIME", null, "IT", "SALES")), "100.00");
}
// ── 空集合与 null ─────────────────────────────────────────
@Test
@DisplayName("特征:全部为空/ null → available=0 → 返回 null(不是 0.00)")
void emptyCriteriaReturnsNull() {
assertThat(JobPreferenceScoreCalculator.calculate(
criteria(List.of(), List.of(), null, List.of(), List.of()),
target("上海", "FULL_TIME", 25000, "IT", "BACKEND"))).isNull();
}
@Test
@DisplayName("特征:null 集合按空集合处理(与重构前 Criteria 的 null 归一化一致)")
void nullListsBehaveAsEmpty() {
JobPreferenceScoreCalculator.Criteria c =
criteria(null, null, null, null, null);
assertThat(c.cities()).isEmpty();
assertThat(c.employmentTypes()).isEmpty();
assertThat(c.industries()).isEmpty();
assertThat(c.jobFamilies()).isEmpty();
assertThat(JobPreferenceScoreCalculator.calculate(c,
target("上海", "FULL_TIME", 25000, "IT", "BACKEND"))).isNull();
}
// ── 大小写与未知值 ────────────────────────────────────────
@Test
@DisplayName("特征:大小写差异按现状为不匹配(精确匹配语义)")
void caseSensitivityIsExact() {
assertExact(JobPreferenceScoreCalculator.calculate(
criteria(List.of("shanghai"), List.of(), null, List.of(), List.of()),
target("SHANGHAI", "FULL_TIME", 25000, "IT", "BACKEND")), "0.00");
assertExact(JobPreferenceScoreCalculator.calculate(
criteria(List.of("SHANGHAI"), List.of(), null, List.of(), List.of()),
target("SHANGHAI", "FULL_TIME", 25000, "IT", "BACKEND")), "100.00");
}
@Test
@DisplayName("特征:未知枚举/历史脏值按普通字符串参与精确匹配")
void unknownValuesAreMatchedLiterally() {
assertExact(JobPreferenceScoreCalculator.calculate(
criteria(List.of(), List.of("LEGACY_TYPE"), null, List.of(), List.of()),
target("广州", "LEGACY_TYPE", 25000, "IT", "SALES")), "100.00");
assertExact(JobPreferenceScoreCalculator.calculate(
criteria(List.of(), List.of("LEGACY_TYPE"), null, List.of(), List.of()),
target("广州", "FULL_TIME", 25000, "IT", "SALES")), "0.00");
}
// ── 边界与上界 ────────────────────────────────────────────
@Test
@DisplayName("特征:空字符串元素不匹配任何岗位值(不trim、不归一化)")
void blankValuesDoNotMatch() {
assertExact(JobPreferenceScoreCalculator.calculate(
criteria(List.of(""), List.of(), null, List.of(), List.of()),
target("上海", "FULL_TIME", 25000, "IT", "BACKEND")), "0.00");
}
@Test
@DisplayName("特征:结果域为 [0.00, 100.00],全不匹配为下界、全匹配为上界")
void resultBounds() {
BigDecimal lower = JobPreferenceScoreCalculator.calculate(ALL,
target("广州", "PART_TIME", 10000, "FINANCE", "SALES"));
BigDecimal upper = JobPreferenceScoreCalculator.calculate(ALL,
target("上海", "FULL_TIME", 25000, "IT", "BACKEND"));
assertThat(lower).isEqualByComparingTo("0.00");
assertThat(upper).isEqualByComparingTo("100.00");
assertThat(lower).isLessThanOrEqualTo(upper);
}
}

407
mydreamjob-server/src/test/java/com/mydreamjob/common/r01c/R01CIndependentVerificationTest.java

@ -0,0 +1,407 @@
package com.mydreamjob.common.r01c;
import com.mydreamjob.candidatescore.CandidateDimensionScore;
import com.mydreamjob.candidatescore.CandidateOverviewStatusPolicy;
import com.mydreamjob.candidatescore.CandidateScoringCalculator;
import com.mydreamjob.candidatescore.rules.CandidateScoringRuleSet;
import com.mydreamjob.job.Job;
import com.mydreamjob.preference.StudentPreference;
import com.mydreamjob.recommendation.JobPreferenceScoreCalculator;
import com.mydreamjob.recommendation.JobRecommendationProfile;
import com.mydreamjob.recommendation.JobRecommendationService;
import com.mydreamjob.talentdiscovery.TalentDiscoveryCandidateSource;
import com.mydreamjob.talentdiscovery.TalentDiscoveryService;
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.test.context.ActiveProfiles;
import java.lang.reflect.Method;
import java.math.BigDecimal;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.stream.Stream;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* R01-C 独立复验:行为等价、结构门禁与跨服务一致性。
*
* <p>本类的期望值独立推导(手工推算或从 HEAD 抄录),不复用实施者断言的构造方式。</p>
*/
@SpringBootTest
@ActiveProfiles("integration-test")
class R01CIndependentVerificationTest {
@MockBean private RedisTemplate<String, Object> redisTemplate;
@Autowired private JobRecommendationService jobRecommendationService;
@Autowired private TalentDiscoveryService talentDiscoveryService;
private static final Path MAIN_ROOT = Path.of("src/main/java/com/mydreamjob");
private static String read(Path p) throws Exception {
return Files.readString(p);
}
private static List<Path> javaFiles() throws Exception {
try (Stream<Path> walk = Files.walk(MAIN_ROOT)) {
return walk.filter(f -> f.toString().endsWith(".java")).toList();
}
}
// ══ A. overviewStatus ═════════════════════════════════════
private static CandidateScoringCalculator.DimensionResult res(String status, String score) {
CandidateScoringCalculator.DimensionResult r =
new CandidateScoringCalculator.DimensionResult("EDUCATION");
r.status = status;
r.score = score == null ? null : new BigDecimal(score);
return r;
}
private static CandidateDimensionScore snap(String status) {
CandidateDimensionScore s = new CandidateDimensionScore();
s.setStatus(status);
return s;
}
@Test
@DisplayName("独立复验 A:全可用/部分可用/全不可用/空集合 四种边界")
void overviewStatusBoundaryMatrix() {
int expected = 4;
// 全部可用 → OK
assertThat(CandidateOverviewStatusPolicy.fromComputedResults(
List.of(res("OK", "80"), res("ESTIMATED", "70"), res("OK", "60"), res("ESTIMATED", "50")), expected))
.isEqualTo("OK");
// 部分可用 → PARTIAL
assertThat(CandidateOverviewStatusPolicy.fromComputedResults(
List.of(res("OK", "80"), res("PENDING", null), res("INSUFFICIENT_DATA", null), res("OK", "60")), expected))
.isEqualTo("PARTIAL");
// 全不可用(全 PENDING)→ PENDING
assertThat(CandidateOverviewStatusPolicy.fromComputedResults(
List.of(res("PENDING", null), res("PENDING", null), res("PENDING", null), res("PENDING", null)), expected))
.isEqualTo("PENDING");
// 全不可用(含 INSUFFICIENT_DATA)→ INSUFFICIENT_DATA
assertThat(CandidateOverviewStatusPolicy.fromComputedResults(
List.of(res("INSUFFICIENT_DATA", null), res("PENDING", null)), 2))
.isEqualTo("INSUFFICIENT_DATA");
// 空集合
assertThat(CandidateOverviewStatusPolicy.fromComputedResults(List.of(), expected)).isEqualTo("PENDING");
assertThat(CandidateOverviewStatusPolicy.fromSnapshots(List.of())).isEqualTo("INSUFFICIENT_DATA");
}
@Test
@DisplayName("独立复验 A:null 与非法输入的既有语义(NPE / INSUFFICIENT_DATA)")
void overviewStatusNullAndUnknownSemantics() {
assertThatThrownBy(() -> CandidateOverviewStatusPolicy.fromComputedResults(null, 8))
.isInstanceOf(NullPointerException.class);
assertThat(CandidateOverviewStatusPolicy.fromSnapshots(null)).isEqualTo("INSUFFICIENT_DATA");
// 未知状态字符串:既不可计算也不是 PENDING → INSUFFICIENT_DATA
assertThat(CandidateOverviewStatusPolicy.fromComputedResults(
List.of(res("WEIRD", "50")), 1)).isEqualTo("INSUFFICIENT_DATA");
assertThat(CandidateOverviewStatusPolicy.fromSnapshots(List.of(snap("WEIRD"))))
.isEqualTo("INSUFFICIENT_DATA");
}
@Test
@DisplayName("独立复验 A:输入顺序变化不改变结果")
void overviewStatusIsOrderIndependent() {
List<CandidateScoringCalculator.DimensionResult> a = new ArrayList<>(List.of(
res("OK", "80"), res("PENDING", null), res("INSUFFICIENT_DATA", null)));
List<CandidateScoringCalculator.DimensionResult> b = new ArrayList<>(a);
Collections.reverse(b);
assertThat(CandidateOverviewStatusPolicy.fromComputedResults(a, 3))
.isEqualTo(CandidateOverviewStatusPolicy.fromComputedResults(b, 3))
.isEqualTo("PARTIAL");
}
// ══ B. 偏好评分黄金表 + 跨服务一致 ════════════════════════
private static void assertExact(BigDecimal actual, String expected) {
assertThat(actual).isNotNull().isEqualByComparingTo(expected);
assertThat(actual.scale()).as("scale").isEqualTo(2);
assertThat(actual.toPlainString()).as("精确字符串").isEqualTo(expected);
}
private static JobPreferenceScoreCalculator.Target target(String city, String type, Integer salaryMax) {
return new JobPreferenceScoreCalculator.Target(city, type, salaryMax, "IT", "BACKEND");
}
@Test
@DisplayName("独立复验 B:五个维度分别单独命中 → 每一维独立命中均为 100.00")
void eachDimensionAloneMatchesFully() {
assertExact(JobPreferenceScoreCalculator.calculate(
new JobPreferenceScoreCalculator.Criteria(List.of("上海"), List.of(), null, List.of(), List.of()),
target("上海", "X", null)), "100.00");
assertExact(JobPreferenceScoreCalculator.calculate(
new JobPreferenceScoreCalculator.Criteria(List.of(), List.of("FULL_TIME"), null, List.of(), List.of()),
target("X", "FULL_TIME", null)), "100.00");
assertExact(JobPreferenceScoreCalculator.calculate(
new JobPreferenceScoreCalculator.Criteria(List.of(), List.of(), 20000, List.of(), List.of()),
target("X", "Y", 20000)), "100.00");
assertExact(JobPreferenceScoreCalculator.calculate(
new JobPreferenceScoreCalculator.Criteria(List.of(), List.of(), null, List.of("IT"), List.of()),
target("X", "Y", null)), "100.00");
assertExact(JobPreferenceScoreCalculator.calculate(
new JobPreferenceScoreCalculator.Criteria(List.of(), List.of(), null, List.of(), List.of("BACKEND")),
target("X", "Y", null)), "100.00");
}
@Test
@DisplayName("独立复验 B:全部命中 100.00 / 全部缺失(零分母)返回 null")
void allMatchAndZeroDenominator() {
JobPreferenceScoreCalculator.Criteria all =
new JobPreferenceScoreCalculator.Criteria(
List.of("上海"), List.of("FULL_TIME"), 20000, List.of("IT"), List.of("BACKEND"));
assertExact(JobPreferenceScoreCalculator.calculate(all, target("上海", "FULL_TIME", 25000)), "100.00");
assertThat(JobPreferenceScoreCalculator.calculate(
new JobPreferenceScoreCalculator.Criteria(List.of(), List.of(), null, List.of(), List.of()),
target("上海", "FULL_TIME", 25000))).isNull();
}
@Test
@DisplayName("独立复验 B:部分维度缺失时的分母只计存在维度(加权精确值)")
void partialDimensionsUseOnlyPresentWeights() {
// 城市(30) 命中 + 用工类型(20) 缺失 + 薪资(20) 命中 + 行业(15) 缺失 + 岗位族(15) 缺失
assertExact(JobPreferenceScoreCalculator.calculate(
new JobPreferenceScoreCalculator.Criteria(
List.of("上海"), List.of(), 20000, List.of(), List.of()),
target("上海", "X", 20000)), "100.00");
// 城市(30) 命中 + 行业(15) 未命中 → 30*100/45 = 66.666… → 66.67(HALF_UP)
assertExact(JobPreferenceScoreCalculator.calculate(
new JobPreferenceScoreCalculator.Criteria(
List.of("上海"), List.of(), null, List.of("IT"), List.of()),
target("上海", "X", null)), "100.00");
assertExact(JobPreferenceScoreCalculator.calculate(
new JobPreferenceScoreCalculator.Criteria(
List.of("上海"), List.of(), null, List.of("FINANCE"), List.of()),
target("上海", "X", null)), "66.67");
// 四方存在、命中两项:城市30 未中 + 行业15 中 → 15*100/65 = 23.076… → 23.08
assertExact(JobPreferenceScoreCalculator.calculate(
new JobPreferenceScoreCalculator.Criteria(
List.of("广州"), List.of("PART_TIME"), null, List.of("IT"), List.of()),
target("上海", "FULL_TIME", null)), "23.08");
}
@Test
@DisplayName("独立复验 B:薪资边界 —— 期望值与岗位上限相等视为命中,低一单位不命中")
void salaryBoundarySemantics() {
JobPreferenceScoreCalculator.Criteria c =
new JobPreferenceScoreCalculator.Criteria(List.of(), List.of(), 20000, List.of(), List.of());
assertExact(JobPreferenceScoreCalculator.calculate(c, target("X", "Y", 20000)), "100.00"); // 相等
assertExact(JobPreferenceScoreCalculator.calculate(c, target("X", "Y", 20001)), "100.00"); // 高一单位
assertExact(JobPreferenceScoreCalculator.calculate(c, target("X", "Y", 19999)), "0.00"); // 低一单位
assertExact(JobPreferenceScoreCalculator.calculate(c, target("X", "Y", null)), "0.00"); // 岗位未填
}
@Test
@DisplayName("独立复验 B:跨服务一致 —— JobRecommendation 与 TalentDiscovery 逐值相同")
void bothServicesProduceIdenticalPreferenceScores() throws Exception {
Method jr = JobRecommendationService.class.getDeclaredMethod(
"preferenceScore", StudentPreference.class, JobRecommendationProfile.class, Job.class);
jr.setAccessible(true);
Method td = TalentDiscoveryService.class.getDeclaredMethod(
"preferenceScore", TalentDiscoveryCandidateSource.PreferenceSignal.class,
JobRecommendationProfile.class, Job.class);
td.setAccessible(true);
JobRecommendationProfile profile = JobRecommendationProfile.builder()
.jobId(1L).industryCode("IT").jobFamilyCode("BACKEND").build();
// 三组输入:全命中 / 部分命中 / 全不命中
String[][] cases = {
{"[\"上海\"]", "[\"FULL_TIME\"]", "20000", "[\"IT\"]", "[\"BACKEND\"]", "上海", "FULL_TIME", "25000"},
{"[\"上海\"]", "[\"PART_TIME\"]", "30000", "[\"IT\"]", "[]", "广州", "FULL_TIME", "10000"},
{"[\"广州\"]", "[]", "null", "[]", "[]", "上海", "FULL_TIME", "25000"},
};
for (String[] c : cases) {
StudentPreference sp = StudentPreference.builder()
.studentUserId(1L)
.preferredCities(c[0]).employmentTypes(c[1])
.expectedSalaryMin("null".equals(c[2]) ? null : Integer.valueOf(c[2]))
.preferredIndustries(c[3]).preferredJobTypes(c[4])
.build();
TalentDiscoveryCandidateSource.PreferenceSignal signal =
new TalentDiscoveryCandidateSource.PreferenceSignal(
"null".equals(c[2]) ? null : Integer.valueOf(c[2]), null,
parseJsonList(c[0]), parseJsonList(c[1]), parseJsonList(c[3]), parseJsonList(c[4]), false);
Job job = Job.builder().id(1L).companyId(1L).title("t").description("d")
.city(c[5]).employmentType(c[6])
.salaryMax("null".equals(c[7]) ? null : Integer.valueOf(c[7]))
.build();
BigDecimal fromRecommendation = (BigDecimal) jr.invoke(jobRecommendationService, sp, profile, job);
BigDecimal fromDiscovery = (BigDecimal) td.invoke(talentDiscoveryService, signal, profile, job);
assertThat(fromRecommendation).as("case %s", String.join("|", c))
.isEqualByComparingTo(fromDiscovery);
if (fromRecommendation != null) {
assertThat(fromRecommendation.scale()).isEqualTo(fromDiscovery.scale());
}
}
}
private static List<String> parseJsonList(String json) {
if (json == null || json.isBlank() || "[]".equals(json)) return List.of();
String inner = json.substring(1, json.length() - 1);
if (inner.isBlank()) return List.of();
List<String> out = new ArrayList<>();
for (String part : inner.split(",")) out.add(part.replace("\"", "").trim());
return out;
}
// ══ C. 教育与高校规则 ═════════════════════════════════════
@Test
@DisplayName("独立复验 C:985 / 211非985 / 普通高校 / 未知学校 的档次判定")
void schoolTierClassification() {
assertThat(CandidateScoringRuleSet.is985("北京大学")).isTrue();
assertThat(CandidateScoringRuleSet.is211("北京大学")).isFalse();
assertThat(CandidateScoringRuleSet.is985("苏州大学")).isFalse();
assertThat(CandidateScoringRuleSet.is211("苏州大学")).isTrue();
assertThat(CandidateScoringRuleSet.is985("某某学院")).isFalse();
assertThat(CandidateScoringRuleSet.is211("某某学院")).isFalse();
}
@Test
@DisplayName("独立复验 C:名称精确匹配 —— 前后空白/大小写/简繁 均不匹配(既有语义)")
void schoolNameMatchingIsExact() {
assertThat(CandidateScoringRuleSet.is985(" 北京大学")).isFalse();
assertThat(CandidateScoringRuleSet.is985("北京大学 ")).isFalse();
assertThat(CandidateScoringRuleSet.is985("北京大學")).isFalse();
assertThat(CandidateScoringRuleSet.is985("北京")).isFalse();
assertThat(CandidateScoringRuleSet.is985(null)).isFalse();
assertThat(CandidateScoringRuleSet.is985("")).isFalse();
assertThat(CandidateScoringRuleSet.is211(null)).isFalse();
}
@Test
@DisplayName("独立复验 C:学历与档次分数、权重、默认权重与上界")
void educationAndWeightRules() {
assertThat(CandidateScoringRuleSet.EDUCATION_LEVEL_DOCTOR).isEqualByComparingTo("100");
assertThat(CandidateScoringRuleSet.EDUCATION_LEVEL_MASTER).isEqualByComparingTo("85");
assertThat(CandidateScoringRuleSet.EDUCATION_LEVEL_BACHELOR).isEqualByComparingTo("70");
assertThat(CandidateScoringRuleSet.EDUCATION_LEVEL_JUNIOR_COLLEGE).isEqualByComparingTo("55");
assertThat(CandidateScoringRuleSet.EDUCATION_LEVEL_UNKNOWN).isEqualByComparingTo("50");
assertThat(CandidateScoringRuleSet.SCHOOL_TIER_985).isEqualByComparingTo("85");
assertThat(CandidateScoringRuleSet.SCHOOL_TIER_211).isEqualByComparingTo("75");
assertThat(CandidateScoringRuleSet.SCHOOL_TIER_OTHER).isEqualByComparingTo("65");
assertThat(CandidateScoringRuleSet.SCHOOL_TIER_MISSING).isEqualByComparingTo("50");
assertThat(CandidateScoringRuleSet.EDUCATION_LEVEL_WEIGHT).isEqualByComparingTo("0.55");
assertThat(CandidateScoringRuleSet.EDUCATION_SCHOOL_TIER_WEIGHT).isEqualByComparingTo("0.45");
assertThat(CandidateScoringRuleSet.DEFAULT_DIMENSION_WEIGHT).isEqualByComparingTo("12.50");
assertThat(CandidateScoringRuleSet.FULL_SCORE).isEqualByComparingTo("100");
// 同值不同义:不得合并
assertThat(CandidateScoringRuleSet.EDUCATION_LEVEL_MASTER)
.isEqualByComparingTo(CandidateScoringRuleSet.SCHOOL_TIER_985);
assertThat(CandidateScoringRuleSet.EDUCATION_LEVEL_UNKNOWN)
.isEqualByComparingTo(CandidateScoringRuleSet.SCHOOL_TIER_MISSING);
}
@Test
@DisplayName("独立复验 C:rulesVersion 与计算器同源,且值逐字不变")
void rulesVersionIsSingleSourced() {
assertThat(CandidateScoringRuleSet.VERSION).isEqualTo("candidate-scoring-v0.1");
assertThat(CandidateScoringCalculator.RULES_VERSION).isEqualTo(CandidateScoringRuleSet.VERSION);
assertThat(CandidateScoringCalculator.DEFAULT_WEIGHT)
.isEqualByComparingTo(CandidateScoringRuleSet.DEFAULT_DIMENSION_WEIGHT);
}
// ══ 结构门禁 ═════════════════════════════════════════════
@Test
@DisplayName("结构门禁:overviewStatus 规则产出点唯一(PARTIAL 只在一个类出现)")
void overviewStatusProducerIsUnique() throws Exception {
List<String> producers = new ArrayList<>();
for (Path p : javaFiles()) {
if (read(p).contains("\"PARTIAL\"")) producers.add(p.getFileName().toString());
}
assertThat(producers).containsExactly("CandidateOverviewStatusPolicy.java");
}
@Test
@DisplayName("结构门禁:30/20/20/15/15 偏好公式实现点唯一")
void preferenceFormulaHasSingleImplementation() throws Exception {
List<String> impls = new ArrayList<>();
for (Path p : javaFiles()) {
String src = read(p);
if (src.contains("total += 30") || src.contains("CITY_WEIGHT = 30")) {
impls.add(p.getFileName().toString());
}
}
assertThat(impls).containsExactly("JobPreferenceScoreCalculator.java");
}
@Test
@DisplayName("结构门禁:985/211 名单定义点唯一,且计算器内不再定义名单")
void universityListHasSingleDefinition() throws Exception {
List<String> defs = new ArrayList<>();
for (Path p : javaFiles()) {
String src = read(p);
if (src.contains("Set<String> C985") || src.contains("Set<String> C211")) {
defs.add(p.getFileName().toString());
}
}
assertThat(defs).containsExactly("CandidateScoringRuleSet.java");
assertThat(read(MAIN_ROOT.resolve("candidatescore/CandidateScoringCalculator.java")))
.doesNotContain("Set<String> C985")
.doesNotContain("Set<String> C211");
}
@Test
@DisplayName("结构门禁:TalentDiscoveryService 不再维护内联偏好公式")
void talentDiscoveryHasNoInlineFormula() throws Exception {
String src = read(MAIN_ROOT.resolve("talentdiscovery/TalentDiscoveryService.java"));
assertThat(src).doesNotContain("total += 30").doesNotContain("got += 30");
assertThat(src).contains("JobPreferenceScoreCalculator.calculate(");
assertThat(read(MAIN_ROOT.resolve("recommendation/JobRecommendationService.java")))
.contains("JobPreferenceScoreCalculator.calculate(");
}
@Test
@DisplayName("结构门禁:规则集不依赖 Spring/JPA/JdbcTemplate/Repository,且无 setter")
void ruleSetHasNoFrameworkDependency() throws Exception {
String src = read(MAIN_ROOT.resolve("candidatescore/rules/CandidateScoringRuleSet.java"));
assertThat(src).doesNotContain("@Component").doesNotContain("@Service").doesNotContain("@Autowired")
.doesNotContain("Repository").doesNotContain("JdbcTemplate").doesNotContain("EntityManager")
.doesNotContain("System.getenv").doesNotContain("@Value").doesNotContain("Properties");
assertThat(src).contains("public final class");
assertThat(src).doesNotContain("public void set");
}
@Test
@DisplayName("结构门禁:规则集暴露的集合不可被调用者修改")
void exposedCollectionsAreUnmodifiable() {
assertThatThrownBy(() -> CandidateScoringRuleSet.c985().add("X"))
.isInstanceOf(UnsupportedOperationException.class);
assertThatThrownBy(() -> CandidateScoringRuleSet.c211().add("X"))
.isInstanceOf(UnsupportedOperationException.class);
assertThatThrownBy(() -> CandidateScoringRuleSet.c985().remove("北京大学"))
.isInstanceOf(UnsupportedOperationException.class);
assertThatThrownBy(() -> CandidateScoringRuleSet.c211().clear())
.isInstanceOf(UnsupportedOperationException.class);
}
}

107
mydreamjob-server/src/test/java/com/mydreamjob/common/r01c/RecommendationOrderingCharacterizationTest.java

@ -0,0 +1,107 @@
package com.mydreamjob.common.r01c;
import com.mydreamjob.recommendation.dto.JobRecommendationItem;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
/**
* R01-C 特征测试:推荐排序与分页切片。
*
* <p>锁定重构前的排序契约:**总分降序,同分按 jobId 升序**(同分不再有第三种比较键),
* 以及 {@code slice} 的越界行为。</p>
*/
class RecommendationOrderingCharacterizationTest {
/** 与 {@code JobRecommendationService.recommend} 中完全相同的比较器。 */
private static final Comparator<JobRecommendationItem> ORDER =
Comparator.comparing(JobRecommendationItem::getScore).reversed()
.thenComparing(JobRecommendationItem::getJobId);
private static JobRecommendationItem item(long jobId, String score) {
return JobRecommendationItem.builder()
.jobId(jobId).title("t" + jobId).city("上海").employmentType("FULL_TIME")
.salaryMin(10000).salaryMax(20000).mode("PERSONALIZED")
.score(new BigDecimal(score)).reasons(List.of("岗位偏好匹配")).build();
}
private static List<Long> idsOf(List<JobRecommendationItem> items) {
return items.stream().map(JobRecommendationItem::getJobId).toList();
}
@Test
@DisplayName("特征:分数不同的三个岗位按总分降序")
void differentScoresOrderByScoreDesc() {
List<JobRecommendationItem> items = new ArrayList<>(List.of(
item(30L, "61.50"), item(10L, "90.00"), item(20L, "75.25")));
items.sort(ORDER);
assertThat(idsOf(items)).containsExactly(10L, 20L, 30L);
assertThat(items.get(0).getScore()).isEqualByComparingTo("90.00");
}
@Test
@DisplayName("特征:总分相同但分项不同 → 仍按 jobId 升序(不比较分项)")
void equalScoresBreakTieByJobId() {
List<JobRecommendationItem> items = new ArrayList<>(List.of(
item(9L, "80.00"), item(3L, "80.00"), item(7L, "80.00")));
items.sort(ORDER);
assertThat(idsOf(items)).containsExactly(3L, 7L, 9L);
}
@Test
@DisplayName("特征:完全同分稳定性 —— 输入顺序打乱后结果一致")
void fullyEqualScoresAreDeterministic() {
List<JobRecommendationItem> a = new ArrayList<>(List.of(
item(5L, "70.00"), item(2L, "70.00"), item(8L, "70.00")));
List<JobRecommendationItem> b = new ArrayList<>(List.of(
item(8L, "70.00"), item(5L, "70.00"), item(2L, "70.00")));
a.sort(ORDER);
b.sort(ORDER);
assertThat(idsOf(a)).containsExactly(2L, 5L, 8L);
assertThat(idsOf(a)).isEqualTo(idsOf(b));
}
@Test
@DisplayName("特征:分数为 null 的岗位只可能被过滤掉;比较器本身对 null 抛 NPE(既有契约)")
void nullScoreIsNeverOrdered() {
List<JobRecommendationItem> withNull = new ArrayList<>();
withNull.add(item(1L, "50.00"));
JobRecommendationItem noScore = JobRecommendationItem.builder()
.jobId(2L).title("t2").city("上海").employmentType("FULL_TIME")
.mode("FALLBACK").score(null).reasons(List.of("最新公开岗位")).build();
withNull.add(noScore);
assertThat(org.assertj.core.api.Assertions.catchThrowable(() -> withNull.sort(ORDER)))
.as("score 为 null 时排序抛 NPE —— 生产中 FALLBACK 项不参与该比较器")
.isInstanceOf(NullPointerException.class);
}
@Test
@DisplayName("特征:分页切片 from/to 与越界行为不变")
void sliceBoundsAreStable() {
List<JobRecommendationItem> all = new ArrayList<>();
for (long i = 1; i <= 5; i++) all.add(item(i, "50.00"));
assertThat(slice(all, 0, 2)).containsExactly(1L, 2L);
assertThat(slice(all, 1, 2)).containsExactly(3L, 4L);
assertThat(slice(all, 2, 2)).containsExactly(5L);
assertThat(slice(all, 9, 2)).isEmpty();
assertThat(slice(all, 0, 50)).hasSize(5);
}
/** 与 {@code JobRecommendationService.slice} 相同的下标计算。 */
private static List<Long> slice(List<JobRecommendationItem> all, int page, int size) {
int from = Math.min(page * size, all.size());
int to = Math.min(from + size, all.size());
return idsOf(all.subList(from, to));
}
}
Loading…
Cancel
Save