Browse Source

feat: integrate ability assessment into candidate scoring

master
Zhang Shun 2 months ago
parent
commit
bae4f5c521
  1. 12
      docs/backend-development-roadmap.md
  2. 9
      docs/candidate-scoring-api.md
  3. 4
      docs/frontend-integration.md
  4. 319
      docs/v38-ability-score-integration-design.md
  5. 77
      mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/AbilityAssessmentScoreSource.java
  6. 185
      mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/AbilityScoreMapper.java
  7. 100
      mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/CandidateScoringCalculator.java
  8. 8
      mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/CandidateScoringEngineService.java
  9. 4
      mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/governance/CandidateScoringGovernanceService.java
  10. 3
      mydreamjob-server/src/main/java/com/mydreamjob/careerprogress/CareerProgressService.java
  11. 3
      mydreamjob-server/src/main/java/com/mydreamjob/competition/CompetitionRankingService.java
  12. 4
      mydreamjob-server/src/main/resources/application.yml
  13. 87
      mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/AbilityAssessmentScoreSourceTest.java
  14. 212
      mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/AbilityScoreIntegrationTest.java
  15. 181
      mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/AbilityScoreMapperTest.java
  16. 121
      mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoringCalculatorTest.java
  17. 79
      mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoringEngineServiceTest.java
  18. 183
      mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/V38AbilityRankingIntegrationTest.java
  19. 2
      mydreamjob-server/src/test/java/com/mydreamjob/careerprogress/CareerProgressServiceTest.java
  20. 2
      mydreamjob-server/src/test/java/com/mydreamjob/competition/CompetitionRankingServiceTest.java

12
docs/backend-development-roadmap.md

@ -28,16 +28,16 @@
|---|---| |---|---|
| OpenAPI | **148 paths、182 operations** | | OpenAPI | **148 paths、182 operations** |
| Flyway | **V1–V36 已应用**;**V34 为 RIASEC 测评迁移(功能版本 V35)**;**V35 为四维能力测评迁移(功能版本 V36)**;**V36 为防刷风险概览与规则配置迁移(功能版本 V37)** | | Flyway | **V1–V36 已应用**;**V34 为 RIASEC 测评迁移(功能版本 V35)**;**V35 为四维能力测评迁移(功能版本 V36)**;**V36 为防刷风险概览与规则配置迁移(功能版本 V37)** |
| 全量测试 | **1389/1389 通过**(`mvn clean verify`,0 Failures / 0 Errors / 0 Skipped) | | 全量测试 | **1427/1427 通过**(`mvn clean verify`,0 Failures / 0 Errors / 0 Skipped) |
| 已完成范围 | V24 Candidate Scoring、V25 Governance、V26 Offer Advance、V27 Privacy、V28/V29 Account Deletion、V30 Data Export、V31 Interview Score、V32 Competition Ranking、V33 Report/Block、V34 统一求职进度聚合、**V35 RIASEC 职业兴趣测评(Flyway V34,已真实验收)**、**V36 四维能力测评(Flyway V35)**、**V37 防刷风险概览与运营规则配置(Flyway V36)**,及认证、资料、岗位、投递、面试、Offer、看板等核心闭环 | | 已完成范围 | V24 Candidate Scoring、V25 Governance、V26 Offer Advance、V27 Privacy、V28/V29 Account Deletion、V30 Data Export、V31 Interview Score、V32 Competition Ranking、V33 Report/Block、V34 统一求职进度聚合、**V35 RIASEC 职业兴趣测评(Flyway V34,已真实验收)**、**V36 四维能力测评(Flyway V35)**、**V37 防刷风险概览与运营规则配置(Flyway V36)**、**V38 四维能力测评有限接入候选评分(无新 Flyway、无新公网接口)**,及认证、资料、岗位、投递、面试、Offer、看板等核心闭环 |
| 当前结论 | V35 已通过真实 MySQL/Docker 验收(PASS=39/FAIL=0/SKIP=1;唯一 SKIP 为验收库无 INACTIVE 定义)。**V36 四维能力测评已通过真实 MySQL/Docker 验收(PASS=93/FAIL=0/SKIP=0);V37 防刷风险治理已通过真实 MySQL/Docker 验收(PASS=76/FAIL=0/SKIP=0)。** V37 修复前 4 条 `RISK_EVENT_DETECTED.entity_id=NULL` 审计只作历史观察项,不回填、不影响修复后验收。运行时/静态 OpenAPI 均为 148/182,风险扫描不写候选评分引擎。 | | 当前结论 | V35 已通过真实 MySQL/Docker 验收(PASS=39/FAIL=0/SKIP=1;唯一 SKIP 为验收库无 INACTIVE 定义)。**V36 四维能力测评已通过真实 MySQL/Docker 验收(PASS=93/FAIL=0/SKIP=0);V37 防刷风险治理已通过真实 MySQL/Docker 验收(PASS=76/FAIL=0/SKIP=0)。** V37 修复前 4 条 `RISK_EVENT_DETECTED.entity_id=NULL` 审计只作历史观察项,不回填、不影响修复后验收。**V38 已完成代码、自动化与真实 MySQL/Docker 关键黑盒验收:开关关闭/开启、ABILITY 映射、最新结果优先、过期回退、V32 排名、权限、脱敏、快照与队列均通过,未发现 FAIL;当前仅待建立 Git 检查点。** 运行时/静态 OpenAPI 均为 148/182,风险扫描不写候选评分引擎。 |
| 本规划范围 | 代码修复、新功能开发、接口契约、数据权限和治理能力 | | 本规划范围 | 代码修复、新功能开发、接口契约、数据权限和治理能力 |
### 2.0 模块状态分类 ### 2.0 模块状态分类
| 类别 | 模块 | | 类别 | 模块 |
|---|---| |---|---|
| 已完成并已验收 | V24 Candidate Scoring、V25 Governance、V26 Offer Advance、V27 Privacy、V28/V29 Account Deletion、V30 Data Export、V31 Interview Score、V32 Competition Ranking、V33 Report/Block、V34 统一求职进度聚合、**V35 RIASEC 职业兴趣测评(Flyway V34,PASS=39/FAIL=0/SKIP=1)**、**V36 四维能力测评(Flyway V35,PASS=93/FAIL=0/SKIP=0)**、**V37 防刷风险概览与运营规则配置(Flyway V36,PASS=76/FAIL=0/SKIP=0)** | | 已完成并已验收 | V24 Candidate Scoring、V25 Governance、V26 Offer Advance、V27 Privacy、V28/V29 Account Deletion、V30 Data Export、V31 Interview Score、V32 Competition Ranking、V33 Report/Block、V34 统一求职进度聚合、**V35 RIASEC 职业兴趣测评(Flyway V34,PASS=39/FAIL=0/SKIP=1)**、**V36 四维能力测评(Flyway V35,PASS=93/FAIL=0/SKIP=0)**、**V37 防刷风险概览与运营规则配置(Flyway V36,PASS=76/FAIL=0/SKIP=0)**、**V38 四维能力测评有限接入候选评分(无新 Flyway/公网接口,真实 MySQL/Docker 关键黑盒通过,待 Git 检查点)** |
| 已完成、待真实 MySQL/Docker 复验 | — | | 已完成、待真实 MySQL/Docker 复验 | — |
| 已设计未编码 | 公平性检测、SHAP/规则解释、企业评价管理、企业人才推荐 | | 已设计未编码 | 公平性检测、SHAP/规则解释、企业评价管理、企业人才推荐 |
| 未开始 | AI 简历诊断/优化、AI 模拟面试、行业技能词云、入职准备清单、薪资计算器、诚信积分、消息撤回 | | 未开始 | AI 简历诊断/优化、AI 模拟面试、行业技能词云、入职准备清单、薪资计算器、诚信积分、消息撤回 |
@ -142,7 +142,7 @@
- SHAP/规则解释; - SHAP/规则解释;
- ~~ADMIN 防刷审计和规则配置~~(已由 V37 实现:仅 ADMIN 的确定性风险概览、规则配置和人工处置,Flyway V36); - ~~ADMIN 防刷审计和规则配置~~(已由 V37 实现:仅 ADMIN 的确定性风险概览、规则配置和人工处置,Flyway V36);
- 诚信积分和激励。 - 诚信积分和激励。
- 已非缺失(已实现):个人数据导出(V30)、隐私设置(V27)、账号注销(V28/V29)、举报与屏蔽(V33)、结构化面试评分(V31)、同岗位竞争力排名(V32)、统一求职进度聚合(V34)、RIASEC 职业兴趣测评(V35)、四维能力测评(V36)、ADMIN 防刷审计与规则配置(V37)。 - 已非缺失(已实现):个人数据导出(V30)、隐私设置(V27)、账号注销(V28/V29)、举报与屏蔽(V33)、结构化面试评分(V31)、同岗位竞争力排名(V32)、统一求职进度聚合(V34)、RIASEC 职业兴趣测评(V35)、四维能力测评(V36)、ADMIN 防刷审计与规则配置(V37)、四维能力测评有限接入候选评分(V38,仅 ABILITY,GROWTH 仍未接入)。
## 5. 已有代码的优先修复项 ## 5. 已有代码的优先修复项
@ -341,7 +341,7 @@ jobs
1. ~~霍兰德职业兴趣测评~~(V35 已完成); 1. ~~霍兰德职业兴趣测评~~(V35 已完成);
2. ~~四维能力测评、答题和进度保存~~(V36 已完成); 2. ~~四维能力测评、答题和进度保存~~(V36 已完成);
3. ~~测评结果~~(V35/V36 已完成); 3. ~~测评结果~~(V35/V36 已完成);
4. 测评结果进入 ABILITY/GROWTH(需独立评审,当前未接入评分引擎); 4. 测评结果进入 ABILITY/GROWTH(V38 已完成 ABILITY 有限接入;GROWTH 仍需独立评审与后续实现);
5. 根据测评结果推荐岗位。 5. 根据测评结果推荐岗位。
建议接口: 建议接口:

9
docs/candidate-scoring-api.md

@ -1,6 +1,7 @@
# 求职者评分体系 API # 求职者评分体系 API
> 求职者评分体系(Candidate Scoring Engine)v0.1 · 规则版本 `candidate-scoring-v0.1` > 求职者评分体系(Candidate Scoring Engine)v0.1 · 规则版本 `candidate-scoring-v0.1`
> V38 起:功能开关 `scoring.ability-assessment.enabled`(默认 `false`)开启时,`ABILITY` 按 V36 四维能力测评的最新有效结果映射,候选评分规则版本升级为 `candidate-scoring-v0.2`;关闭时保持 `candidate-scoring-v0.1` 与既有 `ABILITY=PENDING` 行为。
> 基础路径:`/api/v1` · 全部接口需 `Authorization: Bearer <accessToken>` > 基础路径:`/api/v1` · 全部接口需 `Authorization: Bearer <accessToken>`
## 概述 ## 概述
@ -19,7 +20,7 @@
| `OK` | 输入齐备,按规则确定性计算 | | `OK` | 输入齐备,按规则确定性计算 |
| `ESTIMATED` | 部分输入缺失,使用兜底值并标注估算(如学校未知、偏好字段部分填写) | | `ESTIMATED` | 部分输入缺失,使用兜底值并标注估算(如学校未知、偏好字段部分填写) |
| `INSUFFICIENT_DATA` | 关键输入缺失,不计算(score=null) | | `INSUFFICIENT_DATA` | 关键输入缺失,不计算(score=null) |
| `PENDING` | 数据源未就绪(INTERNSHIP 无实习表、ABILITY 无测评),score=null | | `PENDING` | 数据源未就绪(INTERNSHIP 无实习表;ABILITY 无测评、仅有过期/结构异常结果或开关关闭),score=null |
综合卡 `status`:全部 8 维可计算→`OK`;部分可计算→`PARTIAL`;无可计算且存在 PENDING→`PENDING`;否则→`INSUFFICIENT_DATA`。 综合卡 `status`:全部 8 维可计算→`OK`;部分可计算→`PARTIAL`;无可计算且存在 PENDING→`PENDING`;否则→`INSUFFICIENT_DATA`。
@ -33,13 +34,15 @@
返回单维度明细:`dimension`、`score`、`status`、`subScores[]`、`reasons[]`、`dataSources[]`、`updatedAt`、`rulesVersion`。非法 `dimension` → 400/1001。 返回单维度明细:`dimension`、`score`、`status`、`subScores[]`、`reasons[]`、`dataSources[]`、`updatedAt`、`rulesVersion`。非法 `dimension` → 400/1001。
> 学生单维明细(仅 STUDENT 本人)。V38 起 `dimensions[n]=ABILITY` 时,`subScores[]` 为四条白名单聚合分(逻辑能力/35、执行能力/30、学习能力/20、沟通能力/15),`dataSources[]/reasons[]` 仅表述“最新有效四维能力测评”“完成时间”“有效期”“映射规则版本”;**不返回** `sourceResultId`、答题/答卷 ID、题目、答案、答案键、选项分值、`competencyTag`、完整内部 `sub_scores_json`。其他维度响应与既有保持兼容。非法 `dimension` → 400/1001。
### 3. GET `/profile/me/candidate-scoring/trend?months=6`(仅 STUDENT 本人) ### 3. GET `/profile/me/candidate-scoring/trend?months=6`(仅 STUDENT 本人)
返回快照月度趋势(`months` 1–24,越界 400/1001)。每个 `period` 仅取该期 `OK/ESTIMATED` 维度重新归一化计算 `overallScore`,并携带该期 `rulesVersion`。 返回快照月度趋势(`months` 1–24,越界 400/1001)。每个 `period` 仅取该期 `OK/ESTIMATED` 维度重新归一化计算 `overallScore`,并携带该期 `rulesVersion`。
### 4. GET `/company/candidates/{applicationId}/scoring`(仅岗位所属企业) ### 4. GET `/company/candidates/{applicationId}/scoring`(仅岗位所属企业)
企业只读评分卡。**仅返回** `applicationId`、`overallScore`、`status`、`dimensions[]{dimension,score,status}`、`generatedAt`、`rulesVersion`;**禁止返回** `studentUserId`、`candidateId`、`phone`、`email`、`resumeUrl`、`sub_scores` 细节、内部日志。 企业只读评分卡。**仅返回** `applicationId`、`overallScore`、`status`、`dimensions[]{dimension,score,status}`、`generatedAt`、`rulesVersion`;**禁止返回** `studentUserId`、`candidateId`、`phone`、`email`、`resumeUrl`、`sub_scores` 细节、内部日志。V38 起等效于对 `ABILITY` 只暴露聚合 `score/status`,**禁止**返回 LOGIC/COMMUNICATION/EXECUTION/LEARNING 细分、`validUntil`、attemptId/resultId、`answerKey`、`scoringRule`、`questionCode`、`competencyTag`。
## 权限矩阵 ## 权限矩阵
@ -56,7 +59,7 @@
|---|---| |---|---|
| EDUCATION | student_profiles.education/school(学校档次用内置名单,未知→ESTIMATED 兜底 50) | | EDUCATION | student_profiles.education/school(学校档次用内置名单,未知→ESTIMATED 兜底 50) |
| INTERNSHIP | 数据源未就绪 → PENDING | | INTERNSHIP | 数据源未就绪 → PENDING |
| ABILITY | 能力测评未实现 → PENDING | | ABILITY | 默认 `PENDING`;开关开启且有最新有效四维能力测评(WORKPLACE_ABILITY,COMPLETED、答题 24/24、`valid_until > capturedNow`)时按 `LOGIC×0.35 + EXECUTION×0.30 + LEARNING×0.20 + COMMUNICATION×0.15`(scale=2、HALF_UP)映射为 OK |
| COMPLIANCE | student_preferences(薪资/城市/用工/行业/岗位类型 5 字段加权;remoteAllowed 不计分) | | COMPLIANCE | student_preferences(薪资/城市/用工/行业/岗位类型 5 字段加权;remoteAllowed 不计分) |
| STABILITY | users.createdAt(账号年龄)、applications(投递/撤回率)、audit_logs(近 30 天 LOGIN) | | STABILITY | users.createdAt(账号年龄)、applications(投递/撤回率)、audit_logs(近 30 天 LOGIN) |
| GROWTH | resume_versions(ACTIVE 数量/最近更新)、student_profiles.skillsJson(当前值,无历史) | | GROWTH | resume_versions(ACTIVE 数量/最近更新)、student_profiles.skillsJson(当前值,无历史) |

4
docs/frontend-integration.md

@ -186,7 +186,9 @@ Authorization: Bearer <accessToken>
## 9. Candidate Scoring(求职者评分体系)联调 ## 9. Candidate Scoring(求职者评分体系)联调
规则版本固定 `rulesVersion = "candidate-scoring-v0.1"`;八维 `EDUCATION / INTERNSHIP / ABILITY / COMPLIANCE / STABILITY / GROWTH / INTEGRITY / ACTIVITY`,默认权重各 12.50,参与计算维度重新归一化。 默认关闭时规则版本固定 `rulesVersion = "candidate-scoring-v0.1"`;八维 `EDUCATION / INTERNSHIP / ABILITY / COMPLIANCE / STABILITY / GROWTH / INTEGRITY / ACTIVITY`,默认权重各 12.50,参与计算维度重新归一化。
> V38 联调(功能开关 `scoring.ability-assessment.enabled`,默认 `false`):开关开启后规则版本为 `"candidate-scoring-v0.2"`,`ABILITY` 可由最新有效四维能力测评(WORKPLACE_ABILITY、COMPLETED、答题 24/24、`valid_until > capturedNow`)映射为 `OK` 并参与综合分。学生 `ABILITY` 单维明细的 `subScores[]` 为四条白名单聚合分(逻辑能力/35、执行能力/30、学习能力/20、沟通能力/15),不含任何答案/答卷/来源结果 ID;企业评分卡对 `ABILITY` 仍仅暴露聚合 `score/status`。后端本地验证已通过,真实 MySQL/Docker 黑盒待负责人验收。
### 9.1 接口 ### 9.1 接口

319
docs/v38-ability-score-integration-design.md

@ -0,0 +1,319 @@
# V38:四维能力测评有限接入候选人评分设计
> 状态:**已实现并通过真实 MySQL/Docker 关键黑盒验收,待建立 Git 检查点**。
> 功能版本:V38。
> 基线:Flyway `V1–V36`、OpenAPI `148 paths / 182 operations`、全量测试 `1427/1427`(`mvn clean verify`,0 Failures / 0 Errors / 0 Skipped)。
> 本设计只接入 V36 四维能力测评,不接入 V35 RIASEC 兴趣测评。
## 1. 目标与边界
### 1.1 目标
在不改变 V36 测评主链路的前提下,将学生**最新、完整、仍有效**的四维能力测评结果映射为候选人评分中的 `ABILITY` 维度,使候选评分卡可有限地反映能力测评结果。
### 1.2 必须满足的边界
- 仅使用 V36 `ability_results`,不使用 V35 兴趣类型或职业方向结果。
- 仅映射到 `ABILITY`;不写入或推断 `GROWTH`、`ACTIVITY` 等其他维度。
- 不在测评提交时直接写 `candidate_dimension_scores`;V36 提交仍只写其 `ability_*` 表和既有审计。
- 不新增企业查看测评详情的接口。企业仅通过既有候选评分卡看到 `ABILITY` 的聚合 `score/status`。
- 不向任何评分接口返回题目、答案键、选项分值、题库标签、题目解析或测评答卷 ID。
- 功能开关默认关闭;关闭时现有 `ABILITY=PENDING` 行为必须完全保持。
- 不引入 AI 评分、人工覆写、最高分挑选、自动封禁或对既有学生数据的回填。
## 2. 已确认的产品与规则口径
| 项目 | V38 锁定口径 |
|---|---|
| 可采纳来源 | V36 最新 `COMPLETED` 且 `valid_until > capturedNow` 的结果 |
| 四维权重 | `LOGIC=35%`、`EXECUTION=30%`、`LEARNING=20%`、`COMMUNICATION=15%` |
| 结果选择 | 只取最新有效完成结果;按 `completed_at DESC, attempt_id DESC` 稳定排序;绝不取历史最高分 |
| 有效期 | 复用 V36 的 7 天;过期结果仍可由学生查看历史,但不得参与新评分计算 |
| 重测治理 | 复用 V36:有效结果期间禁止重测,结果到期后可重测,滚动 90 天至多完成 3 次 |
| 企业可见性 | 仅既有公司评分卡的 `dimensions[]{dimension,score,status}`;无四维细分、短板、答卷或题目反馈 |
| 默认发布状态 | `OFF`,不影响候选综合分、V32 排名或 V34 求职进度 |
## 3. 总体方案:评分时只读投影
采用**评分时只读投影(read-through projection)**,而不是在 V36 提交后跨模块写入候选评分表。
```text
V36 完成测评
└─ 写 ability_attempts / ability_results(既有行为,不调用评分引擎)
学生或企业读取既有候选评分卡
└─ CandidateScoringCalculator
└─ AbilityAssessmentScoreSource(只读选择最新有效 ability_result)
└─ AbilityScoreMapper(按 V38 固定权重计算 ABILITY)
└─ 既有批量 upsert candidate_dimension_scores(既有评分快照机制)
```
这样做的原因:
1. V36 仍可独立工作,测评提交失败或评分开关关闭都不会影响其完成、结果和历史查询。
2. 评分只在既有评分查询/重算时产生快照,沿用当前 `candidate_dimension_scores` 的写入模型。
3. 不需要异步消息、定时回填或跨服务事务;结果到期后下一次评分自然变为 `PENDING`。
4. 评分行的 `sub_scores_json` 保留最小化、可追溯的来源版本信息,不需新建投影表。
## 4. 数据选择与有效性判定
### 4.1 单一查询契约
新增一个只读仓库查询,语义为:
```text
findLatestValidCompletedResult(studentUserId, typeCode, capturedNow)
```
查询必须同时满足:
- `ability_attempts.student_user_id = :studentUserId`;
- `ability_attempts.status = 'COMPLETED'`;
- `ability_results.valid_until > :capturedNow`;
- 测评定义为 V36 能力测评类型且当前/答卷题集版本可识别;
- 排序固定为 `completed_at DESC, ability_attempts.id DESC`;
- 最多取一条。
`capturedNow` 必须在一次评分计算开始时由后端生成并全程复用,不能在同一计算中多次调用 `now()`,以避免临界过期时出现不一致。
### 4.2 结果结构防御
即使查询到结果,也仅在以下条件全部成立时计算:
- `dimension_scores_json` 可被安全解析;
- 恰有 `LOGIC`、`COMMUNICATION`、`EXECUTION`、`LEARNING` 四维;
- 每维分数均在 `[0,100]`,且均为有限数值;
- 每维已答题数均为 6,答卷总已答题数为 24;
- `overall_score`、题集版本和 V36 规则版本存在。
> 实现细则(V38 发布前 P1):`answeredCount` 使用精确整数校验,仅接受数值意义上等于整数的表示
> (如 JSON 整数 6 或 6.0、`"6"`、`"6.0"`);`6.9`、`6.5`、`"6.5"`、溢出 int、空串、`null` 均视为不可用
> (`stripTrailingZeros` + `intValueExact`,不使用可能截断小数的 `intValue()`)。`attemptAnsweredCount`
> 必须非空且严格等于 24,不允许 `null` 放行。
任一条件不满足时,不抛给前端 500,也不使用部分数据;该学生本次 `ABILITY` 返回 `PENDING`,原因写为“无可用的完整能力测评结果”。内部日志仅记录结果 ID 与结构错误类型,禁止记录题目、答案或选项分值。
### 4.3 过期与重测
- 无有效结果、仅有过期结果、或最新结果格式无效:`ABILITY=PENDING`,`score=null`。
- 有效期内没有重测入口;到期后的新测评完成后,在下一次评分计算中自然替换为新结果。
- 结果选择不比较分数,因此不能通过“多次测到高分”挑选最高成绩。
- 已写入的旧月度快照保留为历史;当前月份按现有 upsert 语义在下一次评分查询/重算时刷新。
## 5. 分数映射
### 5.1 公式
设 V36 四维百分制结果为 `L`(逻辑)、`C`(沟通)、`E`(执行)、`R`(学习),则:
```text
abilityScore = roundHalfUp(L × 0.35 + E × 0.30 + R × 0.20 + C × 0.15, 2)
```
分数范围为 `0.00–100.00`。所有中间计算使用 `BigDecimal`;最终 `scale=2`、`HALF_UP`。不得复用 V36 等权 `overallScore` 直接作为候选 `ABILITY` 分数,因为 V38 已确认使用不同的、显式的业务权重。
### 5.2 维度结果
| 条件 | `ABILITY.status` | `ABILITY.score` | 对综合分的影响 |
|---|---:|---:|---|
| 开关关闭 | `PENDING` | `null` | 与 V37 前完全一致,不参与平均 |
| 无有效完整结果 | `PENDING` | `null` | 不参与平均 |
| 有效完整结果且开关开启 | `OK` | 映射后的 0–100 分 | 按既有候选评分规则参与可计算维度平均 |
| 来源读取异常/结构异常 | `PENDING` | `null` | 不参与平均,记录脱敏内部告警 |
候选综合分继续遵循现有规则:只对 `OK/ESTIMATED` 且分数非空的维度重新归一化计算。故 V38 启用后会对拥有有效能力结果的学生产生真实影响;这是功能开关默认关闭的原因。
### 5.3 版本
- 能力映射规则版本:`ability-assessment-scoring-v0.1`。
- 开关开启后的候选评分规则版本:`candidate-scoring-v0.2`。
- 开关关闭时保留现有 `candidate-scoring-v0.1`。
- 每个写入的 `ABILITY` 快照必须记录两类版本,保证规则升级后可解释、可重算。
## 6. 快照、溯源与数据最小化
不新增表、不新增 Flyway。复用 `candidate_dimension_scores` 现有的唯一键 `(student_user_id, dimension, period)` 与 `sub_scores_json`。
### 6.1 持久化的最小来源字段
`ABILITY` 行的内部 `sub_scores_json` 仅允许下列字段:
```json
{
"source": "V36_ABILITY_ASSESSMENT",
"sourceResultId": 123,
"sourceCompletedAt": "2026-09-04T10:00:00Z",
"validUntil": "2026-09-11T10:00:00Z",
"questionSetVersion": "ability-v0.1",
"assessmentRulesVersion": "ability-v0.1",
"mappingRulesVersion": "ability-assessment-scoring-v0.1",
"weights": {"LOGIC": 35, "EXECUTION": 30, "LEARNING": 20, "COMMUNICATION": 15},
"dimensionScores": {"LOGIC": 80.00, "EXECUTION": 75.00, "LEARNING": 70.00, "COMMUNICATION": 65.00}
}
```
禁止写入:答卷 ID、题目 ID、题目编码、题库标签、题干、选项、用户选择、答案键、选项分值、题目解析、手机号、邮箱、令牌、请求体和完整审计详情。
`sourceResultId` 仅作内部溯源,任何 HTTP 响应均不得返回该字段。
### 6.2 响应投影
- 学生综合卡:沿用既有 `ABILITY` 聚合分和状态。
- 学生单维详情:可返回四维**聚合分**、权重、有效期、规则版本和说明,用于解释自己的分数;不得返回 `sourceResultId` 或题目级内容。
- 企业候选评分卡:保持既有字段,只返回 `ABILITY` 的 `dimension/score/status`。
- 趋势接口:只使用已写候选快照,不回读题目或 V36 原始答案。
## 7. 功能开关与上线策略
新增配置:
```yaml
scoring:
ability-assessment:
enabled: false
```
| 模式 | 行为 |
|---|---|
| `false`(默认) | 不查询 V36 结果,`ABILITY=PENDING`;无评分行为变更 |
| `true` | 查询最新有效结果,按本设计映射;仅在既有评分计算/重算时写候选评分快照 |
发布顺序:
1. 合入代码且以 `false` 发布;验证所有既有候选评分卡仍是 V37 行为。
2. 在验收/小范围环境设为 `true`,核验映射、脱敏、排名影响与回滚。
3. 经负责人确认后才在正式环境启用;启用后仍可立即设回 `false`。
关闭开关只停止后续映射,不删除已存在的快照或 V36 数据。若需消除当前月份的已映射 `ABILITY`,必须走单独、经批准的重算流程,不能在开关切换时隐式删除数据。
## 8. 模块职责与实现边界
建议新增以下内部组件:
- `AbilityAssessmentScoreSource`:一次查询并解析最新有效 V36 结果;不暴露实体给 Controller。
- `AbilityScoreMapper`:纯函数,校验四维、计算权重分、构造最小化来源描述。
- `CandidateScoringCalculator#computeAbility(...)`:替换现有硬编码 `PENDING`;开关关闭时保留旧分支。
- `CandidateScoringService`:每次评分计算只调用一次 `AbilityAssessmentScoreSource`,不在八维循环内查询。
不得:
- 改动 `AbilityAttemptService.submit()` 的事务边界、状态机、重测限制或审计。
- 让评分模块更新 `ability_attempts`、`ability_answers`、`ability_results`。
- 在 Controller 接受 `studentUserId`、能力分或结果 ID 来影响评分。
- 新增企业端能力测评详情、管理员修改能力分或公开的“重算某学生能力分”接口。
## 9. 并发、一致性与性能
- 评分计算使用一次 `capturedNow` 和最多一次 V36 来源查询;不得按四维各查一次,也不得循环读取题目/答案。
- 已有候选评分批量 upsert 继续处理同月并发刷新;V38 不新增新的评分写表。
- V36 提交与评分查询并发时:评分要么读取提交前无结果并返回 `PENDING`,要么读取完整、已提交的结果并返回 `OK`;不能读取半成品。
- 结果刚好到期时以 `capturedNow` 判定;该次计算结束后不回滚同次快照,下一次计算按新时间重新判定。
- 利用既有 `ability_attempts(student_user_id, definition_id, completed_at)` 索引进行学生级最新结果查询;实现前必须用 MySQL `EXPLAIN` 验证不扫全表。若实际查询计划无法命中索引,停止编码并单独评审最小索引迁移,不得临时修改既有 V35 迁移。
## 10. 权限、隐私与审计
### 10.1 权限矩阵
| 数据 | STUDENT | COMPANY | ADMIN |
|---|---|---|---|
| 本人完整 V36 结果与题目反馈 | 仅本人 | 禁止 | 禁止(V36 原有口径) |
| 候选评分 `ABILITY` 聚合分 | 本人 | 仅有岗位归属的候选评分卡 | 禁止(沿用现有评分接口) |
| 四维聚合分与有效期 | 本人单维评分详情 | 禁止 | 禁止 |
| 题目/答案键/评分规则/标签 | 禁止通过评分接口返回 | 禁止 | 禁止 |
### 10.2 审计
V38 不对普通评分查询新增逐次审计,避免读取评分卡时产生审计噪声。现有评分治理申诉/回滚审计保持原样。
如内部结构无效需记录诊断日志,日志白名单仅为 `studentUserId`(内部日志)、`sourceResultId`、`failureKind`、`mappingRulesVersion` 和 `requestId`;禁止记录测评内容。不得将该诊断写入对企业可见的响应或 `audit_logs.details`。
## 11. 评分治理与申诉兼容
- 既有 `ABILITY` 申诉/评分回滚若可用,回滚仍然是按当前数据确定性重算,不恢复人工指定的旧能力分。
- V38 不新增人工修改、豁免、补分或上传证明材料的入口。
- 来源结果在回滚时已过期或无效,则重算为 `PENDING`;这是规则结果,不属于回滚失败。
- V36 测评本身的题目、答案或判分争议不通过候选评分申诉接口处理,后续需单独定义测评申诉治理。
## 12. 接口与 OpenAPI 影响
本版不新增、删除或修改公网路径;既有候选评分 API 的顶层契约保持兼容:
```text
GET /profile/me/candidate-scoring
GET /profile/me/candidate-scoring/dimensions/ABILITY
GET /profile/me/candidate-scoring/trend
GET /company/candidates/{applicationId}/scoring
```
因此静态和运行时 OpenAPI 均应继续为 **148 paths / 182 operations**。
学生的 `ABILITY` 单维详情可在既有通用 `subScores/reasons/dataSources` 容器中增加白名单字段;实现前须确认这些对象的现有 schema 允许兼容增加字段。若需要新增可见字段,必须同步 `openapi-rc.json`、`verify_openapi.py` 与联调文档,但不得改变公司端 schema。
## 13. 错误码
V38 不新增公网接口,正常的“无有效测评结果”属于 `ABILITY=PENDING`,不是 HTTP 错误。因此不新增 ErrorCode。
任何来源 JSON 异常、查询异常或规则版本无法识别都必须被隔离为 `PENDING`,并保留 200 的既有评分响应。数据库不可用等系统级故障仍按全局异常处理器既有语义处理,不伪造评分结果。
## 14. 测试计划
### 14.1 单元测试
- 开关关闭:不查询 V36 来源,`ABILITY=PENDING`,候选评分规则版本仍为 v0.1。
- 无结果、仅过期结果、结构不完整结果、分数越界结果:均为 `PENDING`,无 500。
- 有效结果:严格验证权重公式、两位小数 `HALF_UP`、`OK` 状态、来源版本。
- 多份有效历史:选 `completed_at` 最新者,不选总分最高者;同时间用 attempt ID 稳定决胜。
- 来源字段白名单:持久化 JSON 和 HTTP 响应均不含答卷 ID、题目、答案、答案键、分值、标签。
- `computeAbility` 只触发一次来源查询;不得产生 N+1。
### 14.2 集成与 MockMvc 测试
- H2 MySQL 模式下完整 V36 有效结果 → 候选评分写入 `ABILITY` 快照;过期后重算同周期 `ABILITY=PENDING`。
- V36 提交本身不写 `candidate_dimension_scores`;只有既有候选评分查询/重算写快照。
- 学生单维详情可见聚合四维和有效期;公司端只见 `ABILITY` 总分/状态;COMPANY/ADMIN/匿名权限回归。
- 并发 V36 提交与候选评分读取不出现半结果、500 或重复快照异常。
- V32 排名和 V34 职业进度在开关关闭时字节级/字段级保持现有结果;开关开启时仅允许因 `ABILITY` 可计算带来的预期分数变化。
### 14.3 MySQL/Docker 黑盒
1. 启动真实 MySQL/Redis/RabbitMQ 和最新 JAR;确认 health 200、Flyway 仍止于 V36、OpenAPI 148/182。
2. 使用独立学生账号完成一份 V36 测评,先验证提交后 `candidate_dimension_scores` 行数不变。
3. 开关 `false` 调用候选评分:`ABILITY=PENDING`;再在隔离环境将开关设为 `true`,调用评分:`ABILITY=OK` 且总分符合公式。
4. 核对 `sub_scores_json` 白名单;企业 API 不含四维、结果 ID、题目、答案、标签或评分规则。
5. 将结果有效期置为过去时间(只在独立验收数据上操作),重算后验证 `ABILITY=PENDING`;历史 V36 结果仍可由本人读取。
6. 创建两份符合 V36 重测规则的历史结果,验证取最新而不是最高;验证并发评分刷新无 500。
7. 队列最终 `ready=0/unacked=0`;回归 V32 排名、V34 职业进度、V35 RIASEC、V36 能力结果、V37 风险概览。
## 15. 回滚与冻结门槛
### 15.1 回滚
| 情况 | 操作 |
|---|---|
| 映射分数不符合预期 | 设 `scoring.ability-assessment.enabled=false`,停止后续接入 |
| 需要恢复当前月评分 | 经批准后调用既有评分重算流程;不直接删表或手改快照 |
| 发现题库/测评结果质量问题 | 保持 V36 历史,关闭 V38 开关;不篡改历史题目或答案 |
V38 不新增迁移,故不存在本模块 DDL 回滚。不得修改已执行的 V35/V36 Flyway 文件。
### 15.2 冻结条件
- 所有 V38 单元、集成、MockMvc 测试通过;全量 `mvn clean verify` 通过。
- `python docs/verify_openapi.py` 通过且 OpenAPI 仍为 148/182。
- 真实 MySQL/Docker 黑盒为 `FAIL=0`,关键项不得 SKIP。
- 开关关闭和开启两条路径均通过;V36 主链路、V32/V34/V35/V37 回归通过。
- 不泄露题目、答案、答案键、评分规则、标签、来源结果/答卷 ID;企业端仅有聚合能力分。
- 未对 V35/V36 题库或既有 Flyway 做回写;没有未授权的业务数据删除或全量回填。
---
## 16. 已确认的编码授权边界
负责人已于 2026-09-04 确认以下边界,编码必须严格遵守:
1. 开关开启时,允许 `ABILITY` 参与候选综合分,并允许其按既有评分口径影响 V32 同岗位排名。
2. 仅在开关开启时,候选评分规则版本允许由 `candidate-scoring-v0.1` 升级为 `candidate-scoring-v0.2`。
3. 学生单维评分详情可展示四维聚合分、权重和有效期;企业端始终只见 `ABILITY` 聚合分与状态。
4. 本版采用无新 Flyway、无新公网接口的只读投影方案。
后续若要新增人工改分、企业端明细、V35/RIASEC 接入、`GROWTH` 映射、历史回填或任何公开接口,必须另行设计和授权,不得作为 V38 编码的附带改动。

77
mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/AbilityAssessmentScoreSource.java

@ -0,0 +1,77 @@
package com.mydreamjob.candidatescore;
import lombok.RequiredArgsConstructor;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
import java.util.List;
/**
* V38:候选人 ABILITY 维度仅读投影源。
*
* <p>一次查询取某学生最新、COMPLETED、且 {@code valid_until > capturedNow} 的 V36
* {@code WORKPLACE_ABILITY} 完整结果。只关联 ability_results / ability_attempts /
* ability_definitions,绝不读取 ability_answers / ability_attempt_questions / 题目或答案键。
* 排序固定为 {@code completed_at DESC, attempt_id DESC},最多一条;不比较分数(不取历史最高分)。</p>
*
* <p>不暴露实体给 Controller,仅作为 CandidateScoringCalculator 的只读数据源。</p>
*/
@Component
@RequiredArgsConstructor
public class AbilityAssessmentScoreSource {
private static final String SQL = """
SELECT r.id AS resultId,
a.completed_at AS completedAt,
r.valid_until AS validUntil,
r.question_set_version AS questionSetVersion,
r.rules_version AS assessmentRulesVersion,
a.answered_count AS answeredCount,
r.dimension_scores_json AS dimensionScoresJson
FROM ability_results r
JOIN ability_attempts a ON a.id = r.attempt_id
JOIN ability_definitions d ON d.id = a.definition_id
WHERE a.student_user_id = ?
AND a.status = 'COMPLETED'
AND d.type_code = 'WORKPLACE_ABILITY'
AND r.valid_until > ?
ORDER BY a.completed_at DESC, a.id DESC
LIMIT 1
""";
private final JdbcTemplate jdbcTemplate;
/**
* 最新有效完整结果的只读投影;无匹配结果或参数为空时返回 {@code null}。
* 单次调用只发起一次查询,禁止按四维循环查询(不存在 N+1)。
*/
public AbilityResultProjection findLatestValidCompletedResult(Long studentUserId, LocalDateTime capturedNow) {
if (studentUserId == null || capturedNow == null) {
return null;
}
List<AbilityResultProjection> rows = jdbcTemplate.query(SQL,
(rs, n) -> new AbilityResultProjection(
rs.getLong("resultId"),
rs.getTimestamp("completedAt") == null ? null : rs.getTimestamp("completedAt").toLocalDateTime(),
rs.getTimestamp("validUntil") == null ? null : rs.getTimestamp("validUntil").toLocalDateTime(),
rs.getString("questionSetVersion"),
rs.getString("assessmentRulesVersion"),
rs.getInt("answeredCount"),
rs.getString("dimensionScoresJson")),
studentUserId, capturedNow);
return rows.isEmpty() ? null : rows.get(0);
}
/**
* 仅含评分所需最小字段的只读投影;不含题目、答案、答案键、选项分值或题库标签。
*/
public record AbilityResultProjection(Long resultId,
LocalDateTime completedAt,
LocalDateTime validUntil,
String questionSetVersion,
String assessmentRulesVersion,
Integer answeredCount,
String dimensionScoresJson) {
}
}

185
mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/AbilityScoreMapper.java

@ -0,0 +1,185 @@
package com.mydreamjob.candidatescore;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
/**
* V38:四维能力结果 → 候选人 ABILITY 维度的纯函数映射器(无数据库访问)。
*
* <p>仅接受恰好包含 LOGIC / COMMUNICATION / EXECUTION / LEARNING 四维、每维错题数均为 6、
* 总错题数为 24、且分数均在 [0,100](有限数值)的完整结果;任何结构异常都返回
* {@link Optional#empty()},由调用方映射为 {@code PENDING},绝不抛给前端 500。</p>
*
* <p>公式(V38 固定业务权重,不复用 V36 等权 overallScore):<br>
* abilityScore = LOGIC×0.35 + EXECUTION×0.30 + LEARNING×0.20 + COMMUNICATION×0.15,
* 最终 scale=2、HALF_UP。</p>
*/
@Component
public class AbilityScoreMapper {
public static final String MAPPING_RULES_VERSION = "ability-assessment-scoring-v0.1";
public static final BigDecimal WEIGHT_LOGIC = new BigDecimal("0.35");
public static final BigDecimal WEIGHT_EXECUTION = new BigDecimal("0.30");
public static final BigDecimal WEIGHT_LEARNING = new BigDecimal("0.20");
public static final BigDecimal WEIGHT_COMMUNICATION = new BigDecimal("0.15");
public static final int PER_DIMENSION_ANSWERED = 6;
public static final int TOTAL_ANSWERED = 24;
public static final BigDecimal MAX_SCORE = new BigDecimal("100");
private static final ObjectMapper MAPPER = new ObjectMapper();
private static final TypeReference<List<LinkedHashMap<String, Object>>> ENTRIES_LIST =
new TypeReference<>() {
};
private static final Set<String> EXPECTED_DIMENSIONS =
Set.of("LOGIC", "COMMUNICATION", "EXECUTION", "LEARNING");
/**
* 计算结果。{@code dimensionScores} 与 {@code weights} 均使用固定有序键,
* 便于调用方构造白名单 subScores 与最小溯源 JSON。
*/
public record MappedScores(BigDecimal abilityScore,
Map<String, BigDecimal> dimensionScores,
Map<String, Integer> weights) {
}
/**
* 安全解析单个 V36 结果。结构异常一律返回空。
*
* @param dimensionScoresJson ability_results.dimension_scores_json(数组:{code,score,answeredCount})
* @param attemptAnsweredCount ability_attempts.answered_count(可为 null)
*/
public Optional<MappedScores> map(String dimensionScoresJson, Integer attemptAnsweredCount) {
if (dimensionScoresJson == null || dimensionScoresJson.isBlank()) {
return Optional.empty();
}
final List<LinkedHashMap<String, Object>> entries;
try {
entries = MAPPER.readValue(dimensionScoresJson, ENTRIES_LIST);
} catch (Exception e) {
return Optional.empty();
}
if (entries == null || entries.size() != EXPECTED_DIMENSIONS.size()) {
return Optional.empty();
}
Map<String, BigDecimal> scores = new LinkedHashMap<>();
Set<String> seen = new java.util.HashSet<>();
int sumAnswered = 0;
for (LinkedHashMap<String, Object> e : entries) {
String code = e.get("code") instanceof String s ? s : null;
if (code == null || !EXPECTED_DIMENSIONS.contains(code) || !seen.add(code)) {
return Optional.empty(); // 缺维 / 重复维 / 意外维
}
BigDecimal score = toFiniteScore(e.get("score"));
if (score == null) {
return Optional.empty();
}
Integer answered = toExactInt(e.get("answeredCount"));
if (answered == null || answered != PER_DIMENSION_ANSWERED) {
return Optional.empty(); // 每维错题数必须精确等于 6(6.9/6.5/溢出/非法均不可用)
}
scores.put(code, score);
sumAnswered += answered;
}
if (scores.size() != EXPECTED_DIMENSIONS.size() || sumAnswered != TOTAL_ANSWERED) {
return Optional.empty();
}
if (attemptAnsweredCount == null || attemptAnsweredCount != TOTAL_ANSWERED) {
return Optional.empty(); // 答卷总错题数必须非空且恰为 24
}
Map<String, Integer> weights = new LinkedHashMap<>();
weights.put("LOGIC", Integer.valueOf(35));
weights.put("EXECUTION", Integer.valueOf(30));
weights.put("LEARNING", Integer.valueOf(20));
weights.put("COMMUNICATION", Integer.valueOf(15));
BigDecimal abilityScore = scores.get("LOGIC").multiply(WEIGHT_LOGIC)
.add(scores.get("EXECUTION").multiply(WEIGHT_EXECUTION))
.add(scores.get("LEARNING").multiply(WEIGHT_LEARNING))
.add(scores.get("COMMUNICATION").multiply(WEIGHT_COMMUNICATION))
.setScale(2, RoundingMode.HALF_UP);
Map<String, BigDecimal> ordered = new LinkedHashMap<>();
ordered.put("LOGIC", scores.get("LOGIC"));
ordered.put("EXECUTION", scores.get("EXECUTION"));
ordered.put("LEARNING", scores.get("LEARNING"));
ordered.put("COMMUNICATION", scores.get("COMMUNICATION"));
return Optional.of(new MappedScores(abilityScore, ordered, weights));
}
private BigDecimal toFiniteScore(Object v) {
BigDecimal b = toBigDecimal(v);
if (b == null) return null;
if (b.compareTo(BigDecimal.ZERO) < 0 || b.compareTo(MAX_SCORE) > 0) return null;
return b;
}
private BigDecimal toBigDecimal(Object v) {
if (v == null) return null;
BigDecimal out;
if (v instanceof BigDecimal bd) {
out = bd;
} else if (v instanceof Number num) {
double d = num.doubleValue();
if (Double.isNaN(d) || Double.isInfinite(d)) return null;
out = BigDecimal.valueOf(d);
} else {
String s = String.valueOf(v).trim();
if (s.isEmpty()) return null;
try {
out = new BigDecimal(s);
} catch (NumberFormatException e) {
return null;
}
}
return out;
}
/**
* 将 answeredCount 精确解析为整数:仅接受数值意义上等于整数的表示
* (如 JSON 整数 6 或 6.0、"6"、"6.0")。6.9、6.5、"6.5"、溢出 int、空串、null
* 一律返回 null,由调用方映射为不可用。不使用 intValue() 截断,避免把小数误判为整数。
*/
private Integer toExactInt(Object v) {
if (v == null) return null;
try {
if (v instanceof BigDecimal bd) {
return exactIntOrNull(bd);
} else if (v instanceof Number num) {
double d = num.doubleValue();
if (Double.isNaN(d) || Double.isInfinite(d)) return null;
return exactIntOrNull(BigDecimal.valueOf(d));
} else if (v instanceof String s) {
String t = s.trim();
if (t.isEmpty()) return null;
return exactIntOrNull(new BigDecimal(t));
}
return null;
} catch (NumberFormatException e) {
return null;
}
}
/** stripTrailingZeros 后能精确转 int 才返回,否则 null。 */
private Integer exactIntOrNull(BigDecimal b) {
if (b == null) return null;
try {
return b.stripTrailingZeros().intValueExact();
} catch (ArithmeticException e) {
return null; // 非精确整数(如 6.5、6.9)或溢出 int
}
}
}

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

@ -16,6 +16,7 @@ import jakarta.persistence.PersistenceContext;
import jakarta.persistence.Query; import jakarta.persistence.Query;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
@ -97,6 +98,11 @@ public class CandidateScoringCalculator {
private final StudentPreferenceRepository preferenceRepository; private final StudentPreferenceRepository preferenceRepository;
private final UserRepository userRepository; private final UserRepository userRepository;
private final JdbcTemplate jdbcTemplate; private final JdbcTemplate jdbcTemplate;
private final AbilityAssessmentScoreSource abilityScoreSource;
private final AbilityScoreMapper abilityScoreMapper;
@Value("${scoring.ability-assessment.enabled:false}")
private boolean abilityAssessmentEnabled;
@PersistenceContext @PersistenceContext
private EntityManager entityManager; private EntityManager entityManager;
@ -114,10 +120,13 @@ public class CandidateScoringCalculator {
ResumeAgg resume = queryResume(studentUserId); ResumeAgg resume = queryResume(studentUserId);
long msgCount = queryMessages(studentUserId); long msgCount = queryMessages(studentUserId);
// 一次评分计算的基准时间只生成一次并全程复用,避免临界过期时出现不一致
LocalDateTime capturedNow = LocalDateTime.now();
List<DimensionResult> list = new ArrayList<>(); List<DimensionResult> list = new ArrayList<>();
list.add(computeEducation(profile)); list.add(computeEducation(profile));
list.add(computeInternship()); list.add(computeInternship());
list.add(computeAbility()); list.add(computeAbility(studentUserId, capturedNow));
list.add(computeCompliance(pref)); list.add(computeCompliance(pref));
list.add(computeStability(user != null ? user.getCreatedAt() : null, list.add(computeStability(user != null ? user.getCreatedAt() : null,
(int) apps.total, (int) apps.withdrawn, (int) audit.login30d)); (int) apps.total, (int) apps.withdrawn, (int) audit.login30d));
@ -241,13 +250,57 @@ public class CandidateScoringCalculator {
return r; return r;
} }
public DimensionResult computeAbility() { /**
* ABILITY 维度:V38 有限接入 V36 四维能力测评(WORKPLACE_ABILITY)。
* <p>开关关闭时严格保持 V37 前行为(PENDING、不查询 V36 来源)。
* 开关开启且存在最新有效完整结果时,按 V38 固定权重映射;无有效结果或结构异常一律
* PENDING 且 score=null,绝不对前端抛 500。</p>
*/
public DimensionResult computeAbility(Long studentUserId, LocalDateTime capturedNow) {
DimensionResult r = new DimensionResult("ABILITY"); DimensionResult r = new DimensionResult("ABILITY");
if (!abilityAssessmentEnabled) {
r.status = "PENDING"; r.status = "PENDING";
r.summary = "能力测评数据源未就绪"; r.summary = "能力测评数据源未就绪";
r.reasons.add("能力测评(F1.2)尚未实现"); r.reasons.add("能力测评(F1.2)尚未实现");
return r; return r;
} }
var proj = abilityScoreSource.findLatestValidCompletedResult(studentUserId, capturedNow);
if (proj == null) {
r.status = "PENDING";
r.summary = "能力测评数据源未就绪";
r.reasons.add("无可用的完整能力测评结果");
return r;
}
var mapped = abilityScoreMapper.map(proj.dimensionScoresJson(), proj.answeredCount());
if (mapped.isEmpty()) {
r.status = "PENDING";
r.summary = "能力测评数据源异常";
r.reasons.add("测评结果结构异常,暂不参与评分");
return r;
}
var m = mapped.get();
r.status = "OK";
r.score = m.abilityScore();
r.summary = "最新有效四维能力测评(LOGIC/EXECUTION/LEARNING/COMMUNICATION)加权映射";
r.subScores.add(sub("逻辑能力", m.dimensionScores().get("LOGIC"), new BigDecimal("35"), "V36 四维能力测评聚合分"));
r.subScores.add(sub("执行能力", m.dimensionScores().get("EXECUTION"), new BigDecimal("30"), "V36 四维能力测评聚合分"));
r.subScores.add(sub("学习能力", m.dimensionScores().get("LEARNING"), new BigDecimal("20"), "V36 四维能力测评聚合分"));
r.subScores.add(sub("沟通能力", m.dimensionScores().get("COMMUNICATION"), new BigDecimal("15"), "V36 四维能力测评聚合分"));
r.dataSources.add(ds("ability_results(最新有效)", proj.completedAt(), "最新有效四维能力测评完成时间"));
r.dataSources.add(ds("ability_results.valid_until", proj.validUntil(), "测评有效期"));
r.reasons.add("映射规则版本 " + AbilityScoreMapper.MAPPING_RULES_VERSION);
r.abilitySnapshotTrace = new AbilitySnapshotTrace(
proj.resultId(), proj.completedAt(), proj.validUntil(),
proj.questionSetVersion(), proj.assessmentRulesVersion(), m.weights(), m.dimensionScores());
return r;
}
/**
* 候选评分规则版本:开关开启时升级为 v0.2,否则保持 v0.1;历史趋势优先使用快照自身版本。
*/
public String currentRulesVersion() {
return abilityAssessmentEnabled ? "candidate-scoring-v0.2" : RULES_VERSION;
}
public DimensionResult computeCompliance(StudentPreference pref) { public DimensionResult computeCompliance(StudentPreference pref) {
DimensionResult r = new DimensionResult("COMPLIANCE"); DimensionResult r = new DimensionResult("COMPLIANCE");
@ -388,9 +441,9 @@ public class CandidateScoringCalculator {
params.add(period); params.add(period);
params.add(r.score); params.add(r.score);
params.add(r.status); params.add(r.status);
params.add(toJson(r.subScores)); params.add(subScoresPayload(r));
params.add(DEFAULT_WEIGHT); params.add(DEFAULT_WEIGHT);
params.add(RULES_VERSION); params.add(currentRulesVersion());
} }
sql.append(" ON DUPLICATE KEY UPDATE score=VALUES(score), status=VALUES(status), " + sql.append(" ON DUPLICATE KEY UPDATE score=VALUES(score), status=VALUES(status), " +
"sub_scores_json=VALUES(sub_scores_json), weight=VALUES(weight), " + "sub_scores_json=VALUES(sub_scores_json), weight=VALUES(weight), " +
@ -447,6 +500,33 @@ public class CandidateScoringCalculator {
} }
} }
/**
* ABILITY 行的 sub_scores_json 保存最小溯源 JSON(§6.1),其他七维保持既有数组格式。
* 溯源 JSON 仅供内部解释/重算,任何 HTTP 响应都不得返回其原文或 sourceResultId。
*/
private String subScoresPayload(DimensionResult r) {
if ("ABILITY".equals(r.dimension) && r.abilitySnapshotTrace != null) {
try {
Map<String, Object> m = new LinkedHashMap<>();
m.put("source", "V36_ABILITY_ASSESSMENT");
m.put("sourceResultId", r.abilitySnapshotTrace.resultId());
m.put("sourceCompletedAt", r.abilitySnapshotTrace.completedAt() == null
? null : r.abilitySnapshotTrace.completedAt().toString());
m.put("validUntil", r.abilitySnapshotTrace.validUntil() == null
? null : r.abilitySnapshotTrace.validUntil().toString());
m.put("questionSetVersion", r.abilitySnapshotTrace.questionSetVersion());
m.put("assessmentRulesVersion", r.abilitySnapshotTrace.assessmentRulesVersion());
m.put("mappingRulesVersion", AbilityScoreMapper.MAPPING_RULES_VERSION);
m.put("weights", r.abilitySnapshotTrace.weights());
m.put("dimensionScores", r.abilitySnapshotTrace.dimensionScores());
return MAPPER.writeValueAsString(m);
} catch (Exception e) {
return null;
}
}
return toJson(r.subScores);
}
private BigDecimal cap(BigDecimal v) { private BigDecimal cap(BigDecimal v) {
return v.max(ZERO).min(HUNDRED).setScale(2, RoundingMode.HALF_UP); return v.max(ZERO).min(HUNDRED).setScale(2, RoundingMode.HALF_UP);
} }
@ -471,12 +551,24 @@ public class CandidateScoringCalculator {
public List<String> reasons = new ArrayList<>(); public List<String> reasons = new ArrayList<>();
public List<DimensionScoreDetailResponse.DataSourceItem> dataSources = new ArrayList<>(); public List<DimensionScoreDetailResponse.DataSourceItem> dataSources = new ArrayList<>();
public String summary; public String summary;
/** 仅 ABILITY(OK) 时携带的最小溯源信息,用于写出 sub_scores_json;其他维度为 null。 */
public AbilitySnapshotTrace abilitySnapshotTrace;
public DimensionResult(String dimension) { public DimensionResult(String dimension) {
this.dimension = dimension; this.dimension = dimension;
} }
} }
/** V38:ABLE 快照最小溯源元数据(仅持久化到内部 sub_scores_json,绝不对外返回)。 */
public record AbilitySnapshotTrace(Long resultId,
LocalDateTime completedAt,
LocalDateTime validUntil,
String questionSetVersion,
String assessmentRulesVersion,
Map<String, Integer> weights,
Map<String, BigDecimal> dimensionScores) {
}
public static class AppAgg { public static class AppAgg {
public long total; public long withdrawn; public LocalDateTime earliest; public long recent90d; public long total; public long withdrawn; public LocalDateTime earliest; public long recent90d;
public AppAgg(long total, long withdrawn, LocalDateTime earliest, long recent90d) { public AppAgg(long total, long withdrawn, LocalDateTime earliest, long recent90d) {

8
mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/CandidateScoringEngineService.java

@ -77,7 +77,7 @@ public class CandidateScoringEngineService {
Map<String, BigDecimal> dims = new LinkedHashMap<>(); Map<String, BigDecimal> dims = new LinkedHashMap<>();
List<BigDecimal> computable = new ArrayList<>(); List<BigDecimal> computable = new ArrayList<>();
String rulesVersion = e.getValue().isEmpty() String rulesVersion = e.getValue().isEmpty()
? CandidateScoringCalculator.RULES_VERSION : e.getValue().get(0).getRulesVersion(); ? calculator.currentRulesVersion() : e.getValue().get(0).getRulesVersion();
for (CandidateDimensionScore s : e.getValue()) { for (CandidateDimensionScore s : e.getValue()) {
if ("OK".equals(s.getStatus()) || "ESTIMATED".equals(s.getStatus())) { if ("OK".equals(s.getStatus()) || "ESTIMATED".equals(s.getStatus())) {
dims.put(s.getDimension(), s.getScore()); dims.put(s.getDimension(), s.getScore());
@ -119,7 +119,7 @@ public class CandidateScoringEngineService {
return CompanyCandidateScoringResponse.builder() return CompanyCandidateScoringResponse.builder()
.applicationId(applicationId).overallScore(overall).status(status) .applicationId(applicationId).overallScore(overall).status(status)
.dimensions(dims).generatedAt(Instant.now()) .dimensions(dims).generatedAt(Instant.now())
.rulesVersion(CandidateScoringCalculator.RULES_VERSION).build(); .rulesVersion(calculator.currentRulesVersion()).build();
} }
// ── 组装 ──────────────────────────────────────────────────── // ── 组装 ────────────────────────────────────────────────────
@ -140,7 +140,7 @@ public class CandidateScoringEngineService {
} }
return CandidateScoringOverviewResponse.builder() return CandidateScoringOverviewResponse.builder()
.candidateId(studentUserId).status(overviewStatus(results)).overallScore(overall) .candidateId(studentUserId).status(overviewStatus(results)).overallScore(overall)
.weights(weights).dimensions(items).rulesVersion(CandidateScoringCalculator.RULES_VERSION) .weights(weights).dimensions(items).rulesVersion(calculator.currentRulesVersion())
.generatedAt(Instant.now()).build(); .generatedAt(Instant.now()).build();
} }
@ -148,7 +148,7 @@ public class CandidateScoringEngineService {
return DimensionScoreDetailResponse.builder() return DimensionScoreDetailResponse.builder()
.dimension(r.dimension).score(r.score).status(r.status) .dimension(r.dimension).score(r.score).status(r.status)
.subScores(r.subScores).reasons(r.reasons).dataSources(r.dataSources) .subScores(r.subScores).reasons(r.reasons).dataSources(r.dataSources)
.updatedAt(LocalDateTime.now()).rulesVersion(CandidateScoringCalculator.RULES_VERSION).build(); .updatedAt(LocalDateTime.now()).rulesVersion(calculator.currentRulesVersion()).build();
} }
private String overviewStatus(List<CandidateScoringCalculator.DimensionResult> results) { private String overviewStatus(List<CandidateScoringCalculator.DimensionResult> results) {

4
mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/governance/CandidateScoringGovernanceService.java

@ -117,7 +117,7 @@ public class CandidateScoringGovernanceService {
CandidateScoringAppeal appeal = CandidateScoringAppeal.builder() CandidateScoringAppeal appeal = CandidateScoringAppeal.builder()
.candidateUserId(studentUserId).dimension(req.getDimension()).period(req.getPeriod()) .candidateUserId(studentUserId).dimension(req.getDimension()).period(req.getPeriod())
.status(CandidateScoringAppeal.STATUS_PENDING).reason(req.getReason().trim()) .status(CandidateScoringAppeal.STATUS_PENDING).reason(req.getReason().trim())
.rulesVersion(CandidateScoringCalculator.RULES_VERSION) .rulesVersion(calculator.currentRulesVersion())
.idempotencyKey(req.getIdempotencyKey()) .idempotencyKey(req.getIdempotencyKey())
.activeKey(activeKey) .activeKey(activeKey)
.scoreAtAppeal(snap != null ? snap.getScore() : null) .scoreAtAppeal(snap != null ? snap.getScore() : null)
@ -294,7 +294,7 @@ public class CandidateScoringGovernanceService {
.beforeVersion(before != null ? before.getVersion() : null) .beforeVersion(before != null ? before.getVersion() : null)
.afterVersion(afterVersion) .afterVersion(afterVersion)
.beforeRulesVersion(before != null ? before.getRulesVersion() : null) .beforeRulesVersion(before != null ? before.getRulesVersion() : null)
.afterRulesVersion(CandidateScoringCalculator.RULES_VERSION) .afterRulesVersion(calculator.currentRulesVersion())
.build()); .build());
String from = appeal.getStatus(); String from = appeal.getStatus();

3
mydreamjob-server/src/main/java/com/mydreamjob/careerprogress/CareerProgressService.java

@ -75,6 +75,7 @@ public class CareerProgressService {
private final CompanyRepository companyRepository; private final CompanyRepository companyRepository;
private final UserRepository userRepository; private final UserRepository userRepository;
private final CompetitionRankingCalculator rankingCalculator; private final CompetitionRankingCalculator rankingCalculator;
private final CandidateScoringCalculator candidateScoringCalculator;
@Transactional(readOnly = true) @Transactional(readOnly = true)
public CareerProgressResponse getCareerProgress(Long studentUserId, int page, int size, String status) { public CareerProgressResponse getCareerProgress(Long studentUserId, int page, int size, String status) {
@ -303,7 +304,7 @@ public class CareerProgressService {
.percentile(cr.percentile) .percentile(cr.percentile)
.status(cr.status) .status(cr.status)
.mySnapshotPeriod(cr.mySnapshotPeriod) .mySnapshotPeriod(cr.mySnapshotPeriod)
.rulesVersion(CandidateScoringCalculator.RULES_VERSION) .rulesVersion(candidateScoringCalculator.currentRulesVersion())
.build(); .build();
} }

3
mydreamjob-server/src/main/java/com/mydreamjob/competition/CompetitionRankingService.java

@ -43,6 +43,7 @@ public class CompetitionRankingService {
private final JobService jobService; private final JobService jobService;
private final ApplicationRepository applicationRepository; private final ApplicationRepository applicationRepository;
private final CompetitionRankingCalculator rankingCalculator; private final CompetitionRankingCalculator rankingCalculator;
private final CandidateScoringCalculator candidateScoringCalculator;
@Transactional(readOnly = true) @Transactional(readOnly = true)
public CompetitionRankingResponse getRanking(Long studentUserId, Long jobId) { public CompetitionRankingResponse getRanking(Long studentUserId, Long jobId) {
@ -105,7 +106,7 @@ public class CompetitionRankingService {
.dimensions(buildDimensions(cr.meSnaps)) .dimensions(buildDimensions(cr.meSnaps))
.gaps(buildGaps(cr.meSnaps)) .gaps(buildGaps(cr.meSnaps))
.topCandidates(top) .topCandidates(top)
.rulesVersion(CandidateScoringCalculator.RULES_VERSION) .rulesVersion(candidateScoringCalculator.currentRulesVersion())
.generatedAt(Instant.now()) .generatedAt(Instant.now())
.build(); .build();
} }

4
mydreamjob-server/src/main/resources/application.yml

@ -133,6 +133,10 @@ app:
cooling-days: ${DELETION_COOLING_DAYS:30} cooling-days: ${DELETION_COOLING_DAYS:30}
phone-hash-secret: ${DELETION_PHONE_HASH_SECRET:mydreamjob-deletion-dev-secret-not-for-prod} phone-hash-secret: ${DELETION_PHONE_HASH_SECRET:mydreamjob-deletion-dev-secret-not-for-prod}
scoring:
ability-assessment:
enabled: ${SCORING_ABILITY_ASSESSMENT_ENABLED:false}
management: management:
endpoints: endpoints:
web: web:

87
mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/AbilityAssessmentScoreSourceTest.java

@ -0,0 +1,87 @@
package com.mydreamjob.candidatescore;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
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 java.sql.ResultSet;
import java.sql.Timestamp;
import java.time.LocalDateTime;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
/**
* V38:AbilityAssessmentScoreSource 单测。用 mock JdbcTemplate 断言单次查询契约与行映射,
* 并覆盖无结果/参数空返回 null。真实数据选择行为由 AbilityScoreIntegrationTest 覆盖。
*/
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class AbilityAssessmentScoreSourceTest {
@Mock private JdbcTemplate jdbcTemplate;
@InjectMocks private AbilityAssessmentScoreSource source;
private static final Long SID = 2L;
private static final LocalDateTime NOW = LocalDateTime.now();
@Test
void findLatest_singleQuery_noNplus1() {
when(jdbcTemplate.query(anyString(), any(RowMapper.class), any(Object[].class)))
.thenReturn(List.of());
source.findLatestValidCompletedResult(SID, NOW);
verify(jdbcTemplate, times(1)).query(anyString(), any(RowMapper.class), any(Object[].class));
}
@Test
void findLatest_mapsRow_toProjection() throws Exception {
ResultSet rs = mock(ResultSet.class);
when(rs.getLong("resultId")).thenReturn(123L);
when(rs.getTimestamp("completedAt")).thenReturn(Timestamp.valueOf(NOW.minusDays(1)));
when(rs.getTimestamp("validUntil")).thenReturn(Timestamp.valueOf(NOW.plusDays(6)));
when(rs.getString("questionSetVersion")).thenReturn("ability-v0.1");
when(rs.getString("assessmentRulesVersion")).thenReturn("ability-v0.1");
when(rs.getInt("answeredCount")).thenReturn(24);
when(rs.getString("dimensionScoresJson")).thenReturn("[]");
when(jdbcTemplate.query(anyString(), any(RowMapper.class), any(Object[].class)))
.thenAnswer(inv -> {
RowMapper<?> rm = inv.getArgument(1);
Object mapped = rm.mapRow(rs, 0);
return java.util.List.of(mapped);
});
AbilityAssessmentScoreSource.AbilityResultProjection p =
source.findLatestValidCompletedResult(SID, NOW);
assertNotNull(p);
assertEquals(123L, p.resultId());
assertEquals(NOW.minusDays(1), p.completedAt());
assertEquals(NOW.plusDays(6), p.validUntil());
assertEquals("ability-v0.1", p.questionSetVersion());
assertEquals("ability-v0.1", p.assessmentRulesVersion());
assertEquals(24, p.answeredCount());
assertEquals("[]", p.dimensionScoresJson());
}
@Test
void findLatest_empty_noResult_null() {
when(jdbcTemplate.query(anyString(), any(RowMapper.class), any(Object[].class)))
.thenReturn(List.of());
assertNull(source.findLatestValidCompletedResult(SID, NOW));
}
@Test
void findLatest_nullParams_returnsNull() {
assertNull(source.findLatestValidCompletedResult(null, NOW));
assertNull(source.findLatestValidCompletedResult(SID, null));
}
}

212
mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/AbilityScoreIntegrationTest.java

@ -0,0 +1,212 @@
package com.mydreamjob.candidatescore;
import com.mydreamjob.ability.AbilityAttempt;
import com.mydreamjob.ability.AbilityAttemptRepository;
import com.mydreamjob.ability.AbilityDefinition;
import com.mydreamjob.ability.AbilityDefinitionRepository;
import com.mydreamjob.ability.AbilityResult;
import com.mydreamjob.ability.AbilityResultRepository;
import com.mydreamjob.auth.User;
import com.mydreamjob.auth.UserRepository;
import com.mydreamjob.candidatescore.dto.CandidateScoringOverviewResponse;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
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.test.util.ReflectionTestUtils;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import java.util.concurrent.atomic.AtomicLong;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
/**
* V38:四维能力结果接入候选评分——真实 H2 集成测试。
* <p>覆盖来源选择(最新有效 COMPLETED/WORKPLACE_ABILITY/过期排除)、V36 submit 不写快照、
* 评分触发才写 ABILITY 快照、结果过期后 PENDING。企业脱敏与版本切换由 EngineService 单测覆盖。</p>
*/
@SpringBootTest
@ActiveProfiles("integration-test")
class AbilityScoreIntegrationTest {
private static final AtomicLong PHONE = new AtomicLong(7100);
@Autowired private UserRepository userRepository;
@Autowired private AbilityDefinitionRepository definitionRepository;
@Autowired private AbilityAttemptRepository attemptRepository;
@Autowired private AbilityResultRepository resultRepository;
@Autowired private AbilityAssessmentScoreSource scoreSource;
@Autowired private CandidateScoringEngineService engineService;
@Autowired private CandidateDimensionScoreRepository dimensionScoreRepository;
@Autowired private CandidateScoringCalculator calculator;
@MockBean private RedisTemplate<String, Object> redisTemplate;
private final List<Long> createdUsers = new ArrayList<>();
private Long defId;
private static final String DIMS_OK =
"[{\"code\":\"LOGIC\",\"score\":80,\"answeredCount\":6},"
+ "{\"code\":\"EXECUTION\",\"score\":75,\"answeredCount\":6},"
+ "{\"code\":\"LEARNING\",\"score\":70,\"answeredCount\":6},"
+ "{\"code\":\"COMMUNICATION\",\"score\":65,\"answeredCount\":6}]";
// 历史高分(早提交)+ 更新低分(晚提交)用于验证“取最新而非最高”
private static final String DIMS_HIGH =
"[{\"code\":\"LOGIC\",\"score\":100,\"answeredCount\":6},"
+ "{\"code\":\"EXECUTION\",\"score\":100,\"answeredCount\":6},"
+ "{\"code\":\"LEARNING\",\"score\":100,\"answeredCount\":6},"
+ "{\"code\":\"COMMUNICATION\",\"score\":100,\"answeredCount\":6}]";
@BeforeEach
void setUp() {
when(redisTemplate.opsForValue()).thenReturn(mock(org.springframework.data.redis.core.ValueOperations.class));
when(redisTemplate.hasKey(any())).thenReturn(false);
// WORKPLACE_ABILITY 由既有 Flyway 迁移种子化;复用而非新建,避免唯一键冲突
AbilityDefinition def = definitionRepository.findAll().stream()
.filter(d -> "WORKPLACE_ABILITY".equals(d.getTypeCode()))
.findFirst().orElseThrow(() -> new IllegalStateException("WORKPLACE_ABILITY must be seeded"));
defId = def.getId();
}
@AfterEach
void tearDown() {
for (Long id : createdUsers) {
userRepository.deleteById(id);
}
createdUsers.clear();
ReflectionTestUtils.setField(calculator, "abilityAssessmentEnabled", false);
}
private Long createUser() {
User u = userRepository.save(User.builder()
.phone("19" + String.format("%08d", PHONE.incrementAndGet())).nickname("u")
.role(User.Role.STUDENT).roles(Set.of(User.Role.STUDENT)).active(true).verified(true).build());
createdUsers.add(u.getId());
return u.getId();
}
private Long attempt(Long studentId, String status, LocalDateTime completedAt,
int answeredCount) {
AbilityAttempt a = attemptRepository.save(AbilityAttempt.builder()
.studentUserId(studentId).definitionId(defId).status(status)
.answeredCount(answeredCount).questionSetVersion("ability-v0.1")
.completedAt(completedAt)
.build());
return a.getId();
}
private Long result(Long attemptId, String dimsJson, LocalDateTime validUntil) {
AbilityResult r = resultRepository.save(AbilityResult.builder()
.attemptId(attemptId).dimensionScoresJson(dimsJson)
.overallScore(new BigDecimal("70.00"))
.questionSetVersion("ability-v0.1").rulesVersion("ability-v0.1")
.validUntil(validUntil).generatedAt(LocalDateTime.now())
.build());
return r.getId();
}
@Test
void source_picksLatestValidCompleted_ignoringHistoricalHighScore() {
Long u = createUser();
LocalDateTime now = LocalDateTime.now();
// 较早提交的高分(100)+ 更晚提交的有效低分(74.25)
Long earlyResultId = result(attempt(u, "COMPLETED", now.minusDays(2), 24),
DIMS_HIGH, now.plusDays(5));
Long lateResultId = result(attempt(u, "COMPLETED", now.minusDays(1), 24),
DIMS_OK, now.plusDays(5));
AbilityAssessmentScoreSource.AbilityResultProjection p =
scoreSource.findLatestValidCompletedResult(u, now);
assertNotNull(p);
assertEquals(lateResultId, p.resultId(), "must pick latest completed, not historical highest");
assertEquals(now.minusDays(1).truncatedTo(java.time.temporal.ChronoUnit.SECONDS),
p.completedAt().truncatedTo(java.time.temporal.ChronoUnit.SECONDS));
assertEquals(DIMS_OK, p.dimensionScoresJson());
}
@Test
void source_excludesExpiredOrNonCompletedOrOtherType() {
Long u = createUser();
LocalDateTime now = LocalDateTime.now();
// 过期结果不被选
Long expiredId = attempt(u, "COMPLETED", now.minusDays(1), 24);
result(expiredId, DIMS_OK, now.minusDays(1));
// DRAFT/IN_PROGRESS 不被选
attempt(u, "DRAFT", null, 0);
attempt(u, "IN_PROGRESS", null, 12);
// 其他类型定义(使用未被种子占用的自定义类型码)
AbilityDefinition other = definitionRepository.save(AbilityDefinition.builder()
.typeCode("FIXTURE_OTHER_99").displayName("其他").status("ACTIVE")
.questionCount(3).attemptQuestionCount(3).questionsPerDimension(1)
.timeLimitMinutes(10).resultValidDays(7).retakeWindowDays(90).retakeMaxCompleted(3)
.questionSetVersion("v0.1").rulesVersion("v0.1").build());
Long otherAttemptId = attemptRepository.save(AbilityAttempt.builder()
.studentUserId(u).definitionId(other.getId()).status("COMPLETED")
.answeredCount(3).questionSetVersion("v0.1").completedAt(now).build()).getId();
result(otherAttemptId, "[{\"code\":\"X\",\"score\":100,\"answeredCount\":3}]", now.plusDays(7));
assertNull(scoreSource.findLatestValidCompletedResult(u, now));
}
@Test
void source_validUntilEqualsCapturedNow_notSelected() {
Long u = createUser();
// 统一截断到秒,保证 valid_until 与传入的 capturedNow 严格相等(H2/MySQL 时间精度差异无关)
LocalDateTime now = LocalDateTime.now().withNano(0);
Long aid = attempt(u, "COMPLETED", now.minusDays(1), 24);
result(aid, DIMS_OK, now);
assertNull(scoreSource.findLatestValidCompletedResult(u, now),
"valid_until == capturedNow must not be selected");
}
@Test
void source_validUntilAfterCapturedNow_selected() {
Long u = createUser();
LocalDateTime now = LocalDateTime.now();
Long aid = attempt(u, "COMPLETED", now.minusDays(1), 24);
Long rid = result(aid, DIMS_OK, now.plusMinutes(1));
AbilityAssessmentScoreSource.AbilityResultProjection p =
scoreSource.findLatestValidCompletedResult(u, now);
assertNotNull(p, "valid_until > capturedNow must be selected");
assertEquals(rid, p.resultId());
}
@Test
void expiredResult_recomputeBecomesPending() {
Long u = createUser();
ReflectionTestUtils.setField(calculator, "abilityAssessmentEnabled", true);
LocalDateTime now = LocalDateTime.now();
Long aid = attempt(u, "COMPLETED", now.minusDays(1), 24);
result(aid, DIMS_OK, now.plusDays(6));
// 有效期内在评分后写入 OK 快照(getOverview 内部 @Transactional 触发既有 upsert)
CandidateScoringOverviewResponse r1 = engineService.getOverview(u);
assertEquals("OK", dimensionStatus(r1, "ABILITY"));
// 强制过期后重算 → PENDING
resultRepository.findByAttemptId(aid).ifPresent(rr -> {
rr.setValidUntil(now.minusDays(1));
resultRepository.save(rr);
});
CandidateScoringOverviewResponse r2 = engineService.getOverview(u);
assertEquals("PENDING", dimensionStatus(r2, "ABILITY"));
}
private String dimensionStatus(CandidateScoringOverviewResponse resp, String dim) {
return resp.getDimensions().stream()
.filter(d -> dim.equals(d.getDimension()))
.findFirst().map(CandidateScoringOverviewResponse.DimensionItem::getStatus)
.orElseThrow();
}
}

181
mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/AbilityScoreMapperTest.java

@ -0,0 +1,181 @@
package com.mydreamjob.candidatescore;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
/**
* V38:AbilityScoreMapper 纯函数测试。
* <p>覆盖固定权重公式与 HALF_UP、结构防御(缺维/重复维/越界/错题数非6/总数非24/非法JSON),
* 以及不依赖答案键或题目级数据。</p>
*/
class AbilityScoreMapperTest {
private final AbilityScoreMapper mapper = new AbilityScoreMapper();
private String dims(int l, int c, int e, int r, int perDimAnswered) {
return dimsWithAnswered(l, c, e, r, String.valueOf(perDimAnswered));
}
private String dimsWithAnswered(int l, int c, int e, int r, String perDimAnswered) {
return "[{\"code\":\"LOGIC\",\"score\":" + l + ",\"answeredCount\":" + perDimAnswered + "},"
+ "{\"code\":\"COMMUNICATION\",\"score\":" + c + ",\"answeredCount\":" + perDimAnswered + "},"
+ "{\"code\":\"EXECUTION\",\"score\":" + e + ",\"answeredCount\":" + perDimAnswered + "},"
+ "{\"code\":\"LEARNING\",\"score\":" + r + ",\"answeredCount\":" + perDimAnswered + "}]";
}
@Test
void validDimensions_weightedFormulaHALFUP() {
// 80*0.35 + 75*0.30 + 70*0.20 + 65*0.15 = 74.25
Optional<AbilityScoreMapper.MappedScores> m = mapper.map(dims(80, 65, 75, 70, 6), 24);
assertTrue(m.isPresent());
assertEquals(new BigDecimal("74.25"), m.get().abilityScore());
assertEquals(2, m.get().abilityScore().scale());
}
@Test
void validDimensions_halfUpRounding_scale2() {
// 三十三点三三 每维:33.33*0.35 + 33.33*0.30 + 33.33*0.20 + 33.33*0.15 = 33.33
String json = "[{\"code\":\"LOGIC\",\"score\":33.33,\"answeredCount\":6},"
+ "{\"code\":\"EXECUTION\",\"score\":33.33,\"answeredCount\":6},"
+ "{\"code\":\"LEARNING\",\"score\":33.33,\"answeredCount\":6},"
+ "{\"code\":\"COMMUNICATION\",\"score\":33.33,\"answeredCount\":6}]";
Optional<AbilityScoreMapper.MappedScores> m = mapper.map(json, 24);
assertTrue(m.isPresent());
assertEquals(2, m.get().abilityScore().scale());
assertEquals(new BigDecimal("33.33"), m.get().abilityScore());
}
@Test
void validDimensions_halfUp_boundaryRoundUp() {
// LOGIC=66.67, 其他=0 => 66.67*0.35 = 23.3345 -> HALF_UP scale2 = 23.33
String json = "[{\"code\":\"LOGIC\",\"score\":66.67,\"answeredCount\":6},"
+ "{\"code\":\"EXECUTION\",\"score\":0,\"answeredCount\":6},"
+ "{\"code\":\"LEARNING\",\"score\":0,\"answeredCount\":6},"
+ "{\"code\":\"COMMUNICATION\",\"score\":0,\"answeredCount\":6}]";
Optional<AbilityScoreMapper.MappedScores> m = mapper.map(json, 24);
assertTrue(m.isPresent());
assertEquals(new BigDecimal("23.33"), m.get().abilityScore());
}
@Test
void missingDimension_unavailable() {
String json = "[{\"code\":\"LOGIC\",\"score\":80,\"answeredCount\":6},"
+ "{\"code\":\"EXECUTION\",\"score\":75,\"answeredCount\":6},"
+ "{\"code\":\"LEARNING\",\"score\":70,\"answeredCount\":6}]";
assertTrue(mapper.map(json, 18).isEmpty());
}
@Test
void duplicateDimension_unavailable() {
String json = "[{\"code\":\"LOGIC\",\"score\":80,\"answeredCount\":6},"
+ "{\"code\":\"LOGIC\",\"score\":80,\"answeredCount\":6},"
+ "{\"code\":\"EXECUTION\",\"score\":75,\"answeredCount\":6},"
+ "{\"code\":\"LEARNING\",\"score\":70,\"answeredCount\":6}]";
assertTrue(mapper.map(json, 24).isEmpty());
}
@Test
void unexpectedDimension_unavailable() {
String json = "[{\"code\":\"LOGIC\",\"score\":80,\"answeredCount\":6},"
+ "{\"code\":\"EXTRA\",\"score\":80,\"answeredCount\":6},"
+ "{\"code\":\"EXECUTION\",\"score\":75,\"answeredCount\":6},"
+ "{\"code\":\"LEARNING\",\"score\":70,\"answeredCount\":6}]";
assertTrue(mapper.map(json, 24).isEmpty());
}
@Test
void outOfRangeScore_unavailable() {
assertTrue(mapper.map(dims(110, 65, 75, 70, 6), 24).isEmpty());
assertTrue(mapper.map(dims(-1, 65, 75, 70, 6), 24).isEmpty());
assertTrue(mapper.map(dims(80, 65, 75, 101, 6), 24).isEmpty());
}
@Test
void nonFiniteScore_unavailable() {
String json = "[{\"code\":\"LOGIC\",\"score\":NaN,\"answeredCount\":6},"
+ "{\"code\":\"EXECUTION\",\"score\":75,\"answeredCount\":6},"
+ "{\"code\":\"LEARNING\",\"score\":70,\"answeredCount\":6},"
+ "{\"code\":\"COMMUNICATION\",\"score\":65,\"answeredCount\":6}]";
assertTrue(mapper.map(json, 24).isEmpty());
}
@Test
void answeredCountNot6_unavailable() {
String json = "[{\"code\":\"LOGIC\",\"score\":80,\"answeredCount\":5},"
+ "{\"code\":\"EXECUTION\",\"score\":75,\"answeredCount\":6},"
+ "{\"code\":\"LEARNING\",\"score\":70,\"answeredCount\":6},"
+ "{\"code\":\"COMMUNICATION\",\"score\":65,\"answeredCount\":6}]";
assertTrue(mapper.map(json, 23).isEmpty());
}
@Test
void totalAnsweredNot24_unavailable() {
// 每维 6 => 总数 24 满足;但 attempt 上 answered_count=23 视为不完整
assertTrue(mapper.map(dims(80, 65, 75, 70, 6), 23).isEmpty());
}
@Test
void attemptAnsweredNull_returnsEmpty() {
// V38 P1:attemptAnsweredCount 必须非空且严格等于 24
assertTrue(mapper.map(dims(80, 65, 75, 70, 6), null).isEmpty());
}
@Test
void attemptAnsweredNot24_returnsEmpty() {
assertTrue(mapper.map(dims(80, 65, 75, 70, 6), 23).isEmpty());
assertTrue(mapper.map(dims(80, 65, 75, 70, 6), 25).isEmpty());
}
@Test
void perDimAnsweredDecimalNotExact_returnsEmpty() {
// 6.9 会被 JSON 解析为浮点数;不得因 intValue 截断成 6
assertTrue(mapper.map(dimsWithAnswered(80, 65, 75, 70, "6.9"), 24).isEmpty());
// 6.5 同理不可用
assertTrue(mapper.map(dimsWithAnswered(80, 65, 75, 70, "6.5"), 24).isEmpty());
}
@Test
void perDimAnsweredStringDecimal_returnsEmpty() {
assertTrue(mapper.map(dimsWithAnswered(80, 65, 75, 70, "\"6.5\""), 24).isEmpty());
assertTrue(mapper.map(dimsWithAnswered(80, 65, 75, 70, "\"6.9\""), 24).isEmpty());
}
@Test
void perDimAnsweredOverflowOrBlank_returnsEmpty() {
// 2147483648 超出 int 精确取值范围(intValueExact 抛 ArithmeticException → null)
assertTrue(mapper.map(dimsWithAnswered(80, 65, 75, 70, "2147483648"), 24).isEmpty());
assertTrue(mapper.map(dimsWithAnswered(80, 65, 75, 70, "\"\""), 24).isEmpty());
assertTrue(mapper.map(dimsWithAnswered(80, 65, 75, 70, "null"), 24).isEmpty());
}
@Test
void perDimAnsweredExactSixAsDecimalOrString_ok() {
// 数值意义等于 6 的整数表示:JSON 数字 6.0 与字符串 "6" / "6.0" 均接受
assertTrue(mapper.map(dimsWithAnswered(80, 65, 75, 70, "6.0"), 24).isPresent());
assertTrue(mapper.map(dimsWithAnswered(80, 65, 75, 70, "\"6\""), 24).isPresent());
assertTrue(mapper.map(dimsWithAnswered(80, 65, 75, 70, "\"6.0\""), 24).isPresent());
}
@Test
void illegalJson_unavailable() {
assertTrue(mapper.map("not json{{{", 24).isEmpty());
assertTrue(mapper.map(null, 24).isEmpty());
assertTrue(mapper.map("", 24).isEmpty());
assertTrue(mapper.map("[]", 24).isEmpty());
}
@Test
void stringScoredValues_parsed() {
String json = "[{\"code\":\"LOGIC\",\"score\":\"80\",\"answeredCount\":6},"
+ "{\"code\":\"EXECUTION\",\"score\":\"75\",\"answeredCount\":6},"
+ "{\"code\":\"LEARNING\",\"score\":\"70\",\"answeredCount\":6},"
+ "{\"code\":\"COMMUNICATION\",\"score\":\"65\",\"answeredCount\":6}]";
Optional<AbilityScoreMapper.MappedScores> m = mapper.map(json, 24);
assertTrue(m.isPresent());
assertEquals(new BigDecimal("74.25"), m.get().abilityScore());
}
}

121
mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoringCalculatorTest.java

@ -15,6 +15,7 @@ import org.mockito.ArgumentCaptor;
import org.mockito.ArgumentMatchers; import org.mockito.ArgumentMatchers;
import org.mockito.InjectMocks; import org.mockito.InjectMocks;
import org.mockito.Mock; import org.mockito.Mock;
import org.mockito.Spy;
import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings; import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness; import org.mockito.quality.Strictness;
@ -24,7 +25,9 @@ import org.springframework.test.util.ReflectionTestUtils;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Optional; import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*; import static org.junit.jupiter.api.Assertions.*;
@ -40,6 +43,8 @@ class CandidateScoringCalculatorTest {
@Mock private UserRepository userRepository; @Mock private UserRepository userRepository;
@Mock private JdbcTemplate jdbcTemplate; @Mock private JdbcTemplate jdbcTemplate;
@Mock private EntityManager entityManager; @Mock private EntityManager entityManager;
@Mock private AbilityAssessmentScoreSource abilityScoreSource;
@Spy private AbilityScoreMapper abilityScoreMapper = new AbilityScoreMapper();
@InjectMocks private CandidateScoringCalculator calculator; @InjectMocks private CandidateScoringCalculator calculator;
private static final Long STUDENT_ID = 2L; private static final Long STUDENT_ID = 2L;
@ -121,7 +126,121 @@ class CandidateScoringCalculatorTest {
@Test void internshipAndAbility_shouldBePending() { @Test void internshipAndAbility_shouldBePending() {
assertEquals("PENDING", calculator.computeInternship().status); assertEquals("PENDING", calculator.computeInternship().status);
assertEquals("PENDING", calculator.computeAbility().status); assertEquals("PENDING", calculator.computeAbility(STUDENT_ID, LocalDateTime.now()).status);
}
// ── V38 ABILITY 接入:开关关闭/开启 ─────────────────────────
private static final LocalDateTime NOW = LocalDateTime.now();
private AbilityAssessmentScoreSource.AbilityResultProjection proj(String dimJson, int answered) {
return new AbilityAssessmentScoreSource.AbilityResultProjection(
123L, NOW.minusDays(1), NOW.plusDays(6),
"ability-v0.1", "ability-v0.1", answered, dimJson);
}
private String dimsJson(int l, int c, int e, int r) {
return "[{\"code\":\"LOGIC\",\"score\":" + l + ",\"answeredCount\":6},"
+ "{\"code\":\"COMMUNICATION\",\"score\":" + c + ",\"answeredCount\":6},"
+ "{\"code\":\"EXECUTION\",\"score\":" + e + ",\"answeredCount\":6},"
+ "{\"code\":\"LEARNING\",\"score\":" + r + ",\"answeredCount\":6}]";
}
@Test void ability_switchOff_noSourceCall_pendingV01() {
setEnabled(false);
CandidateScoringCalculator.DimensionResult r =
calculator.computeAbility(STUDENT_ID, NOW);
assertEquals("PENDING", r.status);
assertNull(r.score);
verify(abilityScoreSource, never()).findLatestValidCompletedResult(anyLong(), any());
assertEquals("candidate-scoring-v0.1", calculator.currentRulesVersion());
}
@Test void ability_switchOn_validComplete_okAndV02() {
setEnabled(true);
when(abilityScoreSource.findLatestValidCompletedResult(eq(STUDENT_ID), any()))
.thenReturn(proj(dimsJson(80, 65, 75, 70), 24));
CandidateScoringCalculator.DimensionResult r =
calculator.computeAbility(STUDENT_ID, NOW);
assertEquals("OK", r.status);
// 80*0.35 + 75*0.30 + 70*0.20 + 65*0.15 = 28 + 22.5 + 14 + 9.75 = 74.25
assertEquals(new BigDecimal("74.25"), r.score);
assertEquals("candidate-scoring-v0.2", calculator.currentRulesVersion());
assertEquals(4, r.subScores.size());
assertNotNull(r.abilitySnapshotTrace);
// 白名单溯源字段,不含题目/答案/结果ID暴露层字段之外的内容
assertEquals(123L, r.abilitySnapshotTrace.resultId());
assertEquals(35, r.abilitySnapshotTrace.weights().get("LOGIC"));
}
@Test void ability_switchOn_noResult_pending() {
setEnabled(true);
when(abilityScoreSource.findLatestValidCompletedResult(eq(STUDENT_ID), any()))
.thenReturn(null);
CandidateScoringCalculator.DimensionResult r =
calculator.computeAbility(STUDENT_ID, NOW);
assertEquals("PENDING", r.status);
assertNull(r.score);
assertEquals("candidate-scoring-v0.2", calculator.currentRulesVersion());
}
@Test void ability_switchOn_malformedJson_pendingNo500() {
setEnabled(true);
when(abilityScoreSource.findLatestValidCompletedResult(eq(STUDENT_ID), any()))
.thenReturn(proj("[{\"code\":\"LOGIC\"}]", 24));
CandidateScoringCalculator.DimensionResult r =
calculator.computeAbility(STUDENT_ID, NOW);
assertEquals("PENDING", r.status);
assertNull(r.score);
}
@Test void ability_switchOn_outOfRangeDimension_pending() {
setEnabled(true);
when(abilityScoreSource.findLatestValidCompletedResult(eq(STUDENT_ID), any()))
.thenReturn(proj(dimsJson(110, 65, 75, 70), 24));
CandidateScoringCalculator.DimensionResult r =
calculator.computeAbility(STUDENT_ID, NOW);
assertEquals("PENDING", r.status);
assertNull(r.score);
}
@Test void ability_onnlyOnceSourceQuery_noNplus1() {
setEnabled(true);
when(abilityScoreSource.findLatestValidCompletedResult(eq(STUDENT_ID), any()))
.thenReturn(proj(dimsJson(80, 65, 75, 70), 24));
calculator.computeAbility(STUDENT_ID, NOW);
verify(abilityScoreSource, times(1)).findLatestValidCompletedResult(eq(STUDENT_ID), any());
}
@Test void ability_upsertWritesMinimalTraceJson_withoutSensitiveWords() {
setEnabled(true);
when(abilityScoreSource.findLatestValidCompletedResult(eq(STUDENT_ID), any()))
.thenReturn(proj(dimsJson(80, 65, 75, 70), 24));
CandidateScoringCalculator.DimensionResult r =
calculator.computeAbility(STUDENT_ID, NOW);
calculator.upsertSnapshots(STUDENT_ID, List.of(r), "2026-08");
ArgumentCaptor<Object> paramCaptor = ArgumentCaptor.forClass(Object.class);
verify(queryMock).setParameter(eq(6), paramCaptor.capture());
String payload = (String) paramCaptor.getValue();
assertNotNull(payload);
assertTrue(payload.contains("\"source\":\"V36_ABILITY_ASSESSMENT\""));
assertTrue(payload.contains("\"sourceResultId\":123"));
assertTrue(payload.contains("\"mappingRulesVersion\":\"ability-assessment-scoring-v0.1\""));
assertTrue(payload.contains("\"weights\""));
assertTrue(payload.contains("\"dimensionScores\""));
for (String banned : List.of("answerKey", "questionCode", "competencyTag", "stem", "selectedOptionId",
"scoringRule", "awardedScore", "explanation", "phone", "email")) {
assertFalse(payload.toLowerCase().contains(banned.toLowerCase()), "must not contain " + banned);
}
// 同一次 ABILITY 写入使用 v0.2 规则版本
ArgumentCaptor<Object> versionCaptor = ArgumentCaptor.forClass(Object.class);
verify(queryMock).setParameter(eq(8), versionCaptor.capture());
assertEquals("candidate-scoring-v0.2", versionCaptor.getValue());
}
private void setEnabled(boolean enabled) {
ReflectionTestUtils.setField(calculator, "abilityAssessmentEnabled", enabled);
} }
// ── COMPLIANCE ──────────────────────────────────────────── // ── COMPLIANCE ────────────────────────────────────────────

79
mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoringEngineServiceTest.java

@ -1,5 +1,8 @@
package com.mydreamjob.candidatescore; package com.mydreamjob.candidatescore;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import com.mydreamjob.application.Application; import com.mydreamjob.application.Application;
import com.mydreamjob.application.ApplicationRepository; import com.mydreamjob.application.ApplicationRepository;
import com.mydreamjob.candidatescore.dto.CandidateScoringOverviewResponse; import com.mydreamjob.candidatescore.dto.CandidateScoringOverviewResponse;
@ -22,6 +25,7 @@ import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness; import org.mockito.quality.Strictness;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
@ -66,6 +70,7 @@ class CandidateScoringEngineServiceTest {
@BeforeEach @BeforeEach
void setUp() { void setUp() {
doNothing().when(calculator).upsertSnapshots(anyLong(), anyList(), anyString()); doNothing().when(calculator).upsertSnapshots(anyLong(), anyList(), anyString());
when(calculator.currentRulesVersion()).thenReturn("candidate-scoring-v0.1");
} }
// ── 综合评分卡 ────────────────────────────────────────────── // ── 综合评分卡 ──────────────────────────────────────────────
@ -104,6 +109,80 @@ class CandidateScoringEngineServiceTest {
assertNull(resp.getOverallScore()); assertNull(resp.getOverallScore());
} }
// ── V38:开关开启规则版本 / 学生单维四维白名单 / 企业脱敏 ─────
private static final ObjectMapper MAPPER = new ObjectMapper()
.registerModule(new JavaTimeModule())
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
@Test void getOverview_switchOn_rulesVersionV02() {
when(calculator.currentRulesVersion()).thenReturn("candidate-scoring-v0.2");
when(calculator.computeAll(STUDENT_ID)).thenReturn(sixComputable());
CandidateScoringOverviewResponse resp = service.getOverview(STUDENT_ID);
assertEquals("candidate-scoring-v0.2", resp.getRulesVersion());
}
@Test void getDimensionDetail_ability_hasFourWhitelistedSubScores() throws Exception {
when(calculator.computeAll(STUDENT_ID)).thenReturn(List.of(abilityDim("74.25")));
DimensionScoreDetailResponse detail = service.getDimensionDetail(STUDENT_ID, "ABILITY");
assertEquals("ABILITY", detail.getDimension());
assertEquals("OK", detail.getStatus());
assertEquals(new BigDecimal("74.25"), detail.getScore());
assertEquals(4, detail.getSubScores().size());
assertEquals(new BigDecimal("35"), detail.getSubScores().get(0).getWeight());
assertEquals("逻辑能力", detail.getSubScores().get(0).getName());
// 真实 JSON 序列化脱敏断言(V38 P1:不再只调 DTO.toString())
String json = MAPPER.writeValueAsString(detail);
// 学生 ABILITY 单维详情可含四维聚合分、权重、有效期
assertTrue(json.contains("逻辑能力"), "must expose 逻辑能力");
assertTrue(json.contains("执行能力"));
assertTrue(json.contains("学习能力"));
assertTrue(json.contains("沟通能力"));
assertTrue(json.contains("测评有效期"), "must expose validity period");
for (String banned : List.of("sourceResultId", "attemptId", "resultId", "answerKey", "questionCode",
"competencyTag", "selectedOptionId", "scoringRule", "stem")) {
assertFalse(json.toLowerCase().contains(banned.toLowerCase()), "student detail must not leak " + banned);
}
}
@Test void getCompanyScoring_abilityOnlyAggregate_noFourDimOrSourceLeak() throws Exception {
when(companyRepository.findByOwnerUserId(COMPANY_USER_ID))
.thenReturn(Optional.of(Company.builder().id(1L).ownerUserId(COMPANY_USER_ID).build()));
when(applicationRepository.findById(APP_ID))
.thenReturn(Optional.of(Application.builder().id(APP_ID).jobId(50L).studentUserId(STUDENT_ID).build()));
when(jobService.getJob(50L)).thenReturn(Job.builder().id(50L).companyId(1L).build());
when(calculator.computeAll(STUDENT_ID)).thenReturn(List.of(abilityDim("74.25"), dim("EDUCATION", "76.75", "OK")));
CompanyCandidateScoringResponse resp = service.getCompanyScoring(COMPANY_USER_ID, APP_ID);
assertEquals(2, resp.getDimensions().size());
String json = MAPPER.writeValueAsString(resp);
for (String banned : List.of("LOGIC", "COMMUNICATION", "EXECUTION", "LEARNING", "validUntil",
"sourceResultId", "attemptId", "resultId", "answerKey", "scoringRule", "questionCode",
"competencyTag", "selectedOptionId", "stem", "subScores", "weights", "dimensionScores")) {
assertFalse(json.contains(banned), "company response must not leak " + banned);
}
}
private CandidateScoringCalculator.DimensionResult abilityDim(String scoreStr) {
CandidateScoringCalculator.DimensionResult r = new CandidateScoringCalculator.DimensionResult("ABILITY");
r.status = "OK";
r.score = new BigDecimal(scoreStr);
r.subScores.add(sub("逻辑能力", "80.00", "35"));
r.subScores.add(sub("执行能力", "75.00", "30"));
r.subScores.add(sub("学习能力", "70.00", "20"));
r.subScores.add(sub("沟通能力", "65.00", "15"));
r.dataSources.add(DimensionScoreDetailResponse.DataSourceItem.builder()
.source("ability_results(最新有效)").time(LocalDateTime.now()).note("测评有效期").build());
r.reasons.add("映射规则版本 ability-assessment-scoring-v0.1");
return r;
}
private DimensionScoreDetailResponse.SubScore sub(String name, String score, String weight) {
return DimensionScoreDetailResponse.SubScore.builder()
.name(name).score(new BigDecimal(score)).weight(new BigDecimal(weight)).build();
}
// ── 单维度明细 ──────────────────────────────────────────── // ── 单维度明细 ────────────────────────────────────────────
@Test void getDimensionDetail_invalid_should400() { @Test void getDimensionDetail_invalid_should400() {

183
mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/V38AbilityRankingIntegrationTest.java

@ -0,0 +1,183 @@
package com.mydreamjob.candidatescore;
import com.mydreamjob.ability.AbilityAttempt;
import com.mydreamjob.ability.AbilityAttemptRepository;
import com.mydreamjob.ability.AbilityDefinition;
import com.mydreamjob.ability.AbilityDefinitionRepository;
import com.mydreamjob.ability.AbilityResult;
import com.mydreamjob.ability.AbilityResultRepository;
import com.mydreamjob.application.Application;
import com.mydreamjob.auth.User;
import com.mydreamjob.auth.UserRepository;
import com.mydreamjob.competition.CompetitionRankingCalculator;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
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.test.util.ReflectionTestUtils;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.atomic.AtomicLong;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
/**
* V38 → V32 排名回归(真实快照写路径 + 真实 CompetitionRankingCalculator)。
*
* <p>不伪造 ABILITY 快照:开关开启且存在最新有效 V36 结果时,先经
* {@link CandidateScoringEngineService#getOverview} 的真实候选评分写快照路径,
* 使 ABILITY 以既定权重公式计入综合分,再交排名组件计算 overallScore/rank/percentile。
* 开关关闭时 ABILITY 保持 PENDING,综合分与既有口径一致(无可计算维度 → 本人无分)。</p>
*
* <p>避免写 student_profiles 等易引跨类 FK 污染的关联表:基线用无资料、无活跃数据的
* 学生,OFF 时无可计算维度;peer 的快照在内存构造(仅作排名对照,非被测对象)。</p>
*/
@SpringBootTest
@ActiveProfiles("integration-test")
class V38AbilityRankingIntegrationTest {
private static final AtomicLong PHONE = new AtomicLong(8200);
private static final String DIMS_OK =
"[{\"code\":\"LOGIC\",\"score\":80,\"answeredCount\":6},"
+ "{\"code\":\"EXECUTION\",\"score\":75,\"answeredCount\":6},"
+ "{\"code\":\"LEARNING\",\"score\":70,\"answeredCount\":6},"
+ "{\"code\":\"COMMUNICATION\",\"score\":65,\"answeredCount\":6}]";
@Autowired private UserRepository userRepository;
@Autowired private AbilityDefinitionRepository definitionRepository;
@Autowired private AbilityAttemptRepository attemptRepository;
@Autowired private AbilityResultRepository resultRepository;
@Autowired private CandidateScoringEngineService engineService;
@Autowired private CandidateScoringCalculator calculator;
@Autowired private CompetitionRankingCalculator rankingCalculator;
@MockBean private RedisTemplate<String, Object> redisTemplate;
private final List<Long> createdUsers = new ArrayList<>();
private Long defId;
@BeforeEach
void setUp() {
when(redisTemplate.opsForValue()).thenReturn(mock(org.springframework.data.redis.core.ValueOperations.class));
when(redisTemplate.hasKey(any())).thenReturn(false);
AbilityDefinition def = definitionRepository.findAll().stream()
.filter(d -> "WORKPLACE_ABILITY".equals(d.getTypeCode()))
.findFirst().orElseThrow(() -> new IllegalStateException("WORKPLACE_ABILITY must be seeded"));
defId = def.getId();
}
@AfterEach
void tearDown() {
for (Long id : createdUsers) {
userRepository.deleteById(id);
}
createdUsers.clear();
ReflectionTestUtils.setField(calculator, "abilityAssessmentEnabled", false);
}
private Long createUser() {
User u = userRepository.save(User.builder()
.phone("20" + String.format("%08d", PHONE.incrementAndGet())).nickname("u")
.role(User.Role.STUDENT).roles(Set.of(User.Role.STUDENT)).active(true).verified(true).build());
createdUsers.add(u.getId());
return u.getId();
}
private void giveValidAbilityResult(Long userId) {
LocalDateTime now = LocalDateTime.now();
AbilityAttempt a = attemptRepository.save(AbilityAttempt.builder()
.studentUserId(userId).definitionId(defId).status("COMPLETED").answeredCount(24)
.questionSetVersion("ability-v0.1").completedAt(now.minusDays(1)).build());
resultRepository.save(AbilityResult.builder()
.attemptId(a.getId()).dimensionScoresJson(DIMS_OK).overallScore(new BigDecimal("70.00"))
.questionSetVersion("ability-v0.1").rulesVersion("ability-v0.1")
.validUntil(now.plusDays(6)).generatedAt(now).build());
}
private String currentMonth() {
return String.format("%04d-%02d", LocalDate.now().getYear(), LocalDate.now().getMonthValue());
}
private Application app(Long studentId, LocalDateTime createdAt) {
return Application.builder().jobId(99L).studentUserId(studentId)
.status("SUBMITTED").createdAt(createdAt).build();
}
private CandidateDimensionScore educationSnap(Long studentId, String score) {
return CandidateDimensionScore.builder().studentUserId(studentId)
.dimension("EDUCATION").score(new BigDecimal(score)).status("OK")
.period(currentMonth()).rulesVersion("candidate-scoring-v0.1")
.weight(new BigDecimal("12.50")).build();
}
private void setEnabled(boolean b) {
ReflectionTestUtils.setField(calculator, "abilityAssessmentEnabled", b);
}
/**
* me 为无资料/无活跃数据学生;peer 由内存快照给出 EDUCATION=70(仅作对照组)。
* 开关关闭:me 无可计算维度 → overall=null、rank=null、percentile=null(ABILITY=PENDING)。
* 开关开启且 me 有最新有效 V36 结果:经真实评分写快照路径 ABILITY=OK 74.25,
* me overall=74.25 → rank1 / percentile100。排名只因 ABILITY 按既定公式变化。
*/
@Test
void ranking_pendingVsAbilityOk_effectsOnlyByFormula() {
Long me = createUser();
Long peer = createUser();
String month = currentMonth();
LocalDateTime tMe = LocalDateTime.now().minusHours(2);
LocalDateTime tPeer = LocalDateTime.now().minusHours(1);
List<Application> cohort = List.of(app(me, tMe), app(peer, tPeer));
Map<Long, List<CandidateDimensionScore>> peerOnly =
Map.of(peer, List.of(educationSnap(peer, "70.00")));
// 关闭:ABILITY=PENDING,me 无可计算维度
setEnabled(false);
engineService.getOverview(me);
Map<Long, List<CandidateDimensionScore>> offSnaps =
new HashMap<>(rankingCalculator.loadLatestSnapshots(List.of(me), month));
offSnaps.putAll(peerOnly);
CompetitionRankingCalculator.CohortRanking off =
rankingCalculator.rankCohort(cohort, offSnaps, me);
assertNull(off.overallScore, "OFF: no computable dimension → overall null");
assertNull(off.rank, "OFF: unscored → rank null");
assertNull(off.percentile, "OFF: unscored → percentile null");
assertEquals("PENDING", off.meSnaps.stream()
.filter(s -> "ABILITY".equals(s.getDimension())).findFirst().get().getStatus());
// 开启:真实评分写快照 → ABILITY=OK 74.25 计入综合分
setEnabled(true);
giveValidAbilityResult(me);
engineService.getOverview(me);
Map<Long, List<CandidateDimensionScore>> onSnaps =
new HashMap<>(rankingCalculator.loadLatestSnapshots(List.of(me), month));
onSnaps.putAll(peerOnly);
CompetitionRankingCalculator.CohortRanking on =
rankingCalculator.rankCohort(cohort, onSnaps, me);
assertEquals(new BigDecimal("74.25"), on.overallScore, "ON: (76.75→nothing else) ABILITY alone=74.25");
assertEquals(1, on.rank, "me(74.25) > peer(70) → rank1");
assertEquals(new BigDecimal("100.00"), on.percentile, "strictly-less(peer)=1 over scored-1 → 100");
assertEquals("OK", on.meSnaps.stream()
.filter(s -> "ABILITY".equals(s.getDimension())).findFirst().get().getStatus());
assertNotEquals(on.overallScore, off.overallScore, "ABILITY 计入后综合分必须按既定公式变化");
assertNotEquals(on.rank, off.rank);
assertNotEquals(on.percentile, off.percentile);
}
}

2
mydreamjob-server/src/test/java/com/mydreamjob/careerprogress/CareerProgressServiceTest.java

@ -84,7 +84,7 @@ class CareerProgressServiceTest {
calculator = spy(new CompetitionRankingCalculator(dimensionScoreRepository, scoringCalculator)); calculator = spy(new CompetitionRankingCalculator(dimensionScoreRepository, scoringCalculator));
service = new CareerProgressService(applicationRepository, interviewRepository, service = new CareerProgressService(applicationRepository, interviewRepository,
interviewScoreRepository, offerRepository, notificationRepository, interviewScoreRepository, offerRepository, notificationRepository,
jobRepository, companyRepository, userRepository, calculator); jobRepository, companyRepository, userRepository, calculator, scoringCalculator);
when(scoringCalculator.overallScoreFromSnapshots(anyList())).thenAnswer(inv -> { when(scoringCalculator.overallScoreFromSnapshots(anyList())).thenAnswer(inv -> {
List<?> snaps = inv.getArgument(0); List<?> snaps = inv.getArgument(0);

2
mydreamjob-server/src/test/java/com/mydreamjob/competition/CompetitionRankingServiceTest.java

@ -5,6 +5,7 @@ import com.mydreamjob.application.ApplicationRepository;
import com.mydreamjob.auth.User; import com.mydreamjob.auth.User;
import com.mydreamjob.auth.UserRepository; import com.mydreamjob.auth.UserRepository;
import com.mydreamjob.candidatescore.CandidateDimensionScore; import com.mydreamjob.candidatescore.CandidateDimensionScore;
import com.mydreamjob.candidatescore.CandidateScoringCalculator;
import com.mydreamjob.common.exception.BusinessException; import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode; import com.mydreamjob.common.exception.ErrorCode;
import com.mydreamjob.competition.dto.CompetitionRankingResponse; import com.mydreamjob.competition.dto.CompetitionRankingResponse;
@ -48,6 +49,7 @@ class CompetitionRankingServiceTest {
@Mock private JobService jobService; @Mock private JobService jobService;
@Mock private ApplicationRepository applicationRepository; @Mock private ApplicationRepository applicationRepository;
@Mock private CompetitionRankingCalculator rankingCalculator; @Mock private CompetitionRankingCalculator rankingCalculator;
@Mock private CandidateScoringCalculator candidateScoringCalculator;
@InjectMocks private CompetitionRankingService service; @InjectMocks private CompetitionRankingService service;

Loading…
Cancel
Save