diff --git a/docs/backend-development-roadmap.md b/docs/backend-development-roadmap.md index 824e790..03bac59 100644 --- a/docs/backend-development-roadmap.md +++ b/docs/backend-development-roadmap.md @@ -28,16 +28,16 @@ |---|---| | OpenAPI | **148 paths、182 operations** | | Flyway | **V1–V36 已应用**;**V34 为 RIASEC 测评迁移(功能版本 V35)**;**V35 为四维能力测评迁移(功能版本 V36)**;**V36 为防刷风险概览与规则配置迁移(功能版本 V37)** | -| 全量测试 | **1389/1389 通过**(`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、看板等核心闭环 | -| 当前结论 | 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,风险扫描不写候选评分引擎。 | +| 全量测试 | **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)**、**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` 审计只作历史观察项,不回填、不影响修复后验收。**V38 已完成代码、自动化与真实 MySQL/Docker 关键黑盒验收:开关关闭/开启、ABILITY 映射、最新结果优先、过期回退、V32 排名、权限、脱敏、快照与队列均通过,未发现 FAIL;当前仅待建立 Git 检查点。** 运行时/静态 OpenAPI 均为 148/182,风险扫描不写候选评分引擎。 | | 本规划范围 | 代码修复、新功能开发、接口契约、数据权限和治理能力 | ### 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 复验 | — | | 已设计未编码 | 公平性检测、SHAP/规则解释、企业评价管理、企业人才推荐 | | 未开始 | AI 简历诊断/优化、AI 模拟面试、行业技能词云、入职准备清单、薪资计算器、诚信积分、消息撤回 | @@ -142,7 +142,7 @@ - SHAP/规则解释; - ~~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. 已有代码的优先修复项 @@ -341,7 +341,7 @@ jobs 1. ~~霍兰德职业兴趣测评~~(V35 已完成); 2. ~~四维能力测评、答题和进度保存~~(V36 已完成); 3. ~~测评结果~~(V35/V36 已完成); -4. 测评结果进入 ABILITY/GROWTH(需独立评审,当前未接入评分引擎); +4. 测评结果进入 ABILITY/GROWTH(V38 已完成 ABILITY 有限接入;GROWTH 仍需独立评审与后续实现); 5. 根据测评结果推荐岗位。 建议接口: diff --git a/docs/candidate-scoring-api.md b/docs/candidate-scoring-api.md index b019ead..ae92e15 100644 --- a/docs/candidate-scoring-api.md +++ b/docs/candidate-scoring-api.md @@ -1,6 +1,7 @@ # 求职者评分体系 API > 求职者评分体系(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 ` ## 概述 @@ -19,7 +20,7 @@ | `OK` | 输入齐备,按规则确定性计算 | | `ESTIMATED` | 部分输入缺失,使用兜底值并标注估算(如学校未知、偏好字段部分填写) | | `INSUFFICIENT_DATA` | 关键输入缺失,不计算(score=null) | -| `PENDING` | 数据源未就绪(INTERNSHIP 无实习表、ABILITY 无测评),score=null | +| `PENDING` | 数据源未就绪(INTERNSHIP 无实习表;ABILITY 无测评、仅有过期/结构异常结果或开关关闭),score=null | 综合卡 `status`:全部 8 维可计算→`OK`;部分可计算→`PARTIAL`;无可计算且存在 PENDING→`PENDING`;否则→`INSUFFICIENT_DATA`。 @@ -33,13 +34,15 @@ 返回单维度明细:`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 本人) 返回快照月度趋势(`months` 1–24,越界 400/1001)。每个 `period` 仅取该期 `OK/ESTIMATED` 维度重新归一化计算 `overallScore`,并携带该期 `rulesVersion`。 ### 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) | | 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 不计分) | | STABILITY | users.createdAt(账号年龄)、applications(投递/撤回率)、audit_logs(近 30 天 LOGIN) | | GROWTH | resume_versions(ACTIVE 数量/最近更新)、student_profiles.skillsJson(当前值,无历史) | diff --git a/docs/frontend-integration.md b/docs/frontend-integration.md index fe61687..dc1f5b9 100644 --- a/docs/frontend-integration.md +++ b/docs/frontend-integration.md @@ -186,7 +186,9 @@ Authorization: Bearer ## 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 接口 diff --git a/docs/v38-ability-score-integration-design.md b/docs/v38-ability-score-integration-design.md new file mode 100644 index 0000000..9e0160a --- /dev/null +++ b/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 编码的附带改动。 diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/AbilityAssessmentScoreSource.java b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/AbilityAssessmentScoreSource.java new file mode 100644 index 0000000..62fcccb --- /dev/null +++ b/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 维度仅读投影源。 + * + *

一次查询取某学生最新、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},最多一条;不比较分数(不取历史最高分)。

+ * + *

不暴露实体给 Controller,仅作为 CandidateScoringCalculator 的只读数据源。

+ */ +@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 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) { + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/AbilityScoreMapper.java b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/AbilityScoreMapper.java new file mode 100644 index 0000000..088b058 --- /dev/null +++ b/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 维度的纯函数映射器(无数据库访问)。 + * + *

仅接受恰好包含 LOGIC / COMMUNICATION / EXECUTION / LEARNING 四维、每维错题数均为 6、 + * 总错题数为 24、且分数均在 [0,100](有限数值)的完整结果;任何结构异常都返回 + * {@link Optional#empty()},由调用方映射为 {@code PENDING},绝不抛给前端 500。

+ * + *

公式(V38 固定业务权重,不复用 V36 等权 overallScore):
+ * abilityScore = LOGIC×0.35 + EXECUTION×0.30 + LEARNING×0.20 + COMMUNICATION×0.15, + * 最终 scale=2、HALF_UP。

+ */ +@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>> ENTRIES_LIST = + new TypeReference<>() { + }; + private static final Set EXPECTED_DIMENSIONS = + Set.of("LOGIC", "COMMUNICATION", "EXECUTION", "LEARNING"); + + /** + * 计算结果。{@code dimensionScores} 与 {@code weights} 均使用固定有序键, + * 便于调用方构造白名单 subScores 与最小溯源 JSON。 + */ + public record MappedScores(BigDecimal abilityScore, + Map dimensionScores, + Map weights) { + } + + /** + * 安全解析单个 V36 结果。结构异常一律返回空。 + * + * @param dimensionScoresJson ability_results.dimension_scores_json(数组:{code,score,answeredCount}) + * @param attemptAnsweredCount ability_attempts.answered_count(可为 null) + */ + public Optional map(String dimensionScoresJson, Integer attemptAnsweredCount) { + if (dimensionScoresJson == null || dimensionScoresJson.isBlank()) { + return Optional.empty(); + } + final List> 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 scores = new LinkedHashMap<>(); + Set seen = new java.util.HashSet<>(); + int sumAnswered = 0; + for (LinkedHashMap 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 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 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 + } + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/CandidateScoringCalculator.java b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/CandidateScoringCalculator.java index fc41729..4481db7 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/CandidateScoringCalculator.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/CandidateScoringCalculator.java @@ -16,6 +16,7 @@ import jakarta.persistence.PersistenceContext; import jakarta.persistence.Query; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Component; @@ -97,6 +98,11 @@ public class CandidateScoringCalculator { private final StudentPreferenceRepository preferenceRepository; private final UserRepository userRepository; private final JdbcTemplate jdbcTemplate; + private final AbilityAssessmentScoreSource abilityScoreSource; + private final AbilityScoreMapper abilityScoreMapper; + + @Value("${scoring.ability-assessment.enabled:false}") + private boolean abilityAssessmentEnabled; @PersistenceContext private EntityManager entityManager; @@ -114,10 +120,13 @@ public class CandidateScoringCalculator { ResumeAgg resume = queryResume(studentUserId); long msgCount = queryMessages(studentUserId); + // 一次评分计算的基准时间只生成一次并全程复用,避免临界过期时出现不一致 + LocalDateTime capturedNow = LocalDateTime.now(); + List list = new ArrayList<>(); list.add(computeEducation(profile)); list.add(computeInternship()); - list.add(computeAbility()); + list.add(computeAbility(studentUserId, capturedNow)); list.add(computeCompliance(pref)); list.add(computeStability(user != null ? user.getCreatedAt() : null, (int) apps.total, (int) apps.withdrawn, (int) audit.login30d)); @@ -241,14 +250,58 @@ public class CandidateScoringCalculator { return r; } - public DimensionResult computeAbility() { + /** + * ABILITY 维度:V38 有限接入 V36 四维能力测评(WORKPLACE_ABILITY)。 + *

开关关闭时严格保持 V37 前行为(PENDING、不查询 V36 来源)。 + * 开关开启且存在最新有效完整结果时,按 V38 固定权重映射;无有效结果或结构异常一律 + * PENDING 且 score=null,绝不对前端抛 500。

+ */ + public DimensionResult computeAbility(Long studentUserId, LocalDateTime capturedNow) { DimensionResult r = new DimensionResult("ABILITY"); - r.status = "PENDING"; - r.summary = "能力测评数据源未就绪"; - r.reasons.add("能力测评(F1.2)尚未实现"); + if (!abilityAssessmentEnabled) { + r.status = "PENDING"; + r.summary = "能力测评数据源未就绪"; + r.reasons.add("能力测评(F1.2)尚未实现"); + 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) { DimensionResult r = new DimensionResult("COMPLIANCE"); if (pref == null) { @@ -388,9 +441,9 @@ public class CandidateScoringCalculator { params.add(period); params.add(r.score); params.add(r.status); - params.add(toJson(r.subScores)); + params.add(subScoresPayload(r)); params.add(DEFAULT_WEIGHT); - params.add(RULES_VERSION); + params.add(currentRulesVersion()); } sql.append(" ON DUPLICATE KEY UPDATE score=VALUES(score), status=VALUES(status), " + "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 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) { return v.max(ZERO).min(HUNDRED).setScale(2, RoundingMode.HALF_UP); } @@ -471,12 +551,24 @@ public class CandidateScoringCalculator { public List reasons = new ArrayList<>(); public List dataSources = new ArrayList<>(); public String summary; + /** 仅 ABILITY(OK) 时携带的最小溯源信息,用于写出 sub_scores_json;其他维度为 null。 */ + public AbilitySnapshotTrace abilitySnapshotTrace; public DimensionResult(String dimension) { this.dimension = dimension; } } + /** V38:ABLE 快照最小溯源元数据(仅持久化到内部 sub_scores_json,绝不对外返回)。 */ + public record AbilitySnapshotTrace(Long resultId, + LocalDateTime completedAt, + LocalDateTime validUntil, + String questionSetVersion, + String assessmentRulesVersion, + Map weights, + Map dimensionScores) { + } + public static class AppAgg { public long total; public long withdrawn; public LocalDateTime earliest; public long recent90d; public AppAgg(long total, long withdrawn, LocalDateTime earliest, long recent90d) { diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/CandidateScoringEngineService.java b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/CandidateScoringEngineService.java index 23c58a4..4d540ca 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/CandidateScoringEngineService.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/CandidateScoringEngineService.java @@ -77,7 +77,7 @@ public class CandidateScoringEngineService { Map dims = new LinkedHashMap<>(); List computable = new ArrayList<>(); 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()) { if ("OK".equals(s.getStatus()) || "ESTIMATED".equals(s.getStatus())) { dims.put(s.getDimension(), s.getScore()); @@ -119,7 +119,7 @@ public class CandidateScoringEngineService { return CompanyCandidateScoringResponse.builder() .applicationId(applicationId).overallScore(overall).status(status) .dimensions(dims).generatedAt(Instant.now()) - .rulesVersion(CandidateScoringCalculator.RULES_VERSION).build(); + .rulesVersion(calculator.currentRulesVersion()).build(); } // ── 组装 ──────────────────────────────────────────────────── @@ -140,7 +140,7 @@ public class CandidateScoringEngineService { } return CandidateScoringOverviewResponse.builder() .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(); } @@ -148,7 +148,7 @@ public class CandidateScoringEngineService { return DimensionScoreDetailResponse.builder() .dimension(r.dimension).score(r.score).status(r.status) .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 results) { diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/governance/CandidateScoringGovernanceService.java b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/governance/CandidateScoringGovernanceService.java index d05159e..4dde419 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/governance/CandidateScoringGovernanceService.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/governance/CandidateScoringGovernanceService.java @@ -117,7 +117,7 @@ public class CandidateScoringGovernanceService { CandidateScoringAppeal appeal = CandidateScoringAppeal.builder() .candidateUserId(studentUserId).dimension(req.getDimension()).period(req.getPeriod()) .status(CandidateScoringAppeal.STATUS_PENDING).reason(req.getReason().trim()) - .rulesVersion(CandidateScoringCalculator.RULES_VERSION) + .rulesVersion(calculator.currentRulesVersion()) .idempotencyKey(req.getIdempotencyKey()) .activeKey(activeKey) .scoreAtAppeal(snap != null ? snap.getScore() : null) @@ -294,7 +294,7 @@ public class CandidateScoringGovernanceService { .beforeVersion(before != null ? before.getVersion() : null) .afterVersion(afterVersion) .beforeRulesVersion(before != null ? before.getRulesVersion() : null) - .afterRulesVersion(CandidateScoringCalculator.RULES_VERSION) + .afterRulesVersion(calculator.currentRulesVersion()) .build()); String from = appeal.getStatus(); diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/careerprogress/CareerProgressService.java b/mydreamjob-server/src/main/java/com/mydreamjob/careerprogress/CareerProgressService.java index 7f1a649..9f72571 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/careerprogress/CareerProgressService.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/careerprogress/CareerProgressService.java @@ -75,6 +75,7 @@ public class CareerProgressService { private final CompanyRepository companyRepository; private final UserRepository userRepository; private final CompetitionRankingCalculator rankingCalculator; + private final CandidateScoringCalculator candidateScoringCalculator; @Transactional(readOnly = true) public CareerProgressResponse getCareerProgress(Long studentUserId, int page, int size, String status) { @@ -303,7 +304,7 @@ public class CareerProgressService { .percentile(cr.percentile) .status(cr.status) .mySnapshotPeriod(cr.mySnapshotPeriod) - .rulesVersion(CandidateScoringCalculator.RULES_VERSION) + .rulesVersion(candidateScoringCalculator.currentRulesVersion()) .build(); } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/competition/CompetitionRankingService.java b/mydreamjob-server/src/main/java/com/mydreamjob/competition/CompetitionRankingService.java index 8bb281d..db7c903 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/competition/CompetitionRankingService.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/competition/CompetitionRankingService.java @@ -43,6 +43,7 @@ public class CompetitionRankingService { private final JobService jobService; private final ApplicationRepository applicationRepository; private final CompetitionRankingCalculator rankingCalculator; + private final CandidateScoringCalculator candidateScoringCalculator; @Transactional(readOnly = true) public CompetitionRankingResponse getRanking(Long studentUserId, Long jobId) { @@ -105,7 +106,7 @@ public class CompetitionRankingService { .dimensions(buildDimensions(cr.meSnaps)) .gaps(buildGaps(cr.meSnaps)) .topCandidates(top) - .rulesVersion(CandidateScoringCalculator.RULES_VERSION) + .rulesVersion(candidateScoringCalculator.currentRulesVersion()) .generatedAt(Instant.now()) .build(); } diff --git a/mydreamjob-server/src/main/resources/application.yml b/mydreamjob-server/src/main/resources/application.yml index 213f600..fa944d2 100644 --- a/mydreamjob-server/src/main/resources/application.yml +++ b/mydreamjob-server/src/main/resources/application.yml @@ -133,6 +133,10 @@ app: cooling-days: ${DELETION_COOLING_DAYS:30} phone-hash-secret: ${DELETION_PHONE_HASH_SECRET:mydreamjob-deletion-dev-secret-not-for-prod} +scoring: + ability-assessment: + enabled: ${SCORING_ABILITY_ASSESSMENT_ENABLED:false} + management: endpoints: web: diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/AbilityAssessmentScoreSourceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/AbilityAssessmentScoreSourceTest.java new file mode 100644 index 0000000..f613652 --- /dev/null +++ b/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)); + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/AbilityScoreIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/AbilityScoreIntegrationTest.java new file mode 100644 index 0000000..6508735 --- /dev/null +++ b/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 集成测试。 + *

覆盖来源选择(最新有效 COMPLETED/WORKPLACE_ABILITY/过期排除)、V36 submit 不写快照、 + * 评分触发才写 ABILITY 快照、结果过期后 PENDING。企业脱敏与版本切换由 EngineService 单测覆盖。

+ */ +@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 redisTemplate; + + private final List 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(); + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/AbilityScoreMapperTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/AbilityScoreMapperTest.java new file mode 100644 index 0000000..8dfd522 --- /dev/null +++ b/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 纯函数测试。 + *

覆盖固定权重公式与 HALF_UP、结构防御(缺维/重复维/越界/错题数非6/总数非24/非法JSON), + * 以及不依赖答案键或题目级数据。

+ */ +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 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 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 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 m = mapper.map(json, 24); + assertTrue(m.isPresent()); + assertEquals(new BigDecimal("74.25"), m.get().abilityScore()); + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoringCalculatorTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoringCalculatorTest.java index 0bf6ea4..5750316 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoringCalculatorTest.java +++ b/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.InjectMocks; import org.mockito.Mock; +import org.mockito.Spy; import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoSettings; import org.mockito.quality.Strictness; @@ -24,7 +25,9 @@ import org.springframework.test.util.ReflectionTestUtils; import java.math.BigDecimal; import java.time.LocalDateTime; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.Optional; import static org.junit.jupiter.api.Assertions.*; @@ -40,6 +43,8 @@ class CandidateScoringCalculatorTest { @Mock private UserRepository userRepository; @Mock private JdbcTemplate jdbcTemplate; @Mock private EntityManager entityManager; + @Mock private AbilityAssessmentScoreSource abilityScoreSource; + @Spy private AbilityScoreMapper abilityScoreMapper = new AbilityScoreMapper(); @InjectMocks private CandidateScoringCalculator calculator; private static final Long STUDENT_ID = 2L; @@ -121,7 +126,121 @@ class CandidateScoringCalculatorTest { @Test void internshipAndAbility_shouldBePending() { 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 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 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 ──────────────────────────────────────────── diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoringEngineServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoringEngineServiceTest.java index ea03ff1..89fb36c 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoringEngineServiceTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoringEngineServiceTest.java @@ -1,5 +1,8 @@ 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.ApplicationRepository; import com.mydreamjob.candidatescore.dto.CandidateScoringOverviewResponse; @@ -22,6 +25,7 @@ import org.mockito.junit.jupiter.MockitoSettings; import org.mockito.quality.Strictness; import java.math.BigDecimal; +import java.time.LocalDateTime; import java.util.List; import java.util.Optional; @@ -66,6 +70,7 @@ class CandidateScoringEngineServiceTest { @BeforeEach void setUp() { doNothing().when(calculator).upsertSnapshots(anyLong(), anyList(), anyString()); + when(calculator.currentRulesVersion()).thenReturn("candidate-scoring-v0.1"); } // ── 综合评分卡 ────────────────────────────────────────────── @@ -104,6 +109,80 @@ class CandidateScoringEngineServiceTest { 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() { diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/V38AbilityRankingIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/V38AbilityRankingIntegrationTest.java new file mode 100644 index 0000000..e68c278 --- /dev/null +++ b/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)。 + * + *

不伪造 ABILITY 快照:开关开启且存在最新有效 V36 结果时,先经 + * {@link CandidateScoringEngineService#getOverview} 的真实候选评分写快照路径, + * 使 ABILITY 以既定权重公式计入综合分,再交排名组件计算 overallScore/rank/percentile。 + * 开关关闭时 ABILITY 保持 PENDING,综合分与既有口径一致(无可计算维度 → 本人无分)。

+ * + *

避免写 student_profiles 等易引跨类 FK 污染的关联表:基线用无资料、无活跃数据的 + * 学生,OFF 时无可计算维度;peer 的快照在内存构造(仅作排名对照,非被测对象)。

+ */ +@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 redisTemplate; + + private final List 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 cohort = List.of(app(me, tMe), app(peer, tPeer)); + Map> peerOnly = + Map.of(peer, List.of(educationSnap(peer, "70.00"))); + + // 关闭:ABILITY=PENDING,me 无可计算维度 + setEnabled(false); + engineService.getOverview(me); + Map> 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> 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); + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/careerprogress/CareerProgressServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/careerprogress/CareerProgressServiceTest.java index 5443ba8..8a75b69 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/careerprogress/CareerProgressServiceTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/careerprogress/CareerProgressServiceTest.java @@ -84,7 +84,7 @@ class CareerProgressServiceTest { calculator = spy(new CompetitionRankingCalculator(dimensionScoreRepository, scoringCalculator)); service = new CareerProgressService(applicationRepository, interviewRepository, interviewScoreRepository, offerRepository, notificationRepository, - jobRepository, companyRepository, userRepository, calculator); + jobRepository, companyRepository, userRepository, calculator, scoringCalculator); when(scoringCalculator.overallScoreFromSnapshots(anyList())).thenAnswer(inv -> { List snaps = inv.getArgument(0); diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/competition/CompetitionRankingServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/competition/CompetitionRankingServiceTest.java index ccfa0e9..bdbe060 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/competition/CompetitionRankingServiceTest.java +++ b/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.UserRepository; import com.mydreamjob.candidatescore.CandidateDimensionScore; +import com.mydreamjob.candidatescore.CandidateScoringCalculator; import com.mydreamjob.common.exception.BusinessException; import com.mydreamjob.common.exception.ErrorCode; import com.mydreamjob.competition.dto.CompetitionRankingResponse; @@ -48,6 +49,7 @@ class CompetitionRankingServiceTest { @Mock private JobService jobService; @Mock private ApplicationRepository applicationRepository; @Mock private CompetitionRankingCalculator rankingCalculator; + @Mock private CandidateScoringCalculator candidateScoringCalculator; @InjectMocks private CompetitionRankingService service;