28 changed files with 2256 additions and 116 deletions
@ -0,0 +1,302 @@ |
|||||
|
# N02 候选评分规则解释与合理性:详细设计 |
||||
|
|
||||
|
状态:N02 独立代码复验与真实 MySQL/Docker 黑盒验收通过,待 Git 检查点。 |
||||
|
|
||||
|
文档类型:解释。目标读者为产品负责人、后端开发、独立验收人员和前端联调人员。本设计说明现有候选评分实际上计算了什么、一期应如何向学生解释,以及哪些结论不得从这些分数推出。 |
||||
|
|
||||
|
## 1. 背景与目标 |
||||
|
|
||||
|
当前候选评分由 `CandidateScoringCalculator` 计算八个维度,并由 `CandidateScoringEngineService` 写入或读取 `candidate_dimension_scores`。学生单维接口已有 `subScores`、`reasons` 和 `dataSources`,但这些信息由各计算方法临时拼装,缺少稳定的规则编码、解释版本、适用范围和限制说明。 |
||||
|
|
||||
|
N02 的一期目标是让学生能够回答以下问题: |
||||
|
|
||||
|
1. 这个分数使用了哪些数据; |
||||
|
2. 哪些确定性规则实际命中; |
||||
|
3. 每条规则对当前结果产生了什么影响; |
||||
|
4. 哪些数据缺失、过期或仍未接入; |
||||
|
5. 用户可以通过哪些真实、合理的方式完善资料; |
||||
|
6. 该分数不能代表什么。 |
||||
|
|
||||
|
N02 同时要求企业端继续只查看聚合分与状态,不获得学生的私人明细、行为次数、测评细分或改进建议。 |
||||
|
|
||||
|
## 2. 非目标 |
||||
|
|
||||
|
一期不做: |
||||
|
|
||||
|
- SHAP、机器学习特征贡献或 AI 生成解释; |
||||
|
- 人口属性公平性推断; |
||||
|
- 宣称规则已具备心理测量效度或预测效度; |
||||
|
- 自动修改评分权重、排名或推荐逻辑; |
||||
|
- 新增评分事实表、解释历史表或规则配置后台; |
||||
|
- 向企业暴露学生的细分评分证据; |
||||
|
- 将行为频次解释为人格、诚信、成长潜力或职业能力; |
||||
|
- 用解释功能引导用户刷投递、刷消息、刷简历版本。 |
||||
|
|
||||
|
## 3. 当前真实实现 |
||||
|
|
||||
|
### 3.1 代码入口 |
||||
|
|
||||
|
- 计算:`mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/CandidateScoringCalculator.java` |
||||
|
- 服务:`CandidateScoringEngineService.java` |
||||
|
- 学生与企业接口:`CandidateScoringController.java` |
||||
|
- 快照:`CandidateDimensionScore.java`、`CandidateDimensionScoreRepository.java` |
||||
|
- 能力映射:`AbilityAssessmentScoreSource.java`、`AbilityScoreMapper.java` |
||||
|
- 申诉与回滚:`candidatescore/governance/` |
||||
|
- 排名:`competition/CompetitionRankingCalculator.java` |
||||
|
|
||||
|
### 3.2 综合分 |
||||
|
|
||||
|
当前八维默认展示权重均为 12.50。只有状态为 `OK` 或 `ESTIMATED` 且分数非空的维度参与综合分: |
||||
|
|
||||
|
```text |
||||
|
overallScore = sum(可计算维度分数) / 可计算维度数量 |
||||
|
``` |
||||
|
|
||||
|
最终保留两位小数,`HALF_UP`。`PENDING` 和 `INSUFFICIENT_DATA` 被排除后重新归一化,因此两个相同综合分可能来自不同的维度集合。解释必须展示参与维度数量与排除维度,禁止把综合分描述为完整、统一口径的能力总分。 |
||||
|
|
||||
|
### 3.3 规则版本 |
||||
|
|
||||
|
- 能力测评开关关闭:`candidate-scoring-v0.1`; |
||||
|
- 能力测评开关开启:`candidate-scoring-v0.2`; |
||||
|
- ABILITY 映射规则:`ability-assessment-scoring-v0.1`; |
||||
|
- 历史趋势优先使用快照自身 `rulesVersion`。 |
||||
|
|
||||
|
N02 只增加解释契约时不升级候选评分规则版本,建议新增独立的 `explanationVersion=candidate-scoring-explanation-v1`。只有公式、阈值、参与维度或排名输入发生变化时才升级评分规则版本。 |
||||
|
|
||||
|
## 4. 设计原则 |
||||
|
|
||||
|
### 4.1 解释必须与计算同源 |
||||
|
|
||||
|
每个计分分支在计算贡献值的同时生成内部 `RuleContribution`,不能在 Controller、DTO mapper 或另一套公式中根据最终分数反推解释。 |
||||
|
|
||||
|
建议内部结构: |
||||
|
|
||||
|
```text |
||||
|
RuleContribution |
||||
|
- ruleCode 稳定规则编码 |
||||
|
- outcome APPLIED / NOT_APPLIED / MISSING / EXPIRED / UNAVAILABLE |
||||
|
- contribution 实际加减分;不适用时为 null |
||||
|
- maxContribution 该规则可能贡献的绝对上限 |
||||
|
- publicLabel 面向学生的中性名称 |
||||
|
- publicObservation 安全的区间或状态,不输出内部数据库字段 |
||||
|
- explanationKey 固定解释模板编码 |
||||
|
``` |
||||
|
|
||||
|
规则元数据由 `CandidateScoringExplanationCatalog` 按 `ruleCode` 提供名称、限制、建议和证据等级。计算器产生“事实和贡献”,目录提供“稳定语义”,Assembler 只组合,不重复计算。 |
||||
|
|
||||
|
当逐项贡献按两位小数展示而整体分数采用 HALF_UP 产生舍入差时,使用 |
||||
|
`SCORE_ROUNDING_ADJUSTMENT` 显式记录对账差;它只解释展示精度,不改变任何评分公式。 |
||||
|
|
||||
|
### 4.2 不把解释做成刷分指南 |
||||
|
|
||||
|
学生可以看到区间和缺失项,例如“近 90 天存在投递记录”“求职偏好尚未完整”,但默认不暴露风控阈值组合、审计动作名称、原始消息数量或内部 SQL 字段。已经在界面契约中公开的固定能力权重可继续展示。 |
||||
|
|
||||
|
### 4.3 区分事实、推断与限制 |
||||
|
|
||||
|
每个维度必须有: |
||||
|
|
||||
|
- `whatItMeasures`:规则直接使用的数据; |
||||
|
- `interpretation`:可谨慎解释的含义; |
||||
|
- `limitations`:不能据此得出的结论; |
||||
|
- `evidenceLevel`:`DIRECT_RULE`、`SELF_REPORTED`、`BEHAVIOR_PROXY`、`UNVALIDATED_PROXY`; |
||||
|
- `improvementActions`:只给可合理完成的操作,不鼓励刷行为。 |
||||
|
|
||||
|
## 5. 一期响应方案 |
||||
|
|
||||
|
### 5.1 路径范围 |
||||
|
|
||||
|
保持现有 4 个路径,不新增 operation: |
||||
|
|
||||
|
- `GET /profile/me/candidate-scoring` |
||||
|
- `GET /profile/me/candidate-scoring/dimensions/{dimension}` |
||||
|
- `GET /profile/me/candidate-scoring/trend` |
||||
|
- `GET /company/candidates/{applicationId}/scoring` |
||||
|
|
||||
|
OpenAPI 路径/操作数保持当前基线 172/210。 |
||||
|
|
||||
|
### 5.2 学生综合卡兼容扩展 |
||||
|
|
||||
|
建议新增: |
||||
|
|
||||
|
- `explanationVersion`; |
||||
|
- `coverage.computableDimensions`; |
||||
|
- `coverage.totalDimensions=8`; |
||||
|
- `coverage.includedDimensions`; |
||||
|
- `coverage.excludedDimensions[]{dimension,status,reason}`; |
||||
|
- `comparabilityNotice`,固定说明不同参与维度集合的综合分不宜直接比较。 |
||||
|
|
||||
|
不得返回内部快照 ID、测评结果 ID、题目、答案、审计详情、消息正文或数据库字段名。 |
||||
|
|
||||
|
### 5.3 学生单维详情兼容扩展 |
||||
|
|
||||
|
保留现有 `subScores/reasons/dataSources`,新增: |
||||
|
|
||||
|
- `displayName`; |
||||
|
- `whatItMeasures`; |
||||
|
- `interpretation`; |
||||
|
- `limitations[]`; |
||||
|
- `evidenceLevel`; |
||||
|
- `ruleContributions[]`; |
||||
|
- `improvementActions[]`; |
||||
|
- `explanationVersion`; |
||||
|
- `validity`:仅在存在明确有效期时返回,例如 ABILITY; |
||||
|
- `calculatedAt`:本次真实计算基准时间。 |
||||
|
|
||||
|
`ruleContributions` 只面向学生本人。旧字段在一期保留,避免前端破坏性升级;文档标记旧 `reasons/dataSources` 后续逐步由结构化字段替代。 |
||||
|
|
||||
|
### 5.4 企业端 |
||||
|
|
||||
|
企业端保持现有字段:`applicationId/overallScore/status/dimensions/generatedAt/rulesVersion`。不新增 `ruleContributions`、`limitations`、行为次数、学校分档、资料缺失项、四维能力细分或有效期。 |
||||
|
|
||||
|
企业页面可增加平台级固定说明,但不得把学生解释 DTO 直接复用到企业响应。 |
||||
|
|
||||
|
## 6. 时间一致性 |
||||
|
|
||||
|
`computeAll` 应在入口生成一次 `capturedNow`,并传递给所有时间敏感查询与规则: |
||||
|
|
||||
|
- 90 天投递; |
||||
|
- 30 天登录和活跃操作; |
||||
|
- 账号月龄; |
||||
|
- 简历 90 天更新; |
||||
|
- ABILITY 有效期; |
||||
|
- `calculatedAt` 和数据源时间说明。 |
||||
|
|
||||
|
不得在同一次计算中多次调用 `LocalDateTime.now()` 形成不同边界。建议注入 `Clock`,生产使用系统时钟,测试使用固定时钟。 |
||||
|
|
||||
|
## 7. 八维解释口径 |
||||
|
|
||||
|
| 维度 | 一期允许的名称 | 允许解释 | 禁止解释 | |
||||
|
|---|---|---|---| |
||||
|
| EDUCATION | 教育背景信息 | 已填写学历与学校分类规则的确定性结果 | 智力、能力上限、人才质量 | |
||||
|
| INTERNSHIP | 实习经历信息 | 当前数据源尚未接入 | 无实习能力、经验不足 | |
||||
|
| ABILITY | 四维能力测评 | 最新有效自研测评的规则化聚合 | 确定性职业能力、招聘结论 | |
||||
|
| COMPLIANCE | 求职偏好完整度 | 五类偏好字段填写情况 | 合规意识、服从性 | |
||||
|
| STABILITY | 平台求职行为稳定信号 | 账号时长、撤回与登录形成的行为代理 | 人格稳定、忠诚度 | |
||||
|
| GROWTH | 简历维护与技能展示信号 | 简历维护和技能标签状态 | 成长潜力、学习速度 | |
||||
|
| INTEGRITY | 履约与资料一致性信号 | Offer 响应、反馈与资料完整度的行为代理 | 道德、诚信人格、信用评级 | |
||||
|
| ACTIVITY | 平台求职活跃度 | 近期投递、平台操作和沟通信号 | 工作积极性、职业投入度 | |
||||
|
|
||||
|
维度代码为兼容字段,本版不改数据库枚举。产品展示名称采用上述中性名称。 |
||||
|
|
||||
|
## 8. 合理性整改分级 |
||||
|
|
||||
|
### 8.1 仅解释整改即可上线 |
||||
|
|
||||
|
- ABILITY:保留现有公式和有效期,补自评/情境测验限制; |
||||
|
- INTERNSHIP:保持 PENDING,明确“未接入”不是低分; |
||||
|
- 综合分:补参与维度和不可横向比较提示。 |
||||
|
|
||||
|
### 8.2 需要改名但暂不改公式 |
||||
|
|
||||
|
- COMPLIANCE → 求职偏好完整度; |
||||
|
- STABILITY → 平台求职行为稳定信号; |
||||
|
- GROWTH → 简历维护与技能展示信号; |
||||
|
- INTEGRITY → 履约与资料一致性信号; |
||||
|
- ACTIVITY → 平台求职活跃度。 |
||||
|
|
||||
|
这里只修改展示语义,不改变维度代码、快照或历史接口。 |
||||
|
|
||||
|
### 8.3 需要后续规则版本评审 |
||||
|
|
||||
|
- EDUCATION 的学校名单及 45% 权重; |
||||
|
- STABILITY 的账号时长奖励和撤回惩罚; |
||||
|
- GROWTH 的简历版本数量奖励; |
||||
|
- INTEGRITY 的 Offer 接受奖励、过期惩罚和反馈评分; |
||||
|
- ACTIVITY 的投递、审计和消息计数可操纵性; |
||||
|
- 缺失维度重新归一化后仍进入企业评分与排名。 |
||||
|
|
||||
|
任何一项改变分数都必须升级评分版本、完成新旧结果对账和 V32 排名回归,不能混入纯解释提交。 |
||||
|
|
||||
|
## 9. 权限与隐私 |
||||
|
|
||||
|
学生本人可以查看自身安全聚合解释。企业只能查看现有聚合评分卡。ADMIN 沿用治理接口,不因 N02 获得新的个人解释查询入口。 |
||||
|
|
||||
|
所有 HTTP 响应禁止新增: |
||||
|
|
||||
|
- 原始审计动作、IP、User-Agent; |
||||
|
- 消息正文或对话对象; |
||||
|
- Offer 具体企业、岗位或时间线; |
||||
|
- 评价正文与评价人身份; |
||||
|
- assessment/ability 的题目、答案、答案键、评分规则和结果 ID; |
||||
|
- 内部 SQL、表名、快照 ID、用户 ID; |
||||
|
- 密码、Token、密钥。 |
||||
|
|
||||
|
学生响应中的数据来源改为产品语义名称,如“个人教育资料”“求职偏好”“平台求职活动”,不直接返回 `student_profiles.education/school` 等物理字段名。 |
||||
|
|
||||
|
## 10. 实现建议 |
||||
|
|
||||
|
建议新增内部类: |
||||
|
|
||||
|
- `CandidateScoringExplanationCatalog`:规则元数据白名单; |
||||
|
- `CandidateScoringExplanationAssembler`:将 `DimensionResult` 与规则元数据组装为学生 DTO; |
||||
|
- `RuleContribution`、`ScoreCoverage` 等内部/DTO 类型。 |
||||
|
|
||||
|
修改: |
||||
|
|
||||
|
- `CandidateScoringCalculator`:统一 `capturedNow/Clock`,每个实际分支同步生成贡献; |
||||
|
- `CandidateScoringEngineService`:组装覆盖率与学生解释; |
||||
|
- 学生 DTO:兼容新增字段; |
||||
|
- OpenAPI、校验脚本、API 和联调文档。 |
||||
|
|
||||
|
原则上无新 Flyway、无新 ErrorCode、无新公网路径。解释元数据先随代码版本发布,不引入可在线修改规则的后台。 |
||||
|
|
||||
|
## 11. 测试与验收 |
||||
|
|
||||
|
### 11.1 规则同源 |
||||
|
|
||||
|
- 每个阈值边界独立手算; |
||||
|
- `contribution` 之和与真实维度分形成可证明关系; |
||||
|
- 未命中、缺失、过期和不可用规则不能伪造成 0 分命中; |
||||
|
- 固定 Clock 验证 30/90 天和有效期边界; |
||||
|
- 不允许修改解释而改变分数,或修改分支后遗漏解释。 |
||||
|
|
||||
|
### 11.2 综合分 |
||||
|
|
||||
|
- 0、1、2、8 个可计算维度; |
||||
|
- `OK/ESTIMATED` 参与,其他状态排除; |
||||
|
- coverage 与实际参与集合完全一致; |
||||
|
- 学生综合卡、趋势、企业评分和 V32 排名与既有纯函数对账。 |
||||
|
|
||||
|
### 11.3 权限与脱敏 |
||||
|
|
||||
|
- STUDENT 仅查看本人; |
||||
|
- COMPANY 只见聚合字段; |
||||
|
- ADMIN 不获得新增学生详情入口; |
||||
|
- 匿名 401,角色越权 403/2002; |
||||
|
- 对完整 JSON 递归扫描禁止字段; |
||||
|
- ABILITY 不泄露题目、答案或来源结果 ID。 |
||||
|
|
||||
|
### 11.4 防刷与边界评审 |
||||
|
|
||||
|
用固定样例证明重复投递、发消息、制造简历版本可能改变哪些分数,并在本版明确标为未验证代理信号。若决定保留其排名影响,必须形成书面产品决策和监测指标。 |
||||
|
|
||||
|
### 11.5 发布验证 |
||||
|
|
||||
|
- N02 专项、候选评分、治理、V32、V38、V39、V41、V42、V43 回归; |
||||
|
- `mvn clean verify`; |
||||
|
- 静态与运行时 OpenAPI 对账; |
||||
|
- 真实 MySQL 主链路和响应脱敏; |
||||
|
- 查询前后快照写入行为保持既有语义; |
||||
|
- 无新增 RabbitMQ 消息。 |
||||
|
|
||||
|
## 12. 编码前决策表 |
||||
|
|
||||
|
| 编号 | 决策项 | 建议方案 | 状态 | |
||||
|
|---|---|---|---| |
||||
|
| D01 | N02 是否只做解释,不改任何现有分数、权重和排名 | 是;规则合理性问题记录为后续独立评分版本 | 已确认 | |
||||
|
| D02 | 是否保持现有 4 个接口、0 个新增 path/operation | 是 | 已确认 | |
||||
|
| D03 | 是否无新 Flyway、无规则配置后台 | 是 | 已确认 | |
||||
|
| D04 | 是否增加独立 `explanationVersion`,不升级评分 `rulesVersion` | 是,值为 `candidate-scoring-explanation-v1` | 已确认 | |
||||
|
| D05 | 是否兼容扩展学生综合卡,展示参与/排除维度和可比性提示 | 是 | 已确认 | |
||||
|
| D06 | 是否兼容扩展学生单维详情,增加结构化规则贡献、限制和建议 | 是 | 已确认 | |
||||
|
| D07 | 企业端是否保持现有 schema,不展示任何规则贡献与私人细分 | 是 | 已确认 | |
||||
|
| D08 | 是否采用中性展示名,但保持八个维度代码不变 | 是 | 已确认 | |
||||
|
| D09 | 是否明确 `INTEGRITY` 不能解释为人格诚信或信用评级 | 是 | 已确认 | |
||||
|
| D10 | 是否明确 `GROWTH/STABILITY/COMPLIANCE/ACTIVITY` 仅为平台数据代理 | 是 | 已确认 | |
||||
|
| D11 | 是否用单次 `capturedNow` 并注入 `Clock` 修复解释时间一致性 | 是,不改变阈值与公式 | 已确认 | |
||||
|
| D12 | 学生是否只看安全区间/状态,不显示可用于刷分的内部计数与审计动作 | 是 | 已确认 | |
||||
|
| D13 | EDUCATION 学校分档是否暂时保留计算,但增加未验证与公平性警示 | 暂留,另开评分版本评审决定移除/降权 | 已确认 | |
||||
|
| D14 | 缺失维度重新归一化是否暂时保留 | 暂留,明确可比性限制并在规则评审中决定 | 已确认 | |
||||
|
| D15 | 是否安排题库和规则证据验证,不将当前确定性规则称为已验证模型 | 是 | 已确认 | |
||||
|
|
||||
|
D01–D15 已全部确认并按本设计实现。若未来 D01、D13 或 D14 的产品决策改变计分,本模块必须另行升级评分规则版本,不能通过解释增强提交混入。 |
||||
@ -0,0 +1,209 @@ |
|||||
|
# 候选评分规则证据表 |
||||
|
|
||||
|
状态:N02 独立代码复验与真实 MySQL/Docker 黑盒验收通过,待 Git 检查点;D01–D15 已确认。 |
||||
|
|
||||
|
文档类型:技术参考。本文记录 `CandidateScoringCalculator` 当前真实规则、数据、公式和证据限制。它不是对规则科学有效性的背书,也不是招聘决策指南。 |
||||
|
|
||||
|
## 1. 证据等级 |
||||
|
|
||||
|
| 等级 | 含义 | |
||||
|
|---|---| |
||||
|
| DIRECT_RULE | 可直接验证的确定性业务计算,例如字段是否填写、固定加权公式 | |
||||
|
| SELF_REPORTED | 主要来自学生本人填写,真实性和完整性依赖用户维护 | |
||||
|
| BEHAVIOR_PROXY | 平台行为与目标概念可能有关,但不能等同于目标概念 | |
||||
|
| UNVALIDATED_PROXY | 尚无试测、效度或公平性证据,不应据此形成强结论 | |
||||
|
|
||||
|
当前系统没有足够证据将任何行为代理称为人格测量、信用评估或招聘成功预测模型。 |
||||
|
|
||||
|
## 2. 全局规则 |
||||
|
|
||||
|
| 项目 | 当前实现 | 证据与限制 | |
||||
|
|---|---|---| |
||||
|
| 维度 | EDUCATION、INTERNSHIP、ABILITY、COMPLIANCE、STABILITY、GROWTH、INTEGRITY、ACTIVITY | 名称不一定等于实际被测概念 | |
||||
|
| 展示权重 | 每维 12.50 | 实际综合分是可计算维度算术平均 | |
||||
|
| 参与状态 | OK、ESTIMATED | PENDING、INSUFFICIENT_DATA 和 null score 排除 | |
||||
|
| 综合分 | `sum(score)/count`,scale=2,HALF_UP | 不同参与维度集合之间不可直接比较 | |
||||
|
| 分数边界 | 多数行为维度经 `cap` 限制至 0–100 | 上限截断会隐藏额外行为差异 | |
||||
|
| 快照 | 同学生、维度、月份覆盖更新 | 月度快照不是不可变历史事实 | |
||||
|
| 版本 | v0.1;能力开关开启为 v0.2 | 版本变化必须随响应和快照保存 | |
||||
|
|
||||
|
## 3. EDUCATION |
||||
|
|
||||
|
### 3.1 当前公式 |
||||
|
|
||||
|
```text |
||||
|
educationScore = 学历层次分 × 55% + 学校档次分 × 45% |
||||
|
``` |
||||
|
|
||||
|
学历层次:博士 100、硕士 85、本科 70、大专 55、其他 50。 |
||||
|
|
||||
|
学校档次:内置 985 名单 85、内置 211 名单 75、名称以“大学/学院”结尾 65、未填写或其他 50。未填写或无法识别时状态为 `ESTIMATED`;学历缺失时 `INSUFFICIENT_DATA`。 |
||||
|
|
||||
|
| 项目 | 结论 | |
||||
|
|---|---| |
||||
|
| 数据来源 | 学生教育资料中的学历和学校 | |
||||
|
| 证据等级 | SELF_REPORTED + UNVALIDATED_PROXY | |
||||
|
| 可以说明 | 当前资料按平台固定分类规则得到的教育背景分 | |
||||
|
| 不能说明 | 智力、实际技能、岗位绩效、学习能力或人才质量 | |
||||
|
| 主要风险 | 学校名单硬编码、维护过期、学校名称匹配误差、社会经济背景代理、公平性风险 | |
||||
|
| 可操纵性 | 学生可修改自报字段;需要资料真实性治理,但不能因此扩大企业隐私权限 | |
||||
|
| 建议 | 一期只准确解释并标注未验证;后续评审移除学校项或显著降权 | |
||||
|
|
||||
|
## 4. INTERNSHIP |
||||
|
|
||||
|
当前固定返回 `PENDING`、`score=null`,原因是结构化实习数据源未接入。 |
||||
|
|
||||
|
| 项目 | 结论 | |
||||
|
|---|---| |
||||
|
| 证据等级 | DIRECT_RULE | |
||||
|
| 可以说明 | 当前平台无法计算该维度 | |
||||
|
| 不能说明 | 学生没有实习、实习不足或经验较弱 | |
||||
|
| 建议 | 保持 PENDING;解释必须使用“未接入”,不得显示为 0 分 | |
||||
|
|
||||
|
## 5. ABILITY |
||||
|
|
||||
|
默认开关关闭时返回 `PENDING`。开启后只读取最新、有效、完整的 `WORKPLACE_ABILITY` 结果: |
||||
|
|
||||
|
```text |
||||
|
ABILITY = LOGIC×35% + EXECUTION×30% + LEARNING×20% + COMMUNICATION×15% |
||||
|
``` |
||||
|
|
||||
|
要求四维各回答 6 题、总回答 24 题、分数均在 0–100,`validUntil > capturedNow`。最终 scale=2、HALF_UP。 |
||||
|
|
||||
|
| 项目 | 结论 | |
||||
|
|---|---| |
||||
|
| 数据来源 | V36 自研四维能力测评最新有效聚合结果 | |
||||
|
| 证据等级 | DIRECT_RULE;题库测量意义仍属 UNVALIDATED_PROXY | |
||||
|
| 可以说明 | 用户在当前自研题库和评分规则下的四维聚合表现 | |
||||
|
| 不能说明 | 确定性工作能力、真实绩效或录用适合度 | |
||||
|
| 防滥用 | 不取历史最高分;结果过期或结构异常时不参与;不泄露答案键 | |
||||
|
| 待验证 | 小样本试测、信度、题目区分度、重测效应、与外部标准的关联 | |
||||
|
|
||||
|
## 6. COMPLIANCE |
||||
|
|
||||
|
当前实际计算求职偏好填写完整度:薪资 25、城市 25、用工类型 20、行业 15、岗位类型 15。五项均填写为 `OK`;至少一项填写为 `ESTIMATED`;全部缺失为 `INSUFFICIENT_DATA`。 |
||||
|
|
||||
|
| 项目 | 结论 | |
||||
|
|---|---| |
||||
|
| 证据等级 | DIRECT_RULE + SELF_REPORTED | |
||||
|
| 可以说明 | 五类求职偏好字段的填写完整程度 | |
||||
|
| 不能说明 | 合规意识、纪律性、服从性或职业道德 | |
||||
|
| 风险 | 名称严重超出计算内容;填写更多即可得到更高分,但不代表偏好更合理 | |
||||
|
| 建议展示名 | 求职偏好完整度 | |
||||
|
| 后续建议 | 评审是否应退出跨候选排名,仅用于资料完善提示 | |
||||
|
|
||||
|
## 7. STABILITY |
||||
|
|
||||
|
有投递或近 30 天登录时以 50 为基础:账号超过 12 个月 +25,超过 6 个月 +15;有投递时撤回率低于 20% +15,高于 50% -20;近 30 天有登录 +10;结果截断至 0–100。无投递且无登录为 `INSUFFICIENT_DATA`。 |
||||
|
|
||||
|
| 项目 | 结论 | |
||||
|
|---|---| |
||||
|
| 证据等级 | BEHAVIOR_PROXY + UNVALIDATED_PROXY | |
||||
|
| 可以说明 | 当前账号和平台求职行为形成的稳定信号 | |
||||
|
| 不能说明 | 人格稳定、工作稳定、忠诚度或离职概率 | |
||||
|
| 风险 | 新用户天然劣势;撤回可能由岗位变化、误投或企业原因造成;登录记录受产品使用习惯影响 | |
||||
|
| 可操纵性 | 登录和减少撤回可改变分数;阈值公开可能诱导行为 | |
||||
|
| 建议展示名 | 平台求职行为稳定信号 | |
||||
|
| 后续建议 | 决定是否从企业评分及排名中移除账号月龄,或仅作为可信度信息 | |
||||
|
|
||||
|
## 8. GROWTH |
||||
|
|
||||
|
存在简历或技能时以 40 为基础:ACTIVE 简历数量至少 2 个 +15;最近简历更新时间在 90 天内 +15;技能标签至少 5 个 +15;结果截断至 0–100。二者均无数据时 `INSUFFICIENT_DATA`。 |
||||
|
|
||||
|
| 项目 | 结论 | |
||||
|
|---|---| |
||||
|
| 证据等级 | BEHAVIOR_PROXY + UNVALIDATED_PROXY | |
||||
|
| 可以说明 | 简历维护和技能标签展示情况 | |
||||
|
| 不能说明 | 成长潜力、学习速度、职业上升空间 | |
||||
|
| 风险 | 多建简历版本和增加标签即可加分;技能数量不等于技能质量;无历史能力增量 | |
||||
|
| 建议展示名 | 简历维护与技能展示信号 | |
||||
|
| 后续建议 | 退出综合分或改成非评分的资料完善进度;若保留需加入去重、质量和冷却规则 | |
||||
|
|
||||
|
## 9. INTEGRITY |
||||
|
|
||||
|
有 Offer 历史或候选反馈时以 60 为基础:每个已接受 Offer +15,最多两个;每个过期 Offer -10,最多两个;反馈至少 3 条且均分至少 4 时 +10,有反馈且均分低于 3 时 -10;资料完整 +10;结果截断至 0–100。没有 Offer 且没有反馈时 `INSUFFICIENT_DATA`。 |
||||
|
|
||||
|
| 项目 | 结论 | |
||||
|
|---|---| |
||||
|
| 证据等级 | BEHAVIOR_PROXY + UNVALIDATED_PROXY | |
||||
|
| 可以说明 | Offer 响应、反馈和资料完整度形成的履约代理信号 | |
||||
|
| 不能说明 | 道德诚信、个人信用、欺诈风险或人格品质 | |
||||
|
| 风险 | 接受 Offer 不必然等于守约;过期可能来自通知失败;反馈评分可能存在偏见或样本不足;资料完整与诚信不是同一概念 | |
||||
|
| 可操纵性 | 接受行为、反馈数量及资料填写会改变结果 | |
||||
|
| 建议展示名 | 履约与资料一致性信号 | |
||||
|
| 强制文案 | “该信号不是个人征信、信用评级或道德评价” | |
||||
|
| 后续建议 | 优先评审退出综合分和排名;独立诚信积分模块不得复用该分数充当积分余额 | |
||||
|
|
||||
|
## 10. ACTIVITY |
||||
|
|
||||
|
近 90 天投递:至少 6 次 +50、至少 3 次 +40、至少 1 次 +20;近 30 天平台操作至少 3 次 +30;存在发信记录 +20;结果截断至 0–100。全部为零时 `INSUFFICIENT_DATA`。 |
||||
|
|
||||
|
| 项目 | 结论 | |
||||
|
|---|---| |
||||
|
| 证据等级 | BEHAVIOR_PROXY | |
||||
|
| 可以说明 | 指定窗口内的平台求职活动程度 | |
||||
|
| 不能说明 | 工作积极性、职业动机、岗位能力或录用意愿 | |
||||
|
| 风险 | 为得分而投递、登录、发消息;消息只判断是否存在且当前查询未限定时间窗口 | |
||||
|
| 可操纵性 | 高;重复投递和任意消息可直接增加信号 | |
||||
|
| 建议展示名 | 平台求职活跃度 | |
||||
|
| 后续建议 | 保留为时效排序信号须单独评审;不建议与能力类维度等权解释 | |
||||
|
|
||||
|
## 11. 跨维度问题 |
||||
|
|
||||
|
### 11.1 概念重叠 |
||||
|
|
||||
|
资料完整度同时影响 COMPLIANCE、GROWTH 和 INTEGRITY,平台活跃行为同时影响 STABILITY 和 ACTIVITY。相同底层行为可能被重复计入综合分。 |
||||
|
|
||||
|
### 11.2 缺失并非零分 |
||||
|
|
||||
|
当前缺失维度会退出分母,而不是记为零。解释必须明确“未参与”,不能显示成“表现较差”。但重新归一化也使不同人的综合分口径不一致。 |
||||
|
|
||||
|
### 11.3 估算值 |
||||
|
|
||||
|
`ESTIMATED` 与 `OK` 同等进入综合分。学校未知和偏好部分填写均可能产生估算分,企业端目前只看到状态和聚合分,必须避免将估算分表述为精确结果。 |
||||
|
|
||||
|
### 11.4 时间窗口 |
||||
|
|
||||
|
APPLICATION、LOGIN、AUDIT 和简历更新时间使用多个 `now()`,消息数量没有时间窗口。N02 应先统一计算时钟,但是否修改消息窗口属于评分规则变更,不能混入解释版本。 |
||||
|
|
||||
|
## 12. 规则证据验证计划 |
||||
|
|
||||
|
### 12.1 代码级确定性验证 |
||||
|
|
||||
|
- 为每条规则建立边界表和独立手算样例; |
||||
|
- 固定 Clock 验证恰好 30/90 天、6/12 个月和 ABILITY 到期时刻; |
||||
|
- 检查分数、贡献、原因与数据来源完全一致; |
||||
|
- 验证 cap、负分、HALF_UP 和 null 分支。 |
||||
|
|
||||
|
### 12.2 数据质量验证 |
||||
|
|
||||
|
- 统计各维度 OK/ESTIMATED/INSUFFICIENT_DATA/PENDING 覆盖率; |
||||
|
- 检查学校名称无法识别率、偏好 JSON 错误率、Offer 通知失败率; |
||||
|
- 检查同一底层行为跨维度重复计分比例; |
||||
|
- 监测规则版本和异常结构,复用 V43 聚合能力但不扩大其个人数据范围。 |
||||
|
|
||||
|
### 12.3 防操纵验证 |
||||
|
|
||||
|
- 重复投递、撤回、登录、发消息、创建简历版本和堆叠技能标签; |
||||
|
- 比较一次行为与大量重复行为的边际影响; |
||||
|
- 验证冷却、幂等和上限是否真正生效; |
||||
|
- 发现高可操纵性时,不以隐藏阈值代替规则整改。 |
||||
|
|
||||
|
### 12.4 测评证据 |
||||
|
|
||||
|
ABILITY 题库先完成小样本试测,再评估内部一致性、题目区分度、重测影响和题目淘汰。RIASEC 当前不直接进入候选八维评分,不应在 N02 中混入。 |
||||
|
|
||||
|
## 13. N02 实现核对 |
||||
|
|
||||
|
- 解释版本固定为 `candidate-scoring-explanation-v1`,与 `candidate-scoring-v0.1/v0.2` 分离。 |
||||
|
- 规则贡献由 `CandidateScoringCalculator` 的真实分支同步生成,`CandidateScoringExplanationAssembler` 只做白名单组装。 |
||||
|
- 能力测评小数分数的逐项展示舍入差以 `SCORE_ROUNDING_ADJUSTMENT` 显式对账;不改变 V38 映射分数。 |
||||
|
- 学生综合卡增加 `coverage` 与 `comparabilityNotice`;企业评分卡字段和 JSON 结构保持不变。 |
||||
|
- `Clock`/`capturedNow` 贯穿一次评分计算及快照 `calculated_at`;未改变任何阈值、公式、权重或排名。 |
||||
|
- 本轮无新 Flyway、表、错误码、公网路径或业务消息;真实 MySQL/Docker 验收仍待独立执行。 |
||||
|
|
||||
|
## 14. 审计结论 |
||||
|
|
||||
|
当前代码在“确定性、可重复计算”层面具备基础,但不具备将多个代理维度解释为人格、诚信、成长潜力或招聘成功预测的证据。N02 可以先完成可信解释和限制披露;涉及分数、排名和企业决策影响的合理性整改必须作为独立规则版本处理。 |
||||
|
|
||||
|
D01–D15 已确认。当前代码在“确定性、可重复计算”层面具备基础,但不具备将多个代理维度称为人格、诚信、成长潜力或招聘成功预测模型的证据。N02 仅完成可信解释和限制披露;任何改变分数、权重或排名的后续动作必须另开评分规则版本并重新验收。 |
||||
@ -0,0 +1,76 @@ |
|||||
|
package com.mydreamjob.candidatescore.explanation; |
||||
|
|
||||
|
import com.mydreamjob.candidatescore.CandidateScoringCalculator; |
||||
|
import com.mydreamjob.candidatescore.dto.DimensionScoreDetailResponse; |
||||
|
|
||||
|
import java.math.BigDecimal; |
||||
|
import java.math.RoundingMode; |
||||
|
import java.time.LocalDateTime; |
||||
|
import java.util.List; |
||||
|
import java.util.Set; |
||||
|
|
||||
|
/** 只负责把同源计算事实组装为学生白名单响应,不包含任何评分公式。 */ |
||||
|
public final class CandidateScoringExplanationAssembler { |
||||
|
private static final Set<String> SUPPORTED_OUTCOMES = Set.of( |
||||
|
"APPLIED", "NOT_APPLIED", "MISSING", "EXPIRED", "UNAVAILABLE"); |
||||
|
|
||||
|
private CandidateScoringExplanationAssembler() {} |
||||
|
|
||||
|
public static DimensionScoreDetailResponse assemble(CandidateScoringCalculator.DimensionResult r, |
||||
|
String rulesVersion, |
||||
|
LocalDateTime capturedNow) { |
||||
|
CandidateScoringExplanationCatalog.Metadata m = CandidateScoringExplanationCatalog.metadata(r.dimension); |
||||
|
validateContributions(r); |
||||
|
List<DimensionScoreDetailResponse.RuleContribution> contributions = r.ruleContributions == null |
||||
|
? List.of() : r.ruleContributions.stream().map(RuleContribution::toResponse).toList(); |
||||
|
DimensionScoreDetailResponse.Validity validity = null; |
||||
|
if (r.abilitySnapshotTrace != null) { |
||||
|
LocalDateTime until = r.abilitySnapshotTrace.validUntil(); |
||||
|
validity = DimensionScoreDetailResponse.Validity.builder() |
||||
|
.completedAt(r.abilitySnapshotTrace.completedAt()).validUntil(until) |
||||
|
.expired(until != null && !until.isAfter(capturedNow)).build(); |
||||
|
} |
||||
|
return DimensionScoreDetailResponse.builder() |
||||
|
.dimension(r.dimension).score(r.score).status(r.status) |
||||
|
.subScores(r.subScores).reasons(r.reasons).dataSources(r.dataSources) |
||||
|
.updatedAt(capturedNow).rulesVersion(rulesVersion) |
||||
|
.displayName(m.displayName()).whatItMeasures(m.whatItMeasures()) |
||||
|
.interpretation(m.interpretation()).limitations(m.limitations()) |
||||
|
.evidenceLevel(m.evidenceLevel()).ruleContributions(contributions) |
||||
|
.improvementActions(m.improvementActions()).explanationVersion(CandidateScoringExplanationCatalog.VERSION) |
||||
|
.validity(validity).calculatedAt(capturedNow).build(); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 解释必须与真实计分事实完整对账。未知或损坏的内部规则不能被静默丢弃, |
||||
|
* 否则会生成“成功但贡献不等于分数”的不可信响应。异常文本固定且不回显内部值。 |
||||
|
*/ |
||||
|
private static void validateContributions(CandidateScoringCalculator.DimensionResult result) { |
||||
|
if (result.ruleContributions == null || result.ruleContributions.isEmpty()) { |
||||
|
throw unsupportedContribution(); |
||||
|
} |
||||
|
for (RuleContribution contribution : result.ruleContributions) { |
||||
|
if (contribution == null || contribution.ruleCode() == null |
||||
|
|| contribution.ruleCode().isBlank() |
||||
|
|| !CandidateScoringExplanationCatalog.isKnownRuleCode(contribution.ruleCode()) |
||||
|
|| contribution.outcome() == null |
||||
|
|| !SUPPORTED_OUTCOMES.contains(contribution.outcome())) { |
||||
|
throw unsupportedContribution(); |
||||
|
} |
||||
|
} |
||||
|
if (result.score != null) { |
||||
|
BigDecimal sum = result.ruleContributions.stream() |
||||
|
.map(c -> c.contribution() == null ? BigDecimal.ZERO : c.contribution()) |
||||
|
.reduce(BigDecimal.ZERO, BigDecimal::add) |
||||
|
.setScale(2, RoundingMode.HALF_UP); |
||||
|
BigDecimal score = result.score.setScale(2, RoundingMode.HALF_UP); |
||||
|
if (sum.compareTo(score) != 0) { |
||||
|
throw unsupportedContribution(); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private static IllegalStateException unsupportedContribution() { |
||||
|
return new IllegalStateException("candidate scoring explanation contains an unsupported rule"); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,68 @@ |
|||||
|
package com.mydreamjob.candidatescore.explanation; |
||||
|
|
||||
|
import java.util.List; |
||||
|
import java.util.Map; |
||||
|
|
||||
|
/** 固定白名单解释元数据;不访问数据库、不计算分数、不生成动态文案。 */ |
||||
|
public final class CandidateScoringExplanationCatalog { |
||||
|
public static final String VERSION = "candidate-scoring-explanation-v1"; |
||||
|
private static final java.util.Set<String> RULE_CODES = java.util.Set.of( |
||||
|
"EDUCATION_LEVEL", "EDUCATION_SCHOOL_TIER", "EDUCATION_MISSING", |
||||
|
"INTERNSHIP_SOURCE_UNAVAILABLE", |
||||
|
"ABILITY_FEATURE_DISABLED", "ABILITY_RESULT_MISSING", "ABILITY_RESULT_EXPIRED", |
||||
|
"ABILITY_RESULT_INVALID", "ABILITY_LOGIC", "ABILITY_EXECUTION", "ABILITY_LEARNING", |
||||
|
"ABILITY_COMMUNICATION", |
||||
|
"PREFERENCE_SALARY_PRESENT", "PREFERENCE_CITY_PRESENT", |
||||
|
"PREFERENCE_EMPLOYMENT_TYPE_PRESENT", "PREFERENCE_INDUSTRY_PRESENT", |
||||
|
"PREFERENCE_JOB_TYPE_PRESENT", "PREFERENCE_ALL_MISSING", |
||||
|
"STABILITY_BASE", "STABILITY_ACCOUNT_AGE", "STABILITY_WITHDRAWAL_RATE", |
||||
|
"STABILITY_RECENT_LOGIN", "STABILITY_DATA_MISSING", |
||||
|
"GROWTH_BASE", "GROWTH_RESUME_VERSIONS", "GROWTH_RECENT_RESUME_UPDATE", |
||||
|
"GROWTH_SKILL_TAGS", "GROWTH_DATA_MISSING", |
||||
|
"INTEGRITY_BASE", "INTEGRITY_ACCEPTED_OFFERS", "INTEGRITY_EXPIRED_OFFERS", |
||||
|
"INTEGRITY_FEEDBACK", "INTEGRITY_PROFILE_COMPLETENESS", "INTEGRITY_DATA_MISSING", |
||||
|
"ACTIVITY_APPLICATIONS", "ACTIVITY_PLATFORM_OPERATIONS", "ACTIVITY_MESSAGES", |
||||
|
"ACTIVITY_DATA_MISSING", "SCORE_CAP_ADJUSTMENT", "SCORE_ROUNDING_ADJUSTMENT"); |
||||
|
private static final Map<String, Metadata> ITEMS = Map.of( |
||||
|
"EDUCATION", new Metadata("教育背景信息", "个人教育资料按固定分类规则形成的背景信号", |
||||
|
"反映资料字段在平台分类规则下的结果", List.of("不能代表智力、实际技能或录用适合度"), "UNVALIDATED_PROXY", |
||||
|
List.of("完善真实教育资料", "保持资料真实准确")), |
||||
|
"INTERNSHIP", new Metadata("实习经历信息", "结构化实习经历数据源的可用性", |
||||
|
"当前平台是否具备该维度数据", List.of("数据源未接入不等于没有实习"), "DIRECT_RULE", |
||||
|
List.of("待平台接入结构化实习资料")), |
||||
|
"ABILITY", new Metadata("四维能力测评", "V36 四维能力测评的规则化聚合结果", |
||||
|
"反映当前自研测评规则下的聚合表现", List.of("不代表确定性工作能力或录用结论"), "UNVALIDATED_PROXY", |
||||
|
List.of("完成有效测评", "测评结果过期后重新测评")), |
||||
|
"COMPLIANCE", new Metadata("求职偏好完整度", "五类求职偏好字段的填写情况", |
||||
|
"反映资料完整程度,不作个人行为评价", List.of("不代表纪律性、合规意识或职业道德"), "SELF_REPORTED", |
||||
|
List.of("完善真实求职偏好", "保持偏好资料准确")), |
||||
|
"STABILITY", new Metadata("平台求职行为稳定信号", "账号时长、撤回率和近期登录形成的行为信号", |
||||
|
"反映平台行为规则下的稳定信号", List.of("不代表人格稳定、忠诚度或离职概率"), "UNVALIDATED_PROXY", |
||||
|
List.of("保持资料真实准确", "在有效期内处理 Offer")), |
||||
|
"GROWTH", new Metadata("简历维护与技能展示信号", "简历维护和技能标签展示情况", |
||||
|
"反映资料维护状态,不作个人发展结论", List.of("不代表成长潜力、学习速度或晋升能力"), "UNVALIDATED_PROXY", |
||||
|
List.of("核对简历和技能标签", "保持资料真实准确")), |
||||
|
"INTEGRITY", new Metadata("履约与资料一致性信号", "Offer 响应、反馈和资料完整度形成的行为信号", |
||||
|
"反映平台履约与资料一致性规则下的结果", List.of("该信号不是个人征信、信用评级、道德评价或人格判断"), "UNVALIDATED_PROXY", |
||||
|
List.of("及时查看并处理有效 Offer", "保持资料真实准确")), |
||||
|
"ACTIVITY", new Metadata("平台求职活跃度", "指定窗口内的投递、平台操作和沟通信号", |
||||
|
"反映平台内近期活动状态", List.of("不代表工作积极性、职业动机或职业能力"), "BEHAVIOR_PROXY", |
||||
|
List.of("保持资料真实准确")) |
||||
|
); |
||||
|
|
||||
|
private CandidateScoringExplanationCatalog() {} |
||||
|
|
||||
|
public static Metadata metadata(String dimension) { |
||||
|
Metadata m = ITEMS.get(dimension); |
||||
|
if (m == null) throw new IllegalArgumentException("unknown scoring dimension"); |
||||
|
return m; |
||||
|
} |
||||
|
|
||||
|
public static boolean isKnownRuleCode(String ruleCode) { |
||||
|
return ruleCode != null && RULE_CODES.contains(ruleCode); |
||||
|
} |
||||
|
|
||||
|
public record Metadata(String displayName, String whatItMeasures, String interpretation, |
||||
|
List<String> limitations, String evidenceLevel, |
||||
|
List<String> improvementActions) {} |
||||
|
} |
||||
@ -0,0 +1,17 @@ |
|||||
|
package com.mydreamjob.candidatescore.explanation; |
||||
|
|
||||
|
import com.mydreamjob.candidatescore.dto.DimensionScoreDetailResponse; |
||||
|
|
||||
|
import java.math.BigDecimal; |
||||
|
|
||||
|
/** 由真实计分分支产生的解释事实;不访问数据库,也不重新计算分数。 */ |
||||
|
public record RuleContribution(String ruleCode, String outcome, BigDecimal contribution, |
||||
|
BigDecimal maxContribution, String label, |
||||
|
String observation, String explanation) { |
||||
|
public DimensionScoreDetailResponse.RuleContribution toResponse() { |
||||
|
return DimensionScoreDetailResponse.RuleContribution.builder() |
||||
|
.ruleCode(ruleCode).outcome(outcome).contribution(contribution) |
||||
|
.maxContribution(maxContribution).label(label).observation(observation) |
||||
|
.explanation(explanation).build(); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,114 @@ |
|||||
|
package com.mydreamjob.candidatescore; |
||||
|
|
||||
|
import com.mydreamjob.auth.User; |
||||
|
import com.mydreamjob.auth.UserRepository; |
||||
|
import com.mydreamjob.company.Company; |
||||
|
import com.mydreamjob.company.CompanyRepository; |
||||
|
import com.mydreamjob.job.Job; |
||||
|
import com.mydreamjob.job.JobRepository; |
||||
|
import org.junit.jupiter.api.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 org.springframework.transaction.annotation.Transactional; |
||||
|
|
||||
|
import java.math.BigDecimal; |
||||
|
import java.time.LocalDateTime; |
||||
|
import java.time.ZoneId; |
||||
|
import java.util.HashSet; |
||||
|
import java.util.List; |
||||
|
import java.util.Set; |
||||
|
import java.util.concurrent.atomic.AtomicLong; |
||||
|
|
||||
|
import static org.junit.jupiter.api.Assertions.assertEquals; |
||||
|
import static org.mockito.ArgumentMatchers.any; |
||||
|
import static org.mockito.Mockito.mock; |
||||
|
import static org.mockito.Mockito.when; |
||||
|
|
||||
|
/** |
||||
|
* N02 的真实 H2 SQL 边界证据:不 Mock JdbcTemplate 或 Calculator,直接执行正式数据采集路径。 |
||||
|
*/ |
||||
|
@SpringBootTest |
||||
|
@ActiveProfiles("integration-test") |
||||
|
@Transactional |
||||
|
class CandidateScoringClockBoundaryIntegrationTest { |
||||
|
|
||||
|
private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai"); |
||||
|
private static final LocalDateTime CAPTURED = LocalDateTime.of(2026, 9, 9, 12, 0); |
||||
|
private static final AtomicLong PHONE = new AtomicLong(930000000L); |
||||
|
|
||||
|
@Autowired private CandidateScoringCalculator calculator; |
||||
|
@Autowired private UserRepository userRepository; |
||||
|
@Autowired private CompanyRepository companyRepository; |
||||
|
@Autowired private JobRepository jobRepository; |
||||
|
@Autowired private org.springframework.jdbc.core.JdbcTemplate jdbcTemplate; |
||||
|
@MockBean private RedisTemplate<String, Object> redisTemplate; |
||||
|
|
||||
|
@Test |
||||
|
void exactThirtyDaysAuditBoundary_isIncludedAndEarlierRecordExcluded() { |
||||
|
User user = createStudent(); |
||||
|
insertAudit(user.getId(), "LOGIN", CAPTURED.minusDays(30)); |
||||
|
insertAudit(user.getId(), "LOGIN", CAPTURED.minusDays(30).minusSeconds(1)); |
||||
|
|
||||
|
List<CandidateScoringCalculator.DimensionResult> results = calculator.computeAll(user.getId(), CAPTURED); |
||||
|
|
||||
|
CandidateScoringCalculator.DimensionResult stability = result(results, "STABILITY"); |
||||
|
CandidateScoringCalculator.DimensionResult activity = result(results, "ACTIVITY"); |
||||
|
assertEquals("OK", stability.status); |
||||
|
assertEquals(new BigDecimal("60.00"), stability.score, |
||||
|
"one exact-boundary login is included without changing the existing formula"); |
||||
|
assertEquals(new BigDecimal("0.00"), activity.score, |
||||
|
"a single login is below the activity threshold"); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void exactNinetyDaysApplicationBoundary_isIncludedAndEarlierRecordExcluded() { |
||||
|
User user = createStudent(); |
||||
|
Company company = companyRepository.save(Company.builder() |
||||
|
.ownerUserId(user.getId()).name("clock-boundary-company").verified(true).build()); |
||||
|
Job exact = createJob(company.getId(), "clock-exact"); |
||||
|
Job earlier = createJob(company.getId(), "clock-earlier"); |
||||
|
insertApplication(exact.getId(), user.getId(), CAPTURED.minusDays(90)); |
||||
|
insertApplication(earlier.getId(), user.getId(), CAPTURED.minusDays(90).minusSeconds(1)); |
||||
|
|
||||
|
List<CandidateScoringCalculator.DimensionResult> results = calculator.computeAll(user.getId(), CAPTURED); |
||||
|
|
||||
|
CandidateScoringCalculator.DimensionResult activity = result(results, "ACTIVITY"); |
||||
|
assertEquals("OK", activity.status); |
||||
|
assertEquals(new BigDecimal("20.00"), activity.score, |
||||
|
"only the exact-boundary application contributes one recent application"); |
||||
|
} |
||||
|
|
||||
|
private User createStudent() { |
||||
|
User user = User.builder() |
||||
|
.phone("1" + PHONE.incrementAndGet()) |
||||
|
.nickname("clock-boundary-student") |
||||
|
.role(User.Role.STUDENT) |
||||
|
.roles(new HashSet<>(Set.of(User.Role.STUDENT))) |
||||
|
.active(true).verified(true).build(); |
||||
|
return userRepository.save(user); |
||||
|
} |
||||
|
|
||||
|
private Job createJob(Long companyId, String title) { |
||||
|
return jobRepository.save(Job.builder().companyId(companyId).title(title) |
||||
|
.description("clock boundary fixture").city("上海") |
||||
|
.employmentType("FULL_TIME").status("PUBLISHED").build()); |
||||
|
} |
||||
|
|
||||
|
private void insertAudit(Long userId, String action, LocalDateTime createdAt) { |
||||
|
jdbcTemplate.update("INSERT INTO audit_logs (user_id, action, created_at) VALUES (?, ?, ?)", |
||||
|
userId, action, createdAt); |
||||
|
} |
||||
|
|
||||
|
private void insertApplication(Long jobId, Long studentId, LocalDateTime createdAt) { |
||||
|
jdbcTemplate.update("INSERT INTO applications (job_id, student_user_id, status, created_at) VALUES (?, ?, ?, ?)", |
||||
|
jobId, studentId, "SUBMITTED", createdAt); |
||||
|
} |
||||
|
|
||||
|
private CandidateScoringCalculator.DimensionResult result( |
||||
|
List<CandidateScoringCalculator.DimensionResult> results, String dimension) { |
||||
|
return results.stream().filter(r -> dimension.equals(r.dimension)).findFirst().orElseThrow(); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,324 @@ |
|||||
|
package com.mydreamjob.candidatescore; |
||||
|
|
||||
|
import com.mydreamjob.auth.User; |
||||
|
import com.mydreamjob.candidatescore.explanation.RuleContribution; |
||||
|
import org.junit.jupiter.api.Test; |
||||
|
import org.junit.jupiter.api.extension.ExtendWith; |
||||
|
import org.mockito.ArgumentCaptor; |
||||
|
import org.mockito.ArgumentMatchers; |
||||
|
import org.mockito.Mock; |
||||
|
import org.mockito.junit.jupiter.MockitoExtension; |
||||
|
import org.mockito.junit.jupiter.MockitoSettings; |
||||
|
import org.mockito.quality.Strictness; |
||||
|
import org.springframework.jdbc.core.JdbcTemplate; |
||||
|
import org.springframework.jdbc.core.RowMapper; |
||||
|
import org.springframework.test.util.ReflectionTestUtils; |
||||
|
import jakarta.persistence.EntityManager; |
||||
|
|
||||
|
import java.math.BigDecimal; |
||||
|
import java.time.Clock; |
||||
|
import java.time.Instant; |
||||
|
import java.time.LocalDateTime; |
||||
|
import java.time.ZoneId; |
||||
|
import java.util.List; |
||||
|
import java.util.Optional; |
||||
|
import java.util.concurrent.atomic.AtomicInteger; |
||||
|
|
||||
|
import static org.junit.jupiter.api.Assertions.assertEquals; |
||||
|
import static org.junit.jupiter.api.Assertions.assertNull; |
||||
|
import static org.mockito.ArgumentMatchers.*; |
||||
|
import static org.mockito.Mockito.*; |
||||
|
|
||||
|
@ExtendWith(MockitoExtension.class) |
||||
|
@MockitoSettings(strictness = Strictness.LENIENT) |
||||
|
class CandidateScoringClockConsistencyTest { |
||||
|
|
||||
|
@Mock CandidateScoringCalculator calculator; |
||||
|
@Mock CandidateDimensionScoreRepository snapshotRepository; |
||||
|
@Mock com.mydreamjob.company.CompanyRepository companyRepository; |
||||
|
@Mock com.mydreamjob.application.ApplicationRepository applicationRepository; |
||||
|
@Mock com.mydreamjob.job.JobService jobService; |
||||
|
@Mock com.mydreamjob.profile.StudentProfileRepository profileRepository; |
||||
|
@Mock com.mydreamjob.preference.StudentPreferenceRepository preferenceRepository; |
||||
|
@Mock com.mydreamjob.auth.UserRepository userRepository; |
||||
|
@Mock JdbcTemplate jdbcTemplate; |
||||
|
@Mock AbilityAssessmentScoreSource abilityScoreSource; |
||||
|
@Mock EntityManager entityManager; |
||||
|
|
||||
|
private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai"); |
||||
|
private static final LocalDateTime CAPTURED = LocalDateTime.of(2026, 9, 9, 12, 0); |
||||
|
|
||||
|
@Test |
||||
|
void overviewUsesOneCapturedTimeForComputeAndSnapshot() { |
||||
|
CandidateScoringEngineService service = new CandidateScoringEngineService( |
||||
|
calculator, snapshotRepository, companyRepository, applicationRepository, jobService); |
||||
|
LocalDateTime captured = LocalDateTime.of(2026, 9, 9, 12, 0); |
||||
|
service.setClock(Clock.fixed(captured.atZone(BUSINESS_ZONE).toInstant(), BUSINESS_ZONE)); |
||||
|
var result = new CandidateScoringCalculator.DimensionResult("EDUCATION"); |
||||
|
result.status = "OK"; |
||||
|
result.score = java.math.BigDecimal.TEN; |
||||
|
when(calculator.computeAll(eq(7L), any(LocalDateTime.class))).thenReturn(List.of(result)); |
||||
|
when(calculator.currentRulesVersion()).thenReturn(CandidateScoringCalculator.RULES_VERSION); |
||||
|
|
||||
|
service.getOverview(7L); |
||||
|
|
||||
|
ArgumentCaptor<LocalDateTime> computeTime = ArgumentCaptor.forClass(LocalDateTime.class); |
||||
|
verify(calculator).computeAll(eq(7L), computeTime.capture()); |
||||
|
ArgumentCaptor<LocalDateTime> snapshotTime = ArgumentCaptor.forClass(LocalDateTime.class); |
||||
|
verify(calculator).upsertSnapshots(eq(7L), anyList(), anyString(), snapshotTime.capture()); |
||||
|
assertEquals(captured, computeTime.getValue()); |
||||
|
assertEquals(computeTime.getValue(), snapshotTime.getValue()); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void exactThirtyDays_usesExistingBoundarySemantics() { |
||||
|
BoundaryCapture capture = new BoundaryCapture(); |
||||
|
CandidateScoringCalculator real = calculatorWithJdbcAggregates(CAPTURED, capture); |
||||
|
List<CandidateScoringCalculator.DimensionResult> results = real.computeAll(7L, CAPTURED); |
||||
|
|
||||
|
assertEquals(new BigDecimal("20.00"), results.stream() |
||||
|
.filter(r -> "ACTIVITY".equals(r.dimension)).findFirst().orElseThrow().score); |
||||
|
// queryAudit uses created_at >= capturedNow.minusDays(30), including the exact boundary.
|
||||
|
assertEquals(CAPTURED.minusDays(30), capture.auditBoundary); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void exactNinetyDays_usesExistingBoundarySemantics() { |
||||
|
BoundaryCapture capture = new BoundaryCapture(); |
||||
|
CandidateScoringCalculator real = calculatorWithJdbcAggregates(CAPTURED, capture); |
||||
|
List<CandidateScoringCalculator.DimensionResult> results = real.computeAll(7L, CAPTURED); |
||||
|
|
||||
|
assertEquals(new BigDecimal("20.00"), results.stream() |
||||
|
.filter(r -> "ACTIVITY".equals(r.dimension)).findFirst().orElseThrow().score); |
||||
|
// queryApps uses created_at >= capturedNow.minusDays(90); messages remain unwindowed.
|
||||
|
assertEquals(CAPTURED.minusDays(90), capture.applicationBoundary); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void accountAgeSixAndTwelveMonths_preservesStrictThresholds() { |
||||
|
CandidateScoringCalculator real = calculatorWithJdbcAggregates(CAPTURED, new BoundaryCapture()); |
||||
|
var exactSix = real.computeStability(CAPTURED.minusMonths(6), 1, 0, 0, CAPTURED); |
||||
|
var overSix = real.computeStability(CAPTURED.minusMonths(7), 1, 0, 0, CAPTURED); |
||||
|
var exactTwelve = real.computeStability(CAPTURED.minusMonths(12), 1, 0, 0, CAPTURED); |
||||
|
var overTwelve = real.computeStability(CAPTURED.minusMonths(13), 1, 0, 0, CAPTURED); |
||||
|
|
||||
|
assertEquals(new BigDecimal("65.00"), exactSix.score); |
||||
|
assertEquals(new BigDecimal("80.00"), overSix.score); |
||||
|
assertEquals(new BigDecimal("80.00"), exactTwelve.score); |
||||
|
assertEquals(new BigDecimal("90.00"), overTwelve.score); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void resumeUpdateExactlyNinetyDays_preservesExistingComparison() { |
||||
|
CandidateScoringCalculator real = calculatorWithJdbcAggregates(CAPTURED, new BoundaryCapture()); |
||||
|
var exact = real.computeGrowth(1, CAPTURED.minusDays(90), 0, CAPTURED); |
||||
|
var newer = real.computeGrowth(1, CAPTURED.minusDays(89), 0, CAPTURED); |
||||
|
|
||||
|
assertEquals(new BigDecimal("40.00"), exact.score); |
||||
|
assertEquals(new BigDecimal("55.00"), newer.score); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void abilityValidUntilEqualsCapturedNow_isExpiredOrUnavailable() { |
||||
|
CandidateScoringCalculator real = calculatorWithJdbcAggregates(CAPTURED, new BoundaryCapture()); |
||||
|
ReflectionTestUtils.setField(real, "abilityAssessmentEnabled", true); |
||||
|
var projection = new AbilityAssessmentScoreSource.AbilityResultProjection( |
||||
|
101L, CAPTURED.minusDays(1), CAPTURED, "ability-v0.1", "ability-v0.1", 24, |
||||
|
abilityJson(80, 70, 60, 50)); |
||||
|
when(abilityScoreSource.findLatestValidCompletedResult(7L, CAPTURED)).thenReturn(projection); |
||||
|
|
||||
|
var result = real.computeAbility(7L, CAPTURED); |
||||
|
|
||||
|
assertEquals("PENDING", result.status); |
||||
|
assertNull(result.score); |
||||
|
assertEquals("EXPIRED", result.ruleContributions.get(0).outcome()); |
||||
|
assertEquals("ABILITY_RESULT_EXPIRED", result.ruleContributions.get(0).ruleCode()); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void abilityValidUntilAfterCapturedNow_isValid() { |
||||
|
CandidateScoringCalculator real = calculatorWithJdbcAggregates(CAPTURED, new BoundaryCapture()); |
||||
|
ReflectionTestUtils.setField(real, "abilityAssessmentEnabled", true); |
||||
|
var projection = new AbilityAssessmentScoreSource.AbilityResultProjection( |
||||
|
102L, CAPTURED.minusDays(1), CAPTURED.plusNanos(1), "ability-v0.1", "ability-v0.1", 24, |
||||
|
abilityJson(80, 70, 60, 50)); |
||||
|
when(abilityScoreSource.findLatestValidCompletedResult(7L, CAPTURED)).thenReturn(projection); |
||||
|
|
||||
|
var result = real.computeAbility(7L, CAPTURED); |
||||
|
|
||||
|
assertEquals("OK", result.status); |
||||
|
assertEquals(new BigDecimal("66.50"), result.score); |
||||
|
assertEquals("ABILITY_LOGIC", result.ruleContributions.get(0).ruleCode()); |
||||
|
assertEquals(4, result.ruleContributions.size()); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void advancingClockWithinOneRequest_usesOnlyInitialCapturedNow() { |
||||
|
CandidateScoringEngineService service = new CandidateScoringEngineService( |
||||
|
calculator, snapshotRepository, companyRepository, applicationRepository, jobService); |
||||
|
AdvancingClock advancing = new AdvancingClock(CAPTURED, BUSINESS_ZONE); |
||||
|
service.setClock(advancing); |
||||
|
var result = new CandidateScoringCalculator.DimensionResult("EDUCATION"); |
||||
|
result.status = "OK"; |
||||
|
result.score = BigDecimal.TEN; |
||||
|
result.ruleContributions = List.of(new RuleContribution("EDUCATION_LEVEL", "APPLIED", |
||||
|
BigDecimal.TEN, new BigDecimal("100"), "教育背景信息", "测试输入", "测试输入")); |
||||
|
when(calculator.computeAll(eq(7L), any(LocalDateTime.class))).thenReturn(List.of(result)); |
||||
|
when(calculator.currentRulesVersion()).thenReturn(CandidateScoringCalculator.RULES_VERSION); |
||||
|
|
||||
|
var overview = service.getOverview(7L); |
||||
|
|
||||
|
ArgumentCaptor<LocalDateTime> computeTime = ArgumentCaptor.forClass(LocalDateTime.class); |
||||
|
verify(calculator).computeAll(eq(7L), computeTime.capture()); |
||||
|
ArgumentCaptor<LocalDateTime> snapshotTime = ArgumentCaptor.forClass(LocalDateTime.class); |
||||
|
verify(calculator).upsertSnapshots(eq(7L), anyList(), anyString(), snapshotTime.capture()); |
||||
|
assertEquals(CAPTURED, computeTime.getValue()); |
||||
|
assertEquals(CAPTURED, snapshotTime.getValue()); |
||||
|
assertEquals(CAPTURED.atZone(BUSINESS_ZONE).toInstant(), overview.getGeneratedAt()); |
||||
|
assertEquals(1, advancing.instantCalls.get()); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void companyScoring_usesOneCapturedClockAndExactPeriod() { |
||||
|
CandidateScoringEngineService service = new CandidateScoringEngineService( |
||||
|
calculator, snapshotRepository, companyRepository, applicationRepository, jobService); |
||||
|
AdvancingClock advancing = new AdvancingClock(CAPTURED, BUSINESS_ZONE); |
||||
|
service.setClock(advancing); |
||||
|
when(companyRepository.findByOwnerUserId(200L)).thenReturn(Optional.of( |
||||
|
com.mydreamjob.company.Company.builder().id(9L).ownerUserId(200L).build())); |
||||
|
when(applicationRepository.findById(5L)).thenReturn(Optional.of( |
||||
|
com.mydreamjob.application.Application.builder().id(5L).jobId(50L).studentUserId(7L).build())); |
||||
|
when(jobService.getJob(50L)).thenReturn(com.mydreamjob.job.Job.builder().id(50L).companyId(9L).build()); |
||||
|
var result = new CandidateScoringCalculator.DimensionResult("EDUCATION"); |
||||
|
result.status = "OK"; |
||||
|
result.score = BigDecimal.TEN; |
||||
|
when(calculator.computeAll(eq(7L), any(LocalDateTime.class))).thenReturn(List.of(result)); |
||||
|
when(calculator.currentRulesVersion()).thenReturn(CandidateScoringCalculator.RULES_VERSION); |
||||
|
|
||||
|
var response = service.getCompanyScoring(200L, 5L); |
||||
|
|
||||
|
ArgumentCaptor<LocalDateTime> computeTime = ArgumentCaptor.forClass(LocalDateTime.class); |
||||
|
verify(calculator).computeAll(eq(7L), computeTime.capture()); |
||||
|
ArgumentCaptor<LocalDateTime> snapshotTime = ArgumentCaptor.forClass(LocalDateTime.class); |
||||
|
ArgumentCaptor<String> period = ArgumentCaptor.forClass(String.class); |
||||
|
verify(calculator).upsertSnapshots(eq(7L), anyList(), period.capture(), snapshotTime.capture()); |
||||
|
assertEquals(CAPTURED, computeTime.getValue()); |
||||
|
assertEquals(CAPTURED, snapshotTime.getValue()); |
||||
|
assertEquals("2026-09", period.getValue()); |
||||
|
assertEquals(CAPTURED.atZone(BUSINESS_ZONE).toInstant(), response.getGeneratedAt()); |
||||
|
assertEquals(1, advancing.instantCalls.get()); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void generatedAtCalculatedAtPeriodAndValidity_useSameCapturedTime() { |
||||
|
CandidateScoringEngineService service = new CandidateScoringEngineService( |
||||
|
calculator, snapshotRepository, companyRepository, applicationRepository, jobService); |
||||
|
AdvancingClock advancing = new AdvancingClock(CAPTURED, BUSINESS_ZONE); |
||||
|
service.setClock(advancing); |
||||
|
var result = new CandidateScoringCalculator.DimensionResult("EDUCATION"); |
||||
|
result.status = "OK"; |
||||
|
result.score = BigDecimal.TEN; |
||||
|
result.ruleContributions = List.of(new RuleContribution("EDUCATION_LEVEL", "APPLIED", |
||||
|
BigDecimal.TEN, new BigDecimal("100"), "教育背景信息", "测试输入", "测试输入")); |
||||
|
when(calculator.computeAll(eq(7L), any(LocalDateTime.class))).thenReturn(List.of(result)); |
||||
|
when(calculator.currentRulesVersion()).thenReturn(CandidateScoringCalculator.RULES_VERSION); |
||||
|
|
||||
|
var detail = service.getDimensionDetail(7L, "EDUCATION"); |
||||
|
|
||||
|
ArgumentCaptor<LocalDateTime> computeTime = ArgumentCaptor.forClass(LocalDateTime.class); |
||||
|
verify(calculator).computeAll(eq(7L), computeTime.capture()); |
||||
|
ArgumentCaptor<LocalDateTime> snapshotTime = ArgumentCaptor.forClass(LocalDateTime.class); |
||||
|
ArgumentCaptor<String> period = ArgumentCaptor.forClass(String.class); |
||||
|
verify(calculator).upsertSnapshots(eq(7L), anyList(), period.capture(), snapshotTime.capture()); |
||||
|
assertEquals(CAPTURED, computeTime.getValue()); |
||||
|
assertEquals(CAPTURED, snapshotTime.getValue()); |
||||
|
assertEquals("2026-09", period.getValue()); |
||||
|
assertEquals(CAPTURED, detail.getCalculatedAt()); |
||||
|
assertEquals(CAPTURED, detail.getUpdatedAt()); |
||||
|
assertEquals(1, advancing.instantCalls.get()); |
||||
|
} |
||||
|
|
||||
|
private CandidateScoringCalculator calculatorWithJdbcAggregates(LocalDateTime captured, |
||||
|
BoundaryCapture capture) { |
||||
|
when(userRepository.findById(7L)).thenReturn(Optional.of(User.builder().id(7L) |
||||
|
.createdAt(captured.minusMonths(24)).build())); |
||||
|
when(profileRepository.findByUserId(7L)).thenReturn(Optional.of( |
||||
|
com.mydreamjob.profile.StudentProfile.builder().userId(7L) |
||||
|
.education("本科").school("清华大学").build())); |
||||
|
when(preferenceRepository.findByStudentUserId(7L)).thenReturn(Optional.empty()); |
||||
|
when(jdbcTemplate.queryForObject(anyString(), ArgumentMatchers.<RowMapper<Object>>any(), any(), any(), any())) |
||||
|
.thenAnswer(invocation -> { |
||||
|
String sql = invocation.getArgument(0); |
||||
|
if (sql.contains("FROM audit_logs")) { |
||||
|
capture.auditBoundary = invocation.getArgument(2, LocalDateTime.class); |
||||
|
return new CandidateScoringCalculator.AuditAgg(1, 1); |
||||
|
} |
||||
|
throw new AssertionError("unexpected three-parameter aggregate SQL: " + sql); |
||||
|
}); |
||||
|
when(jdbcTemplate.queryForObject(anyString(), ArgumentMatchers.<RowMapper<Object>>any(), any())) |
||||
|
.thenAnswer(invocation -> { |
||||
|
String sql = invocation.getArgument(0); |
||||
|
if (sql.contains("FROM offers")) return new CandidateScoringCalculator.OfferAgg(0, 0); |
||||
|
if (sql.contains("FROM candidate_feedback")) return new CandidateScoringCalculator.FbAgg(0, 0); |
||||
|
if (sql.contains("FROM resume_versions")) return new CandidateScoringCalculator.ResumeAgg(0, null); |
||||
|
throw new AssertionError("unexpected one-parameter aggregate SQL: " + sql); |
||||
|
}); |
||||
|
when(jdbcTemplate.queryForObject(anyString(), ArgumentMatchers.<RowMapper<Object>>any(), any(), any())) |
||||
|
.thenAnswer(invocation -> { |
||||
|
String sql = invocation.getArgument(0); |
||||
|
if (sql.contains("FROM applications")) { |
||||
|
capture.applicationBoundary = invocation.getArgument(2, LocalDateTime.class); |
||||
|
return new CandidateScoringCalculator.AppAgg(1, 0, captured.minusMonths(1), 1); |
||||
|
} |
||||
|
if (sql.contains("FROM audit_logs")) { |
||||
|
capture.auditBoundary = invocation.getArgument(2, LocalDateTime.class); |
||||
|
return new CandidateScoringCalculator.AuditAgg(1, 1); |
||||
|
} |
||||
|
if (sql.contains("FROM offers")) return new CandidateScoringCalculator.OfferAgg(0, 0); |
||||
|
if (sql.contains("FROM candidate_feedback")) return new CandidateScoringCalculator.FbAgg(0, 0); |
||||
|
if (sql.contains("FROM resume_versions")) return new CandidateScoringCalculator.ResumeAgg(0, null); |
||||
|
throw new AssertionError("unexpected aggregate SQL: " + sql); |
||||
|
}); |
||||
|
when(jdbcTemplate.queryForObject(anyString(), eq(Long.class), any(Object[].class))).thenReturn(0L); |
||||
|
CandidateScoringCalculator real = new CandidateScoringCalculator( |
||||
|
profileRepository, preferenceRepository, userRepository, jdbcTemplate, |
||||
|
abilityScoreSource, new AbilityScoreMapper()); |
||||
|
ReflectionTestUtils.setField(real, "entityManager", entityManager); |
||||
|
return real; |
||||
|
} |
||||
|
|
||||
|
private static String abilityJson(int logic, int communication, int execution, int learning) { |
||||
|
return "[{\"code\":\"LOGIC\",\"score\":" + logic + ",\"answeredCount\":6}," + |
||||
|
"{\"code\":\"COMMUNICATION\",\"score\":" + communication + ",\"answeredCount\":6}," + |
||||
|
"{\"code\":\"EXECUTION\",\"score\":" + execution + ",\"answeredCount\":6}," + |
||||
|
"{\"code\":\"LEARNING\",\"score\":" + learning + ",\"answeredCount\":6}]"; |
||||
|
} |
||||
|
|
||||
|
private static final class BoundaryCapture { |
||||
|
private LocalDateTime applicationBoundary; |
||||
|
private LocalDateTime auditBoundary; |
||||
|
} |
||||
|
|
||||
|
private static final class AdvancingClock extends Clock { |
||||
|
private final LocalDateTime first; |
||||
|
private final ZoneId zone; |
||||
|
private final AtomicInteger instantCalls = new AtomicInteger(); |
||||
|
|
||||
|
private AdvancingClock(LocalDateTime first, ZoneId zone) { |
||||
|
this.first = first; |
||||
|
this.zone = zone; |
||||
|
} |
||||
|
|
||||
|
@Override |
||||
|
public ZoneId getZone() { return zone; } |
||||
|
|
||||
|
@Override |
||||
|
public Clock withZone(ZoneId zone) { return new AdvancingClock(first, zone); } |
||||
|
|
||||
|
@Override |
||||
|
public Instant instant() { |
||||
|
int call = instantCalls.getAndIncrement(); |
||||
|
return first.plusDays(call).atZone(zone).toInstant(); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,170 @@ |
|||||
|
package com.mydreamjob.candidatescore; |
||||
|
|
||||
|
import com.mydreamjob.candidatescore.dto.DimensionScoreDetailResponse; |
||||
|
import com.mydreamjob.candidatescore.explanation.CandidateScoringExplanationAssembler; |
||||
|
import com.mydreamjob.candidatescore.explanation.RuleContribution; |
||||
|
import org.junit.jupiter.api.Test; |
||||
|
|
||||
|
import java.math.BigDecimal; |
||||
|
import java.time.LocalDateTime; |
||||
|
import java.util.List; |
||||
|
|
||||
|
import static org.junit.jupiter.api.Assertions.*; |
||||
|
|
||||
|
class CandidateScoringExplanationAssemblerTest { |
||||
|
|
||||
|
private static final LocalDateTime NOW = LocalDateTime.of(2026, 9, 9, 12, 0); |
||||
|
|
||||
|
@Test |
||||
|
void contributionSumAndValidityAreMappedFromCalculatorFacts() { |
||||
|
var result = new CandidateScoringCalculator.DimensionResult("ABILITY"); |
||||
|
result.status = "OK"; |
||||
|
result.score = new BigDecimal("74.25"); |
||||
|
result.ruleContributions = List.of( |
||||
|
new RuleContribution("ABILITY_LOGIC", "APPLIED", new BigDecimal("28.00"), new BigDecimal("35"), "逻辑能力", "测评聚合分", "按固定权重映射"), |
||||
|
new RuleContribution("ABILITY_EXECUTION", "APPLIED", new BigDecimal("22.50"), new BigDecimal("30"), "执行能力", "测评聚合分", "按固定权重映射"), |
||||
|
new RuleContribution("ABILITY_LEARNING", "APPLIED", new BigDecimal("14.00"), new BigDecimal("20"), "学习能力", "测评聚合分", "按固定权重映射"), |
||||
|
new RuleContribution("ABILITY_COMMUNICATION", "APPLIED", new BigDecimal("9.75"), new BigDecimal("15"), "沟通能力", "测评聚合分", "按固定权重映射")); |
||||
|
result.abilitySnapshotTrace = new CandidateScoringCalculator.AbilitySnapshotTrace( |
||||
|
9L, NOW.minusHours(1), NOW.plusDays(6), "ability-v0.1", "candidate-scoring-v0.2", |
||||
|
java.util.Map.of("LOGIC", 35, "EXECUTION", 30, "LEARNING", 20, "COMMUNICATION", 15), |
||||
|
java.util.Map.of("LOGIC", new BigDecimal("80"), "EXECUTION", new BigDecimal("75"), |
||||
|
"LEARNING", new BigDecimal("70"), "COMMUNICATION", new BigDecimal("65"))); |
||||
|
|
||||
|
DimensionScoreDetailResponse response = CandidateScoringExplanationAssembler.assemble( |
||||
|
result, "candidate-scoring-v0.2", NOW); |
||||
|
|
||||
|
BigDecimal contributionSum = response.getRuleContributions().stream() |
||||
|
.map(DimensionScoreDetailResponse.RuleContribution::getContribution) |
||||
|
.reduce(BigDecimal.ZERO, BigDecimal::add); |
||||
|
assertEquals(new BigDecimal("74.25"), contributionSum); |
||||
|
assertEquals("candidate-scoring-explanation-v1", response.getExplanationVersion()); |
||||
|
assertNotNull(response.getValidity()); |
||||
|
assertFalse(response.getValidity().getExpired()); |
||||
|
assertEquals(NOW, response.getCalculatedAt()); |
||||
|
assertEquals(NOW, response.getUpdatedAt()); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void unknownInternalRuleCodeFailsClosedWithoutEchoingInternalValue() { |
||||
|
var result = new CandidateScoringCalculator.DimensionResult("EDUCATION"); |
||||
|
result.status = "OK"; |
||||
|
result.score = new BigDecimal("76.75"); |
||||
|
result.ruleContributions = List.of( |
||||
|
new RuleContribution("EDUCATION_LEVEL", "APPLIED", new BigDecimal("38.50"), new BigDecimal("55"), "学历层次", "本科", "固定映射"), |
||||
|
new RuleContribution("INTERNAL_SQL_COLUMN", "APPLIED", new BigDecimal("38.25"), new BigDecimal("45"), "内部", "secret", "内部对象")); |
||||
|
IllegalStateException ex = assertThrows(IllegalStateException.class, |
||||
|
() -> CandidateScoringExplanationAssembler.assemble(result, "candidate-scoring-v0.1", NOW)); |
||||
|
assertEquals("candidate scoring explanation contains an unsupported rule", ex.getMessage()); |
||||
|
assertFalse(ex.getMessage().contains("INTERNAL_SQL_COLUMN")); |
||||
|
assertFalse(ex.getMessage().contains("secret")); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void unknownOutcomeFailsClosedWithoutEchoingInternalValue() { |
||||
|
var result = result("OK", new BigDecimal("10.00")); |
||||
|
result.ruleContributions = List.of(new RuleContribution( |
||||
|
"EDUCATION_LEVEL", "FORGED", new BigDecimal("10.00"), |
||||
|
new BigDecimal("100"), "内部标签", "secret", "内部对象")); |
||||
|
|
||||
|
IllegalStateException ex = assertThrows(IllegalStateException.class, |
||||
|
() -> CandidateScoringExplanationAssembler.assemble( |
||||
|
result, "candidate-scoring-v0.1", NOW)); |
||||
|
|
||||
|
assertEquals("candidate scoring explanation contains an unsupported rule", ex.getMessage()); |
||||
|
assertFalse(ex.getMessage().contains("FORGED")); |
||||
|
assertFalse(ex.getMessage().contains("secret")); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void nullOrBlankOutcomeFailsClosed() { |
||||
|
for (String outcome : new String[]{null, "", " "}) { |
||||
|
var result = result("OK", new BigDecimal("10.00")); |
||||
|
result.ruleContributions = List.of(new RuleContribution( |
||||
|
"EDUCATION_LEVEL", outcome, new BigDecimal("10.00"), |
||||
|
new BigDecimal("100"), "教育背景信息", "测试", "测试")); |
||||
|
assertSafeFailure(result); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void allSupportedOutcomesRemainAccepted() { |
||||
|
for (String outcome : List.of("APPLIED", "NOT_APPLIED", "MISSING", "EXPIRED", "UNAVAILABLE")) { |
||||
|
var result = result("PENDING", null); |
||||
|
result.ruleContributions = List.of(new RuleContribution( |
||||
|
"ABILITY_RESULT_MISSING", outcome, null, null, |
||||
|
"四维能力测评", "安全说明", "安全说明")); |
||||
|
|
||||
|
var response = CandidateScoringExplanationAssembler.assemble( |
||||
|
result, "candidate-scoring-v0.1", NOW); |
||||
|
|
||||
|
assertEquals(outcome, response.getRuleContributions().get(0).getOutcome()); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void knownContributionsThatDoNotReconcileFailClosed() { |
||||
|
var result = new CandidateScoringCalculator.DimensionResult("EDUCATION"); |
||||
|
result.status = "OK"; |
||||
|
result.score = new BigDecimal("76.75"); |
||||
|
result.ruleContributions = List.of( |
||||
|
new RuleContribution("EDUCATION_LEVEL", "APPLIED", new BigDecimal("38.50"), new BigDecimal("55"), "学历层次", "本科", "固定映射"), |
||||
|
new RuleContribution("EDUCATION_SCHOOL_TIER", "APPLIED", new BigDecimal("38.00"), new BigDecimal("45"), "学校层次", "其他", "固定映射")); |
||||
|
IllegalStateException ex = assertThrows(IllegalStateException.class, |
||||
|
() -> CandidateScoringExplanationAssembler.assemble(result, "candidate-scoring-v0.1", NOW)); |
||||
|
assertEquals("candidate scoring explanation contains an unsupported rule", ex.getMessage()); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void nullContributionListFailsClosed() { |
||||
|
var result = result("PENDING", null); |
||||
|
result.ruleContributions = null; |
||||
|
assertSafeFailure(result); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void emptyContributionListWithScoreFailsClosed() { |
||||
|
var result = result("OK", new BigDecimal("76.75")); |
||||
|
result.ruleContributions = List.of(); |
||||
|
assertSafeFailure(result); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void pendingWithoutDiagnosticContributionFailsClosed() { |
||||
|
var result = result("PENDING", null); |
||||
|
result.ruleContributions = List.of(); |
||||
|
assertSafeFailure(result); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void nullContributionElementFailsClosed() { |
||||
|
var result = result("OK", BigDecimal.TEN); |
||||
|
result.ruleContributions = new java.util.ArrayList<>(); |
||||
|
result.ruleContributions.add(null); |
||||
|
assertSafeFailure(result); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void validDiagnosticContributionWithoutScoreSucceeds() { |
||||
|
var result = result("PENDING", null); |
||||
|
result.ruleContributions = List.of(new RuleContribution( |
||||
|
"ABILITY_RESULT_MISSING", "MISSING", null, null, |
||||
|
"四维能力测评", "没有可用结果", "没有可用结果")); |
||||
|
var response = CandidateScoringExplanationAssembler.assemble(result, "candidate-scoring-v0.1", NOW); |
||||
|
assertNull(response.getScore()); |
||||
|
assertEquals(1, response.getRuleContributions().size()); |
||||
|
} |
||||
|
|
||||
|
private CandidateScoringCalculator.DimensionResult result(String status, BigDecimal score) { |
||||
|
var result = new CandidateScoringCalculator.DimensionResult("ABILITY"); |
||||
|
result.status = status; |
||||
|
result.score = score; |
||||
|
return result; |
||||
|
} |
||||
|
|
||||
|
private void assertSafeFailure(CandidateScoringCalculator.DimensionResult result) { |
||||
|
IllegalStateException ex = assertThrows(IllegalStateException.class, |
||||
|
() -> CandidateScoringExplanationAssembler.assemble(result, "candidate-scoring-v0.1", NOW)); |
||||
|
assertEquals("candidate scoring explanation contains an unsupported rule", ex.getMessage()); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,53 @@ |
|||||
|
package com.mydreamjob.candidatescore; |
||||
|
|
||||
|
import com.mydreamjob.candidatescore.explanation.CandidateScoringExplanationCatalog; |
||||
|
import org.junit.jupiter.api.Test; |
||||
|
|
||||
|
import java.util.List; |
||||
|
|
||||
|
import static org.junit.jupiter.api.Assertions.*; |
||||
|
|
||||
|
class CandidateScoringExplanationCatalogTest { |
||||
|
|
||||
|
@Test |
||||
|
void catalogContainsNeutralMetadataForAllEightDimensions() { |
||||
|
List<String> dimensions = List.of("EDUCATION", "INTERNSHIP", "ABILITY", "COMPLIANCE", |
||||
|
"STABILITY", "GROWTH", "INTEGRITY", "ACTIVITY"); |
||||
|
for (String dimension : dimensions) { |
||||
|
var metadata = CandidateScoringExplanationCatalog.metadata(dimension); |
||||
|
assertNotBlank(metadata.displayName()); |
||||
|
assertNotBlank(metadata.whatItMeasures()); |
||||
|
assertNotBlank(metadata.interpretation()); |
||||
|
assertFalse(metadata.limitations().isEmpty()); |
||||
|
assertFalse(metadata.improvementActions().isEmpty()); |
||||
|
assertTrue(List.of("DIRECT_RULE", "SELF_REPORTED", "BEHAVIOR_PROXY", "UNVALIDATED_PROXY") |
||||
|
.contains(metadata.evidenceLevel())); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void catalogUsesRequiredNeutralSafetyLanguage() { |
||||
|
var integrity = CandidateScoringExplanationCatalog.metadata("INTEGRITY"); |
||||
|
assertTrue(integrity.limitations().stream().anyMatch(s -> s.contains("不是个人征信") && s.contains("人格判断"))); |
||||
|
assertFalse(CandidateScoringExplanationCatalog.metadata("GROWTH").interpretation().contains("成长潜力")); |
||||
|
assertFalse(CandidateScoringExplanationCatalog.metadata("STABILITY").interpretation().contains("忠诚度")); |
||||
|
assertFalse(CandidateScoringExplanationCatalog.metadata("COMPLIANCE").interpretation().contains("服从性")); |
||||
|
assertFalse(CandidateScoringExplanationCatalog.metadata("ACTIVITY").interpretation().contains("工作积极性")); |
||||
|
assertTrue(CandidateScoringExplanationCatalog.metadata("EDUCATION").limitations().stream() |
||||
|
.anyMatch(s -> s.contains("不能代表智力") && s.contains("录用适合度"))); |
||||
|
assertTrue(CandidateScoringExplanationCatalog.metadata("ABILITY").limitations().stream() |
||||
|
.anyMatch(s -> s.contains("不代表确定性工作能力") && s.contains("录用结论"))); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void unknownDimensionAndRuleCodeAreNotExposed() { |
||||
|
assertThrows(IllegalArgumentException.class, () -> CandidateScoringExplanationCatalog.metadata("UNKNOWN")); |
||||
|
assertFalse(CandidateScoringExplanationCatalog.isKnownRuleCode("INTERNAL_SQL_COLUMN")); |
||||
|
assertTrue(CandidateScoringExplanationCatalog.isKnownRuleCode("SCORE_CAP_ADJUSTMENT")); |
||||
|
} |
||||
|
|
||||
|
private static void assertNotBlank(String value) { |
||||
|
assertNotNull(value); |
||||
|
assertFalse(value.isBlank()); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,68 @@ |
|||||
|
package com.mydreamjob.candidatescore; |
||||
|
|
||||
|
import com.mydreamjob.auth.UserRepository; |
||||
|
import com.mydreamjob.preference.StudentPreferenceRepository; |
||||
|
import com.mydreamjob.profile.StudentProfile; |
||||
|
import com.mydreamjob.profile.StudentProfileRepository; |
||||
|
import jakarta.persistence.EntityManager; |
||||
|
import org.junit.jupiter.api.Test; |
||||
|
import org.junit.jupiter.api.extension.ExtendWith; |
||||
|
import org.mockito.InjectMocks; |
||||
|
import org.mockito.Mock; |
||||
|
import org.mockito.junit.jupiter.MockitoExtension; |
||||
|
import org.springframework.jdbc.core.JdbcTemplate; |
||||
|
|
||||
|
import java.math.BigDecimal; |
||||
|
import java.time.LocalDateTime; |
||||
|
import java.util.List; |
||||
|
|
||||
|
import static org.junit.jupiter.api.Assertions.*; |
||||
|
|
||||
|
@ExtendWith(MockitoExtension.class) |
||||
|
class CandidateScoringExplanationIntegrationTest { |
||||
|
@Mock StudentProfileRepository profileRepository; |
||||
|
@Mock StudentPreferenceRepository preferenceRepository; |
||||
|
@Mock UserRepository userRepository; |
||||
|
@Mock JdbcTemplate jdbcTemplate; |
||||
|
@Mock EntityManager entityManager; |
||||
|
@Mock AbilityAssessmentScoreSource abilityScoreSource; |
||||
|
@Mock AbilityScoreMapper abilityScoreMapper; |
||||
|
@InjectMocks CandidateScoringCalculator calculator; |
||||
|
|
||||
|
@Test |
||||
|
void realCalculatorBranchesProduceContributionsThatReconcileToScore() { |
||||
|
var captured = LocalDateTime.of(2026, 9, 9, 12, 0); |
||||
|
var education = calculator.computeEducation( |
||||
|
StudentProfile.builder().education("本科").school("清华大学").build(), captured); |
||||
|
var stability = calculator.computeStability(captured.minusMonths(24), 3, 0, 2, captured); |
||||
|
var growth = calculator.computeGrowth(2, captured.minusDays(10), 5, captured); |
||||
|
var integrity = calculator.computeIntegrity(2, 0, 3, 4.5, true, captured); |
||||
|
var activity = calculator.computeActivity(6, 3, 1, captured); |
||||
|
|
||||
|
for (var result : List.of(education, stability, growth, integrity, activity)) { |
||||
|
assertNotNull(result.score, result.dimension + " score"); |
||||
|
BigDecimal sum = result.ruleContributions.stream() |
||||
|
.map(r -> r.contribution() == null ? BigDecimal.ZERO : r.contribution()) |
||||
|
.reduce(BigDecimal.ZERO, BigDecimal::add); |
||||
|
assertEquals(result.score, sum, result.dimension + " contribution reconciliation"); |
||||
|
assertTrue(result.dataSources.stream().noneMatch(source -> source.getSource().contains(".") || source.getSource().contains("/")), |
||||
|
result.dimension + " must use semantic source labels"); |
||||
|
} |
||||
|
assertTrue(integrity.ruleContributions.stream().anyMatch(r -> "INTEGRITY_BASE".equals(r.ruleCode()))); |
||||
|
assertTrue(activity.ruleContributions.stream().anyMatch(r -> "ACTIVITY_MESSAGES".equals(r.ruleCode()))); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void missingAndUnavailableBranchesDoNotPretendToContributeZero() { |
||||
|
var missing = calculator.computeEducation(null, LocalDateTime.of(2026, 9, 9, 12, 0)); |
||||
|
assertEquals("INSUFFICIENT_DATA", missing.status); |
||||
|
assertNull(missing.score); |
||||
|
assertEquals("MISSING", missing.ruleContributions.get(0).outcome()); |
||||
|
assertNull(missing.ruleContributions.get(0).contribution()); |
||||
|
|
||||
|
var internship = calculator.computeInternship(LocalDateTime.of(2026, 9, 9, 12, 0)); |
||||
|
assertEquals("PENDING", internship.status); |
||||
|
assertEquals("UNAVAILABLE", internship.ruleContributions.get(0).outcome()); |
||||
|
assertNull(internship.ruleContributions.get(0).contribution()); |
||||
|
} |
||||
|
} |
||||
Loading…
Reference in new issue