From 7d4704ee28e4d8e2d1fbcd174a990fc6bfe68169 Mon Sep 17 00:00:00 2001 From: Zhang Shun Date: Fri, 18 Sep 2026 11:33:34 +0800 Subject: [PATCH] refactor: centralize scoring and recommendation rules --- docs/backend-development-roadmap.md | 2 +- ...r01-c-independent-verification-20260918.md | 347 ++++++++++++++ ...ng-recommendation-rules-refactor-report.md | 437 ++++++++++++++++++ .../CandidateScoringCalculator.java | 68 +-- .../rules/CandidateScoringRuleSet.java | 137 ++++++ .../TalentDiscoveryService.java | 29 +- ...ateScoringRuleSetCharacterizationTest.java | 159 +++++++ ...rviewStatusPolicyCharacterizationTest.java | 141 ++++++ .../PreferenceScoreCharacterizationTest.java | 211 +++++++++ .../r01c/R01CIndependentVerificationTest.java | 407 ++++++++++++++++ ...mendationOrderingCharacterizationTest.java | 107 +++++ 11 files changed, 1991 insertions(+), 54 deletions(-) create mode 100644 docs/r01-c-independent-verification-20260918.md create mode 100644 docs/r01-c-scoring-recommendation-rules-refactor-report.md create mode 100644 mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/rules/CandidateScoringRuleSet.java create mode 100644 mydreamjob-server/src/test/java/com/mydreamjob/common/r01c/CandidateScoringRuleSetCharacterizationTest.java create mode 100644 mydreamjob-server/src/test/java/com/mydreamjob/common/r01c/OverviewStatusPolicyCharacterizationTest.java create mode 100644 mydreamjob-server/src/test/java/com/mydreamjob/common/r01c/PreferenceScoreCharacterizationTest.java create mode 100644 mydreamjob-server/src/test/java/com/mydreamjob/common/r01c/R01CIndependentVerificationTest.java create mode 100644 mydreamjob-server/src/test/java/com/mydreamjob/common/r01c/RecommendationOrderingCharacterizationTest.java diff --git a/docs/backend-development-roadmap.md b/docs/backend-development-roadmap.md index 2cc7fe2..20a31f9 100644 --- a/docs/backend-development-roadmap.md +++ b/docs/backend-development-roadmap.md @@ -15,7 +15,7 @@ | S02 Access/Refresh Token 隔离 | 已实现并完成自动化验证与干净构建,待独立代码复验及真实 MySQL/Docker 黑盒验收 | 类型隔离、独立密钥、轮换、重放处置、会话吊销已锁定;OpenAPI 175/213 | | 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 | -| 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仍属一期,细节设计后编码 | | 上线保障 | 现有资产待逐项核验 | G01–G04,一期交付门槛 | | 后续候选池 | AI简历、AI模拟面试/预测、SHAP高级解释等 | 以清单第6节为准,移出一期 | diff --git a/docs/r01-c-independent-verification-20260918.md b/docs/r01-c-independent-verification-20260918.md new file mode 100644 index 0000000..5802a69 --- /dev/null +++ b/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 C985\|Set C211" HEAD` | +| 工作区 | **2**(`C985`、`C211`)**均在** `candidatescore/rules/CandidateScoringRuleSet.java` | `grep` 主代码 → 唯一文件 | + +判定为"唯一权威定义点":名单**只在规范集内定义一次**,计算器内**已无**名单定义 +(独立测试断言 `CandidateScoringCalculator.java` 不含 `Set 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独立代码复验通过,可以进入精确暂存与独立提交检查点。** diff --git a/docs/r01-c-scoring-recommendation-rules-refactor-report.md b/docs/r01-c-scoring-recommendation-rules-refactor-report.md new file mode 100644 index 0000000..07a9732 --- /dev/null +++ b/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, int expectedDimensions)`、`fromSnapshots(List)` | +| 调用方 | `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:` +> 比对,两者 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评分与推荐规则结构收束完成,待独立代码复验。** diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/CandidateScoringCalculator.java b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/CandidateScoringCalculator.java index fd62249..b5670d7 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/CandidateScoringCalculator.java +++ b/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.UserRepository; import com.mydreamjob.candidatescore.dto.DimensionScoreDetailResponse; +import com.mydreamjob.candidatescore.rules.CandidateScoringRuleSet; import com.mydreamjob.common.exception.BusinessException; import com.mydreamjob.common.exception.ErrorCode; import com.mydreamjob.preference.StudentPreference; @@ -36,8 +37,9 @@ import java.util.stream.Collectors; @RequiredArgsConstructor public class CandidateScoringCalculator { - public static final String RULES_VERSION = "candidate-scoring-v0.1"; - public static final BigDecimal DEFAULT_WEIGHT = new BigDecimal("12.50"); + /** R01-C:版本与默认权重来自唯一规则源 CandidateScoringRuleSet。 */ + public static final String RULES_VERSION = CandidateScoringRuleSet.VERSION; + public static final BigDecimal DEFAULT_WEIGHT = CandidateScoringRuleSet.DEFAULT_DIMENSION_WEIGHT; public static boolean isValidDimension(String dimension) { return DIMENSIONS.contains(dimension); @@ -68,30 +70,6 @@ public class CandidateScoringCalculator { private static final ObjectMapper MAPPER = new ObjectMapper(); - // 内置学校档次名单(v0.1 硬编码,非数据库表) - private static final Set C985 = new HashSet<>(List.of( - "北京大学", "清华大学", "中国人民大学", "北京航空航天大学", "北京理工大学", - "中国农业大学", "北京师范大学", "中央民族大学", "南开大学", "天津大学", - "大连理工大学", "吉林大学", "哈尔滨工业大学", "复旦大学", "同济大学", - "上海交通大学", "华东师范大学", "南京大学", "东南大学", "浙江大学", - "中国科学技术大学", "厦门大学", "山东大学", "中国海洋大学", "武汉大学", - "华中科技大学", "湖南大学", "中南大学", "中山大学", "华南理工大学", - "四川大学", "重庆大学", "电子科技大学", "西安交通大学", "西北工业大学", - "兰州大学", "东北大学", "西北农林科技大学")); - private static final Set C211 = new HashSet<>(List.of( - "北京交通大学", "北京工业大学", "北京科技大学", "北京化工大学", "北京邮电大学", - "北京林业大学", "北京中医药大学", "北京外国语大学", "中国传媒大学", "中央财经大学", - "对外经济贸易大学", "中国政法大学", "华北电力大学", "天津医科大学", "河北工业大学", - "太原理工大学", "内蒙古大学", "辽宁大学", "大连海事大学", "延边大学", - "哈尔滨工程大学", "东北农业大学", "东北林业大学", "东华大学", "上海大学", - "上海财经大学", "上海外国语大学", "苏州大学", "南京航空航天大学", "南京理工大学", - "中国矿业大学", "河海大学", "江南大学", "南京农业大学", "中国药科大学", - "南京师范大学", "安徽大学", "合肥工业大学", "福州大学", "南昌大学", - "郑州大学", "武汉理工大学", "华中农业大学", "华中师范大学", "中南财经政法大学", - "湖南师范大学", "暨南大学", "华南师范大学", "广西大学", "海南大学", - "西南交通大学", "西南财经大学", "贵州大学", "云南大学", "西北大学", - "西安电子科技大学", "长安大学", "新疆大学", "石河子大学", "宁夏大学", - "青海大学", "北京科技大学")); private final StudentProfileRepository profileRepository; private final StudentPreferenceRepository preferenceRepository; @@ -223,50 +201,50 @@ public class CandidateScoringCalculator { if (profile == null || isBlank(profile.getEducation())) { r.status = "INSUFFICIENT_DATA"; r.summary = "未填写学历信息"; - addContribution(r, "EDUCATION_MISSING", "MISSING", null, new BigDecimal("100"), "教育资料", + addContribution(r, "EDUCATION_MISSING", "MISSING", null, CandidateScoringRuleSet.EDUCATION_MISSING_SCORE, "教育资料", "学历资料未填写,当前维度不计算"); return r; } BigDecimal eduLevel = switch (profile.getEducation()) { - case "博士" -> new BigDecimal("100"); - case "硕士" -> new BigDecimal("85"); - case "本科" -> new BigDecimal("70"); - case "大专" -> new BigDecimal("55"); - default -> new BigDecimal("50"); + case "博士" -> CandidateScoringRuleSet.EDUCATION_LEVEL_DOCTOR; + case "硕士" -> CandidateScoringRuleSet.EDUCATION_LEVEL_MASTER; + case "本科" -> CandidateScoringRuleSet.EDUCATION_LEVEL_BACHELOR; + case "大专" -> CandidateScoringRuleSet.EDUCATION_LEVEL_JUNIOR_COLLEGE; + default -> CandidateScoringRuleSet.EDUCATION_LEVEL_UNKNOWN; }; String school = profile.getSchool(); boolean estimated = false; BigDecimal schoolTier; if (isBlank(school)) { - schoolTier = new BigDecimal("50"); + schoolTier = CandidateScoringRuleSet.SCHOOL_TIER_MISSING; estimated = true; r.reasons.add("学校未填写,按兜底档位估算"); - } else if (C985.contains(school)) { - schoolTier = new BigDecimal("85"); - } else if (C211.contains(school)) { - schoolTier = new BigDecimal("75"); + } else if (CandidateScoringRuleSet.is985(school)) { + schoolTier = CandidateScoringRuleSet.SCHOOL_TIER_985; + } else if (CandidateScoringRuleSet.is211(school)) { + schoolTier = CandidateScoringRuleSet.SCHOOL_TIER_211; } else if (school.endsWith("大学") || school.endsWith("学院")) { - schoolTier = new BigDecimal("65"); + schoolTier = CandidateScoringRuleSet.SCHOOL_TIER_OTHER; } else { - schoolTier = new BigDecimal("50"); + schoolTier = CandidateScoringRuleSet.SCHOOL_TIER_MISSING; estimated = true; r.reasons.add("学校不在内置名单,按兜底档位估算"); } - r.score = eduLevel.multiply(new BigDecimal("0.55")) - .add(schoolTier.multiply(new BigDecimal("0.45"))) + r.score = eduLevel.multiply(CandidateScoringRuleSet.EDUCATION_LEVEL_WEIGHT) + .add(schoolTier.multiply(CandidateScoringRuleSet.EDUCATION_SCHOOL_TIER_WEIGHT)) .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(), "学历字段按固定映射形成贡献"); if (isBlank(school)) { 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) ? "未填写" : "已按学校分类规则处理", "学校分类按固定规则形成贡献,不能代表能力"); r.status = estimated ? "ESTIMATED" : "OK"; r.summary = "学历层次与学校档次的加权综合"; - r.subScores.add(sub("学历层次", eduLevel, new BigDecimal("55"), profile.getEducation())); - r.subScores.add(sub("学校档次", schoolTier, new BigDecimal("45"), isBlank(school) ? "未填写" : school)); + r.subScores.add(sub("学历层次", eduLevel, CandidateScoringRuleSet.EDUCATION_LEVEL_FULL_SCORE, profile.getEducation())); + r.subScores.add(sub("学校档次", schoolTier, CandidateScoringRuleSet.EDUCATION_SCHOOL_TIER_FULL_SCORE, isBlank(school) ? "未填写" : school)); r.dataSources.add(ds("个人教育资料", profile.getUpdatedAt(), "学历与学校分类信息")); return r; } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/rules/CandidateScoringRuleSet.java b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/rules/CandidateScoringRuleSet.java new file mode 100644 index 0000000..22ded7b --- /dev/null +++ b/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)。 + * + *

本类只承载"规则值本身":版本标识、高校档次名单、教育背景分数与权重、默认维度权重。 + * 规则随**代码发布**变更,不接数据库、不接外部动态配置、无 setter、不暴露可变集合。

+ * + *

不变量

+ *
    + *
  • {@link #VERSION} 必须与重构前 {@code CandidateScoringCalculator.RULES_VERSION} 完全一致 + * ({@code candidate-scoring-v0.1})—— 本轮**不新增版本、不改变响应中的版本值**;
  • + *
  • 高校名单内容、数量、字符串与判定优先级逐项不变;
  • + *
  • 所有 BigDecimal 使用**字符串构造**,scale 与重构前一致;
  • + *
  • 不同业务语义的相同数字**不合并**(例如学历层次 85 与学校档次 85 是两个命名常量)。
  • + *
+ */ +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 项)。 + * + *

内容逐项抄录自重构前的 {@code CandidateScoringCalculator.C985},顺序与字符串均未改动。

+ */ + private static final Set C985 = immutableSet(List.of( + "北京大学", "清华大学", "中国人民大学", "北京航空航天大学", "北京理工大学", + "中国农业大学", "北京师范大学", "中央民族大学", "南开大学", "天津大学", + "大连理工大学", "吉林大学", "哈尔滨工业大学", "复旦大学", "同济大学", + "上海交通大学", "华东师范大学", "南京大学", "东南大学", "浙江大学", + "中国科学技术大学", "厦门大学", "山东大学", "中国海洋大学", "武汉大学", + "华中科技大学", "湖南大学", "中南大学", "中山大学", "华南理工大学", + "四川大学", "重庆大学", "电子科技大学", "西安交通大学", "西北工业大学", + "兰州大学", "东北大学", "西北农林科技大学")); + + /** + * 211(非 985)高校名单:字面量 **62 条**、唯一项 **61**。 + * + *

末项 {@code 北京科技大学} 与首段重复 —— 这是重构前名单的**既有事实**, + * 按"名单内容逐项不变、数量不变"的要求**原样保留**,不擅自去重、不补充、不删除。 + * 集合语义下判定行为与重构前一致({@code HashSet} 去重)。

+ */ + private static final Set C211 = immutableSet(List.of( + "北京交通大学", "北京工业大学", "北京科技大学", "北京化工大学", "北京邮电大学", + "北京林业大学", "北京中医药大学", "北京外国语大学", "中国传媒大学", "中央财经大学", + "对外经济贸易大学", "中国政法大学", "华北电力大学", "天津医科大学", "河北工业大学", + "太原理工大学", "内蒙古大学", "辽宁大学", "大连海事大学", "延边大学", + "哈尔滨工程大学", "东北农业大学", "东北林业大学", "东华大学", "上海大学", + "上海财经大学", "上海外国语大学", "苏州大学", "南京航空航天大学", "南京理工大学", + "中国矿业大学", "河海大学", "江南大学", "南京农业大学", "中国药科大学", + "南京师范大学", "安徽大学", "合肥工业大学", "福州大学", "南昌大学", + "郑州大学", "武汉理工大学", "华中农业大学", "华中师范大学", "中南财经政法大学", + "湖南师范大学", "暨南大学", "华南师范大学", "广西大学", "海南大学", + "西南交通大学", "西南财经大学", "贵州大学", "云南大学", "西北大学", + "西安电子科技大学", "长安大学", "新疆大学", "石河子大学", "宁夏大学", + "青海大学", "北京科技大学")); + + /** 只读视图:985 名单。 */ + public static Set c985() { + return C985; + } + + /** 只读视图:211(非 985)名单。 */ + public static Set 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); + } + + /** + * 构造不可变集合。 + * + *

刻意使用 {@code unmodifiableSet(new HashSet<>(...))} 而非 {@code Set.copyOf}: + * 后者遇到重复元素会抛 {@code IllegalArgumentException},而 211 名单的重复项必须原样保留。

+ */ + private static Set immutableSet(List values) { + return Collections.unmodifiableSet(new HashSet<>(values)); + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/talentdiscovery/TalentDiscoveryService.java b/mydreamjob-server/src/main/java/com/mydreamjob/talentdiscovery/TalentDiscoveryService.java index 54b63b5..67f5f1a 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/talentdiscovery/TalentDiscoveryService.java +++ b/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.job.Job; import com.mydreamjob.job.JobRepository; +import com.mydreamjob.recommendation.JobPreferenceScoreCalculator; import com.mydreamjob.recommendation.JobRecommendationProfile; import com.mydreamjob.recommendation.JobRecommendationProfileRepository; import com.mydreamjob.talentdiscovery.dto.TalentDiscoveryCandidateResponse; @@ -178,17 +179,29 @@ public class TalentDiscoveryService { .build(); } + /** + * R01-C:改为委托 {@link JobPreferenceScoreCalculator}。 + * + *

原先此处内联了与 {@code JobRecommendationService} 完全相同的一套 30/20/20/15/15 规则 + * (维度顺序、权重、匹配语义、{@code matched*100/available}、scale=2、HALF_UP 与 + * available==0 → null 均逐字一致)。收束后两处共用唯一实现,**行为不变**。

+ */ private BigDecimal preferenceScore(TalentDiscoveryCandidateSource.PreferenceSignal preference, JobRecommendationProfile profile, Job job) { if (preference == null) return null; - int got = 0; - int total = 0; - if (!preference.cities().isEmpty()) { total += 30; if (preference.cities().contains(job.getCity())) got += 30; } - if (!preference.employmentTypes().isEmpty()) { total += 20; if (preference.employmentTypes().contains(job.getEmploymentType())) got += 20; } - if (preference.expectedSalaryMin() != null) { total += 20; if (job.getSalaryMax() != null && job.getSalaryMax() >= preference.expectedSalaryMin()) got += 20; } - if (!preference.industries().isEmpty()) { total += 15; if (preference.industries().contains(profile.getIndustryCode())) got += 15; } - if (!preference.jobTypes().isEmpty()) { total += 15; if (preference.jobTypes().contains(profile.getJobFamilyCode())) got += 15; } - return total == 0 ? null : BigDecimal.valueOf(got * 100L).divide(BigDecimal.valueOf(total), 2, RoundingMode.HALF_UP); + return JobPreferenceScoreCalculator.calculate( + new JobPreferenceScoreCalculator.Criteria( + preference.cities(), + preference.employmentTypes(), + preference.expectedSalaryMin(), + preference.industries(), + preference.jobTypes()), + new JobPreferenceScoreCalculator.Target( + job.getCity(), + job.getEmploymentType(), + job.getSalaryMax(), + profile.getIndustryCode(), + profile.getJobFamilyCode())); } private BigDecimal weighted(Map scores, Map expected, Set allowed) { diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/common/r01c/CandidateScoringRuleSetCharacterizationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01c/CandidateScoringRuleSetCharacterizationTest.java new file mode 100644 index 0000000..a075a37 --- /dev/null +++ b/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 特征测试:评分规则基线。 + * + *

期望值由独立抄录自重构前的 {@code CandidateScoringCalculator}(名单 38 / 62 条、 + * 学历 100/85/70/55/50、权重 0.55/0.45、版本 {@code candidate-scoring-v0.1}), + * 不经由被测实现自身推导。

+ */ +class CandidateScoringRuleSetCharacterizationTest { + + // ── 高校名单 ────────────────────────────────────────────── + + @Test + @DisplayName("特征:C985 名单 38 项,首/中/末逐项一致") + void c985ListContentIsStable() { + Set c985 = CandidateScoringRuleSet.c985(); + + assertThat(c985).as("C985 唯一项数量").hasSize(38); + assertThat(c985).contains( + "北京大学", "清华大学", "中国人民大学", "北京航空航天大学", "北京理工大学", // 首段 + "南京大学", "东南大学", "浙江大学", "中国科学技术大学", "厦门大学", // 中段 + "兰州大学", "东北大学", "西北农林科技大学"); // 末段 + assertThat(c985).doesNotContain("北京交通大学", "苏州大学", "郑州大学", "青海大学"); + } + + @Test + @DisplayName("特征:C211 名单 61 唯一项(62 条字面量含 `北京科技大学` 重复),首/中/末一致") + void c211ListContentIsStable() { + Set 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 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); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/common/r01c/OverviewStatusPolicyCharacterizationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01c/OverviewStatusPolicyCharacterizationTest.java new file mode 100644 index 0000000..7826e06 --- /dev/null +++ b/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})。 + * + *

覆盖四种状态、空集合、null、以及"可计算但分数缺失"的边界; + * 并锁定两个入口(计算结果 / 快照)在等价输入下的产出关系。

+ */ +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 results(String status, String score, int n) { + List 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 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 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 producers = new java.util.ArrayList<>(); + try (java.util.stream.Stream 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"); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/common/r01c/PreferenceScoreCharacterizationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01c/PreferenceScoreCharacterizationTest.java new file mode 100644 index 0000000..bc9f255 --- /dev/null +++ b/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)黄金样本。 + * + *

期望值由 30/20/20/15/15 规则**手工推算**({@code matched*100/available},scale=2,HALF_UP), + * 不调用被测实现生成,避免同源假证明。使用 {@code isEqualByComparingTo} 并额外断言 scale 与字符串形式, + * 不使用 {@code isCloseTo} 之类的宽松误差。

+ */ +class PreferenceScoreCharacterizationTest { + + private static final List CITIES = List.of("上海", "北京"); + private static final List TYPES = List.of("FULL_TIME"); + private static final List INDUSTRIES = List.of("IT"); + private static final List FAMILIES = List.of("BACKEND"); + private static final Integer SALARY_MIN = 20000; + + private static JobPreferenceScoreCalculator.Criteria criteria( + List cities, List types, Integer salaryMin, + List industries, List 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); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/common/r01c/R01CIndependentVerificationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01c/R01CIndependentVerificationTest.java new file mode 100644 index 0000000..1c16a81 --- /dev/null +++ b/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 独立复验:行为等价、结构门禁与跨服务一致性。 + * + *

本类的期望值独立推导(手工推算或从 HEAD 抄录),不复用实施者断言的构造方式。

+ */ +@SpringBootTest +@ActiveProfiles("integration-test") +class R01CIndependentVerificationTest { + + @MockBean private RedisTemplate 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 javaFiles() throws Exception { + try (Stream 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 a = new ArrayList<>(List.of( + res("OK", "80"), res("PENDING", null), res("INSUFFICIENT_DATA", null))); + List 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 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 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 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 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 defs = new ArrayList<>(); + for (Path p : javaFiles()) { + String src = read(p); + if (src.contains("Set C985") || src.contains("Set C211")) { + defs.add(p.getFileName().toString()); + } + } + assertThat(defs).containsExactly("CandidateScoringRuleSet.java"); + assertThat(read(MAIN_ROOT.resolve("candidatescore/CandidateScoringCalculator.java"))) + .doesNotContain("Set C985") + .doesNotContain("Set 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); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/common/r01c/RecommendationOrderingCharacterizationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01c/RecommendationOrderingCharacterizationTest.java new file mode 100644 index 0000000..21b2cb2 --- /dev/null +++ b/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 特征测试:推荐排序与分页切片。 + * + *

锁定重构前的排序契约:**总分降序,同分按 jobId 升序**(同分不再有第三种比较键), + * 以及 {@code slice} 的越界行为。

+ */ +class RecommendationOrderingCharacterizationTest { + + /** 与 {@code JobRecommendationService.recommend} 中完全相同的比较器。 */ + private static final Comparator 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 idsOf(List items) { + return items.stream().map(JobRecommendationItem::getJobId).toList(); + } + + @Test + @DisplayName("特征:分数不同的三个岗位按总分降序") + void differentScoresOrderByScoreDesc() { + List 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 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 a = new ArrayList<>(List.of( + item(5L, "70.00"), item(2L, "70.00"), item(8L, "70.00"))); + List 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 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 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 slice(List 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)); + } +}