You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

23 KiB

R01-C 评分与推荐规则结构收束报告

日期:2026-09-18 分支:refactor/r01-structure-performance HEAD:9954600650c3e7cd40a5f4fa55bc38595973f214(R01-B 提交 70b9e10 + 夹具修复 9954600) 本轮性质:结构重构 —— 未暂存、未提交、未推送,待独立代码复验 铁律:所有输入对应的评分、状态、排序、推荐结果、解释内容、规则版本与舍入结果必须与重构前逐项一致


0. 结论与一处必须先说明的口径差异

R01-C评分与推荐规则结构收束完成,待独立代码复验。

权威库存(§2)推翻了指令的两项前提,已按你的裁决实施:

指令主题 库存实际 本轮处置
1. 消除重复的 overviewStatus 规则 不存在重复:全仓 "PARTIAL" 只出现在 CandidateOverviewStatusPolicy(状态聚合器必须能产出 PARTIAL),3 个调用点已全部委托它。该策略由既有提交 b68519e 创建 无可合并项,不做任何改动;以结构门禁测试锁定"唯一产出点"
2. 拆分 JobRecommendationService.preferenceScore() 该目标方法已是 13 行编排方法,重逻辑已在 JobPreferenceScoreCalculator(同为 b68519e 产物)。但 TalentDiscoveryService.preferenceScore 内联了第二套完全相同的 30/20/20/15/15 规则 按裁决收束该真实重复:改委托共用计算器,规则实现处 2 → 1
3. 高校名单/阈值/权重强类型化 真实待办:CandidateScoringCalculator 内为裸常量,无规则集类 已完成:新增 candidatescore/rules/CandidateScoringRuleSet

未为了"grep 归零"制造改动;主题 1 的"已唯一"结论附结构门禁测试可证。


1. 分支、HEAD、Git 状态

项 结果
git branch --show-current refactor/r01-structure-performance
git rev-parse HEAD 9954600650c3e7cd40a5f4fa55bc38595973f214 ✔
git rev-list --left-right --count HEAD...@{upstream} 0 0 ✔
git diff --cached --name-only 空 ✔
git diff --check exit 0,无空白错误 ✔
开始前未提交的已跟踪修改 无(工作区干净)✔
历史未跟踪文件 完整保留 ✔

2. 上游: 三份权威库存

2.1 overviewStatus 库存

项 事实
实现处 唯一:candidatescore/CandidateOverviewStatusPolicy(final、纯静态、无 Spring 依赖、无 DB、无系统时间、无副作用)
入口 fromComputedResults(List<DimensionResult>, int expectedDimensions)、fromSnapshots(List<CandidateDimensionScore>)
调用方 CandidateScoringEngineService(2 处)、CompetitionRankingCalculator(1 处)—— 全部为委托
判断顺序 ① 空/null → emptyStatus(计算结果入口为 PENDING,快照入口为 INSUFFICIENT_DATA);② 可计算数 == 期望数 → OK;③ 可计算数 > 0 → PARTIAL;④ 全为 PENDING → PENDING;⑤ 否则 INSUFFICIENT_DATA
可计算定义 `valueAvailable && ("OK".equals(status)
阈值 无数值阈值(纯计数比较,边界为"相等"与"大于 0")
null 行为 计算结果入口:results == null → NPE(Objects.requireNonNull);快照入口:null → INSUFFICIENT_DATA
边界相等 computableCount == completeCount 判 OK(相等即 OK,不是严格大于)
返回值 字符串状态(沿用既有字符串契约,未新建语义重复的枚举)
是否参与响应/快照/解释/排名/监控 参与:候选评分概览响应、竞争力排名卡状态;不参与快照版本推进
两份实现是否等价 不适用 —— 只有一份实现

2.2 preferenceScore 库存(重构前,两套)

项 JobPreferenceScoreCalculator(共用) TalentDiscoveryService(内联,已收束)
输入 Criteria(cities, employmentTypes, expectedSalaryMin, industries, jobFamilies) + Target(city, employmentType, salaryMax, industryCode, jobFamilyCode) PreferenceSignal(cities, employmentTypes, expectedSalaryMin, industries, jobTypes, …) + Job + JobRecommendationProfile
维度与权重 城市 30 / 用工类型 20 / 最低薪资 20 / 行业 15 / 岗位族 15 完全相同
缺失维度 该维度不计入 available(等效"不计分") 完全相同
空集合/null Criteria 紧凑构造器把 null 归一化为 List.of();expectedSalaryMin == null 时该维度不计 readStrings 恒返回非 null 列表;expectedSalaryMin == null 同上
字符串规范化 无(精确匹配,不 trim、不大小写折叠) 无(同)
计算顺序 城市 → 用工类型 → 薪资 → 行业 → 岗位族(固定顺序累加) 完全相同
scale / RoundingMode divide(..., 2, HALF_UP),BigDecimal.valueOf(matched * 100L) BigDecimal.valueOf(got * 100L).divide(..., 2, HALF_UP) —— 同一式子
上下界 available == 0 → null;否则 ∈ [0.00, 100.00] 同
对总分/排序影响 JobRecommendationService 中偏好分权重 40(与 RIASEC 35、能力 25 加权平均后降序 + jobId 升序) TalentDiscoveryService 中作为候选打分的一个分量

2.3 硬编码规则库存

项 重构前位置 数量/值
985 高校集合 CandidateScoringCalculator.C985(new HashSet<>(List.of(...))) 38 项(38 唯一)
211 高校集合 CandidateScoringCalculator.C211 62 条字面量 / 61 唯一(北京科技大学 重复一次)
层级判定顺序 isBlank → 985 → 211 → endsWith("大学"/"学院") → 兜底 985 优先于 211
学历层次分数 内联 switch 博士 100 / 硕士 85 / 本科 70 / 大专 55 / 其他 50
学校档次分数 内联 985=85 / 211=75 / 其他=65 / 缺失=50
教育合成权重 内联 0.55 / 0.45;子项满分 55 / 45
默认维度权重 DEFAULT_WEIGHT new BigDecimal("12.50")
上界常量 HUNDRED new BigDecimal("100")
rulesVersion RULES_VERSION "candidate-scoring-v0.1"(另有 AbilityScoreMapper.MAPPING_RULES_VERSION = "ability-assessment-scoring-v0.1",语义不同)
消费者 CandidateScoringEngineService、CompetitionRankingCalculator、TalentDiscoveryService、CandidateScoreCardService 等
同数不同义 存在且保持独立命名:85(学历硕士)≠ 85(学校档次 985);50(学历其他)≠ 50(学校缺失/兜底)

3. 重构前特征测试(先建基线)

目录 mydreamjob-server/src/test/java/com/mydreamjob/common/r01c/,40 个用例, 期望值均为手工推算或独立抄录(不调用被测实现生成),使用 isEqualByComparingTo 并额外断言 scale 与 toPlainString(),未使用 isCloseTo 之类宽松误差。

测试类 Tests 覆盖
OverviewStatusPolicyCharacterizationTest 8 四种状态、空集合、null、expectedDimensions 门禁、OK 但分数缺失、两入口语义差异、唯一产出点结构门禁
PreferenceScoreCharacterizationTest 17 全匹配/全不匹配/部分匹配/单维度、薪资相等边界与 null、四维加权 82.35、空集合与 null、大小写与脏值、空串、上下界
CandidateScoringRuleSetCharacterizationTest 10 985 首/中/末、211 首/中/末与重复项、两名单无交集、精确匹配(空白/简繁/大小写不匹配)、学历与档次分数、版本、八维度、只读集合
RecommendationOrderingCharacterizationTest 5 分数不同降序、同分按 jobId、完全同分确定性、score=null 抛 NPE、分页切片越界

基线运行(重构前):Tests run: 40, Failures: 0, Errors: 0, Skipped: 0 —— 见 docs/acceptance/r01c/baseline.log。 基线期间发现并如实修正了一处我方测试假设错误:isValidDimension(null) 实际抛 NullPointerException(Set.of 契约),并非返回 false;已按真实行为锁定,未改生产代码。


4. overviewStatus 唯一策略

未新增组件:CandidateOverviewStatusPolicy 已是唯一策略且无 Spring/DB/时间依赖。 本轮仅新增结构门禁测试:扫描 src/main/java/com/mydreamjob 下所有 .java, 断言 "PARTIAL" 的出现文件恰好为 CandidateOverviewStatusPolicy.java。 若将来出现第二个聚合器,该测试立即失败。


5. 两份旧实现是否完全等价

不存在两份 overviewStatus 实现,故不适用"合并前等价性比对"。§六 的停止条件(两实现不等价)未触发。

preferenceScore 的两套实现经逐行比对为等价:

比对项 结论
维度顺序 城市 → 用工类型 → 薪资 → 行业 → 岗位族,一致
权重 30/20/20/15/15,一致
缺失维度处理 不计入分母,一致
薪资判定 job.salaryMax != null && salaryMax >= expectedSalaryMin,一致
岗位族取值 profile.getJobFamilyCode(),一致
计算式 BigDecimal.valueOf(x * 100L).divide(BigDecimal.valueOf(total), 2, HALF_UP),一致
零分母 null,一致
null 列表 计算器归一化为空列表;内联版由 readStrings 保证非 null —— 观察行为一致

6. preferenceScore 拆分

  • JobRecommendationService.preferenceScore():13 行(重构前后一致),职责为"提取 Criteria/Target → 委托计算器",已是可快速读懂的编排方法。
  • TalentDiscoveryService.preferenceScore():由 12 行内联公式 改为 17 行编排(含 javadoc),规则实现移出。
  • 拆分粒度按实际存在的维度(5 个),未为匹配示例创造不存在的维度;未引入 Stream;未改变可见性;未产生无意义的一行包装。
  • 30/20/20/15/15 规则实现处:2 → 1(grep "total += 30\|CITY_WEIGHT")。

7. BigDecimal 运算顺序与舍入证明

项 重构前 重构后 一致
偏好分构造 BigDecimal.valueOf(matched * 100L) 同一表达式(改为计算器内执行) ✔
除法 divide(BigDecimal.valueOf(available), 2, RoundingMode.HALF_UP) 同 ✔
教育维度 eduLevel*0.55 + schoolTier*0.45,setScale(2, HALF_UP) 同一表达式(权重改读规则集常量,值相同) ✔
MathContext.DECIMAL128 未使用 未引入 ✔
scale 全部不变(断言 toPlainString()) 同 ✔
新引入 double/float — 无 ✔

特征测试 PreferenceScoreCharacterizationTest 逐项断言 scale == 2 与精确字符串(如 "82.35"、"66.67"、"33.33")。


8. 强类型规则设计

新增 candidatescore/rules/CandidateScoringRuleSet:

要求 落实
final / 不可变 / 无 setter ✔ public final class,私有构造,全部 static final
不可变集合 ✔ Collections.unmodifiableSet(new HashSet<>(...))
唯一当前规则实例 ✔ 静态常量(随代码发布变更)
版本常量 ✔ VERSION = "candidate-scoring-v0.1",与重构前逐字一致
不接数据库/外部动态配置 ✔ 无 Spring、无 JDBC、无配置读取
BigDecimal 字符串构造 ✔ 全部 new BigDecimal("…")
同数不同义不合并 ✔ EDUCATION_LEVEL_MASTER(85) 与 SCHOOL_TIER_985(85) 独立命名;EDUCATION_LEVEL_UNKNOWN(50) 与 SCHOOL_TIER_MISSING(50) 独立命名
按业务语义组织 ✔ 版本与汇总 / 学历层次 / 学校档次 / 高校名单 四组
对外只读 ✔ c985() / c211() 返回不可修改视图(测试断言 add/remove/clear 均抛 UnsupportedOperationException)

未建立"杂物常量类":只收纳本轮明确要求的四组,未把计算器内其余维度阈值一并塞入。


9. 高校名单:数量与逐项一致证明

项 重构前(计算器内) 重构后(规则集)
985 定义处 1 1(位置变更)
985 唯一项 38 38 ✔
211 定义处 1 1(位置变更)
211 字面量条数 62 62 ✔
211 唯一项 61 61 ✔
北京科技大学 重复 存在 原样保留(按裁决不去重)
字符串 — 逐项未改动(首/中/末抽样断言)
判定优先级 985 → 211 985 → 211 ✔
模糊匹配 无 无(新增测试断言"北京大学 "、"北京大学"、"北京大學" 均不匹配)
对外只读 HashSet(可变,仅 private) 不可修改视图 ✔

计算器内名单定义数:2 → 0;名单只在唯一规则源定义。


10. 阈值和权重逐项一致

规则 重构前值 重构后常量 值
学历 博士 100 EDUCATION_LEVEL_DOCTOR 100 ✔
学历 硕士 85 EDUCATION_LEVEL_MASTER 85 ✔
学历 本科 70 EDUCATION_LEVEL_BACHELOR 70 ✔
学历 大专 55 EDUCATION_LEVEL_JUNIOR_COLLEGE 55 ✔
学历 其他 50 EDUCATION_LEVEL_UNKNOWN 50 ✔
档次 985 85 SCHOOL_TIER_985 85 ✔
档次 211 75 SCHOOL_TIER_211 75 ✔
档次 其他 65 SCHOOL_TIER_OTHER 65 ✔
档次 缺失 50 SCHOOL_TIER_MISSING 50 ✔
教育缺失维度分 100 EDUCATION_MISSING_SCORE 100 ✔
学历权重 0.55 EDUCATION_LEVEL_WEIGHT 0.55 ✔
档次权重 0.45 EDUCATION_SCHOOL_TIER_WEIGHT 0.45 ✔
子项满分 55 / 45 EDUCATION_LEVEL_FULL_SCORE / …TIER… 55 / 45 ✔
默认维度权重 12.50 DEFAULT_DIMENSION_WEIGHT 12.50 ✔
满分上界 100 FULL_SCORE 100 ✔

裸教育规则 BigDecimal 命中数(计算器内):23 → 6(其余 6 处为非教育维度阈值,本轮未纳入规则集)。 数值一律为字符串构造,未把整数阈值错误转换为百分比小数。


11. rulesVersion

项 值
常量 CandidateScoringRuleSet.VERSION
值 candidate-scoring-v0.1(与重构前 CandidateScoringCalculator.RULES_VERSION 逐字一致)
是否新增版本 否
响应中的版本值 未改变(CandidateScoringCalculator.RULES_VERSION 现指向同一常量)
同义词区分 AbilityScoreMapper.MAPPING_RULES_VERSION = "ability-assessment-scoring-v0.1" 保持独立,未合并
快照历史一致性 快照写入仍使用同一 RULES_VERSION,未变

12. 排名顺序对账

JobRecommendationService 排序比较器未改动: Comparator.comparing(JobRecommendationItem::getScore).reversed().thenComparing(JobRecommendationItem::getJobId)。 slice 的下标计算未改动。特征测试锁定:分数降序、同分按 jobId 升序、完全同分确定性、越界切片为空。

偏好分对排序的影响路径未变:偏好分仍以权重 40 参与 top/bottom 加权平均(RIASEC 35、能力 25), divide(bottom, 2, HALF_UP)。收束只替换了偏好分的计算处,未触碰分值本身。


13. 解释字段与贡献求和

校验 结论
CandidateScoringExplanationAssembler 贡献求和 未修改该链路;addContribution 的入参在收束后取值相同(改读常量,值不变)
各维度贡献之和 = 总分 教育维度仍为 eduLevel*0.55 + schoolTier*0.45,与两个 contribution 的求和一致 ✔
overviewStatus 在响应/解释/快照/看板一致 唯一策略未被改动,仍由三处同一入口产出 ✔
rulesVersion 在响应/快照/历史一致 同一常量 ✔
推荐解释中的偏好分 = 排序实际使用的分 TalentDiscoveryService 收束后仍把同一计算结果用于打分与解释字段(同一局部变量 p) ✔
"展示一套、排序另一套" 未出现 ✔

14. 查询数与副作用

项 结论
新增数据库查询 无。规则集为静态常量;偏好分计算为纯函数
推荐一次查询的 SQL 数量 不增加:TalentDiscoveryService.preferenceScore 收束后零查询(输入已由批量读取提供);JobRecommendationService 未变
评分计算新增写入 无
GET 评分接口零写快照 未改动该迁移
RabbitMQ / 审计 / 通知 未新增
缓存键 未改变

15. 变异测试(三类,均恢复原字节)

# 变异 目标文件 结果
1 isComputable 中 "ESTIMATED" → "OK"(改变状态边界) CandidateOverviewStatusPolicy 8 run / 2 failures ✔
2 SALARY_WEIGHT 20 → 25(改变偏好权重) JobPreferenceScoreCalculator 17 run / 1 failure ✔
3 985 名单删除 "北京大学" CandidateScoringRuleSet 10 run / 2 failures ✔

恢复核验:三个文件均已还原,其中 #1 因还原脚本误替换了同一行中的另一处 "OK".equals, 已定位并逐字节修正,git diff --quiet 确认与 HEAD 完全一致;#2、#3 的 SHA-256 恢复一致。 未使用 git restore/checkout;变异内容未留在工作区(见 §20 的差异范围核对)。


16. 定向、关联、全量测试数

命令 结果 日志
R01-C 定向(4 个特征类) 40 / 40,F=0 E=0 S=0 ✔ baseline.log / after-characterization.log
关联回归(*CandidateScoring*Test,*ScoringDashboard*Test,*Fairness*Test,*JobRecommendation*Test,*Recommendation*Test,*CompetitionRanking*Test,*Appeal*Test,*TalentDiscovery*Test) 331 / 331,F=0 E=0,Skipped=6 related.log
mvn test 见 §16.1 full-mvntest.log
mvn clean verify 见 §16.1 clean-verify.log

16.1 全量结果与 Docker 说明(如实记录)

mvn test          → Tests run: 2725, Failures: 0, Errors: 0, Skipped: 26   BUILD SUCCESS
mvn clean verify  → Tests run: 2725, Failures: 0, Errors: 0, Skipped: 26   BUILD SUCCESS
项 数值
测试总数 2725 = 本轮基线 2685 + 本轮新增 40 ✔(不低于 2685 的要求满足)
Failures / Errors 0 / 0 ✔
Skipped 26 —— 未达"0"要求,原因如下

Skipped=26 的准确原因

26 个跳过全部来自 4 个 MySQL / Testcontainers 类(与 R01-B 复验时同一批): JobSearchSpecificationMySqlIntegrationTest(15)、CandidateScoringRefreshMySqlIntegrationTest(6)、 JobSearchQuerySideEffectMySqlIntegrationTest(4)、JobSearchQueryExplainMySqlIntegrationTest(1)。 它们标注 @Testcontainers(disabledWithoutDocker = true),Docker 不可用时按设计跳过。

环境状态变化(非代码原因):

  • 本轮开始时 docker info 成功:ServerVersion=29.6.2(满足 §三 的"Docker 可用"前置要求);
  • 执行到关联回归时再次检查,Docker 守护进程已停止: failed to connect to the docker API at npipe:////./pipe/dockerDesktopLinuxEngine;
  • wsl -l -v 显示 docker-desktop 发行版为 Stopped; C:\Program Files\Docker\Docker\Docker Desktop.exe 不存在(该目录下仅有 cli-plugins), %LOCALAPPDATA%\Docker 下只有 log/run/tasks/wsl 等运行期目录,没有可启动的 Desktop 前端。

处置:未强行以非受支持方式拉起 Docker(wsl -d docker-desktop 明确不被支持), 不声称 Skipped=0。除这 26 个 Docker 条件用例外的 2699 个测试全部执行且全部通过。 不构成 R01-C 阻断项。

独立复验补齐(2026-09-18,本段为复验追加,不覆盖上述事实)

独立复验发现了正确的 Docker Desktop 安装路径并据此启动:

项 结果
路径 D:\DevApps\DockerDesktop\Docker Desktop.exe(存在,15,142,832 字节)
启动 Start-Process -FilePath '…\Docker Desktop.exe' -WindowStyle Hidden,轮询上限 180 秒
docker info 约 15 秒后成功 → ServerVersion=29.6.2
原先跳过的 26 项 26 / 26 实际执行,Tests run: 26, Failures: 0, Errors: 0, Skipped: 0

最终全量(复验阶段):

mvn test          → Tests run: 2743, Failures: 0, Errors: 0, Skipped: 0   BUILD SUCCESS
mvn clean verify  → Tests run: 2743, Failures: 0, Errors: 0, Skipped: 0   BUILD SUCCESS

2743 = 2725(本报告阶段总数,含 26 项跳过)+ 18(独立复验新增测试); 其 Skipped=0 正是因为那 26 项已真实执行。本阶段未改动任何代码即补足了这 26 项。


17. OpenAPI

python ..\docs\verify_openapi.py
→ paths: 176 / operations: 214
→ All checks passed!

git diff -- docs/openapi-rc.json → 空 ✔


18. 精确修改文件

生产代码变更总数:新增 1 个生产类、修改 2 个生产类,共 3 个生产文件。

新增生产类(1)

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

修改生产类(2)

candidatescore/CandidateScoringCalculator.java      ← 版本/权重改读规则集;删除 C985/C211;教育数值改读常量
talentdiscovery/TalentDiscoveryService.java        ← preferenceScore 委托 JobPreferenceScoreCalculator

独立复验补充(2026-09-18):git status 会在 Windows 上把 CandidateOverviewStatusPolicy.java 与 JobPreferenceScoreCalculator.java 标为 M (变异实验重写过这两个文件、mtime 变化)。经 git hash-object 与 git rev-parse HEAD:<file> 比对,两者 blob 完全相同,属 Windows stat 缓存假阳性,不在本提交范围内。

新增测试(4):common/r01c/ 下 4 个特征测试类

文档(2):docs/r01-c-scoring-recommendation-rules-refactor-report.md(新增)、 docs/backend-development-roadmap.md(更新)

未新增:CandidateOverviewStatusPolicy 相关改动(已唯一)、JobRecommendationService 改动(已是编排)。


19. Flyway / ErrorCode / DTO / 接口变化

项 结果
Flyway / 迁移 未改动 ✔
ErrorCode 未改动 ✔
DTO / 响应结构 未改动 ✔
公网接口 / OpenAPI 未改动(176/214,openapi-rc.json 未变) ✔
Token / 权限 / requestId 未改动 ✔
依赖 / pom.xml 未改动 ✔
评分公式数值 / 排序 / 同分规则 / 小样本与互补风险抑制 / 申诉回滚 / 快照版本推进 / Fairness / 消息与 Offer 等 均未改动 ✔

20. Git 状态

项 结果
分支 / HEAD refactor/r01-structure-performance / 9954600…(未变)
git diff --cached --name-only 空 ✔
git diff --check exit 0 ✔
生产代码改动范围 恰为 2 个文件,与 §18 一致(变异内容未残留)✔
已暂存 / 已提交 / 已推送 均无 ✔
历史未跟踪文件 未删除 ✔

21. 未暂存、未提交、未推送

确认。 未执行 git add;未创建提交;未推送;未执行 reset/restore/checkout/clean; 未进入 R01-E。


22. 未进入 R01-E

确认。 本轮未处理 N+1、会话未读数、overviewStatus 之外的其他查询收束、 数据库迁移或任何 R01-E 范围内的主题。


R01-C评分与推荐规则结构收束完成,待独立代码复验。