diff --git a/docs/backend-development-roadmap.md b/docs/backend-development-roadmap.md index 7e16027..0c3cccf 100644 --- a/docs/backend-development-roadmap.md +++ b/docs/backend-development-roadmap.md @@ -9,7 +9,7 @@ | 核心招聘与账户能力 | 已有代码与历史验收记录;本轮未重跑验收 | 按清单C01–C08做一期集成回归 | | V39–V42 | 已核对提交55e3975、2698d06、5cc2c86、96b3113;不再标为待Git检查点 | 保留回归,不重复开发企业回复/发现/邀约 | | V43 | 独立代码复验与真实 MySQL/Docker 验收通过,待 Git 检查点 | N01 已关闭版本内小样本补集泄露;30/300/3000 查询预算通过,EXPLAIN 扩容风险保留观察 | -| 评分规则解释与合理性 | 已有部分reasons与行为计分,统一解释及规则证据待完善 | N02,一期必做 | +| 评分规则解释与合理性 | N02 独立代码复验与真实 MySQL/Docker 黑盒验收通过,待 Git 检查点 | 保持分数/权重/排名不变;下一步仅做精确暂存与提交审查 | | 其余六个功能组 | 技能词云、入职清单/公司知识、薪资计算器/参考、诚信积分/激励、消息撤回、简历查重 | N03–N08全部一期必做,细节设计后编码 | | 上线保障 | 现有资产待逐项核验 | G01–G04,一期交付门槛 | | 后续候选池 | AI简历、AI模拟面试/预测、SHAP高级解释等 | 以清单第6节为准,移出一期 | diff --git a/docs/candidate-scoring-api.md b/docs/candidate-scoring-api.md index ae92e15..8003c2a 100644 --- a/docs/candidate-scoring-api.md +++ b/docs/candidate-scoring-api.md @@ -28,13 +28,13 @@ ### 1. GET `/profile/me/candidate-scoring`(仅 STUDENT 本人) -返回综合评分卡:`candidateId`、`status`、`overallScore`、`weights`、`dimensions[]`、`rulesVersion`、`generatedAt`。 +返回综合评分卡:`candidateId`、`status`、`overallScore`、`weights`、`dimensions[]`、`rulesVersion`、`generatedAt`,以及解释版本 `explanationVersion`、参与覆盖 `coverage` 和固定可比性提示 `comparabilityNotice`。`coverage.computableDimensions` 与实际参与 overallScore 的维度严格一致,`excludedDimensions` 列出未参与原因。 ### 2. GET `/profile/me/candidate-scoring/dimensions/{dimension}`(仅 STUDENT 本人) -返回单维度明细:`dimension`、`score`、`status`、`subScores[]`、`reasons[]`、`dataSources[]`、`updatedAt`、`rulesVersion`。非法 `dimension` → 400/1001。 +返回单维度明细:原有 `dimension`、`score`、`status`、`subScores[]`、`reasons[]`、`dataSources[]`、`updatedAt`、`rulesVersion`,以及 `displayName`、`whatItMeasures`、`interpretation`、`limitations[]`、`evidenceLevel`、`ruleContributions[]`、`improvementActions[]`、`explanationVersion`、`validity`(仅 ABILITY 有明确有效期)和 `calculatedAt`。非法 `dimension` → 400/1001。 -> 学生单维明细(仅 STUDENT 本人)。V38 起 `dimensions[n]=ABILITY` 时,`subScores[]` 为四条白名单聚合分(逻辑能力/35、执行能力/30、学习能力/20、沟通能力/15),`dataSources[]/reasons[]` 仅表述“最新有效四维能力测评”“完成时间”“有效期”“映射规则版本”;**不返回** `sourceResultId`、答题/答卷 ID、题目、答案、答案键、选项分值、`competencyTag`、完整内部 `sub_scores_json`。其他维度响应与既有保持兼容。非法 `dimension` → 400/1001。 +> 学生单维明细(仅 STUDENT 本人)。解释版本固定为 `candidate-scoring-explanation-v1`,与评分 `rulesVersion` 分离。`ruleContributions` 由真实计分分支产生,贡献之和可与最终维度分数对账;`SCORE_CAP_ADJUSTMENT` 用于说明 0–100 截断,`SCORE_ROUNDING_ADJUSTMENT` 仅记录逐项展示舍入与最终两位小数的对账差,不改变评分规则。`dataSources` 仅使用“个人教育资料”“求职偏好”“平台求职活动”“简历与技能资料”“Offer 履约与候选反馈”“四维能力测评”等产品语义名称。**不返回** `sourceResultId`、答题/答卷 ID、题目、答案、答案键、选项分值、`competencyTag`、数据库表名、完整内部 `sub_scores_json`。其他维度响应与既有保持兼容。非法 `dimension` → 400/1001。 ### 3. GET `/profile/me/candidate-scoring/trend?months=6`(仅 STUDENT 本人) @@ -79,3 +79,11 @@ ## 错误码 复用:`BAD_REQUEST(1001)`、`UNAUTHORIZED(2001)`、`FORBIDDEN(2002)`、`APPLICATION_NOT_FOUND(8002)`、`APPLICATION_OWNER_MISMATCH(8004)`。不新增错误码。 + +## N02 解释与合理性边界 + +- 本版只增强学生可见解释,不改变八维公式、12.50 默认权重、参与维度、`candidate-scoring-v0.1/v0.2` 切换、V32 排名或快照写入语义。 +- `INTERNSHIP` 未接入时保持 `PENDING`,不显示为 0 分;缺失维度仍退出综合分分母,综合卡通过 `coverage` 和 `comparabilityNotice` 明确提示不同参与集合不可直接横向比较。 +- 规则解释为固定白名单模板,不接入 AI/LLM/SHAP,不把教育、平台行为、履约或活跃信号解释为人格、信用、确定性能力或录用结论。 +- 企业评分接口 schema 保持不变,只返回既有聚合字段;解释、覆盖率、限制、建议、规则贡献和 ABILITY 有效期仅对学生本人可见。 +- `Clock` 在每次评分请求入口捕获一次 `capturedNow`,并用于时间窗口、ABILITY 有效期、`calculatedAt` 与快照时间;真实 MySQL/Docker 验收仍为本版发布前置条件。 diff --git a/docs/candidate-scoring-explanation-design.md b/docs/candidate-scoring-explanation-design.md new file mode 100644 index 0000000..12fd715 --- /dev/null +++ b/docs/candidate-scoring-explanation-design.md @@ -0,0 +1,302 @@ +# N02 候选评分规则解释与合理性:详细设计 + +状态:N02 独立代码复验与真实 MySQL/Docker 黑盒验收通过,待 Git 检查点。 + +文档类型:解释。目标读者为产品负责人、后端开发、独立验收人员和前端联调人员。本设计说明现有候选评分实际上计算了什么、一期应如何向学生解释,以及哪些结论不得从这些分数推出。 + +## 1. 背景与目标 + +当前候选评分由 `CandidateScoringCalculator` 计算八个维度,并由 `CandidateScoringEngineService` 写入或读取 `candidate_dimension_scores`。学生单维接口已有 `subScores`、`reasons` 和 `dataSources`,但这些信息由各计算方法临时拼装,缺少稳定的规则编码、解释版本、适用范围和限制说明。 + +N02 的一期目标是让学生能够回答以下问题: + +1. 这个分数使用了哪些数据; +2. 哪些确定性规则实际命中; +3. 每条规则对当前结果产生了什么影响; +4. 哪些数据缺失、过期或仍未接入; +5. 用户可以通过哪些真实、合理的方式完善资料; +6. 该分数不能代表什么。 + +N02 同时要求企业端继续只查看聚合分与状态,不获得学生的私人明细、行为次数、测评细分或改进建议。 + +## 2. 非目标 + +一期不做: + +- SHAP、机器学习特征贡献或 AI 生成解释; +- 人口属性公平性推断; +- 宣称规则已具备心理测量效度或预测效度; +- 自动修改评分权重、排名或推荐逻辑; +- 新增评分事实表、解释历史表或规则配置后台; +- 向企业暴露学生的细分评分证据; +- 将行为频次解释为人格、诚信、成长潜力或职业能力; +- 用解释功能引导用户刷投递、刷消息、刷简历版本。 + +## 3. 当前真实实现 + +### 3.1 代码入口 + +- 计算:`mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/CandidateScoringCalculator.java` +- 服务:`CandidateScoringEngineService.java` +- 学生与企业接口:`CandidateScoringController.java` +- 快照:`CandidateDimensionScore.java`、`CandidateDimensionScoreRepository.java` +- 能力映射:`AbilityAssessmentScoreSource.java`、`AbilityScoreMapper.java` +- 申诉与回滚:`candidatescore/governance/` +- 排名:`competition/CompetitionRankingCalculator.java` + +### 3.2 综合分 + +当前八维默认展示权重均为 12.50。只有状态为 `OK` 或 `ESTIMATED` 且分数非空的维度参与综合分: + +```text +overallScore = sum(可计算维度分数) / 可计算维度数量 +``` + +最终保留两位小数,`HALF_UP`。`PENDING` 和 `INSUFFICIENT_DATA` 被排除后重新归一化,因此两个相同综合分可能来自不同的维度集合。解释必须展示参与维度数量与排除维度,禁止把综合分描述为完整、统一口径的能力总分。 + +### 3.3 规则版本 + +- 能力测评开关关闭:`candidate-scoring-v0.1`; +- 能力测评开关开启:`candidate-scoring-v0.2`; +- ABILITY 映射规则:`ability-assessment-scoring-v0.1`; +- 历史趋势优先使用快照自身 `rulesVersion`。 + +N02 只增加解释契约时不升级候选评分规则版本,建议新增独立的 `explanationVersion=candidate-scoring-explanation-v1`。只有公式、阈值、参与维度或排名输入发生变化时才升级评分规则版本。 + +## 4. 设计原则 + +### 4.1 解释必须与计算同源 + +每个计分分支在计算贡献值的同时生成内部 `RuleContribution`,不能在 Controller、DTO mapper 或另一套公式中根据最终分数反推解释。 + +建议内部结构: + +```text +RuleContribution +- ruleCode 稳定规则编码 +- outcome APPLIED / NOT_APPLIED / MISSING / EXPIRED / UNAVAILABLE +- contribution 实际加减分;不适用时为 null +- maxContribution 该规则可能贡献的绝对上限 +- publicLabel 面向学生的中性名称 +- publicObservation 安全的区间或状态,不输出内部数据库字段 +- explanationKey 固定解释模板编码 +``` + +规则元数据由 `CandidateScoringExplanationCatalog` 按 `ruleCode` 提供名称、限制、建议和证据等级。计算器产生“事实和贡献”,目录提供“稳定语义”,Assembler 只组合,不重复计算。 + +当逐项贡献按两位小数展示而整体分数采用 HALF_UP 产生舍入差时,使用 +`SCORE_ROUNDING_ADJUSTMENT` 显式记录对账差;它只解释展示精度,不改变任何评分公式。 + +### 4.2 不把解释做成刷分指南 + +学生可以看到区间和缺失项,例如“近 90 天存在投递记录”“求职偏好尚未完整”,但默认不暴露风控阈值组合、审计动作名称、原始消息数量或内部 SQL 字段。已经在界面契约中公开的固定能力权重可继续展示。 + +### 4.3 区分事实、推断与限制 + +每个维度必须有: + +- `whatItMeasures`:规则直接使用的数据; +- `interpretation`:可谨慎解释的含义; +- `limitations`:不能据此得出的结论; +- `evidenceLevel`:`DIRECT_RULE`、`SELF_REPORTED`、`BEHAVIOR_PROXY`、`UNVALIDATED_PROXY`; +- `improvementActions`:只给可合理完成的操作,不鼓励刷行为。 + +## 5. 一期响应方案 + +### 5.1 路径范围 + +保持现有 4 个路径,不新增 operation: + +- `GET /profile/me/candidate-scoring` +- `GET /profile/me/candidate-scoring/dimensions/{dimension}` +- `GET /profile/me/candidate-scoring/trend` +- `GET /company/candidates/{applicationId}/scoring` + +OpenAPI 路径/操作数保持当前基线 172/210。 + +### 5.2 学生综合卡兼容扩展 + +建议新增: + +- `explanationVersion`; +- `coverage.computableDimensions`; +- `coverage.totalDimensions=8`; +- `coverage.includedDimensions`; +- `coverage.excludedDimensions[]{dimension,status,reason}`; +- `comparabilityNotice`,固定说明不同参与维度集合的综合分不宜直接比较。 + +不得返回内部快照 ID、测评结果 ID、题目、答案、审计详情、消息正文或数据库字段名。 + +### 5.3 学生单维详情兼容扩展 + +保留现有 `subScores/reasons/dataSources`,新增: + +- `displayName`; +- `whatItMeasures`; +- `interpretation`; +- `limitations[]`; +- `evidenceLevel`; +- `ruleContributions[]`; +- `improvementActions[]`; +- `explanationVersion`; +- `validity`:仅在存在明确有效期时返回,例如 ABILITY; +- `calculatedAt`:本次真实计算基准时间。 + +`ruleContributions` 只面向学生本人。旧字段在一期保留,避免前端破坏性升级;文档标记旧 `reasons/dataSources` 后续逐步由结构化字段替代。 + +### 5.4 企业端 + +企业端保持现有字段:`applicationId/overallScore/status/dimensions/generatedAt/rulesVersion`。不新增 `ruleContributions`、`limitations`、行为次数、学校分档、资料缺失项、四维能力细分或有效期。 + +企业页面可增加平台级固定说明,但不得把学生解释 DTO 直接复用到企业响应。 + +## 6. 时间一致性 + +`computeAll` 应在入口生成一次 `capturedNow`,并传递给所有时间敏感查询与规则: + +- 90 天投递; +- 30 天登录和活跃操作; +- 账号月龄; +- 简历 90 天更新; +- ABILITY 有效期; +- `calculatedAt` 和数据源时间说明。 + +不得在同一次计算中多次调用 `LocalDateTime.now()` 形成不同边界。建议注入 `Clock`,生产使用系统时钟,测试使用固定时钟。 + +## 7. 八维解释口径 + +| 维度 | 一期允许的名称 | 允许解释 | 禁止解释 | +|---|---|---|---| +| EDUCATION | 教育背景信息 | 已填写学历与学校分类规则的确定性结果 | 智力、能力上限、人才质量 | +| INTERNSHIP | 实习经历信息 | 当前数据源尚未接入 | 无实习能力、经验不足 | +| ABILITY | 四维能力测评 | 最新有效自研测评的规则化聚合 | 确定性职业能力、招聘结论 | +| COMPLIANCE | 求职偏好完整度 | 五类偏好字段填写情况 | 合规意识、服从性 | +| STABILITY | 平台求职行为稳定信号 | 账号时长、撤回与登录形成的行为代理 | 人格稳定、忠诚度 | +| GROWTH | 简历维护与技能展示信号 | 简历维护和技能标签状态 | 成长潜力、学习速度 | +| INTEGRITY | 履约与资料一致性信号 | Offer 响应、反馈与资料完整度的行为代理 | 道德、诚信人格、信用评级 | +| ACTIVITY | 平台求职活跃度 | 近期投递、平台操作和沟通信号 | 工作积极性、职业投入度 | + +维度代码为兼容字段,本版不改数据库枚举。产品展示名称采用上述中性名称。 + +## 8. 合理性整改分级 + +### 8.1 仅解释整改即可上线 + +- ABILITY:保留现有公式和有效期,补自评/情境测验限制; +- INTERNSHIP:保持 PENDING,明确“未接入”不是低分; +- 综合分:补参与维度和不可横向比较提示。 + +### 8.2 需要改名但暂不改公式 + +- COMPLIANCE → 求职偏好完整度; +- STABILITY → 平台求职行为稳定信号; +- GROWTH → 简历维护与技能展示信号; +- INTEGRITY → 履约与资料一致性信号; +- ACTIVITY → 平台求职活跃度。 + +这里只修改展示语义,不改变维度代码、快照或历史接口。 + +### 8.3 需要后续规则版本评审 + +- EDUCATION 的学校名单及 45% 权重; +- STABILITY 的账号时长奖励和撤回惩罚; +- GROWTH 的简历版本数量奖励; +- INTEGRITY 的 Offer 接受奖励、过期惩罚和反馈评分; +- ACTIVITY 的投递、审计和消息计数可操纵性; +- 缺失维度重新归一化后仍进入企业评分与排名。 + +任何一项改变分数都必须升级评分版本、完成新旧结果对账和 V32 排名回归,不能混入纯解释提交。 + +## 9. 权限与隐私 + +学生本人可以查看自身安全聚合解释。企业只能查看现有聚合评分卡。ADMIN 沿用治理接口,不因 N02 获得新的个人解释查询入口。 + +所有 HTTP 响应禁止新增: + +- 原始审计动作、IP、User-Agent; +- 消息正文或对话对象; +- Offer 具体企业、岗位或时间线; +- 评价正文与评价人身份; +- assessment/ability 的题目、答案、答案键、评分规则和结果 ID; +- 内部 SQL、表名、快照 ID、用户 ID; +- 密码、Token、密钥。 + +学生响应中的数据来源改为产品语义名称,如“个人教育资料”“求职偏好”“平台求职活动”,不直接返回 `student_profiles.education/school` 等物理字段名。 + +## 10. 实现建议 + +建议新增内部类: + +- `CandidateScoringExplanationCatalog`:规则元数据白名单; +- `CandidateScoringExplanationAssembler`:将 `DimensionResult` 与规则元数据组装为学生 DTO; +- `RuleContribution`、`ScoreCoverage` 等内部/DTO 类型。 + +修改: + +- `CandidateScoringCalculator`:统一 `capturedNow/Clock`,每个实际分支同步生成贡献; +- `CandidateScoringEngineService`:组装覆盖率与学生解释; +- 学生 DTO:兼容新增字段; +- OpenAPI、校验脚本、API 和联调文档。 + +原则上无新 Flyway、无新 ErrorCode、无新公网路径。解释元数据先随代码版本发布,不引入可在线修改规则的后台。 + +## 11. 测试与验收 + +### 11.1 规则同源 + +- 每个阈值边界独立手算; +- `contribution` 之和与真实维度分形成可证明关系; +- 未命中、缺失、过期和不可用规则不能伪造成 0 分命中; +- 固定 Clock 验证 30/90 天和有效期边界; +- 不允许修改解释而改变分数,或修改分支后遗漏解释。 + +### 11.2 综合分 + +- 0、1、2、8 个可计算维度; +- `OK/ESTIMATED` 参与,其他状态排除; +- coverage 与实际参与集合完全一致; +- 学生综合卡、趋势、企业评分和 V32 排名与既有纯函数对账。 + +### 11.3 权限与脱敏 + +- STUDENT 仅查看本人; +- COMPANY 只见聚合字段; +- ADMIN 不获得新增学生详情入口; +- 匿名 401,角色越权 403/2002; +- 对完整 JSON 递归扫描禁止字段; +- ABILITY 不泄露题目、答案或来源结果 ID。 + +### 11.4 防刷与边界评审 + +用固定样例证明重复投递、发消息、制造简历版本可能改变哪些分数,并在本版明确标为未验证代理信号。若决定保留其排名影响,必须形成书面产品决策和监测指标。 + +### 11.5 发布验证 + +- N02 专项、候选评分、治理、V32、V38、V39、V41、V42、V43 回归; +- `mvn clean verify`; +- 静态与运行时 OpenAPI 对账; +- 真实 MySQL 主链路和响应脱敏; +- 查询前后快照写入行为保持既有语义; +- 无新增 RabbitMQ 消息。 + +## 12. 编码前决策表 + +| 编号 | 决策项 | 建议方案 | 状态 | +|---|---|---|---| +| D01 | N02 是否只做解释,不改任何现有分数、权重和排名 | 是;规则合理性问题记录为后续独立评分版本 | 已确认 | +| D02 | 是否保持现有 4 个接口、0 个新增 path/operation | 是 | 已确认 | +| D03 | 是否无新 Flyway、无规则配置后台 | 是 | 已确认 | +| D04 | 是否增加独立 `explanationVersion`,不升级评分 `rulesVersion` | 是,值为 `candidate-scoring-explanation-v1` | 已确认 | +| D05 | 是否兼容扩展学生综合卡,展示参与/排除维度和可比性提示 | 是 | 已确认 | +| D06 | 是否兼容扩展学生单维详情,增加结构化规则贡献、限制和建议 | 是 | 已确认 | +| D07 | 企业端是否保持现有 schema,不展示任何规则贡献与私人细分 | 是 | 已确认 | +| D08 | 是否采用中性展示名,但保持八个维度代码不变 | 是 | 已确认 | +| D09 | 是否明确 `INTEGRITY` 不能解释为人格诚信或信用评级 | 是 | 已确认 | +| D10 | 是否明确 `GROWTH/STABILITY/COMPLIANCE/ACTIVITY` 仅为平台数据代理 | 是 | 已确认 | +| D11 | 是否用单次 `capturedNow` 并注入 `Clock` 修复解释时间一致性 | 是,不改变阈值与公式 | 已确认 | +| D12 | 学生是否只看安全区间/状态,不显示可用于刷分的内部计数与审计动作 | 是 | 已确认 | +| D13 | EDUCATION 学校分档是否暂时保留计算,但增加未验证与公平性警示 | 暂留,另开评分版本评审决定移除/降权 | 已确认 | +| D14 | 缺失维度重新归一化是否暂时保留 | 暂留,明确可比性限制并在规则评审中决定 | 已确认 | +| D15 | 是否安排题库和规则证据验证,不将当前确定性规则称为已验证模型 | 是 | 已确认 | + +D01–D15 已全部确认并按本设计实现。若未来 D01、D13 或 D14 的产品决策改变计分,本模块必须另行升级评分规则版本,不能通过解释增强提交混入。 diff --git a/docs/candidate-scoring-rule-evidence.md b/docs/candidate-scoring-rule-evidence.md new file mode 100644 index 0000000..a2fa09b --- /dev/null +++ b/docs/candidate-scoring-rule-evidence.md @@ -0,0 +1,209 @@ +# 候选评分规则证据表 + +状态:N02 独立代码复验与真实 MySQL/Docker 黑盒验收通过,待 Git 检查点;D01–D15 已确认。 + +文档类型:技术参考。本文记录 `CandidateScoringCalculator` 当前真实规则、数据、公式和证据限制。它不是对规则科学有效性的背书,也不是招聘决策指南。 + +## 1. 证据等级 + +| 等级 | 含义 | +|---|---| +| DIRECT_RULE | 可直接验证的确定性业务计算,例如字段是否填写、固定加权公式 | +| SELF_REPORTED | 主要来自学生本人填写,真实性和完整性依赖用户维护 | +| BEHAVIOR_PROXY | 平台行为与目标概念可能有关,但不能等同于目标概念 | +| UNVALIDATED_PROXY | 尚无试测、效度或公平性证据,不应据此形成强结论 | + +当前系统没有足够证据将任何行为代理称为人格测量、信用评估或招聘成功预测模型。 + +## 2. 全局规则 + +| 项目 | 当前实现 | 证据与限制 | +|---|---|---| +| 维度 | EDUCATION、INTERNSHIP、ABILITY、COMPLIANCE、STABILITY、GROWTH、INTEGRITY、ACTIVITY | 名称不一定等于实际被测概念 | +| 展示权重 | 每维 12.50 | 实际综合分是可计算维度算术平均 | +| 参与状态 | OK、ESTIMATED | PENDING、INSUFFICIENT_DATA 和 null score 排除 | +| 综合分 | `sum(score)/count`,scale=2,HALF_UP | 不同参与维度集合之间不可直接比较 | +| 分数边界 | 多数行为维度经 `cap` 限制至 0–100 | 上限截断会隐藏额外行为差异 | +| 快照 | 同学生、维度、月份覆盖更新 | 月度快照不是不可变历史事实 | +| 版本 | v0.1;能力开关开启为 v0.2 | 版本变化必须随响应和快照保存 | + +## 3. EDUCATION + +### 3.1 当前公式 + +```text +educationScore = 学历层次分 × 55% + 学校档次分 × 45% +``` + +学历层次:博士 100、硕士 85、本科 70、大专 55、其他 50。 + +学校档次:内置 985 名单 85、内置 211 名单 75、名称以“大学/学院”结尾 65、未填写或其他 50。未填写或无法识别时状态为 `ESTIMATED`;学历缺失时 `INSUFFICIENT_DATA`。 + +| 项目 | 结论 | +|---|---| +| 数据来源 | 学生教育资料中的学历和学校 | +| 证据等级 | SELF_REPORTED + UNVALIDATED_PROXY | +| 可以说明 | 当前资料按平台固定分类规则得到的教育背景分 | +| 不能说明 | 智力、实际技能、岗位绩效、学习能力或人才质量 | +| 主要风险 | 学校名单硬编码、维护过期、学校名称匹配误差、社会经济背景代理、公平性风险 | +| 可操纵性 | 学生可修改自报字段;需要资料真实性治理,但不能因此扩大企业隐私权限 | +| 建议 | 一期只准确解释并标注未验证;后续评审移除学校项或显著降权 | + +## 4. INTERNSHIP + +当前固定返回 `PENDING`、`score=null`,原因是结构化实习数据源未接入。 + +| 项目 | 结论 | +|---|---| +| 证据等级 | DIRECT_RULE | +| 可以说明 | 当前平台无法计算该维度 | +| 不能说明 | 学生没有实习、实习不足或经验较弱 | +| 建议 | 保持 PENDING;解释必须使用“未接入”,不得显示为 0 分 | + +## 5. ABILITY + +默认开关关闭时返回 `PENDING`。开启后只读取最新、有效、完整的 `WORKPLACE_ABILITY` 结果: + +```text +ABILITY = LOGIC×35% + EXECUTION×30% + LEARNING×20% + COMMUNICATION×15% +``` + +要求四维各回答 6 题、总回答 24 题、分数均在 0–100,`validUntil > capturedNow`。最终 scale=2、HALF_UP。 + +| 项目 | 结论 | +|---|---| +| 数据来源 | V36 自研四维能力测评最新有效聚合结果 | +| 证据等级 | DIRECT_RULE;题库测量意义仍属 UNVALIDATED_PROXY | +| 可以说明 | 用户在当前自研题库和评分规则下的四维聚合表现 | +| 不能说明 | 确定性工作能力、真实绩效或录用适合度 | +| 防滥用 | 不取历史最高分;结果过期或结构异常时不参与;不泄露答案键 | +| 待验证 | 小样本试测、信度、题目区分度、重测效应、与外部标准的关联 | + +## 6. COMPLIANCE + +当前实际计算求职偏好填写完整度:薪资 25、城市 25、用工类型 20、行业 15、岗位类型 15。五项均填写为 `OK`;至少一项填写为 `ESTIMATED`;全部缺失为 `INSUFFICIENT_DATA`。 + +| 项目 | 结论 | +|---|---| +| 证据等级 | DIRECT_RULE + SELF_REPORTED | +| 可以说明 | 五类求职偏好字段的填写完整程度 | +| 不能说明 | 合规意识、纪律性、服从性或职业道德 | +| 风险 | 名称严重超出计算内容;填写更多即可得到更高分,但不代表偏好更合理 | +| 建议展示名 | 求职偏好完整度 | +| 后续建议 | 评审是否应退出跨候选排名,仅用于资料完善提示 | + +## 7. STABILITY + +有投递或近 30 天登录时以 50 为基础:账号超过 12 个月 +25,超过 6 个月 +15;有投递时撤回率低于 20% +15,高于 50% -20;近 30 天有登录 +10;结果截断至 0–100。无投递且无登录为 `INSUFFICIENT_DATA`。 + +| 项目 | 结论 | +|---|---| +| 证据等级 | BEHAVIOR_PROXY + UNVALIDATED_PROXY | +| 可以说明 | 当前账号和平台求职行为形成的稳定信号 | +| 不能说明 | 人格稳定、工作稳定、忠诚度或离职概率 | +| 风险 | 新用户天然劣势;撤回可能由岗位变化、误投或企业原因造成;登录记录受产品使用习惯影响 | +| 可操纵性 | 登录和减少撤回可改变分数;阈值公开可能诱导行为 | +| 建议展示名 | 平台求职行为稳定信号 | +| 后续建议 | 决定是否从企业评分及排名中移除账号月龄,或仅作为可信度信息 | + +## 8. GROWTH + +存在简历或技能时以 40 为基础:ACTIVE 简历数量至少 2 个 +15;最近简历更新时间在 90 天内 +15;技能标签至少 5 个 +15;结果截断至 0–100。二者均无数据时 `INSUFFICIENT_DATA`。 + +| 项目 | 结论 | +|---|---| +| 证据等级 | BEHAVIOR_PROXY + UNVALIDATED_PROXY | +| 可以说明 | 简历维护和技能标签展示情况 | +| 不能说明 | 成长潜力、学习速度、职业上升空间 | +| 风险 | 多建简历版本和增加标签即可加分;技能数量不等于技能质量;无历史能力增量 | +| 建议展示名 | 简历维护与技能展示信号 | +| 后续建议 | 退出综合分或改成非评分的资料完善进度;若保留需加入去重、质量和冷却规则 | + +## 9. INTEGRITY + +有 Offer 历史或候选反馈时以 60 为基础:每个已接受 Offer +15,最多两个;每个过期 Offer -10,最多两个;反馈至少 3 条且均分至少 4 时 +10,有反馈且均分低于 3 时 -10;资料完整 +10;结果截断至 0–100。没有 Offer 且没有反馈时 `INSUFFICIENT_DATA`。 + +| 项目 | 结论 | +|---|---| +| 证据等级 | BEHAVIOR_PROXY + UNVALIDATED_PROXY | +| 可以说明 | Offer 响应、反馈和资料完整度形成的履约代理信号 | +| 不能说明 | 道德诚信、个人信用、欺诈风险或人格品质 | +| 风险 | 接受 Offer 不必然等于守约;过期可能来自通知失败;反馈评分可能存在偏见或样本不足;资料完整与诚信不是同一概念 | +| 可操纵性 | 接受行为、反馈数量及资料填写会改变结果 | +| 建议展示名 | 履约与资料一致性信号 | +| 强制文案 | “该信号不是个人征信、信用评级或道德评价” | +| 后续建议 | 优先评审退出综合分和排名;独立诚信积分模块不得复用该分数充当积分余额 | + +## 10. ACTIVITY + +近 90 天投递:至少 6 次 +50、至少 3 次 +40、至少 1 次 +20;近 30 天平台操作至少 3 次 +30;存在发信记录 +20;结果截断至 0–100。全部为零时 `INSUFFICIENT_DATA`。 + +| 项目 | 结论 | +|---|---| +| 证据等级 | BEHAVIOR_PROXY | +| 可以说明 | 指定窗口内的平台求职活动程度 | +| 不能说明 | 工作积极性、职业动机、岗位能力或录用意愿 | +| 风险 | 为得分而投递、登录、发消息;消息只判断是否存在且当前查询未限定时间窗口 | +| 可操纵性 | 高;重复投递和任意消息可直接增加信号 | +| 建议展示名 | 平台求职活跃度 | +| 后续建议 | 保留为时效排序信号须单独评审;不建议与能力类维度等权解释 | + +## 11. 跨维度问题 + +### 11.1 概念重叠 + +资料完整度同时影响 COMPLIANCE、GROWTH 和 INTEGRITY,平台活跃行为同时影响 STABILITY 和 ACTIVITY。相同底层行为可能被重复计入综合分。 + +### 11.2 缺失并非零分 + +当前缺失维度会退出分母,而不是记为零。解释必须明确“未参与”,不能显示成“表现较差”。但重新归一化也使不同人的综合分口径不一致。 + +### 11.3 估算值 + +`ESTIMATED` 与 `OK` 同等进入综合分。学校未知和偏好部分填写均可能产生估算分,企业端目前只看到状态和聚合分,必须避免将估算分表述为精确结果。 + +### 11.4 时间窗口 + +APPLICATION、LOGIN、AUDIT 和简历更新时间使用多个 `now()`,消息数量没有时间窗口。N02 应先统一计算时钟,但是否修改消息窗口属于评分规则变更,不能混入解释版本。 + +## 12. 规则证据验证计划 + +### 12.1 代码级确定性验证 + +- 为每条规则建立边界表和独立手算样例; +- 固定 Clock 验证恰好 30/90 天、6/12 个月和 ABILITY 到期时刻; +- 检查分数、贡献、原因与数据来源完全一致; +- 验证 cap、负分、HALF_UP 和 null 分支。 + +### 12.2 数据质量验证 + +- 统计各维度 OK/ESTIMATED/INSUFFICIENT_DATA/PENDING 覆盖率; +- 检查学校名称无法识别率、偏好 JSON 错误率、Offer 通知失败率; +- 检查同一底层行为跨维度重复计分比例; +- 监测规则版本和异常结构,复用 V43 聚合能力但不扩大其个人数据范围。 + +### 12.3 防操纵验证 + +- 重复投递、撤回、登录、发消息、创建简历版本和堆叠技能标签; +- 比较一次行为与大量重复行为的边际影响; +- 验证冷却、幂等和上限是否真正生效; +- 发现高可操纵性时,不以隐藏阈值代替规则整改。 + +### 12.4 测评证据 + +ABILITY 题库先完成小样本试测,再评估内部一致性、题目区分度、重测影响和题目淘汰。RIASEC 当前不直接进入候选八维评分,不应在 N02 中混入。 + +## 13. N02 实现核对 + +- 解释版本固定为 `candidate-scoring-explanation-v1`,与 `candidate-scoring-v0.1/v0.2` 分离。 +- 规则贡献由 `CandidateScoringCalculator` 的真实分支同步生成,`CandidateScoringExplanationAssembler` 只做白名单组装。 +- 能力测评小数分数的逐项展示舍入差以 `SCORE_ROUNDING_ADJUSTMENT` 显式对账;不改变 V38 映射分数。 +- 学生综合卡增加 `coverage` 与 `comparabilityNotice`;企业评分卡字段和 JSON 结构保持不变。 +- `Clock`/`capturedNow` 贯穿一次评分计算及快照 `calculated_at`;未改变任何阈值、公式、权重或排名。 +- 本轮无新 Flyway、表、错误码、公网路径或业务消息;真实 MySQL/Docker 验收仍待独立执行。 + +## 14. 审计结论 + +当前代码在“确定性、可重复计算”层面具备基础,但不具备将多个代理维度解释为人格、诚信、成长潜力或招聘成功预测的证据。N02 可以先完成可信解释和限制披露;涉及分数、排名和企业决策影响的合理性整改必须作为独立规则版本处理。 + +D01–D15 已确认。当前代码在“确定性、可重复计算”层面具备基础,但不具备将多个代理维度称为人格、诚信、成长潜力或招聘成功预测模型的证据。N02 仅完成可信解释和限制披露;任何改变分数、权重或排名的后续动作必须另开评分规则版本并重新验收。 diff --git a/docs/frontend-integration.md b/docs/frontend-integration.md index 2a286b3..2b5a3d3 100644 --- a/docs/frontend-integration.md +++ b/docs/frontend-integration.md @@ -936,6 +936,14 @@ POST /admin/review-replies/{replyId}/decision V43 仅供管理员后台使用,前端不得向学生或企业展示。当前状态为版本内补集定向修复完成、待独立复验;总览和申诉使用最终 SQL 聚合,并统一执行独立候选人数与补集抑制。`drift` 和 `ranking-stability` 在当前没有不可变评分历史与排名快照时返回 `availability=UNAVAILABLE`,页面应显示“暂无可比历史数据”。 +## 26. N02 候选评分规则解释与合理性 + +学生评分卡和单维详情在保持原有路径不变的前提下增加固定解释字段。综合卡新增 `explanationVersion=candidate-scoring-explanation-v1`、`coverage`(实际参与 overallScore 的维度及排除原因)和 `comparabilityNotice`;单维详情新增中性 `displayName`、`whatItMeasures`、`interpretation`、`limitations`、`evidenceLevel`、`ruleContributions`、`improvementActions`、`explanationVersion`、`validity`(仅 ABILITY)和 `calculatedAt`。 + +`ruleContributions` 与真实评分分支同源,贡献之和可对账最终维度分数;固定模板不使用 AI/LLM/SHAP。`dataSources` 只显示“个人教育资料”“求职偏好”“平台求职活动”“简历与技能资料”“Offer 履约与候选反馈”“四维能力测评”等产品语义名称,不展示表名、字段路径、题目、答案、答案键或评分规则。`INTEGRITY`、`GROWTH`、`STABILITY`、`COMPLIANCE`、`ACTIVITY` 等信号均明确为平台资料/行为代理,不代表人格、信用、成长潜力、服从性、工作积极性或录用结论。 + +企业评分接口 `/company/candidates/{applicationId}/scoring` 的字段和 JSON 结构保持不变,企业不返回任何解释字段、行为细分、规则贡献、改进建议或 ABILITY 四维详情。N02 不新增 Flyway、表、ErrorCode、公网路径、消息或审计动作,也不改变分数、权重、排名、推荐、申诉和快照写入语义。 + 可用接口、参数和小样本规则见 [candidate-scoring-fairness-api.md](candidate-scoring-fairness-api.md)。前端不得根据城市、学校、姓名等字段创建敏感人群筛选,也不得尝试通过多次小样本查询反推出候选人。 总览、维度和申诉响应都显式返回 `availability/reason`。状态/版本/回滚原因及分布桶均按独立候选人数计算;`appealCount` 仅表示事件数。`SUPPRESSED` 时不得用总体计数、补集或其它接口字段反推出被隐藏比例,`NO_DATA`、`INSUFFICIENT_SAMPLE`、`SUPPRESSED` 均不得渲染为 `NORMAL`。 diff --git a/docs/openapi-rc.json b/docs/openapi-rc.json index 35279b2..3bdc1c8 100644 --- a/docs/openapi-rc.json +++ b/docs/openapi-rc.json @@ -11450,6 +11450,16 @@ "generatedAt": { "type": "string", "format": "date-time" + }, + "explanationVersion": { + "type": "string", + "description": "解释契约版本,与评分规则版本独立" + }, + "coverage": { + "$ref": "#/components/schemas/CandidateScoringCoverage" + }, + "comparabilityNotice": { + "type": "string" } } }, @@ -11805,6 +11815,41 @@ }, "rulesVersion": { "type": "string" + }, + "displayName": { + "type": "string" + }, + "whatItMeasures": { + "type": "string" + }, + "interpretation": { + "type": "string" + }, + "limitations": { + "type": "array", + "items": { "type": "string" } + }, + "evidenceLevel": { + "type": "string", + "enum": ["DIRECT_RULE", "SELF_REPORTED", "BEHAVIOR_PROXY", "UNVALIDATED_PROXY"] + }, + "ruleContributions": { + "type": "array", + "items": { "$ref": "#/components/schemas/CandidateScoringRuleContribution" } + }, + "improvementActions": { + "type": "array", + "items": { "type": "string" } + }, + "explanationVersion": { + "type": "string" + }, + "validity": { + "$ref": "#/components/schemas/CandidateScoringValidity" + }, + "calculatedAt": { + "type": "string", + "format": "date-time" } } }, @@ -15199,6 +15244,46 @@ } } }, + "CandidateScoringCoverage": { + "type": "object", + "properties": { + "computableDimensions": { "type": "integer", "format": "int32" }, + "totalDimensions": { "type": "integer", "format": "int32" }, + "includedDimensions": { "type": "array", "items": { "type": "string" } }, + "excludedDimensions": { + "type": "array", + "items": { "$ref": "#/components/schemas/CandidateScoringExcludedDimension" } + } + } + }, + "CandidateScoringExcludedDimension": { + "type": "object", + "properties": { + "dimension": { "type": "string" }, + "status": { "type": "string" }, + "reason": { "type": "string" } + } + }, + "CandidateScoringRuleContribution": { + "type": "object", + "properties": { + "ruleCode": { "type": "string" }, + "outcome": { "type": "string", "enum": ["APPLIED", "NOT_APPLIED", "MISSING", "EXPIRED", "UNAVAILABLE"] }, + "contribution": { "type": "number", "nullable": true }, + "maxContribution": { "type": "number", "nullable": true }, + "label": { "type": "string" }, + "observation": { "type": "string" }, + "explanation": { "type": "string" } + } + }, + "CandidateScoringValidity": { + "type": "object", + "properties": { + "completedAt": { "type": "string", "format": "date-time" }, + "validUntil": { "type": "string", "format": "date-time" }, + "expired": { "type": "boolean" } + } + }, "StudentProfileResponse": { "type": "object", "properties": { diff --git a/docs/phase1-scope-and-acceptance.md b/docs/phase1-scope-and-acceptance.md index 9955f5f..0485a18 100644 --- a/docs/phase1-scope-and-acceptance.md +++ b/docs/phase1-scope-and-acceptance.md @@ -37,7 +37,7 @@ | 编号/功能 | 当前状态与一期需要什么 | 代码在哪里 | 如何验收 | 主责 / 复核 | |---|---|---|---|---| | N01 V43过程公平性与稳定性监测 | 独立代码复验与真实 MySQL/Docker 验收通过,待 Git 检查点;历史不足返回UNAVAILABLE | 现有 scoringmonitoring/;docs/v43-candidate-scoring-fairness-design.md | 版本内补集、申诉集合、权限、只读性及30/300/3000查询预算通过;EXPLAIN扩容风险纳入容量观察 | 张顺 / 小刘 | -| N02 评分规则解释与合理性 | 部分reasons已存在;统一解释来源、权重、缺失、有效期、规则版本;逐条审查“成长/诚信”等信号的含义与可操纵性;不承诺已验证测量效度 | 现有 candidatescore/CandidateScoringCalculator.java、CandidateScoringEngineService.java、governance/;ability/、assessment/ | 对固定样例独立手算对账;解释与实际分数同源;企业不见私人细分;边界与刷分案例评审;形成规则证据表和测评试测安排 | 张顺 / 小刘 | +| N02 评分规则解释与合理性 | 独立代码复验与真实 MySQL/Docker 黑盒验收通过,待 Git 检查点 | candidatescore/CandidateScoringCalculator.java、CandidateScoringEngineService.java、candidatescore/explanation/;docs/candidate-scoring-explanation-design.md、docs/candidate-scoring-rule-evidence.md | 未知规则安全失败;真实 H2 时间边界、四入口单次 capturedNow、企业完整 JSON 脱敏、运行时 OpenAPI 与真库回归均已通过 | 张顺 / 小刘 | | N03 行业技能词云 | 未确认有独立实现;用有授权、可追溯的数据生成技能频次,支持空数据与更新时间;不是装饰性随机词云 | 现有 job/、recommendation/ 可复用数据入口;拟新增 skillinsight/ | 同义词归一/去重/计数用已知样本对账;行业筛选与来源日期准确;无数据不编造;批量性能与页面联调 | 小刘 / 张顺 | | N04 入职清单与公司知识介绍 | 未确认有独立实现;清单模板、个人完成进度、公司可公开介绍;公司原有介绍与新增入职知识明确区分 | 现有 company/、offer/、careerprogress/;拟新增 onboarding/ | 本人进度隔离、企业内容归属、模板变更行为、重复更新;未认证内容不得冒充平台核验;页面完整走通 | 小刘 / 张顺 | | N05 薪资计算器与薪资参考 | 未确认有独立实现;可解释的计算明细与有来源的薪资参考;明确币种、周期、地区、规则生效期和估算性质 | 现有 job/、offer/ 可提供薪资字段;拟新增 salary/ | 经确认的规则用独立算例验证舍入与边界;来源/版本/更新时间;缺地区或参考样本时明确不可计算/不足;不伪造市场数据 | 小刘 / 张顺 | @@ -49,7 +49,7 @@ 范围已确认,以下细节未确认。负责人先交付短设计,双方评审后再实现,不因缺细节将功能移出一期。 -- N02:各规则的业务含义、修改兼容方式、测评试测样本与证据门槛;现有分数是否继续影响企业和排名须明确,不能默默改变。 +- N02:各规则的业务含义、兼容解释方式、测评试测样本与证据门槛已形成;本版不改变现有分数、企业聚合或排名,后续规则调整必须另开评分版本。 - N03:行业受控字典、数据来源和授权、统计窗口、最小样本、更新方式。 - N04:清单适用人群与触发点、模板维护方、公司内容发布/审核机制、进度保存方式。 - N05:税前/税后/年包范围、支持地区、规则与市场参考数据来源、更新责任。涉及税费规则时设计阶段查验权威资料。 diff --git a/docs/verify_openapi.py b/docs/verify_openapi.py index 9df3255..85ef48c 100644 --- a/docs/verify_openapi.py +++ b/docs/verify_openapi.py @@ -515,6 +515,50 @@ assert '独立候选人数' in d['components']['schemas']['FairnessResponseAppea assert '/admin/scoring/fairness/overview' in paths print(" V43 exactly 5 paths / 5 operations; aggregate fairness schemas are redacted") +# N02 候选评分解释增强:只扩展学生评分载荷,企业评分卡保持原有白名单。 +student_overview = d['components']['schemas']['CandidateScoringOverviewResponse'] +overview_props = student_overview.get('properties', {}) +for field in ('explanationVersion', 'coverage', 'comparabilityNotice'): + assert field in overview_props, f'CandidateScoringOverviewResponse missing {field}' +detail_props = d['components']['schemas']['DimensionScoreDetailResponse'].get('properties', {}) +for field in ('displayName', 'whatItMeasures', 'interpretation', 'limitations', 'evidenceLevel', + 'ruleContributions', 'improvementActions', 'explanationVersion', 'validity', 'calculatedAt'): + assert field in detail_props, f'DimensionScoreDetailResponse missing {field}' +assert overview_props['coverage'].get('$ref', '').endswith('CandidateScoringCoverage') +assert detail_props['validity'].get('$ref', '').endswith('CandidateScoringValidity') +assert detail_props['ruleContributions'].get('items', {}).get('$ref', '').endswith('CandidateScoringRuleContribution') + +student_explanation_forbidden = [ + 'sourceResultId', 'attemptId', 'resultId', 'questionSetId', 'questionCode', + 'answerKey', 'scoringRule', 'competencyTag', 'selectedOptionId', 'stem', + 'studentUserId', 'phone', 'email', 'resumeUrl', 'token', 'password' +] +for name in ('CandidateScoringOverviewResponse', 'CandidateScoringCoverage', + 'CandidateScoringExcludedDimension', 'DimensionScoreDetailResponse', + 'CandidateScoringRuleContribution', 'CandidateScoringValidity'): + assert name in d['components']['schemas'], f'Missing N02 schema {name}' + encoded = json.dumps(resolve_schema(d['components']['schemas'][name]), ensure_ascii=False) + for field in student_explanation_forbidden: + assert field not in encoded, f'N02 schema {name} exposes forbidden field {field}' + +company_schema_text = json.dumps(resolve_schema(d['components']['schemas']['CompanyCandidateScoringResponse']), ensure_ascii=False) +for field in ('explanationVersion', 'coverage', 'comparabilityNotice', 'displayName', 'ruleContributions', + 'reasons', 'dataSources', 'limitations', 'improvementActions', 'evidenceLevel', + 'validity', 'completedAt', 'validUntil', 'LOGIC', 'COMMUNICATION', 'EXECUTION', + 'LEARNING', 'studentUserId', 'candidateId', 'resultId', 'attemptId', 'questionCode', + 'answerKey', 'scoringRule', 'competencyTag', 'phone', 'email', 'resumeUrl'): + assert field not in company_schema_text, f'CompanyCandidateScoringResponse exposes N02 field {field}' + +candidate_scoring_paths = [ + '/profile/me/candidate-scoring', + '/profile/me/candidate-scoring/dimensions/{dimension}', + '/profile/me/candidate-scoring/trend', + '/company/candidates/{applicationId}/scoring', +] +assert all(path in paths for path in candidate_scoring_paths), 'Candidate scoring path set changed' +assert sum(len(paths[path]) for path in candidate_scoring_paths) == 4, 'Candidate scoring operation count changed' +print(' N02 explanation schemas validated; enterprise scoring schema unchanged; 4 scoring operations preserved') + # 统计 operation 总数并校验 V43 计数 210 operations = sum(len(methods) for methods in d['paths'].values()) print('operations:', operations) diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/AbilityAssessmentScoreSource.java b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/AbilityAssessmentScoreSource.java index 62fcccb..9678afc 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/AbilityAssessmentScoreSource.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/AbilityAssessmentScoreSource.java @@ -40,6 +40,29 @@ public class AbilityAssessmentScoreSource { LIMIT 1 """; + /** + * 仅在没有任何有效结果时读取最新的已完成过期结果,用于向学生解释“已过期”。 + * 该查询不参与分数计算;正常有效结果仍只走 {@link #SQL}。 + */ + private static final String NON_VALID_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 IS NULL OR r.valid_until <= ?) + ORDER BY a.completed_at DESC, a.id DESC + LIMIT 1 + """; + private final JdbcTemplate jdbcTemplate; /** @@ -50,19 +73,34 @@ public class AbilityAssessmentScoreSource { 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); + List rows = jdbcTemplate.query(SQL, rowMapper(), studentUserId, capturedNow); + return rows.isEmpty() ? null : rows.get(0); + } + + /** + * 仅在 {@link #findLatestValidCompletedResult(Long, LocalDateTime)} 没有命中时调用, + * 读取最新已完成但当前不可参与评分的结果,供解释层区分 EXPIRED 与 INVALID。 + * 不存在结果时返回 null。 + */ + public AbilityResultProjection findLatestNonValidCompletedResult(Long studentUserId, LocalDateTime capturedNow) { + if (studentUserId == null || capturedNow == null) { + return null; + } + List rows = jdbcTemplate.query(NON_VALID_SQL, rowMapper(), studentUserId, capturedNow); return rows.isEmpty() ? null : rows.get(0); } + private org.springframework.jdbc.core.RowMapper rowMapper() { + return (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")); + } + /** * 仅含评分所需最小字段的只读投影;不含题目、答案、答案键、选项分值或题库标签。 */ @@ -74,4 +112,4 @@ public class AbilityAssessmentScoreSource { 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 index 088b058..531d08e 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/AbilityScoreMapper.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/AbilityScoreMapper.java @@ -57,7 +57,7 @@ public class AbilityScoreMapper { * 安全解析单个 V36 结果。结构异常一律返回空。 * * @param dimensionScoresJson ability_results.dimension_scores_json(数组:{code,score,answeredCount}) - * @param attemptAnsweredCount ability_attempts.answered_count(可为 null) + * @param attemptAnsweredCount ability_attempts.answered_count(必须为 24) */ public Optional map(String dimensionScoresJson, Integer attemptAnsweredCount) { if (dimensionScoresJson == null || dimensionScoresJson.isBlank()) { @@ -182,4 +182,4 @@ public class AbilityScoreMapper { 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 4481db7..6a82b64 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/CandidateScoringCalculator.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/CandidateScoringCalculator.java @@ -17,6 +17,7 @@ import jakarta.persistence.Query; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Component; @@ -24,6 +25,7 @@ import java.math.BigDecimal; import java.math.RoundingMode; import java.time.LocalDate; import java.time.LocalDateTime; +import java.time.Clock; import java.time.temporal.ChronoUnit; import java.util.*; import java.util.stream.Collectors; @@ -101,39 +103,49 @@ public class CandidateScoringCalculator { private final AbilityAssessmentScoreSource abilityScoreSource; private final AbilityScoreMapper abilityScoreMapper; + /** 可测试的时间源;未提供 Spring Clock 时保持系统时区语义。 */ + private Clock clock = Clock.systemDefaultZone(); + @Value("${scoring.ability-assessment.enabled:false}") private boolean abilityAssessmentEnabled; @PersistenceContext private EntityManager entityManager; + @Autowired(required = false) + public void setClock(Clock clock) { + if (clock != null) this.clock = clock; + } + // ── 数据采集(一次性、单学生过滤,无 N+1)───────────────────── public List computeAll(Long studentUserId) { + return computeAll(studentUserId, LocalDateTime.now(clock)); + } + + public List computeAll(Long studentUserId, LocalDateTime capturedNow) { User user = userRepository.findById(studentUserId).orElse(null); StudentProfile profile = profileRepository.findByUserId(studentUserId).orElse(null); StudentPreference pref = preferenceRepository.findByStudentUserId(studentUserId).orElse(null); - AppAgg apps = queryApps(studentUserId); - AuditAgg audit = queryAudit(studentUserId); + AppAgg apps = queryApps(studentUserId, capturedNow); + AuditAgg audit = queryAudit(studentUserId, capturedNow); OfferAgg offers = queryOffers(studentUserId); FbAgg fb = queryFeedback(studentUserId); 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(computeEducation(profile, capturedNow)); + list.add(computeInternship(capturedNow)); list.add(computeAbility(studentUserId, capturedNow)); - list.add(computeCompliance(pref)); + list.add(computeCompliance(pref, capturedNow)); list.add(computeStability(user != null ? user.getCreatedAt() : null, - (int) apps.total, (int) apps.withdrawn, (int) audit.login30d)); - list.add(computeGrowth((int) resume.count, resume.latestUpdate, skillCount(profile))); + (int) apps.total, (int) apps.withdrawn, (int) audit.login30d, capturedNow)); + list.add(computeGrowth((int) resume.count, resume.latestUpdate, skillCount(profile), capturedNow)); list.add(computeIntegrity((int) offers.accepted, (int) offers.expired, - fb.count, fb.avg, isProfileComplete(profile, resume.count))); - list.add(computeActivity((int) apps.recent90d, (int) audit.total30d, msgCount)); + fb.count, fb.avg, isProfileComplete(profile, resume.count), capturedNow)); + list.add(computeActivity((int) apps.recent90d, (int) audit.total30d, msgCount, capturedNow)); + list.forEach(result -> result.capturedAt = capturedNow); return list; } @@ -141,33 +153,38 @@ public class CandidateScoringCalculator { * 按当前数据确定性重算单维度并写入指定 period 的快照(评分治理回滚复用) */ public DimensionResult recomputeAndSnapshot(Long studentUserId, String dimension, String period) { + return recomputeAndSnapshot(studentUserId, dimension, period, LocalDateTime.now(clock)); + } + + public DimensionResult recomputeAndSnapshot(Long studentUserId, String dimension, String period, + LocalDateTime capturedNow) { if (!DIMENSIONS.contains(dimension)) { throw new BusinessException(ErrorCode.BAD_REQUEST, "invalid dimension: " + dimension); } - DimensionResult r = computeAll(studentUserId).stream() + DimensionResult r = computeAll(studentUserId, capturedNow).stream() .filter(x -> x.dimension.equals(dimension)).findFirst() .orElseThrow(() -> new BusinessException(ErrorCode.BAD_REQUEST, "invalid dimension")); - upsertSnapshots(studentUserId, List.of(r), period); + upsertSnapshots(studentUserId, List.of(r), period, capturedNow); return r; } - private AppAgg queryApps(Long sid) { + private AppAgg queryApps(Long sid, LocalDateTime capturedNow) { return jdbcTemplate.queryForObject( "SELECT COUNT(*), COALESCE(SUM(CASE WHEN status='WITHDRAWN' THEN 1 ELSE 0 END),0), " + "MIN(created_at), COALESCE(SUM(CASE WHEN created_at >= ? THEN 1 ELSE 0 END),0) " + "FROM applications WHERE student_user_id = ?", (rs, n) -> new AppAgg(rs.getLong(1), rs.getLong(2), rs.getTimestamp(3) == null ? null : rs.getTimestamp(3).toLocalDateTime(), rs.getLong(4)), - LocalDateTime.now().minusDays(90), sid); + capturedNow.minusDays(90), sid); } - private AuditAgg queryAudit(Long sid) { + private AuditAgg queryAudit(Long sid, LocalDateTime capturedNow) { return jdbcTemplate.queryForObject( "SELECT COALESCE(SUM(CASE WHEN created_at >= ? THEN 1 ELSE 0 END),0), " + "COALESCE(SUM(CASE WHEN action='LOGIN' AND created_at >= ? THEN 1 ELSE 0 END),0) " + "FROM audit_logs WHERE user_id = ?", (rs, n) -> new AuditAgg(rs.getLong(1), rs.getLong(2)), - LocalDateTime.now().minusDays(30), LocalDateTime.now().minusDays(30), sid); + capturedNow.minusDays(30), capturedNow.minusDays(30), sid); } private OfferAgg queryOffers(Long sid) { @@ -200,10 +217,16 @@ public class CandidateScoringCalculator { // ── 八维规则(确定性)────────────────────────────────────── public DimensionResult computeEducation(StudentProfile profile) { + return computeEducation(profile, LocalDateTime.now(clock)); + } + + public DimensionResult computeEducation(StudentProfile profile, LocalDateTime capturedNow) { DimensionResult r = new DimensionResult("EDUCATION"); if (profile == null || isBlank(profile.getEducation())) { r.status = "INSUFFICIENT_DATA"; r.summary = "未填写学历信息"; + addContribution(r, "EDUCATION_MISSING", "MISSING", null, new BigDecimal("100"), "教育资料", + "学历资料未填写,当前维度不计算"); return r; } BigDecimal eduLevel = switch (profile.getEducation()) { @@ -234,19 +257,33 @@ public class CandidateScoringCalculator { r.score = eduLevel.multiply(new BigDecimal("0.55")) .add(schoolTier.multiply(new BigDecimal("0.45"))) .setScale(2, RoundingMode.HALF_UP); + addContribution(r, "EDUCATION_LEVEL", "APPLIED", eduLevel.multiply(new BigDecimal("0.55")), new BigDecimal("55"), + "学历层次", profile.getEducation(), "学历字段按固定映射形成贡献"); + if (isBlank(school)) { + addContribution(r, "EDUCATION_MISSING", "MISSING", null, null, "教育资料缺失", + "学校资料未填写,学校档位使用估算兜底"); + } + addContribution(r, "EDUCATION_SCHOOL_TIER", "APPLIED", schoolTier.multiply(new BigDecimal("0.45")), new BigDecimal("45"), + "学校分类", isBlank(school) ? "未填写" : "已按学校分类规则处理", "学校分类按固定规则形成贡献,不能代表能力"); r.status = estimated ? "ESTIMATED" : "OK"; r.summary = "学历层次与学校档次的加权综合"; r.subScores.add(sub("学历层次", eduLevel, new BigDecimal("55"), profile.getEducation())); r.subScores.add(sub("学校档次", schoolTier, new BigDecimal("45"), isBlank(school) ? "未填写" : school)); - r.dataSources.add(ds("student_profiles.education/school", profile.getUpdatedAt(), "学历与学校字段")); + r.dataSources.add(ds("个人教育资料", profile.getUpdatedAt(), "学历与学校分类信息")); return r; } public DimensionResult computeInternship() { + return computeInternship(LocalDateTime.now(clock)); + } + + public DimensionResult computeInternship(LocalDateTime capturedNow) { DimensionResult r = new DimensionResult("INTERNSHIP"); r.status = "PENDING"; r.summary = "实习经历数据源未就绪"; r.reasons.add("结构化实习经历尚未提供"); + addContribution(r, "INTERNSHIP_SOURCE_UNAVAILABLE", "UNAVAILABLE", null, null, "实习经历数据源", + "未接入,不等于没有实习经历"); return r; } @@ -262,13 +299,43 @@ public class CandidateScoringCalculator { r.status = "PENDING"; r.summary = "能力测评数据源未就绪"; r.reasons.add("能力测评(F1.2)尚未实现"); + addContribution(r, "ABILITY_FEATURE_DISABLED", "UNAVAILABLE", null, null, "四维能力测评", + "能力测评功能开关当前关闭"); return r; } var proj = abilityScoreSource.findLatestValidCompletedResult(studentUserId, capturedNow); + if (proj == null) { + // 有效结果查询为空时,仅额外读取最新不可用结果用于解释;不存在则保持 MISSING/PENDING。 + proj = abilityScoreSource.findLatestNonValidCompletedResult(studentUserId, capturedNow); + } if (proj == null) { r.status = "PENDING"; r.summary = "能力测评数据源未就绪"; r.reasons.add("无可用的完整能力测评结果"); + addContribution(r, "ABILITY_RESULT_MISSING", "MISSING", null, null, "四维能力测评", + "没有可用的完整测评结果"); + return r; + } + if (proj.validUntil() == null) { + r.status = "PENDING"; + r.summary = "能力测评结果结构异常"; + r.reasons.add("测评有效期缺失,暂不参与评分"); + addContribution(r, "ABILITY_RESULT_INVALID", "UNAVAILABLE", null, null, "四维能力测评", + "测评有效期缺失,暂不参与评分"); + r.dataSources.add(ds("四维能力测评", proj.completedAt(), "最近一次测评结果不可用")); + return r; + } + if (!proj.validUntil().isAfter(capturedNow)) { + r.status = "PENDING"; + r.summary = "能力测评结果已过期"; + r.reasons.add("测评有效期已结束,暂不参与评分"); + addContribution(r, "ABILITY_RESULT_EXPIRED", "EXPIRED", null, null, "四维能力测评", + "测评有效期已结束,暂不参与评分"); + r.dataSources.add(ds("四维能力测评", proj.completedAt(), "最近一次测评已过期")); + r.dataSources.add(ds("四维能力测评有效期", proj.validUntil(), "测评有效期")); + r.abilitySnapshotTrace = new AbilitySnapshotTrace( + proj.resultId(), proj.completedAt(), proj.validUntil(), + proj.questionSetVersion(), proj.assessmentRulesVersion(), null, null); return r; } var mapped = abilityScoreMapper.map(proj.dimensionScoresJson(), proj.answeredCount()); @@ -276,6 +343,8 @@ public class CandidateScoringCalculator { r.status = "PENDING"; r.summary = "能力测评数据源异常"; r.reasons.add("测评结果结构异常,暂不参与评分"); + addContribution(r, "ABILITY_RESULT_INVALID", "UNAVAILABLE", null, null, "四维能力测评", + "测评结果结构异常,暂不参与评分"); return r; } var m = mapped.get(); @@ -286,8 +355,13 @@ public class CandidateScoringCalculator { 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(), "测评有效期")); + addContribution(r, "ABILITY_LOGIC", "APPLIED", m.dimensionScores().get("LOGIC").multiply(new BigDecimal("0.35")), new BigDecimal("35"), "逻辑能力", "测评聚合分", "按 V38 固定权重映射"); + addContribution(r, "ABILITY_EXECUTION", "APPLIED", m.dimensionScores().get("EXECUTION").multiply(new BigDecimal("0.30")), new BigDecimal("30"), "执行能力", "测评聚合分", "按 V38 固定权重映射"); + addContribution(r, "ABILITY_LEARNING", "APPLIED", m.dimensionScores().get("LEARNING").multiply(new BigDecimal("0.20")), new BigDecimal("20"), "学习能力", "测评聚合分", "按 V38 固定权重映射"); + addContribution(r, "ABILITY_COMMUNICATION", "APPLIED", m.dimensionScores().get("COMMUNICATION").multiply(new BigDecimal("0.15")), new BigDecimal("15"), "沟通能力", "测评聚合分", "按 V38 固定权重映射"); + reconcileContributionRounding(r); + r.dataSources.add(ds("四维能力测评", proj.completedAt(), "最新有效四维能力测评完成时间")); + r.dataSources.add(ds("四维能力测评有效期", proj.validUntil(), "测评有效期")); r.reasons.add("映射规则版本 " + AbilityScoreMapper.MAPPING_RULES_VERSION); r.abilitySnapshotTrace = new AbilitySnapshotTrace( proj.resultId(), proj.completedAt(), proj.validUntil(), @@ -303,10 +377,16 @@ public class CandidateScoringCalculator { } public DimensionResult computeCompliance(StudentPreference pref) { + return computeCompliance(pref, LocalDateTime.now(clock)); + } + + public DimensionResult computeCompliance(StudentPreference pref, LocalDateTime capturedNow) { DimensionResult r = new DimensionResult("COMPLIANCE"); if (pref == null) { r.status = "INSUFFICIENT_DATA"; r.summary = "未设置求职偏好"; + addContribution(r, "PREFERENCE_ALL_MISSING", "MISSING", null, null, "求职偏好", + "没有可比较的偏好字段"); return r; } boolean salary = pref.getExpectedSalaryMin() != null || pref.getExpectedSalaryMax() != null; @@ -324,6 +404,8 @@ public class CandidateScoringCalculator { if (present == 0) { r.status = "INSUFFICIENT_DATA"; r.summary = "未填写任何求职偏好字段"; + addContribution(r, "PREFERENCE_ALL_MISSING", "MISSING", null, null, "求职偏好", + "没有可比较的偏好字段"); return r; } r.score = score.setScale(2, RoundingMode.HALF_UP); @@ -334,99 +416,172 @@ public class CandidateScoringCalculator { r.subScores.add(sub("用工类型", type ? new BigDecimal("20") : ZERO, new BigDecimal("20"), type ? "已填" : "未填")); r.subScores.add(sub("行业偏好", industry ? new BigDecimal("15") : ZERO, new BigDecimal("15"), industry ? "已填" : "未填")); r.subScores.add(sub("岗位类型", jobType ? new BigDecimal("15") : ZERO, new BigDecimal("15"), jobType ? "已填" : "未填")); - r.dataSources.add(ds("student_preferences", pref.getUpdatedAt(), "薪资/城市/用工/行业/岗位偏好")); + addContribution(r, "PREFERENCE_SALARY_PRESENT", salary ? "APPLIED" : "NOT_APPLIED", salary ? new BigDecimal("25") : ZERO, new BigDecimal("25"), "薪资偏好", salary ? "已填写" : "未填写", "仅反映资料完整度"); + addContribution(r, "PREFERENCE_CITY_PRESENT", city ? "APPLIED" : "NOT_APPLIED", city ? new BigDecimal("25") : ZERO, new BigDecimal("25"), "城市偏好", city ? "已填写" : "未填写", "仅反映资料完整度"); + addContribution(r, "PREFERENCE_EMPLOYMENT_TYPE_PRESENT", type ? "APPLIED" : "NOT_APPLIED", type ? new BigDecimal("20") : ZERO, new BigDecimal("20"), "用工类型偏好", type ? "已填写" : "未填写", "仅反映资料完整度"); + addContribution(r, "PREFERENCE_INDUSTRY_PRESENT", industry ? "APPLIED" : "NOT_APPLIED", industry ? new BigDecimal("15") : ZERO, new BigDecimal("15"), "行业偏好", industry ? "已填写" : "未填写", "仅反映资料完整度"); + addContribution(r, "PREFERENCE_JOB_TYPE_PRESENT", jobType ? "APPLIED" : "NOT_APPLIED", jobType ? new BigDecimal("15") : ZERO, new BigDecimal("15"), "岗位类型偏好", jobType ? "已填写" : "未填写", "仅反映资料完整度"); + r.dataSources.add(ds("求职偏好", pref.getUpdatedAt(), "薪资/城市/用工/行业/岗位偏好")); return r; } public DimensionResult computeStability(LocalDateTime accountCreatedAt, int totalApps, int withdrawnApps, int login30d) { + return computeStability(accountCreatedAt, totalApps, withdrawnApps, login30d, LocalDateTime.now(clock)); + } + + public DimensionResult computeStability(LocalDateTime accountCreatedAt, int totalApps, int withdrawnApps, int login30d, + LocalDateTime capturedNow) { DimensionResult r = new DimensionResult("STABILITY"); if (totalApps == 0 && login30d == 0) { r.status = "INSUFFICIENT_DATA"; r.summary = "无投递且无登录活动"; + addContribution(r, "STABILITY_DATA_MISSING", "MISSING", null, null, "平台行为资料", + "没有足够的平台行为数据"); return r; } BigDecimal score = new BigDecimal("50"); - long ageMonths = accountCreatedAt == null ? 0 : ChronoUnit.MONTHS.between(accountCreatedAt, LocalDateTime.now()); + addContribution(r, "STABILITY_BASE", "APPLIED", new BigDecimal("50"), new BigDecimal("50"), "基础信号", "已计算", "按当前稳定信号规则计算"); + long ageMonths = accountCreatedAt == null ? 0 : ChronoUnit.MONTHS.between(accountCreatedAt, capturedNow); + BigDecimal ageContribution = ZERO; if (ageMonths > 12) { score = score.add(new BigDecimal("25")); r.reasons.add("账号活跃期超过 12 个月"); } else if (ageMonths > 6) { score = score.add(new BigDecimal("15")); r.reasons.add("账号活跃期超过 6 个月"); } + if (ageMonths > 12) ageContribution = new BigDecimal("25"); + else if (ageMonths > 6) ageContribution = new BigDecimal("15"); + addContribution(r, "STABILITY_ACCOUNT_AGE", accountCreatedAt == null ? "MISSING" : ageContribution.signum() > 0 ? "APPLIED" : "NOT_APPLIED", + ageContribution, new BigDecimal("25"), "账号时长信号", "按账号时长区间处理", "仅反映平台账号行为代理"); + BigDecimal withdrawalContribution = ZERO; if (totalApps > 0) { double rate = withdrawnApps * 1.0 / totalApps; - if (rate < 0.2) { score = score.add(new BigDecimal("15")); r.reasons.add("撤回率低于 20%"); } - else if (rate > 0.5) { score = score.subtract(new BigDecimal("20")); r.reasons.add("撤回率高于 50%"); } + if (rate < 0.2) { score = score.add(new BigDecimal("15")); withdrawalContribution = new BigDecimal("15"); r.reasons.add("撤回率低于 20%"); } + else if (rate > 0.5) { score = score.subtract(new BigDecimal("20")); withdrawalContribution = new BigDecimal("-20"); r.reasons.add("撤回率高于 50%"); } } - if (login30d > 0) { score = score.add(new BigDecimal("10")); r.reasons.add("近 30 天有登录"); } - r.score = cap(score); + String withdrawalOutcome = totalApps <= 0 ? "MISSING" : withdrawalContribution.signum() == 0 ? "NOT_APPLIED" : "APPLIED"; + addContribution(r, "STABILITY_WITHDRAWAL_RATE", withdrawalOutcome, withdrawalContribution, new BigDecimal("15"), "撤回率信号", "按撤回率区间处理", "仅反映平台行为代理"); + BigDecimal loginContribution = login30d > 0 ? new BigDecimal("10") : ZERO; + if (login30d > 0) { score = score.add(loginContribution); r.reasons.add("近 30 天有登录"); } + addContribution(r, "STABILITY_RECENT_LOGIN", login30d > 0 ? "APPLIED" : "NOT_APPLIED", loginContribution, new BigDecimal("10"), "近期登录信号", login30d > 0 ? "存在近期登录" : "无近期登录", "仅反映平台活动"); + r.score = capWithAdjustment(r, score); r.status = "OK"; r.summary = "账号活跃期、撤回率与登录信号的稳定性评估"; - r.dataSources.add(ds("users.createdAt/applications/audit_logs", LocalDateTime.now(), "账号年龄与求职行为")); + r.dataSources.add(ds("平台求职行为", capturedNow, "账号年龄、投递与近期平台活动")); return r; } public DimensionResult computeGrowth(int activeResumeCount, LocalDateTime latestResumeUpdate, int skillCount) { + return computeGrowth(activeResumeCount, latestResumeUpdate, skillCount, LocalDateTime.now(clock)); + } + + public DimensionResult computeGrowth(int activeResumeCount, LocalDateTime latestResumeUpdate, int skillCount, + LocalDateTime capturedNow) { DimensionResult r = new DimensionResult("GROWTH"); if (activeResumeCount == 0 && skillCount == 0) { r.status = "INSUFFICIENT_DATA"; r.summary = "无简历版本且无技能标签"; + addContribution(r, "GROWTH_DATA_MISSING", "MISSING", null, null, "简历与技能资料", + "没有足够的资料维护数据"); return r; } BigDecimal score = new BigDecimal("40"); - if (activeResumeCount >= 2) { score = score.add(new BigDecimal("15")); r.reasons.add("简历版本数 >= 2"); } - if (latestResumeUpdate != null && latestResumeUpdate.isAfter(LocalDateTime.now().minusDays(90))) { - score = score.add(new BigDecimal("15")); r.reasons.add("近 90 天内更新过简历"); + addContribution(r, "GROWTH_BASE", "APPLIED", new BigDecimal("40"), new BigDecimal("40"), "基础信号", "已计算", "按当前资料维护规则计算"); + BigDecimal resumeContribution = activeResumeCount >= 2 ? new BigDecimal("15") : ZERO; + if (resumeContribution.signum() > 0) { score = score.add(resumeContribution); r.reasons.add("简历版本数 >= 2"); } + addContribution(r, "GROWTH_RESUME_VERSIONS", activeResumeCount == 0 ? "MISSING" : resumeContribution.signum() > 0 ? "APPLIED" : "NOT_APPLIED", + resumeContribution, new BigDecimal("15"), "简历版本信号", activeResumeCount >= 2 ? "达到规则区间" : "未达到规则区间", "仅反映资料维护状态"); + BigDecimal recentContribution = ZERO; + if (latestResumeUpdate != null && latestResumeUpdate.isAfter(capturedNow.minusDays(90))) { + recentContribution = new BigDecimal("15"); score = score.add(recentContribution); r.reasons.add("近 90 天内更新过简历"); } - if (skillCount >= 5) { score = score.add(new BigDecimal("15")); r.reasons.add("技能标签 >= 5"); } - r.score = cap(score); + addContribution(r, "GROWTH_RECENT_RESUME_UPDATE", latestResumeUpdate == null ? "MISSING" : recentContribution.signum() > 0 ? "APPLIED" : "NOT_APPLIED", + recentContribution, new BigDecimal("15"), "近期更新信号", recentContribution.signum() > 0 ? "近 90 天已更新" : "未在近 90 天更新", "仅反映资料维护状态"); + BigDecimal skillContribution = skillCount >= 5 ? new BigDecimal("15") : ZERO; + if (skillContribution.signum() > 0) { score = score.add(skillContribution); r.reasons.add("技能标签 >= 5"); } + addContribution(r, "GROWTH_SKILL_TAGS", skillCount == 0 ? "MISSING" : skillContribution.signum() > 0 ? "APPLIED" : "NOT_APPLIED", + skillContribution, new BigDecimal("15"), "技能展示信号", skillCount >= 5 ? "达到规则区间" : "未达到规则区间", "技能数量不等于技能质量"); + r.score = capWithAdjustment(r, score); r.status = "OK"; r.summary = "简历迭代、更新频率与技能数量的成长信号"; - r.dataSources.add(ds("resume_versions/student_profiles.skillsJson", LocalDateTime.now(), "简历与技能")); + r.dataSources.add(ds("简历与技能资料", capturedNow, "简历维护与技能标签展示")); return r; } public DimensionResult computeIntegrity(int acceptedOffers, int expiredOffers, long feedbackCount, double avgRating, boolean profileComplete) { + return computeIntegrity(acceptedOffers, expiredOffers, feedbackCount, avgRating, profileComplete, + LocalDateTime.now(clock)); + } + + public DimensionResult computeIntegrity(int acceptedOffers, int expiredOffers, long feedbackCount, + double avgRating, boolean profileComplete, LocalDateTime capturedNow) { DimensionResult r = new DimensionResult("INTEGRITY"); if (acceptedOffers == 0 && expiredOffers == 0 && feedbackCount == 0) { r.status = "INSUFFICIENT_DATA"; r.summary = "无 Offer 历史且无候选人反馈"; + addContribution(r, "INTEGRITY_DATA_MISSING", "MISSING", null, null, "履约与资料信号", + "没有足够的履约或反馈数据"); return r; } BigDecimal score = new BigDecimal("60"); - if (acceptedOffers > 0) { score = score.add(new BigDecimal(Math.min(acceptedOffers, 2) * 15)); r.reasons.add("接受 Offer(守约信号)"); } - if (expiredOffers > 0) { score = score.subtract(new BigDecimal(Math.min(expiredOffers, 2) * 10)); r.reasons.add("Offer 过期未响应"); } - if (feedbackCount >= 3 && avgRating >= 4) { score = score.add(new BigDecimal("10")); r.reasons.add("候选人反馈质量良好"); } - else if (feedbackCount > 0 && avgRating < 3) { score = score.subtract(new BigDecimal("10")); r.reasons.add("候选人反馈评分偏低"); } - if (profileComplete) { score = score.add(new BigDecimal("10")); r.reasons.add("资料完整且一致"); } - r.score = cap(score); + addContribution(r, "INTEGRITY_BASE", "APPLIED", new BigDecimal("60"), new BigDecimal("60"), "基础信号", "已计算", "按当前履约与资料规则计算"); + BigDecimal acceptedContribution = new BigDecimal(Math.min(acceptedOffers, 2) * 15); + if (acceptedOffers > 0) { score = score.add(acceptedContribution); r.reasons.add("接受 Offer(守约信号)"); } + addContribution(r, "INTEGRITY_ACCEPTED_OFFERS", acceptedOffers > 0 ? "APPLIED" : "MISSING", acceptedContribution, new BigDecimal("30"), "Offer 响应信号", acceptedOffers > 0 ? "存在接受记录" : "无接受记录", "仅反映平台履约代理"); + BigDecimal expiredContribution = new BigDecimal(-Math.min(expiredOffers, 2) * 10); + if (expiredOffers > 0) { score = score.add(expiredContribution); r.reasons.add("Offer 过期未响应"); } + addContribution(r, "INTEGRITY_EXPIRED_OFFERS", expiredOffers > 0 ? "APPLIED" : "MISSING", expiredContribution, new BigDecimal("20"), "Offer 过期信号", expiredOffers > 0 ? "存在过期记录" : "无过期记录", "可能受通知或岗位变化影响"); + BigDecimal feedbackContribution = ZERO; + if (feedbackCount >= 3 && avgRating >= 4) { feedbackContribution = new BigDecimal("10"); score = score.add(feedbackContribution); r.reasons.add("候选人反馈质量良好"); } + else if (feedbackCount > 0 && avgRating < 3) { feedbackContribution = new BigDecimal("-10"); score = score.add(feedbackContribution); r.reasons.add("候选人反馈评分偏低"); } + addContribution(r, "INTEGRITY_FEEDBACK", feedbackCount == 0 ? "MISSING" : feedbackContribution.signum() == 0 ? "NOT_APPLIED" : "APPLIED", + feedbackContribution, new BigDecimal("10"), "反馈信号", feedbackCount > 0 ? "存在反馈记录" : "无反馈记录", "反馈可能受样本量和评价者差异影响"); + BigDecimal profileContribution = profileComplete ? new BigDecimal("10") : ZERO; + if (profileComplete) { score = score.add(profileContribution); r.reasons.add("资料完整且一致"); } + addContribution(r, "INTEGRITY_PROFILE_COMPLETENESS", profileComplete ? "APPLIED" : "NOT_APPLIED", profileContribution, new BigDecimal("10"), "资料完整度", profileComplete ? "资料较完整" : "资料仍可完善", "资料完整不等于人格诚信"); + r.score = capWithAdjustment(r, score); r.status = "OK"; r.summary = "Offer 守约、反馈质量与资料完整度"; - r.dataSources.add(ds("offers/offer_status_history/candidate_feedback/student_profiles/resume_versions", - LocalDateTime.now(), "守约与资料信号")); + r.dataSources.add(ds("Offer 履约与候选反馈", capturedNow, "履约、反馈与资料完整度")); return r; } public DimensionResult computeActivity(int apps90d, int audit30d, long msgCount) { + return computeActivity(apps90d, audit30d, msgCount, LocalDateTime.now(clock)); + } + + public DimensionResult computeActivity(int apps90d, int audit30d, long msgCount, LocalDateTime capturedNow) { DimensionResult r = new DimensionResult("ACTIVITY"); if (apps90d == 0 && audit30d == 0 && msgCount == 0) { r.status = "INSUFFICIENT_DATA"; r.summary = "近期无投递、登录或沟通活动"; + addContribution(r, "ACTIVITY_DATA_MISSING", "MISSING", null, null, "平台求职活动", + "没有足够的近期平台活动数据"); return r; } BigDecimal score = ZERO; - if (apps90d >= 6) { score = score.add(new BigDecimal("50")); r.reasons.add("近 90 天投递 >= 6"); } - else if (apps90d >= 3) { score = score.add(new BigDecimal("40")); r.reasons.add("近 90 天投递 >= 3"); } - else if (apps90d >= 1) { score = score.add(new BigDecimal("20")); r.reasons.add("近 90 天有投递"); } - if (audit30d >= 3) { score = score.add(new BigDecimal("30")); r.reasons.add("近 30 天活跃操作 >= 3"); } - if (msgCount > 0) { score = score.add(new BigDecimal("20")); r.reasons.add("有沟通消息"); } - r.score = cap(score); + BigDecimal appContribution = apps90d >= 6 ? new BigDecimal("50") : apps90d >= 3 ? new BigDecimal("40") : apps90d >= 1 ? new BigDecimal("20") : ZERO; + if (appContribution.signum() > 0) r.reasons.add(apps90d >= 6 ? "近 90 天投递 >= 6" : apps90d >= 3 ? "近 90 天投递 >= 3" : "近 90 天有投递"); + score = score.add(appContribution); + addContribution(r, "ACTIVITY_APPLICATIONS", appContribution.signum() > 0 ? "APPLIED" : "NOT_APPLIED", appContribution, new BigDecimal("50"), "近期投递信号", appContribution.signum() > 0 ? "近 90 天存在投递" : "近 90 天无投递", "仅反映平台活动"); + BigDecimal opContribution = audit30d >= 3 ? new BigDecimal("30") : ZERO; + if (opContribution.signum() > 0) { score = score.add(opContribution); r.reasons.add("近 30 天活跃操作 >= 3"); } + addContribution(r, "ACTIVITY_PLATFORM_OPERATIONS", opContribution.signum() > 0 ? "APPLIED" : "NOT_APPLIED", opContribution, new BigDecimal("30"), "近期平台操作", opContribution.signum() > 0 ? "达到规则区间" : "未达到规则区间", "仅反映平台活动"); + BigDecimal msgContribution = msgCount > 0 ? new BigDecimal("20") : ZERO; + if (msgContribution.signum() > 0) { score = score.add(msgContribution); r.reasons.add("有沟通消息"); } + addContribution(r, "ACTIVITY_MESSAGES", msgContribution.signum() > 0 ? "APPLIED" : "NOT_APPLIED", msgContribution, new BigDecimal("20"), "沟通信号", msgContribution.signum() > 0 ? "存在沟通记录" : "无沟通记录", "不代表沟通质量或职业能力"); + r.score = capWithAdjustment(r, score); r.status = "OK"; r.summary = "投递、登录与沟通活跃度"; - r.dataSources.add(ds("applications/audit_logs/messages", LocalDateTime.now(), "活跃行为")); + r.dataSources.add(ds("平台求职活动", capturedNow, "投递、平台操作与沟通信号")); return r; } // ── 快照批量 upsert(原生 MySQL)──────────────────────────── public void upsertSnapshots(Long studentUserId, List results, String period) { + upsertSnapshots(studentUserId, results, period, LocalDateTime.now(clock)); + } + + public void upsertSnapshots(Long studentUserId, List results, String period, + LocalDateTime capturedNow) { if (results.isEmpty()) return; StringBuilder sql = new StringBuilder( "INSERT INTO candidate_dimension_scores (student_user_id, dimension, period, score, status, " + @@ -434,7 +589,7 @@ public class CandidateScoringCalculator { List params = new ArrayList<>(); for (int i = 0; i < results.size(); i++) { if (i > 0) sql.append(","); - sql.append("(?,?,?,?,?,?,?,?,NOW(),0,NOW(),NOW())"); + sql.append("(?,?,?,?,?,?,?,?,?,0,NOW(),NOW())"); DimensionResult r = results.get(i); params.add(studentUserId); params.add(r.dimension); @@ -444,6 +599,7 @@ public class CandidateScoringCalculator { params.add(subScoresPayload(r)); params.add(DEFAULT_WEIGHT); params.add(currentRulesVersion()); + params.add(capturedNow); } sql.append(" ON DUPLICATE KEY UPDATE score=VALUES(score), status=VALUES(status), " + "sub_scores_json=VALUES(sub_scores_json), weight=VALUES(weight), " + @@ -531,6 +687,56 @@ public class CandidateScoringCalculator { return v.max(ZERO).min(HUNDRED).setScale(2, RoundingMode.HALF_UP); } + private BigDecimal capWithAdjustment(DimensionResult r, BigDecimal raw) { + BigDecimal capped = cap(raw); + BigDecimal adjustment = capped.subtract(raw); + if (adjustment.signum() != 0) { + addContribution(r, "SCORE_CAP_ADJUSTMENT", "APPLIED", adjustment, new BigDecimal("100"), "分数边界调整", + "结果已限制在 0–100", "仅用于说明边界截断,不代表新的评分规则"); + } + return capped; + } + + private void addContribution(DimensionResult r, String ruleCode, String outcome, + BigDecimal contribution, BigDecimal maxContribution, + String label, String observation, String explanation) { + r.ruleContributions.add(new com.mydreamjob.candidatescore.explanation.RuleContribution( + ruleCode, outcome, + contribution == null ? null : contribution.setScale(2, RoundingMode.HALF_UP), + maxContribution == null ? null : maxContribution.setScale(2, RoundingMode.HALF_UP), + label, observation, explanation)); + } + + /** + * 四维能力分数允许小数。各项贡献展示为两位小数时,逐项舍入可能与整体 + * abilityScore 的 HALF_UP 结果相差 0.01;用独立对账项显式记录该展示舍入差, + * 不修改任何评分公式或最终分数。 + */ + private void reconcileContributionRounding(DimensionResult r) { + if (r.score == null || r.ruleContributions.isEmpty()) return; + BigDecimal sum = r.ruleContributions.stream() + .map(c -> c.contribution() == null ? ZERO : c.contribution()) + .reduce(ZERO, BigDecimal::add) + .setScale(2, RoundingMode.HALF_UP); + BigDecimal adjustment = r.score.subtract(sum).setScale(2, RoundingMode.HALF_UP); + if (adjustment.signum() != 0) { + addContribution(r, "SCORE_ROUNDING_ADJUSTMENT", "APPLIED", adjustment, null, + "展示舍入对账", "各项贡献按两位小数展示", "用于对齐最终维度分数,不改变评分规则"); + } + } + + /** + * Convenience overload for unavailable/missing rules that only have an + * observation. The observation is also used as the stable explanation so + * every contribution remains fully populated without changing scoring. + */ + private void addContribution(DimensionResult r, String ruleCode, String outcome, + BigDecimal contribution, BigDecimal maxContribution, + String label, String observation) { + addContribution(r, ruleCode, outcome, contribution, maxContribution, + label, observation, observation); + } + private DimensionScoreDetailResponse.SubScore sub(String name, BigDecimal score, BigDecimal weight, String desc) { return DimensionScoreDetailResponse.SubScore.builder() .name(name).score(score).weight(weight).description(desc).build(); @@ -550,7 +756,9 @@ public class CandidateScoringCalculator { public List subScores = new ArrayList<>(); public List reasons = new ArrayList<>(); public List dataSources = new ArrayList<>(); + public List ruleContributions = new ArrayList<>(); public String summary; + public LocalDateTime capturedAt; /** 仅 ABILITY(OK) 时携带的最小溯源信息,用于写出 sub_scores_json;其他维度为 null。 */ public AbilitySnapshotTrace abilitySnapshotTrace; 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 4d540ca..d4e99c9 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/CandidateScoringEngineService.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/CandidateScoringEngineService.java @@ -12,6 +12,7 @@ import com.mydreamjob.job.JobService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.transaction.annotation.Transactional; import java.math.BigDecimal; @@ -19,6 +20,7 @@ import java.math.RoundingMode; import java.time.Instant; import java.time.LocalDate; import java.time.LocalDateTime; +import java.time.Clock; import java.util.*; /** @@ -31,32 +33,41 @@ import java.util.*; public class CandidateScoringEngineService { private static final BigDecimal ZERO = BigDecimal.ZERO; + private static final int TOTAL_DIMENSIONS = 8; private final CandidateScoringCalculator calculator; private final CandidateDimensionScoreRepository snapshotRepository; private final CompanyRepository companyRepository; private final ApplicationRepository applicationRepository; private final JobService jobService; + private Clock clock = Clock.systemDefaultZone(); + + @Autowired(required = false) + public void setClock(Clock clock) { + if (clock != null) this.clock = clock; + } // ── 综合评分卡(学生自查)──────────────────────────────────── @Transactional public CandidateScoringOverviewResponse getOverview(Long studentUserId) { - List results = calculator.computeAll(studentUserId); - calculator.upsertSnapshots(studentUserId, results, yearMonth(LocalDate.now())); - return buildOverview(studentUserId, results); + LocalDateTime capturedNow = LocalDateTime.now(clock); + List results = calculator.computeAll(studentUserId, capturedNow); + calculator.upsertSnapshots(studentUserId, results, yearMonth(capturedNow.toLocalDate()), capturedNow); + return buildOverview(studentUserId, results, capturedNow); } // ── 单维度明细 ─────────────────────────────────────────────── @Transactional public DimensionScoreDetailResponse getDimensionDetail(Long studentUserId, String dimension) { - List results = calculator.computeAll(studentUserId); + LocalDateTime capturedNow = LocalDateTime.now(clock); + List results = calculator.computeAll(studentUserId, capturedNow); CandidateScoringCalculator.DimensionResult r = results.stream() .filter(x -> x.dimension.equals(dimension)) .findFirst().orElseThrow(() -> new BusinessException(ErrorCode.BAD_REQUEST, "invalid dimension")); - calculator.upsertSnapshots(studentUserId, List.of(r), yearMonth(LocalDate.now())); - return toDetail(r); + calculator.upsertSnapshots(studentUserId, List.of(r), yearMonth(capturedNow.toLocalDate()), capturedNow); + return toDetail(r, capturedNow); } // ── 趋势(只读快照,不写)──────────────────────────────────── @@ -79,7 +90,7 @@ public class CandidateScoringEngineService { String rulesVersion = e.getValue().isEmpty() ? calculator.currentRulesVersion() : e.getValue().get(0).getRulesVersion(); for (CandidateDimensionScore s : e.getValue()) { - if ("OK".equals(s.getStatus()) || "ESTIMATED".equals(s.getStatus())) { + if (("OK".equals(s.getStatus()) || "ESTIMATED".equals(s.getStatus())) && s.getScore() != null) { dims.put(s.getDimension(), s.getScore()); computable.add(s.getScore()); } @@ -104,10 +115,11 @@ public class CandidateScoringEngineService { if (!company.getId().equals(job.getCompanyId())) { throw new BusinessException(ErrorCode.APPLICATION_OWNER_MISMATCH); } - List results = calculator.computeAll(app.getStudentUserId()); - calculator.upsertSnapshots(app.getStudentUserId(), results, yearMonth(LocalDate.now())); + LocalDateTime capturedNow = LocalDateTime.now(clock); + List results = calculator.computeAll(app.getStudentUserId(), capturedNow); + calculator.upsertSnapshots(app.getStudentUserId(), results, yearMonth(capturedNow.toLocalDate()), capturedNow); List computable = results.stream() - .filter(r -> "OK".equals(r.status) || "ESTIMATED".equals(r.status)) + .filter(r -> ("OK".equals(r.status) || "ESTIMATED".equals(r.status)) && r.score != null) .toList(); BigDecimal overall = computable.isEmpty() ? null : mean(computable.stream().map(r -> r.score).toList()); @@ -118,43 +130,57 @@ public class CandidateScoringEngineService { .toList(); return CompanyCandidateScoringResponse.builder() .applicationId(applicationId).overallScore(overall).status(status) - .dimensions(dims).generatedAt(Instant.now()) + .dimensions(dims).generatedAt(capturedNow.atZone(clock.getZone()).toInstant()) .rulesVersion(calculator.currentRulesVersion()).build(); } // ── 组装 ──────────────────────────────────────────────────── private CandidateScoringOverviewResponse buildOverview(Long studentUserId, - List results) { + List results, + LocalDateTime capturedNow) { List computable = results.stream() - .filter(r -> "OK".equals(r.status) || "ESTIMATED".equals(r.status)).toList(); + .filter(r -> ("OK".equals(r.status) || "ESTIMATED".equals(r.status)) && r.score != null).toList(); BigDecimal overall = computable.isEmpty() ? null : mean(computable.stream().map(r -> r.score).toList()); Map weights = new LinkedHashMap<>(); List items = new ArrayList<>(); + List included = new ArrayList<>(); + List excluded = new ArrayList<>(); for (CandidateScoringCalculator.DimensionResult r : results) { weights.put(r.dimension, CandidateScoringCalculator.DEFAULT_WEIGHT); items.add(CandidateScoringOverviewResponse.DimensionItem.builder() .dimension(r.dimension).score(r.score).status(r.status) .weight(CandidateScoringCalculator.DEFAULT_WEIGHT).summary(r.summary).build()); + if (("OK".equals(r.status) || "ESTIMATED".equals(r.status)) && r.score != null) { + included.add(r.dimension); + } else { + excluded.add(CandidateScoringOverviewResponse.ExcludedDimension.builder() + .dimension(r.dimension).status(r.status).reason(r.summary).build()); + } } return CandidateScoringOverviewResponse.builder() .candidateId(studentUserId).status(overviewStatus(results)).overallScore(overall) .weights(weights).dimensions(items).rulesVersion(calculator.currentRulesVersion()) - .generatedAt(Instant.now()).build(); + .generatedAt(capturedNow.atZone(clock.getZone()).toInstant()) + .explanationVersion(com.mydreamjob.candidatescore.explanation.CandidateScoringExplanationCatalog.VERSION) + .coverage(CandidateScoringOverviewResponse.Coverage.builder() + .computableDimensions(included.size()).totalDimensions(TOTAL_DIMENSIONS) + .includedDimensions(included).excludedDimensions(excluded).build()) + .comparabilityNotice("不同参与维度集合的综合分不适合直接横向比较") + .build(); } - private DimensionScoreDetailResponse toDetail(CandidateScoringCalculator.DimensionResult r) { - 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(calculator.currentRulesVersion()).build(); + private DimensionScoreDetailResponse toDetail(CandidateScoringCalculator.DimensionResult r, + LocalDateTime capturedNow) { + return com.mydreamjob.candidatescore.explanation.CandidateScoringExplanationAssembler.assemble( + r, calculator.currentRulesVersion(), capturedNow); } private String overviewStatus(List results) { long computable = results.stream() - .filter(x -> "OK".equals(x.status) || "ESTIMATED".equals(x.status)).count(); - if (computable == results.size()) return "OK"; + .filter(x -> ("OK".equals(x.status) || "ESTIMATED".equals(x.status)) && x.score != null).count(); + if (computable == TOTAL_DIMENSIONS) return "OK"; if (computable > 0) return "PARTIAL"; boolean allPending = results.stream().allMatch(x -> "PENDING".equals(x.status)); return allPending ? "PENDING" : "INSUFFICIENT_DATA"; diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dto/CandidateScoringOverviewResponse.java b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dto/CandidateScoringOverviewResponse.java index c5fd232..2f7e069 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dto/CandidateScoringOverviewResponse.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dto/CandidateScoringOverviewResponse.java @@ -24,6 +24,26 @@ public class CandidateScoringOverviewResponse { private List dimensions; private String rulesVersion; private Instant generatedAt; + private String explanationVersion; + private Coverage coverage; + private String comparabilityNotice; + + @Data + @Builder + public static class Coverage { + private int computableDimensions; + private int totalDimensions; + private List includedDimensions; + private List excludedDimensions; + } + + @Data + @Builder + public static class ExcludedDimension { + private String dimension; + private String status; + private String reason; + } @Data @Builder diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dto/DimensionScoreDetailResponse.java b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dto/DimensionScoreDetailResponse.java index 22fc03c..b31b009 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dto/DimensionScoreDetailResponse.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/dto/DimensionScoreDetailResponse.java @@ -24,6 +24,16 @@ public class DimensionScoreDetailResponse { private List dataSources; private LocalDateTime updatedAt; private String rulesVersion; + private String displayName; + private String whatItMeasures; + private String interpretation; + private List limitations; + private String evidenceLevel; + private List ruleContributions; + private List improvementActions; + private String explanationVersion; + private Validity validity; + private LocalDateTime calculatedAt; @Data @Builder @@ -41,4 +51,26 @@ public class DimensionScoreDetailResponse { private LocalDateTime time; private String note; } + + @Data + @Builder + public static class RuleContribution { + private String ruleCode; + private String outcome; + @JsonInclude(JsonInclude.Include.ALWAYS) + private BigDecimal contribution; + @JsonInclude(JsonInclude.Include.ALWAYS) + private BigDecimal maxContribution; + private String label; + private String observation; + private String explanation; + } + + @Data + @Builder + public static class Validity { + private LocalDateTime completedAt; + private LocalDateTime validUntil; + private Boolean expired; + } } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/explanation/CandidateScoringExplanationAssembler.java b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/explanation/CandidateScoringExplanationAssembler.java new file mode 100644 index 0000000..bbaf24d --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/explanation/CandidateScoringExplanationAssembler.java @@ -0,0 +1,76 @@ +package com.mydreamjob.candidatescore.explanation; + +import com.mydreamjob.candidatescore.CandidateScoringCalculator; +import com.mydreamjob.candidatescore.dto.DimensionScoreDetailResponse; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Set; + +/** 只负责把同源计算事实组装为学生白名单响应,不包含任何评分公式。 */ +public final class CandidateScoringExplanationAssembler { + private static final Set SUPPORTED_OUTCOMES = Set.of( + "APPLIED", "NOT_APPLIED", "MISSING", "EXPIRED", "UNAVAILABLE"); + + private CandidateScoringExplanationAssembler() {} + + public static DimensionScoreDetailResponse assemble(CandidateScoringCalculator.DimensionResult r, + String rulesVersion, + LocalDateTime capturedNow) { + CandidateScoringExplanationCatalog.Metadata m = CandidateScoringExplanationCatalog.metadata(r.dimension); + validateContributions(r); + List contributions = r.ruleContributions == null + ? List.of() : r.ruleContributions.stream().map(RuleContribution::toResponse).toList(); + DimensionScoreDetailResponse.Validity validity = null; + if (r.abilitySnapshotTrace != null) { + LocalDateTime until = r.abilitySnapshotTrace.validUntil(); + validity = DimensionScoreDetailResponse.Validity.builder() + .completedAt(r.abilitySnapshotTrace.completedAt()).validUntil(until) + .expired(until != null && !until.isAfter(capturedNow)).build(); + } + return DimensionScoreDetailResponse.builder() + .dimension(r.dimension).score(r.score).status(r.status) + .subScores(r.subScores).reasons(r.reasons).dataSources(r.dataSources) + .updatedAt(capturedNow).rulesVersion(rulesVersion) + .displayName(m.displayName()).whatItMeasures(m.whatItMeasures()) + .interpretation(m.interpretation()).limitations(m.limitations()) + .evidenceLevel(m.evidenceLevel()).ruleContributions(contributions) + .improvementActions(m.improvementActions()).explanationVersion(CandidateScoringExplanationCatalog.VERSION) + .validity(validity).calculatedAt(capturedNow).build(); + } + + /** + * 解释必须与真实计分事实完整对账。未知或损坏的内部规则不能被静默丢弃, + * 否则会生成“成功但贡献不等于分数”的不可信响应。异常文本固定且不回显内部值。 + */ + private static void validateContributions(CandidateScoringCalculator.DimensionResult result) { + if (result.ruleContributions == null || result.ruleContributions.isEmpty()) { + throw unsupportedContribution(); + } + for (RuleContribution contribution : result.ruleContributions) { + if (contribution == null || contribution.ruleCode() == null + || contribution.ruleCode().isBlank() + || !CandidateScoringExplanationCatalog.isKnownRuleCode(contribution.ruleCode()) + || contribution.outcome() == null + || !SUPPORTED_OUTCOMES.contains(contribution.outcome())) { + throw unsupportedContribution(); + } + } + if (result.score != null) { + BigDecimal sum = result.ruleContributions.stream() + .map(c -> c.contribution() == null ? BigDecimal.ZERO : c.contribution()) + .reduce(BigDecimal.ZERO, BigDecimal::add) + .setScale(2, RoundingMode.HALF_UP); + BigDecimal score = result.score.setScale(2, RoundingMode.HALF_UP); + if (sum.compareTo(score) != 0) { + throw unsupportedContribution(); + } + } + } + + private static IllegalStateException unsupportedContribution() { + return new IllegalStateException("candidate scoring explanation contains an unsupported rule"); + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/explanation/CandidateScoringExplanationCatalog.java b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/explanation/CandidateScoringExplanationCatalog.java new file mode 100644 index 0000000..5450a5e --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/explanation/CandidateScoringExplanationCatalog.java @@ -0,0 +1,68 @@ +package com.mydreamjob.candidatescore.explanation; + +import java.util.List; +import java.util.Map; + +/** 固定白名单解释元数据;不访问数据库、不计算分数、不生成动态文案。 */ +public final class CandidateScoringExplanationCatalog { + public static final String VERSION = "candidate-scoring-explanation-v1"; + private static final java.util.Set RULE_CODES = java.util.Set.of( + "EDUCATION_LEVEL", "EDUCATION_SCHOOL_TIER", "EDUCATION_MISSING", + "INTERNSHIP_SOURCE_UNAVAILABLE", + "ABILITY_FEATURE_DISABLED", "ABILITY_RESULT_MISSING", "ABILITY_RESULT_EXPIRED", + "ABILITY_RESULT_INVALID", "ABILITY_LOGIC", "ABILITY_EXECUTION", "ABILITY_LEARNING", + "ABILITY_COMMUNICATION", + "PREFERENCE_SALARY_PRESENT", "PREFERENCE_CITY_PRESENT", + "PREFERENCE_EMPLOYMENT_TYPE_PRESENT", "PREFERENCE_INDUSTRY_PRESENT", + "PREFERENCE_JOB_TYPE_PRESENT", "PREFERENCE_ALL_MISSING", + "STABILITY_BASE", "STABILITY_ACCOUNT_AGE", "STABILITY_WITHDRAWAL_RATE", + "STABILITY_RECENT_LOGIN", "STABILITY_DATA_MISSING", + "GROWTH_BASE", "GROWTH_RESUME_VERSIONS", "GROWTH_RECENT_RESUME_UPDATE", + "GROWTH_SKILL_TAGS", "GROWTH_DATA_MISSING", + "INTEGRITY_BASE", "INTEGRITY_ACCEPTED_OFFERS", "INTEGRITY_EXPIRED_OFFERS", + "INTEGRITY_FEEDBACK", "INTEGRITY_PROFILE_COMPLETENESS", "INTEGRITY_DATA_MISSING", + "ACTIVITY_APPLICATIONS", "ACTIVITY_PLATFORM_OPERATIONS", "ACTIVITY_MESSAGES", + "ACTIVITY_DATA_MISSING", "SCORE_CAP_ADJUSTMENT", "SCORE_ROUNDING_ADJUSTMENT"); + private static final Map ITEMS = Map.of( + "EDUCATION", new Metadata("教育背景信息", "个人教育资料按固定分类规则形成的背景信号", + "反映资料字段在平台分类规则下的结果", List.of("不能代表智力、实际技能或录用适合度"), "UNVALIDATED_PROXY", + List.of("完善真实教育资料", "保持资料真实准确")), + "INTERNSHIP", new Metadata("实习经历信息", "结构化实习经历数据源的可用性", + "当前平台是否具备该维度数据", List.of("数据源未接入不等于没有实习"), "DIRECT_RULE", + List.of("待平台接入结构化实习资料")), + "ABILITY", new Metadata("四维能力测评", "V36 四维能力测评的规则化聚合结果", + "反映当前自研测评规则下的聚合表现", List.of("不代表确定性工作能力或录用结论"), "UNVALIDATED_PROXY", + List.of("完成有效测评", "测评结果过期后重新测评")), + "COMPLIANCE", new Metadata("求职偏好完整度", "五类求职偏好字段的填写情况", + "反映资料完整程度,不作个人行为评价", List.of("不代表纪律性、合规意识或职业道德"), "SELF_REPORTED", + List.of("完善真实求职偏好", "保持偏好资料准确")), + "STABILITY", new Metadata("平台求职行为稳定信号", "账号时长、撤回率和近期登录形成的行为信号", + "反映平台行为规则下的稳定信号", List.of("不代表人格稳定、忠诚度或离职概率"), "UNVALIDATED_PROXY", + List.of("保持资料真实准确", "在有效期内处理 Offer")), + "GROWTH", new Metadata("简历维护与技能展示信号", "简历维护和技能标签展示情况", + "反映资料维护状态,不作个人发展结论", List.of("不代表成长潜力、学习速度或晋升能力"), "UNVALIDATED_PROXY", + List.of("核对简历和技能标签", "保持资料真实准确")), + "INTEGRITY", new Metadata("履约与资料一致性信号", "Offer 响应、反馈和资料完整度形成的行为信号", + "反映平台履约与资料一致性规则下的结果", List.of("该信号不是个人征信、信用评级、道德评价或人格判断"), "UNVALIDATED_PROXY", + List.of("及时查看并处理有效 Offer", "保持资料真实准确")), + "ACTIVITY", new Metadata("平台求职活跃度", "指定窗口内的投递、平台操作和沟通信号", + "反映平台内近期活动状态", List.of("不代表工作积极性、职业动机或职业能力"), "BEHAVIOR_PROXY", + List.of("保持资料真实准确")) + ); + + private CandidateScoringExplanationCatalog() {} + + public static Metadata metadata(String dimension) { + Metadata m = ITEMS.get(dimension); + if (m == null) throw new IllegalArgumentException("unknown scoring dimension"); + return m; + } + + public static boolean isKnownRuleCode(String ruleCode) { + return ruleCode != null && RULE_CODES.contains(ruleCode); + } + + public record Metadata(String displayName, String whatItMeasures, String interpretation, + List limitations, String evidenceLevel, + List improvementActions) {} +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/explanation/RuleContribution.java b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/explanation/RuleContribution.java new file mode 100644 index 0000000..0175e31 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/explanation/RuleContribution.java @@ -0,0 +1,17 @@ +package com.mydreamjob.candidatescore.explanation; + +import com.mydreamjob.candidatescore.dto.DimensionScoreDetailResponse; + +import java.math.BigDecimal; + +/** 由真实计分分支产生的解释事实;不访问数据库,也不重新计算分数。 */ +public record RuleContribution(String ruleCode, String outcome, BigDecimal contribution, + BigDecimal maxContribution, String label, + String observation, String explanation) { + public DimensionScoreDetailResponse.RuleContribution toResponse() { + return DimensionScoreDetailResponse.RuleContribution.builder() + .ruleCode(ruleCode).outcome(outcome).contribution(contribution) + .maxContribution(maxContribution).label(label).observation(observation) + .explanation(explanation).build(); + } +} 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 4dde419..4d1ebdc 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 @@ -14,6 +14,7 @@ import jakarta.persistence.EntityManager; import jakarta.persistence.PersistenceContext; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.dao.OptimisticLockingFailureException; import org.springframework.data.domain.Page; @@ -22,7 +23,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; -import java.time.Year; +import java.time.Clock; import java.util.List; import java.util.Optional; import java.util.regex.Pattern; @@ -49,6 +50,14 @@ public class CandidateScoringGovernanceService { private final UserRepository userRepository; private final AuditService auditService; + /** 与评分计算器一致的可注入时间源,保证一次回滚重算使用同一 capturedNow。 */ + private Clock clock = Clock.systemDefaultZone(); + + @Autowired(required = false) + public void setClock(Clock clock) { + if (clock != null) this.clock = clock; + } + @PersistenceContext private EntityManager entityManager; @@ -261,7 +270,8 @@ public class CandidateScoringGovernanceService { throw new BusinessException(ErrorCode.APPEAL_INVALID_TRANSITION); } Long candidateId = appeal.getCandidateUserId(); - int year = Year.now().getValue(); + LocalDateTime capturedNow = LocalDateTime.now(clock); + int year = capturedNow.getYear(); // 并发安全:确保 rollback_limits 行存在(INSERT IGNORE 不抛唯一冲突),再悲观锁串行化 entityManager.createNativeQuery( @@ -282,7 +292,7 @@ public class CandidateScoringGovernanceService { // 按当前数据确定性重算并写新版本 CandidateScoringCalculator.DimensionResult after = calculator.recomputeAndSnapshot( - candidateId, appeal.getDimension(), appeal.getPeriod()); + candidateId, appeal.getDimension(), appeal.getPeriod(), capturedNow); // 原生 upsert 固定 version=version+1:afterVersion 确定性计算(避免读取持久化上下文旧值) Long afterVersion = before != null ? before.getVersion() + 1 : 0L; @@ -299,7 +309,7 @@ public class CandidateScoringGovernanceService { String from = appeal.getStatus(); appeal.setStatus(CandidateScoringAppeal.STATUS_ROLLED_BACK); - appeal.setRollbackAt(LocalDateTime.now()); + appeal.setRollbackAt(capturedNow); appeal.setActiveKey(null); // 终态清空 active_key appealRepository.save(appeal); recordHistory(appeal.getId(), from, CandidateScoringAppeal.STATUS_ROLLED_BACK, diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/AbilityScoreIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/AbilityScoreIntegrationTest.java index 6508735..4fcd701 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/AbilityScoreIntegrationTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/AbilityScoreIntegrationTest.java @@ -1,5 +1,7 @@ package com.mydreamjob.candidatescore; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import com.mydreamjob.ability.AbilityAttempt; import com.mydreamjob.ability.AbilityAttemptRepository; import com.mydreamjob.ability.AbilityDefinition; @@ -20,7 +22,9 @@ import org.springframework.test.context.ActiveProfiles; import org.springframework.test.util.ReflectionTestUtils; import java.math.BigDecimal; +import java.time.Clock; import java.time.LocalDateTime; +import java.time.ZoneId; import java.util.ArrayList; import java.util.List; import java.util.Set; @@ -86,6 +90,9 @@ class AbilityScoreIntegrationTest { } createdUsers.clear(); ReflectionTestUtils.setField(calculator, "abilityAssessmentEnabled", false); + Clock system = Clock.systemDefaultZone(); + ReflectionTestUtils.setField(calculator, "clock", system); + ReflectionTestUtils.setField(engineService, "clock", system); } private Long createUser() { @@ -203,10 +210,94 @@ class AbilityScoreIntegrationTest { assertEquals("PENDING", dimensionStatus(r2, "ABILITY")); } + @Test + void engineDetail_validUntilEqualCapturedNow_isExpiredAndRedacted() throws Exception { + Long u = createUser(); + ReflectionTestUtils.setField(calculator, "abilityAssessmentEnabled", true); + LocalDateTime captured = LocalDateTime.of(2026, 9, 9, 12, 0); + Clock fixed = Clock.fixed(captured.atZone(ZoneId.of("Asia/Shanghai")).toInstant(), + ZoneId.of("Asia/Shanghai")); + ReflectionTestUtils.setField(calculator, "clock", fixed); + ReflectionTestUtils.setField(engineService, "clock", fixed); + Long aid = attempt(u, "COMPLETED", captured.minusDays(1), 24); + result(aid, DIMS_OK, captured); + + var detail = engineService.getDimensionDetail(u, "ABILITY"); + + assertEquals("PENDING", detail.getStatus()); + assertNull(detail.getScore()); + assertNotNull(detail.getValidity()); + assertTrue(detail.getValidity().getExpired()); + assertEquals(captured, detail.getCalculatedAt()); + String json = new ObjectMapper().registerModule(new JavaTimeModule()).writeValueAsString(detail); + for (String banned : List.of("resultId", "attemptId", "answerKey", "scoringRule", + "questionCode", "competencyTag", "selectedOptionId", "stem")) { + assertFalse(json.contains(banned), "ABILITY detail must not expose " + banned); + } + } + + @Test + void engineDetail_validUntilAfterCapturedNow_isValidAndContributionsReconcile() throws Exception { + Long u = createUser(); + ReflectionTestUtils.setField(calculator, "abilityAssessmentEnabled", true); + LocalDateTime captured = LocalDateTime.of(2026, 9, 9, 12, 0); + Clock fixed = Clock.fixed(captured.atZone(ZoneId.of("Asia/Shanghai")).toInstant(), + ZoneId.of("Asia/Shanghai")); + ReflectionTestUtils.setField(calculator, "clock", fixed); + ReflectionTestUtils.setField(engineService, "clock", fixed); + Long aid = attempt(u, "COMPLETED", captured.minusDays(1), 24); + result(aid, DIMS_OK, captured.plusSeconds(1)); + + var detail = engineService.getDimensionDetail(u, "ABILITY"); + + assertEquals("OK", detail.getStatus()); + assertEquals(new BigDecimal("74.25"), detail.getScore()); + assertNotNull(detail.getValidity()); + assertFalse(detail.getValidity().getExpired()); + assertEquals(captured.plusSeconds(1), detail.getValidity().getValidUntil()); + BigDecimal contributionSum = detail.getRuleContributions().stream() + .map(c -> c.getContribution() == null ? BigDecimal.ZERO : c.getContribution()) + .reduce(BigDecimal.ZERO, BigDecimal::add); + assertEquals(detail.getScore(), contributionSum); + String json = new ObjectMapper().registerModule(new JavaTimeModule()).writeValueAsString(detail); + for (String banned : List.of("resultId", "attemptId", "answerKey", "scoringRule", + "questionCode", "competencyTag", "selectedOptionId", "stem")) { + assertFalse(json.contains(banned), "ABILITY detail must not expose " + banned); + } + } + + @Test + void engineDetail_nullValidUntil_isInvalidAndNeverReportedAsExpired() throws Exception { + Long u = createUser(); + ReflectionTestUtils.setField(calculator, "abilityAssessmentEnabled", true); + LocalDateTime captured = LocalDateTime.of(2026, 9, 9, 12, 0); + Clock fixed = Clock.fixed(captured.atZone(ZoneId.of("Asia/Shanghai")).toInstant(), + ZoneId.of("Asia/Shanghai")); + ReflectionTestUtils.setField(calculator, "clock", fixed); + ReflectionTestUtils.setField(engineService, "clock", fixed); + Long aid = attempt(u, "COMPLETED", captured.minusDays(1), 24); + result(aid, DIMS_OK, null); + + var detail = engineService.getDimensionDetail(u, "ABILITY"); + + assertEquals("PENDING", detail.getStatus()); + assertNull(detail.getScore()); + assertNull(detail.getValidity(), "missing validity is invalid data, not an expired validity window"); + assertEquals(1, detail.getRuleContributions().size()); + assertEquals("ABILITY_RESULT_INVALID", detail.getRuleContributions().get(0).getRuleCode()); + assertEquals("UNAVAILABLE", detail.getRuleContributions().get(0).getOutcome()); + String json = new ObjectMapper().registerModule(new JavaTimeModule()).writeValueAsString(detail); + assertFalse(json.contains("ABILITY_RESULT_EXPIRED")); + for (String banned : List.of("resultId", "attemptId", "answerKey", "scoringRule", + "questionCode", "competencyTag", "selectedOptionId", "stem")) { + assertFalse(json.contains(banned), "ABILITY detail must not expose " + banned); + } + } + 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/CandidateScoringCalculatorTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoringCalculatorTest.java index 5750316..5532a06 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoringCalculatorTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoringCalculatorTest.java @@ -184,6 +184,38 @@ class CandidateScoringCalculatorTest { assertEquals("candidate-scoring-v0.2", calculator.currentRulesVersion()); } + @Test void ability_switchOn_expiredProjection_isPendingWithExpiredExplanation() { + setEnabled(true); + when(abilityScoreSource.findLatestValidCompletedResult(eq(STUDENT_ID), any())) + .thenReturn(new AbilityAssessmentScoreSource.AbilityResultProjection( + 124L, NOW.minusDays(8), NOW, "ability-v0.1", "ability-v0.1", 24, + dimsJson(80, 65, 75, 70))); + CandidateScoringCalculator.DimensionResult r = calculator.computeAbility(STUDENT_ID, NOW); + assertEquals("PENDING", r.status); + assertNull(r.score); + assertEquals("ABILITY_RESULT_EXPIRED", r.ruleContributions.get(0).ruleCode()); + assertEquals("EXPIRED", r.ruleContributions.get(0).outcome()); + } + + @Test void ability_decimalContributions_reconcileWithOverallRoundedScore() { + setEnabled(true); + var projection = new AbilityAssessmentScoreSource.AbilityResultProjection( + 125L, NOW.minusDays(1), NOW.plusDays(1), "ability-v0.1", "ability-v0.1", 24, + "[{\"code\":\"LOGIC\",\"score\":33.33,\"answeredCount\":6}," + + "{\"code\":\"COMMUNICATION\",\"score\":33.33,\"answeredCount\":6}," + + "{\"code\":\"EXECUTION\",\"score\":33.33,\"answeredCount\":6}," + + "{\"code\":\"LEARNING\",\"score\":33.33,\"answeredCount\":6}]"); + when(abilityScoreSource.findLatestValidCompletedResult(eq(STUDENT_ID), eq(NOW))) + .thenReturn(projection); + CandidateScoringCalculator.DimensionResult r = calculator.computeAbility(STUDENT_ID, NOW); + BigDecimal sum = r.ruleContributions.stream() + .map(c -> c.contribution() == null ? BigDecimal.ZERO : c.contribution()) + .reduce(BigDecimal.ZERO, BigDecimal::add); + assertEquals(r.score, sum); + assertTrue(r.ruleContributions.stream() + .anyMatch(c -> "SCORE_ROUNDING_ADJUSTMENT".equals(c.ruleCode()))); + } + @Test void ability_switchOn_malformedJson_pendingNo500() { setEnabled(true); when(abilityScoreSource.findLatestValidCompletedResult(eq(STUDENT_ID), any())) diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoringClockBoundaryIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoringClockBoundaryIntegrationTest.java new file mode 100644 index 0000000..cb97818 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoringClockBoundaryIntegrationTest.java @@ -0,0 +1,114 @@ +package com.mydreamjob.candidatescore; + +import com.mydreamjob.auth.User; +import com.mydreamjob.auth.UserRepository; +import com.mydreamjob.company.Company; +import com.mydreamjob.company.CompanyRepository; +import com.mydreamjob.job.Job; +import com.mydreamjob.job.JobRepository; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.concurrent.atomic.AtomicLong; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * N02 的真实 H2 SQL 边界证据:不 Mock JdbcTemplate 或 Calculator,直接执行正式数据采集路径。 + */ +@SpringBootTest +@ActiveProfiles("integration-test") +@Transactional +class CandidateScoringClockBoundaryIntegrationTest { + + private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai"); + private static final LocalDateTime CAPTURED = LocalDateTime.of(2026, 9, 9, 12, 0); + private static final AtomicLong PHONE = new AtomicLong(930000000L); + + @Autowired private CandidateScoringCalculator calculator; + @Autowired private UserRepository userRepository; + @Autowired private CompanyRepository companyRepository; + @Autowired private JobRepository jobRepository; + @Autowired private org.springframework.jdbc.core.JdbcTemplate jdbcTemplate; + @MockBean private RedisTemplate redisTemplate; + + @Test + void exactThirtyDaysAuditBoundary_isIncludedAndEarlierRecordExcluded() { + User user = createStudent(); + insertAudit(user.getId(), "LOGIN", CAPTURED.minusDays(30)); + insertAudit(user.getId(), "LOGIN", CAPTURED.minusDays(30).minusSeconds(1)); + + List results = calculator.computeAll(user.getId(), CAPTURED); + + CandidateScoringCalculator.DimensionResult stability = result(results, "STABILITY"); + CandidateScoringCalculator.DimensionResult activity = result(results, "ACTIVITY"); + assertEquals("OK", stability.status); + assertEquals(new BigDecimal("60.00"), stability.score, + "one exact-boundary login is included without changing the existing formula"); + assertEquals(new BigDecimal("0.00"), activity.score, + "a single login is below the activity threshold"); + } + + @Test + void exactNinetyDaysApplicationBoundary_isIncludedAndEarlierRecordExcluded() { + User user = createStudent(); + Company company = companyRepository.save(Company.builder() + .ownerUserId(user.getId()).name("clock-boundary-company").verified(true).build()); + Job exact = createJob(company.getId(), "clock-exact"); + Job earlier = createJob(company.getId(), "clock-earlier"); + insertApplication(exact.getId(), user.getId(), CAPTURED.minusDays(90)); + insertApplication(earlier.getId(), user.getId(), CAPTURED.minusDays(90).minusSeconds(1)); + + List results = calculator.computeAll(user.getId(), CAPTURED); + + CandidateScoringCalculator.DimensionResult activity = result(results, "ACTIVITY"); + assertEquals("OK", activity.status); + assertEquals(new BigDecimal("20.00"), activity.score, + "only the exact-boundary application contributes one recent application"); + } + + private User createStudent() { + User user = User.builder() + .phone("1" + PHONE.incrementAndGet()) + .nickname("clock-boundary-student") + .role(User.Role.STUDENT) + .roles(new HashSet<>(Set.of(User.Role.STUDENT))) + .active(true).verified(true).build(); + return userRepository.save(user); + } + + private Job createJob(Long companyId, String title) { + return jobRepository.save(Job.builder().companyId(companyId).title(title) + .description("clock boundary fixture").city("上海") + .employmentType("FULL_TIME").status("PUBLISHED").build()); + } + + private void insertAudit(Long userId, String action, LocalDateTime createdAt) { + jdbcTemplate.update("INSERT INTO audit_logs (user_id, action, created_at) VALUES (?, ?, ?)", + userId, action, createdAt); + } + + private void insertApplication(Long jobId, Long studentId, LocalDateTime createdAt) { + jdbcTemplate.update("INSERT INTO applications (job_id, student_user_id, status, created_at) VALUES (?, ?, ?, ?)", + jobId, studentId, "SUBMITTED", createdAt); + } + + private CandidateScoringCalculator.DimensionResult result( + List results, String dimension) { + return results.stream().filter(r -> dimension.equals(r.dimension)).findFirst().orElseThrow(); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoringClockConsistencyTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoringClockConsistencyTest.java new file mode 100644 index 0000000..4968aa1 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoringClockConsistencyTest.java @@ -0,0 +1,324 @@ +package com.mydreamjob.candidatescore; + +import com.mydreamjob.auth.User; +import com.mydreamjob.candidatescore.explanation.RuleContribution; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.ArgumentMatchers; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.test.util.ReflectionTestUtils; +import jakarta.persistence.EntityManager; + +import java.math.BigDecimal; +import java.time.Clock; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class CandidateScoringClockConsistencyTest { + + @Mock CandidateScoringCalculator calculator; + @Mock CandidateDimensionScoreRepository snapshotRepository; + @Mock com.mydreamjob.company.CompanyRepository companyRepository; + @Mock com.mydreamjob.application.ApplicationRepository applicationRepository; + @Mock com.mydreamjob.job.JobService jobService; + @Mock com.mydreamjob.profile.StudentProfileRepository profileRepository; + @Mock com.mydreamjob.preference.StudentPreferenceRepository preferenceRepository; + @Mock com.mydreamjob.auth.UserRepository userRepository; + @Mock JdbcTemplate jdbcTemplate; + @Mock AbilityAssessmentScoreSource abilityScoreSource; + @Mock EntityManager entityManager; + + private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai"); + private static final LocalDateTime CAPTURED = LocalDateTime.of(2026, 9, 9, 12, 0); + + @Test + void overviewUsesOneCapturedTimeForComputeAndSnapshot() { + CandidateScoringEngineService service = new CandidateScoringEngineService( + calculator, snapshotRepository, companyRepository, applicationRepository, jobService); + LocalDateTime captured = LocalDateTime.of(2026, 9, 9, 12, 0); + service.setClock(Clock.fixed(captured.atZone(BUSINESS_ZONE).toInstant(), BUSINESS_ZONE)); + var result = new CandidateScoringCalculator.DimensionResult("EDUCATION"); + result.status = "OK"; + result.score = java.math.BigDecimal.TEN; + when(calculator.computeAll(eq(7L), any(LocalDateTime.class))).thenReturn(List.of(result)); + when(calculator.currentRulesVersion()).thenReturn(CandidateScoringCalculator.RULES_VERSION); + + service.getOverview(7L); + + ArgumentCaptor computeTime = ArgumentCaptor.forClass(LocalDateTime.class); + verify(calculator).computeAll(eq(7L), computeTime.capture()); + ArgumentCaptor snapshotTime = ArgumentCaptor.forClass(LocalDateTime.class); + verify(calculator).upsertSnapshots(eq(7L), anyList(), anyString(), snapshotTime.capture()); + assertEquals(captured, computeTime.getValue()); + assertEquals(computeTime.getValue(), snapshotTime.getValue()); + } + + @Test + void exactThirtyDays_usesExistingBoundarySemantics() { + BoundaryCapture capture = new BoundaryCapture(); + CandidateScoringCalculator real = calculatorWithJdbcAggregates(CAPTURED, capture); + List results = real.computeAll(7L, CAPTURED); + + assertEquals(new BigDecimal("20.00"), results.stream() + .filter(r -> "ACTIVITY".equals(r.dimension)).findFirst().orElseThrow().score); + // queryAudit uses created_at >= capturedNow.minusDays(30), including the exact boundary. + assertEquals(CAPTURED.minusDays(30), capture.auditBoundary); + } + + @Test + void exactNinetyDays_usesExistingBoundarySemantics() { + BoundaryCapture capture = new BoundaryCapture(); + CandidateScoringCalculator real = calculatorWithJdbcAggregates(CAPTURED, capture); + List results = real.computeAll(7L, CAPTURED); + + assertEquals(new BigDecimal("20.00"), results.stream() + .filter(r -> "ACTIVITY".equals(r.dimension)).findFirst().orElseThrow().score); + // queryApps uses created_at >= capturedNow.minusDays(90); messages remain unwindowed. + assertEquals(CAPTURED.minusDays(90), capture.applicationBoundary); + } + + @Test + void accountAgeSixAndTwelveMonths_preservesStrictThresholds() { + CandidateScoringCalculator real = calculatorWithJdbcAggregates(CAPTURED, new BoundaryCapture()); + var exactSix = real.computeStability(CAPTURED.minusMonths(6), 1, 0, 0, CAPTURED); + var overSix = real.computeStability(CAPTURED.minusMonths(7), 1, 0, 0, CAPTURED); + var exactTwelve = real.computeStability(CAPTURED.minusMonths(12), 1, 0, 0, CAPTURED); + var overTwelve = real.computeStability(CAPTURED.minusMonths(13), 1, 0, 0, CAPTURED); + + assertEquals(new BigDecimal("65.00"), exactSix.score); + assertEquals(new BigDecimal("80.00"), overSix.score); + assertEquals(new BigDecimal("80.00"), exactTwelve.score); + assertEquals(new BigDecimal("90.00"), overTwelve.score); + } + + @Test + void resumeUpdateExactlyNinetyDays_preservesExistingComparison() { + CandidateScoringCalculator real = calculatorWithJdbcAggregates(CAPTURED, new BoundaryCapture()); + var exact = real.computeGrowth(1, CAPTURED.minusDays(90), 0, CAPTURED); + var newer = real.computeGrowth(1, CAPTURED.minusDays(89), 0, CAPTURED); + + assertEquals(new BigDecimal("40.00"), exact.score); + assertEquals(new BigDecimal("55.00"), newer.score); + } + + @Test + void abilityValidUntilEqualsCapturedNow_isExpiredOrUnavailable() { + CandidateScoringCalculator real = calculatorWithJdbcAggregates(CAPTURED, new BoundaryCapture()); + ReflectionTestUtils.setField(real, "abilityAssessmentEnabled", true); + var projection = new AbilityAssessmentScoreSource.AbilityResultProjection( + 101L, CAPTURED.minusDays(1), CAPTURED, "ability-v0.1", "ability-v0.1", 24, + abilityJson(80, 70, 60, 50)); + when(abilityScoreSource.findLatestValidCompletedResult(7L, CAPTURED)).thenReturn(projection); + + var result = real.computeAbility(7L, CAPTURED); + + assertEquals("PENDING", result.status); + assertNull(result.score); + assertEquals("EXPIRED", result.ruleContributions.get(0).outcome()); + assertEquals("ABILITY_RESULT_EXPIRED", result.ruleContributions.get(0).ruleCode()); + } + + @Test + void abilityValidUntilAfterCapturedNow_isValid() { + CandidateScoringCalculator real = calculatorWithJdbcAggregates(CAPTURED, new BoundaryCapture()); + ReflectionTestUtils.setField(real, "abilityAssessmentEnabled", true); + var projection = new AbilityAssessmentScoreSource.AbilityResultProjection( + 102L, CAPTURED.minusDays(1), CAPTURED.plusNanos(1), "ability-v0.1", "ability-v0.1", 24, + abilityJson(80, 70, 60, 50)); + when(abilityScoreSource.findLatestValidCompletedResult(7L, CAPTURED)).thenReturn(projection); + + var result = real.computeAbility(7L, CAPTURED); + + assertEquals("OK", result.status); + assertEquals(new BigDecimal("66.50"), result.score); + assertEquals("ABILITY_LOGIC", result.ruleContributions.get(0).ruleCode()); + assertEquals(4, result.ruleContributions.size()); + } + + @Test + void advancingClockWithinOneRequest_usesOnlyInitialCapturedNow() { + CandidateScoringEngineService service = new CandidateScoringEngineService( + calculator, snapshotRepository, companyRepository, applicationRepository, jobService); + AdvancingClock advancing = new AdvancingClock(CAPTURED, BUSINESS_ZONE); + service.setClock(advancing); + var result = new CandidateScoringCalculator.DimensionResult("EDUCATION"); + result.status = "OK"; + result.score = BigDecimal.TEN; + result.ruleContributions = List.of(new RuleContribution("EDUCATION_LEVEL", "APPLIED", + BigDecimal.TEN, new BigDecimal("100"), "教育背景信息", "测试输入", "测试输入")); + when(calculator.computeAll(eq(7L), any(LocalDateTime.class))).thenReturn(List.of(result)); + when(calculator.currentRulesVersion()).thenReturn(CandidateScoringCalculator.RULES_VERSION); + + var overview = service.getOverview(7L); + + ArgumentCaptor computeTime = ArgumentCaptor.forClass(LocalDateTime.class); + verify(calculator).computeAll(eq(7L), computeTime.capture()); + ArgumentCaptor snapshotTime = ArgumentCaptor.forClass(LocalDateTime.class); + verify(calculator).upsertSnapshots(eq(7L), anyList(), anyString(), snapshotTime.capture()); + assertEquals(CAPTURED, computeTime.getValue()); + assertEquals(CAPTURED, snapshotTime.getValue()); + assertEquals(CAPTURED.atZone(BUSINESS_ZONE).toInstant(), overview.getGeneratedAt()); + assertEquals(1, advancing.instantCalls.get()); + } + + @Test + void companyScoring_usesOneCapturedClockAndExactPeriod() { + CandidateScoringEngineService service = new CandidateScoringEngineService( + calculator, snapshotRepository, companyRepository, applicationRepository, jobService); + AdvancingClock advancing = new AdvancingClock(CAPTURED, BUSINESS_ZONE); + service.setClock(advancing); + when(companyRepository.findByOwnerUserId(200L)).thenReturn(Optional.of( + com.mydreamjob.company.Company.builder().id(9L).ownerUserId(200L).build())); + when(applicationRepository.findById(5L)).thenReturn(Optional.of( + com.mydreamjob.application.Application.builder().id(5L).jobId(50L).studentUserId(7L).build())); + when(jobService.getJob(50L)).thenReturn(com.mydreamjob.job.Job.builder().id(50L).companyId(9L).build()); + var result = new CandidateScoringCalculator.DimensionResult("EDUCATION"); + result.status = "OK"; + result.score = BigDecimal.TEN; + when(calculator.computeAll(eq(7L), any(LocalDateTime.class))).thenReturn(List.of(result)); + when(calculator.currentRulesVersion()).thenReturn(CandidateScoringCalculator.RULES_VERSION); + + var response = service.getCompanyScoring(200L, 5L); + + ArgumentCaptor computeTime = ArgumentCaptor.forClass(LocalDateTime.class); + verify(calculator).computeAll(eq(7L), computeTime.capture()); + ArgumentCaptor snapshotTime = ArgumentCaptor.forClass(LocalDateTime.class); + ArgumentCaptor period = ArgumentCaptor.forClass(String.class); + verify(calculator).upsertSnapshots(eq(7L), anyList(), period.capture(), snapshotTime.capture()); + assertEquals(CAPTURED, computeTime.getValue()); + assertEquals(CAPTURED, snapshotTime.getValue()); + assertEquals("2026-09", period.getValue()); + assertEquals(CAPTURED.atZone(BUSINESS_ZONE).toInstant(), response.getGeneratedAt()); + assertEquals(1, advancing.instantCalls.get()); + } + + @Test + void generatedAtCalculatedAtPeriodAndValidity_useSameCapturedTime() { + CandidateScoringEngineService service = new CandidateScoringEngineService( + calculator, snapshotRepository, companyRepository, applicationRepository, jobService); + AdvancingClock advancing = new AdvancingClock(CAPTURED, BUSINESS_ZONE); + service.setClock(advancing); + var result = new CandidateScoringCalculator.DimensionResult("EDUCATION"); + result.status = "OK"; + result.score = BigDecimal.TEN; + result.ruleContributions = List.of(new RuleContribution("EDUCATION_LEVEL", "APPLIED", + BigDecimal.TEN, new BigDecimal("100"), "教育背景信息", "测试输入", "测试输入")); + when(calculator.computeAll(eq(7L), any(LocalDateTime.class))).thenReturn(List.of(result)); + when(calculator.currentRulesVersion()).thenReturn(CandidateScoringCalculator.RULES_VERSION); + + var detail = service.getDimensionDetail(7L, "EDUCATION"); + + ArgumentCaptor computeTime = ArgumentCaptor.forClass(LocalDateTime.class); + verify(calculator).computeAll(eq(7L), computeTime.capture()); + ArgumentCaptor snapshotTime = ArgumentCaptor.forClass(LocalDateTime.class); + ArgumentCaptor period = ArgumentCaptor.forClass(String.class); + verify(calculator).upsertSnapshots(eq(7L), anyList(), period.capture(), snapshotTime.capture()); + assertEquals(CAPTURED, computeTime.getValue()); + assertEquals(CAPTURED, snapshotTime.getValue()); + assertEquals("2026-09", period.getValue()); + assertEquals(CAPTURED, detail.getCalculatedAt()); + assertEquals(CAPTURED, detail.getUpdatedAt()); + assertEquals(1, advancing.instantCalls.get()); + } + + private CandidateScoringCalculator calculatorWithJdbcAggregates(LocalDateTime captured, + BoundaryCapture capture) { + when(userRepository.findById(7L)).thenReturn(Optional.of(User.builder().id(7L) + .createdAt(captured.minusMonths(24)).build())); + when(profileRepository.findByUserId(7L)).thenReturn(Optional.of( + com.mydreamjob.profile.StudentProfile.builder().userId(7L) + .education("本科").school("清华大学").build())); + when(preferenceRepository.findByStudentUserId(7L)).thenReturn(Optional.empty()); + when(jdbcTemplate.queryForObject(anyString(), ArgumentMatchers.>any(), any(), any(), any())) + .thenAnswer(invocation -> { + String sql = invocation.getArgument(0); + if (sql.contains("FROM audit_logs")) { + capture.auditBoundary = invocation.getArgument(2, LocalDateTime.class); + return new CandidateScoringCalculator.AuditAgg(1, 1); + } + throw new AssertionError("unexpected three-parameter aggregate SQL: " + sql); + }); + when(jdbcTemplate.queryForObject(anyString(), ArgumentMatchers.>any(), any())) + .thenAnswer(invocation -> { + String sql = invocation.getArgument(0); + if (sql.contains("FROM offers")) return new CandidateScoringCalculator.OfferAgg(0, 0); + if (sql.contains("FROM candidate_feedback")) return new CandidateScoringCalculator.FbAgg(0, 0); + if (sql.contains("FROM resume_versions")) return new CandidateScoringCalculator.ResumeAgg(0, null); + throw new AssertionError("unexpected one-parameter aggregate SQL: " + sql); + }); + when(jdbcTemplate.queryForObject(anyString(), ArgumentMatchers.>any(), any(), any())) + .thenAnswer(invocation -> { + String sql = invocation.getArgument(0); + if (sql.contains("FROM applications")) { + capture.applicationBoundary = invocation.getArgument(2, LocalDateTime.class); + return new CandidateScoringCalculator.AppAgg(1, 0, captured.minusMonths(1), 1); + } + if (sql.contains("FROM audit_logs")) { + capture.auditBoundary = invocation.getArgument(2, LocalDateTime.class); + return new CandidateScoringCalculator.AuditAgg(1, 1); + } + if (sql.contains("FROM offers")) return new CandidateScoringCalculator.OfferAgg(0, 0); + if (sql.contains("FROM candidate_feedback")) return new CandidateScoringCalculator.FbAgg(0, 0); + if (sql.contains("FROM resume_versions")) return new CandidateScoringCalculator.ResumeAgg(0, null); + throw new AssertionError("unexpected aggregate SQL: " + sql); + }); + when(jdbcTemplate.queryForObject(anyString(), eq(Long.class), any(Object[].class))).thenReturn(0L); + CandidateScoringCalculator real = new CandidateScoringCalculator( + profileRepository, preferenceRepository, userRepository, jdbcTemplate, + abilityScoreSource, new AbilityScoreMapper()); + ReflectionTestUtils.setField(real, "entityManager", entityManager); + return real; + } + + private static String abilityJson(int logic, int communication, int execution, int learning) { + return "[{\"code\":\"LOGIC\",\"score\":" + logic + ",\"answeredCount\":6}," + + "{\"code\":\"COMMUNICATION\",\"score\":" + communication + ",\"answeredCount\":6}," + + "{\"code\":\"EXECUTION\",\"score\":" + execution + ",\"answeredCount\":6}," + + "{\"code\":\"LEARNING\",\"score\":" + learning + ",\"answeredCount\":6}]"; + } + + private static final class BoundaryCapture { + private LocalDateTime applicationBoundary; + private LocalDateTime auditBoundary; + } + + private static final class AdvancingClock extends Clock { + private final LocalDateTime first; + private final ZoneId zone; + private final AtomicInteger instantCalls = new AtomicInteger(); + + private AdvancingClock(LocalDateTime first, ZoneId zone) { + this.first = first; + this.zone = zone; + } + + @Override + public ZoneId getZone() { return zone; } + + @Override + public Clock withZone(ZoneId zone) { return new AdvancingClock(first, zone); } + + @Override + public Instant instant() { + int call = instantCalls.getAndIncrement(); + return first.plusDays(call).atZone(zone).toInstant(); + } + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoringControllerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoringControllerTest.java index bf364ec..eb3255b 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoringControllerTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoringControllerTest.java @@ -74,6 +74,31 @@ class CandidateScoringControllerTest { .rulesVersion("candidate-scoring-v0.1").generatedAt(Instant.now()).build(); } + @Test void getOverview_explanationFieldsAreStudentOnlyAndStructured() throws Exception { + CandidateScoringOverviewResponse response = CandidateScoringOverviewResponse.builder() + .candidateId(100L).status("PARTIAL").overallScore(new BigDecimal("76.75")) + .rulesVersion("candidate-scoring-v0.1").explanationVersion("candidate-scoring-explanation-v1") + .comparabilityNotice("不同参与维度集合的综合分不适合直接横向比较") + .coverage(CandidateScoringOverviewResponse.Coverage.builder() + .computableDimensions(1).totalDimensions(8).includedDimensions(List.of("EDUCATION")) + .excludedDimensions(List.of(CandidateScoringOverviewResponse.ExcludedDimension.builder() + .dimension("INTERNSHIP").status("PENDING").reason("数据源未接入").build())).build()) + .dimensions(List.of(CandidateScoringOverviewResponse.DimensionItem.builder() + .dimension("EDUCATION").score(new BigDecimal("76.75")).status("OK") + .weight(new BigDecimal("12.50")).summary("教育背景信息").build())) + .build(); + when(scoringEngineService.getOverview(100L)).thenReturn(response); + String body = mockMvc.perform(get("/profile/me/candidate-scoring").header("Authorization", studentToken())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.explanationVersion").value("candidate-scoring-explanation-v1")) + .andExpect(jsonPath("$.data.coverage.computableDimensions").value(1)) + .andExpect(jsonPath("$.data.coverage.totalDimensions").value(8)) + .andReturn().getResponse().getContentAsString(); + for (String banned : List.of("answerKey", "scoringRule", "questionCode", "competencyTag", "phone", "email")) { + org.junit.jupiter.api.Assertions.assertFalse(body.contains(banned), "must not expose " + banned); + } + } + private CompanyCandidateScoringResponse buildCompanyScoring() { return CompanyCandidateScoringResponse.builder() .applicationId(5L).overallScore(new BigDecimal("92.79")).status("PARTIAL") @@ -129,6 +154,21 @@ class CandidateScoringControllerTest { .andExpect(status().isBadRequest()); } + @Test + void getDimension_explanationInternalFailure_isSanitized() throws Exception { + when(scoringEngineService.getDimensionDetail(eq(100L), eq("EDUCATION"))) + .thenThrow(new IllegalStateException( + "candidate scoring explanation contains an unsupported rule: INTERNAL_SQL_COLUMN secret")); + + String body = mockMvc.perform(get("/profile/me/candidate-scoring/dimensions/EDUCATION") + .header("Authorization", studentToken())) + .andExpect(status().isInternalServerError()) + .andReturn().getResponse().getContentAsString(); + + org.junit.jupiter.api.Assertions.assertFalse(body.contains("INTERNAL_SQL_COLUMN")); + org.junit.jupiter.api.Assertions.assertFalse(body.contains("secret")); + } + @Test void getDimension_company_shouldReturn403() throws Exception { mockMvc.perform(get("/profile/me/candidate-scoring/dimensions/EDUCATION") .header("Authorization", companyToken())) @@ -175,7 +215,35 @@ class CandidateScoringControllerTest { .andExpect(content().string(not(containsString("candidateId")))) .andExpect(content().string(not(containsString("phone")))) .andExpect(content().string(not(containsString("email")))) - .andExpect(content().string(not(containsString("resumeUrl")))); + .andExpect(content().string(not(containsString("resumeUrl")))) + .andExpect(content().string(not(containsString("explanationVersion")))) + .andExpect(content().string(not(containsString("coverage")))) + .andExpect(content().string(not(containsString("comparabilityNotice")))) + .andExpect(content().string(not(containsString("displayName")))) + .andExpect(content().string(not(containsString("ruleContributions")))) + .andExpect(content().string(not(containsString("reasons")))) + .andExpect(content().string(not(containsString("dataSources")))) + .andExpect(content().string(not(containsString("limitations")))) + .andExpect(content().string(not(containsString("improvementActions")))) + .andExpect(content().string(not(containsString("evidenceLevel")))) + .andExpect(content().string(not(containsString("validity")))) + .andExpect(content().string(not(containsString("completedAt")))) + .andExpect(content().string(not(containsString("validUntil")))) + .andExpect(content().string(not(containsString("LOGIC")))) + .andExpect(content().string(not(containsString("COMMUNICATION")))) + .andExpect(content().string(not(containsString("EXECUTION")))) + .andExpect(content().string(not(containsString("LEARNING")))) + .andExpect(content().string(not(containsString("resultId")))) + .andExpect(content().string(not(containsString("attemptId")))) + .andExpect(content().string(not(containsString("questionCode")))) + .andExpect(content().string(not(containsString("answerKey")))) + .andExpect(content().string(not(containsString("scoringRule")))) + .andExpect(content().string(not(containsString("competencyTag")))) + .andExpect(content().string(not(containsString("auditLogs")))) + .andExpect(content().string(not(containsString("messageContent")))) + .andExpect(content().string(not(containsString("password")))) + .andExpect(content().string(not(containsString("token")))) + .andExpect(content().string(not(containsString("secret")))); } @Test void getCompanyScoring_crossCompany_shouldReturn403() throws Exception { 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 89fb36c..113962b 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoringEngineServiceTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoringEngineServiceTest.java @@ -9,6 +9,7 @@ import com.mydreamjob.candidatescore.dto.CandidateScoringOverviewResponse; import com.mydreamjob.candidatescore.dto.CandidateScoringTrendResponse; import com.mydreamjob.candidatescore.dto.CompanyCandidateScoringResponse; import com.mydreamjob.candidatescore.dto.DimensionScoreDetailResponse; +import com.mydreamjob.candidatescore.explanation.RuleContribution; import com.mydreamjob.common.exception.BusinessException; import com.mydreamjob.common.exception.ErrorCode; import com.mydreamjob.company.Company; @@ -52,6 +53,20 @@ class CandidateScoringEngineServiceTest { CandidateScoringCalculator.DimensionResult r = new CandidateScoringCalculator.DimensionResult(name); r.score = score == null ? null : new BigDecimal(score); r.status = status; + String ruleCode = switch (name) { + case "EDUCATION" -> "EDUCATION_LEVEL"; + case "INTERNSHIP" -> "INTERNSHIP_SOURCE_UNAVAILABLE"; + case "ABILITY" -> "ABILITY_RESULT_MISSING"; + case "COMPLIANCE" -> "PREFERENCE_SALARY_PRESENT"; + case "STABILITY" -> "STABILITY_BASE"; + case "GROWTH" -> "GROWTH_BASE"; + case "INTEGRITY" -> "INTEGRITY_BASE"; + case "ACTIVITY" -> "ACTIVITY_APPLICATIONS"; + default -> throw new IllegalArgumentException("unsupported test dimension"); + }; + String outcome = r.score == null ? ("INTERNSHIP".equals(name) ? "UNAVAILABLE" : "MISSING") : "APPLIED"; + r.ruleContributions = List.of(new RuleContribution(ruleCode, outcome, r.score, + r.score == null ? null : new BigDecimal("100"), name, "test fixture", "test fixture")); return r; } @@ -76,7 +91,7 @@ class CandidateScoringEngineServiceTest { // ── 综合评分卡 ────────────────────────────────────────────── @Test void getOverview_shouldAssembleAndUpsert() { - when(calculator.computeAll(STUDENT_ID)).thenReturn(sixComputable()); + when(calculator.computeAll(eq(STUDENT_ID), any(LocalDateTime.class))).thenReturn(sixComputable()); CandidateScoringOverviewResponse resp = service.getOverview(STUDENT_ID); assertEquals(STUDENT_ID, resp.getCandidateId()); @@ -85,14 +100,14 @@ class CandidateScoringEngineServiceTest { assertEquals("PARTIAL", resp.getStatus()); assertEquals(8, resp.getDimensions().size()); assertEquals(new BigDecimal("12.50"), resp.getWeights().get("EDUCATION")); - verify(calculator, times(1)).computeAll(STUDENT_ID); - verify(calculator, times(1)).upsertSnapshots(eq(STUDENT_ID), anyList(), anyString()); + verify(calculator, times(1)).computeAll(eq(STUDENT_ID), any(LocalDateTime.class)); + verify(calculator, times(1)).upsertSnapshots(eq(STUDENT_ID), anyList(), anyString(), any(LocalDateTime.class)); } @Test void getOverview_allPending_shouldBePending() { List allPending = List.of( dim("INTERNSHIP", null, "PENDING"), dim("ABILITY", null, "PENDING")); - when(calculator.computeAll(STUDENT_ID)).thenReturn(allPending); + when(calculator.computeAll(eq(STUDENT_ID), any(LocalDateTime.class))).thenReturn(allPending); CandidateScoringOverviewResponse resp = service.getOverview(STUDENT_ID); assertEquals("PENDING", resp.getStatus()); assertNull(resp.getOverallScore()); @@ -103,7 +118,7 @@ class CandidateScoringEngineServiceTest { dim("EDUCATION", null, "INSUFFICIENT_DATA"), dim("COMPLIANCE", null, "INSUFFICIENT_DATA"), dim("INTERNSHIP", null, "PENDING")); - when(calculator.computeAll(STUDENT_ID)).thenReturn(all); + when(calculator.computeAll(eq(STUDENT_ID), any(LocalDateTime.class))).thenReturn(all); CandidateScoringOverviewResponse resp = service.getOverview(STUDENT_ID); assertEquals("INSUFFICIENT_DATA", resp.getStatus()); assertNull(resp.getOverallScore()); @@ -117,13 +132,13 @@ class CandidateScoringEngineServiceTest { @Test void getOverview_switchOn_rulesVersionV02() { when(calculator.currentRulesVersion()).thenReturn("candidate-scoring-v0.2"); - when(calculator.computeAll(STUDENT_ID)).thenReturn(sixComputable()); + when(calculator.computeAll(eq(STUDENT_ID), any(LocalDateTime.class))).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"))); + when(calculator.computeAll(eq(STUDENT_ID), any(LocalDateTime.class))).thenReturn(List.of(abilityDim("74.25"))); DimensionScoreDetailResponse detail = service.getDimensionDetail(STUDENT_ID, "ABILITY"); assertEquals("ABILITY", detail.getDimension()); assertEquals("OK", detail.getStatus()); @@ -152,14 +167,18 @@ class CandidateScoringEngineServiceTest { 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"))); + when(calculator.computeAll(eq(STUDENT_ID), any(LocalDateTime.class))).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")) { + for (String banned : List.of("explanationVersion", "coverage", "comparabilityNotice", "displayName", + "ruleContributions", "reasons", "dataSources", "limitations", "improvementActions", + "evidenceLevel", "validity", "completedAt", "validUntil", "LOGIC", "COMMUNICATION", + "EXECUTION", "LEARNING", "studentUserId", "candidateId", "resultId", "attemptId", + "questionCode", "answerKey", "scoringRule", "competencyTag", "auditLogs", "messageContent", + "phone", "email", "resumeUrl", "password", "token", "secret", "selectedOptionId", "stem", + "subScores", "weights", "dimensionScores")) { assertFalse(json.contains(banned), "company response must not leak " + banned); } } @@ -173,8 +192,13 @@ class CandidateScoringEngineServiceTest { 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()); + .source("四维能力测评").time(LocalDateTime.now()).note("测评有效期").build()); r.reasons.add("映射规则版本 ability-assessment-scoring-v0.1"); + r.ruleContributions = List.of( + new RuleContribution("ABILITY_LOGIC", "APPLIED", new BigDecimal("28.00"), new BigDecimal("35"), "逻辑能力", "测评聚合分", "固定权重"), + new RuleContribution("ABILITY_EXECUTION", "APPLIED", new BigDecimal("22.50"), new BigDecimal("30"), "执行能力", "测评聚合分", "固定权重"), + new RuleContribution("ABILITY_LEARNING", "APPLIED", new BigDecimal("14.00"), new BigDecimal("20"), "学习能力", "测评聚合分", "固定权重"), + new RuleContribution("ABILITY_COMMUNICATION", "APPLIED", new BigDecimal("9.75"), new BigDecimal("15"), "沟通能力", "测评聚合分", "固定权重")); return r; } @@ -186,14 +210,14 @@ class CandidateScoringEngineServiceTest { // ── 单维度明细 ──────────────────────────────────────────── @Test void getDimensionDetail_invalid_should400() { - when(calculator.computeAll(STUDENT_ID)).thenReturn(sixComputable()); + when(calculator.computeAll(eq(STUDENT_ID), any(LocalDateTime.class))).thenReturn(sixComputable()); BusinessException ex = assertThrows(BusinessException.class, () -> service.getDimensionDetail(STUDENT_ID, "BOGUS")); assertEquals(ErrorCode.BAD_REQUEST.getCode(), ex.getErrorCodeInt()); } @Test void getDimensionDetail_education_shouldReturnDetail() { - when(calculator.computeAll(STUDENT_ID)).thenReturn(sixComputable()); + when(calculator.computeAll(eq(STUDENT_ID), any(LocalDateTime.class))).thenReturn(sixComputable()); DimensionScoreDetailResponse detail = service.getDimensionDetail(STUDENT_ID, "EDUCATION"); assertEquals("EDUCATION", detail.getDimension()); assertEquals("candidate-scoring-v0.1", detail.getRulesVersion()); @@ -260,21 +284,26 @@ class CandidateScoringEngineServiceTest { assertEquals(ErrorCode.APPLICATION_OWNER_MISMATCH.getCode(), ex.getErrorCodeInt()); } - @Test void getCompanyScoring_success_whitelistOnly() { + @Test void getCompanyScoring_success_whitelistOnly() 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(sixComputable()); + when(calculator.computeAll(eq(STUDENT_ID), any(LocalDateTime.class))).thenReturn(sixComputable()); CompanyCandidateScoringResponse resp = service.getCompanyScoring(COMPANY_USER_ID, APP_ID); assertEquals(APP_ID, resp.getApplicationId()); assertEquals("candidate-scoring-v0.1", resp.getRulesVersion()); assertEquals(8, resp.getDimensions().size()); - String json = resp.toString(); - for (String banned : List.of("studentUserId", "candidateId", "phone", "email", "resumeUrl", "passwordHash")) { - assertFalse(json.toLowerCase().contains(banned.toLowerCase())); + String json = MAPPER.writeValueAsString(resp); + for (String banned : List.of("explanationVersion", "coverage", "comparabilityNotice", "displayName", + "ruleContributions", "reasons", "dataSources", "limitations", "improvementActions", + "evidenceLevel", "validity", "completedAt", "validUntil", "LOGIC", "COMMUNICATION", + "EXECUTION", "LEARNING", "studentUserId", "candidateId", "resultId", "attemptId", + "questionCode", "answerKey", "scoringRule", "competencyTag", "auditLogs", "messageContent", + "phone", "email", "resumeUrl", "password", "token", "secret")) { + assertFalse(json.toLowerCase().contains(banned.toLowerCase()), "company response must not leak " + banned); } } } diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoringExplanationAssemblerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoringExplanationAssemblerTest.java new file mode 100644 index 0000000..7d531af --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoringExplanationAssemblerTest.java @@ -0,0 +1,170 @@ +package com.mydreamjob.candidatescore; + +import com.mydreamjob.candidatescore.dto.DimensionScoreDetailResponse; +import com.mydreamjob.candidatescore.explanation.CandidateScoringExplanationAssembler; +import com.mydreamjob.candidatescore.explanation.RuleContribution; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class CandidateScoringExplanationAssemblerTest { + + private static final LocalDateTime NOW = LocalDateTime.of(2026, 9, 9, 12, 0); + + @Test + void contributionSumAndValidityAreMappedFromCalculatorFacts() { + var result = new CandidateScoringCalculator.DimensionResult("ABILITY"); + result.status = "OK"; + result.score = new BigDecimal("74.25"); + result.ruleContributions = List.of( + new RuleContribution("ABILITY_LOGIC", "APPLIED", new BigDecimal("28.00"), new BigDecimal("35"), "逻辑能力", "测评聚合分", "按固定权重映射"), + new RuleContribution("ABILITY_EXECUTION", "APPLIED", new BigDecimal("22.50"), new BigDecimal("30"), "执行能力", "测评聚合分", "按固定权重映射"), + new RuleContribution("ABILITY_LEARNING", "APPLIED", new BigDecimal("14.00"), new BigDecimal("20"), "学习能力", "测评聚合分", "按固定权重映射"), + new RuleContribution("ABILITY_COMMUNICATION", "APPLIED", new BigDecimal("9.75"), new BigDecimal("15"), "沟通能力", "测评聚合分", "按固定权重映射")); + result.abilitySnapshotTrace = new CandidateScoringCalculator.AbilitySnapshotTrace( + 9L, NOW.minusHours(1), NOW.plusDays(6), "ability-v0.1", "candidate-scoring-v0.2", + java.util.Map.of("LOGIC", 35, "EXECUTION", 30, "LEARNING", 20, "COMMUNICATION", 15), + java.util.Map.of("LOGIC", new BigDecimal("80"), "EXECUTION", new BigDecimal("75"), + "LEARNING", new BigDecimal("70"), "COMMUNICATION", new BigDecimal("65"))); + + DimensionScoreDetailResponse response = CandidateScoringExplanationAssembler.assemble( + result, "candidate-scoring-v0.2", NOW); + + BigDecimal contributionSum = response.getRuleContributions().stream() + .map(DimensionScoreDetailResponse.RuleContribution::getContribution) + .reduce(BigDecimal.ZERO, BigDecimal::add); + assertEquals(new BigDecimal("74.25"), contributionSum); + assertEquals("candidate-scoring-explanation-v1", response.getExplanationVersion()); + assertNotNull(response.getValidity()); + assertFalse(response.getValidity().getExpired()); + assertEquals(NOW, response.getCalculatedAt()); + assertEquals(NOW, response.getUpdatedAt()); + } + + @Test + void unknownInternalRuleCodeFailsClosedWithoutEchoingInternalValue() { + var result = new CandidateScoringCalculator.DimensionResult("EDUCATION"); + result.status = "OK"; + result.score = new BigDecimal("76.75"); + result.ruleContributions = List.of( + new RuleContribution("EDUCATION_LEVEL", "APPLIED", new BigDecimal("38.50"), new BigDecimal("55"), "学历层次", "本科", "固定映射"), + new RuleContribution("INTERNAL_SQL_COLUMN", "APPLIED", new BigDecimal("38.25"), new BigDecimal("45"), "内部", "secret", "内部对象")); + IllegalStateException ex = assertThrows(IllegalStateException.class, + () -> CandidateScoringExplanationAssembler.assemble(result, "candidate-scoring-v0.1", NOW)); + assertEquals("candidate scoring explanation contains an unsupported rule", ex.getMessage()); + assertFalse(ex.getMessage().contains("INTERNAL_SQL_COLUMN")); + assertFalse(ex.getMessage().contains("secret")); + } + + @Test + void unknownOutcomeFailsClosedWithoutEchoingInternalValue() { + var result = result("OK", new BigDecimal("10.00")); + result.ruleContributions = List.of(new RuleContribution( + "EDUCATION_LEVEL", "FORGED", new BigDecimal("10.00"), + new BigDecimal("100"), "内部标签", "secret", "内部对象")); + + IllegalStateException ex = assertThrows(IllegalStateException.class, + () -> CandidateScoringExplanationAssembler.assemble( + result, "candidate-scoring-v0.1", NOW)); + + assertEquals("candidate scoring explanation contains an unsupported rule", ex.getMessage()); + assertFalse(ex.getMessage().contains("FORGED")); + assertFalse(ex.getMessage().contains("secret")); + } + + @Test + void nullOrBlankOutcomeFailsClosed() { + for (String outcome : new String[]{null, "", " "}) { + var result = result("OK", new BigDecimal("10.00")); + result.ruleContributions = List.of(new RuleContribution( + "EDUCATION_LEVEL", outcome, new BigDecimal("10.00"), + new BigDecimal("100"), "教育背景信息", "测试", "测试")); + assertSafeFailure(result); + } + } + + @Test + void allSupportedOutcomesRemainAccepted() { + for (String outcome : List.of("APPLIED", "NOT_APPLIED", "MISSING", "EXPIRED", "UNAVAILABLE")) { + var result = result("PENDING", null); + result.ruleContributions = List.of(new RuleContribution( + "ABILITY_RESULT_MISSING", outcome, null, null, + "四维能力测评", "安全说明", "安全说明")); + + var response = CandidateScoringExplanationAssembler.assemble( + result, "candidate-scoring-v0.1", NOW); + + assertEquals(outcome, response.getRuleContributions().get(0).getOutcome()); + } + } + + @Test + void knownContributionsThatDoNotReconcileFailClosed() { + var result = new CandidateScoringCalculator.DimensionResult("EDUCATION"); + result.status = "OK"; + result.score = new BigDecimal("76.75"); + result.ruleContributions = List.of( + new RuleContribution("EDUCATION_LEVEL", "APPLIED", new BigDecimal("38.50"), new BigDecimal("55"), "学历层次", "本科", "固定映射"), + new RuleContribution("EDUCATION_SCHOOL_TIER", "APPLIED", new BigDecimal("38.00"), new BigDecimal("45"), "学校层次", "其他", "固定映射")); + IllegalStateException ex = assertThrows(IllegalStateException.class, + () -> CandidateScoringExplanationAssembler.assemble(result, "candidate-scoring-v0.1", NOW)); + assertEquals("candidate scoring explanation contains an unsupported rule", ex.getMessage()); + } + + @Test + void nullContributionListFailsClosed() { + var result = result("PENDING", null); + result.ruleContributions = null; + assertSafeFailure(result); + } + + @Test + void emptyContributionListWithScoreFailsClosed() { + var result = result("OK", new BigDecimal("76.75")); + result.ruleContributions = List.of(); + assertSafeFailure(result); + } + + @Test + void pendingWithoutDiagnosticContributionFailsClosed() { + var result = result("PENDING", null); + result.ruleContributions = List.of(); + assertSafeFailure(result); + } + + @Test + void nullContributionElementFailsClosed() { + var result = result("OK", BigDecimal.TEN); + result.ruleContributions = new java.util.ArrayList<>(); + result.ruleContributions.add(null); + assertSafeFailure(result); + } + + @Test + void validDiagnosticContributionWithoutScoreSucceeds() { + var result = result("PENDING", null); + result.ruleContributions = List.of(new RuleContribution( + "ABILITY_RESULT_MISSING", "MISSING", null, null, + "四维能力测评", "没有可用结果", "没有可用结果")); + var response = CandidateScoringExplanationAssembler.assemble(result, "candidate-scoring-v0.1", NOW); + assertNull(response.getScore()); + assertEquals(1, response.getRuleContributions().size()); + } + + private CandidateScoringCalculator.DimensionResult result(String status, BigDecimal score) { + var result = new CandidateScoringCalculator.DimensionResult("ABILITY"); + result.status = status; + result.score = score; + return result; + } + + private void assertSafeFailure(CandidateScoringCalculator.DimensionResult result) { + IllegalStateException ex = assertThrows(IllegalStateException.class, + () -> CandidateScoringExplanationAssembler.assemble(result, "candidate-scoring-v0.1", NOW)); + assertEquals("candidate scoring explanation contains an unsupported rule", ex.getMessage()); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoringExplanationCatalogTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoringExplanationCatalogTest.java new file mode 100644 index 0000000..a8f9638 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoringExplanationCatalogTest.java @@ -0,0 +1,53 @@ +package com.mydreamjob.candidatescore; + +import com.mydreamjob.candidatescore.explanation.CandidateScoringExplanationCatalog; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class CandidateScoringExplanationCatalogTest { + + @Test + void catalogContainsNeutralMetadataForAllEightDimensions() { + List dimensions = List.of("EDUCATION", "INTERNSHIP", "ABILITY", "COMPLIANCE", + "STABILITY", "GROWTH", "INTEGRITY", "ACTIVITY"); + for (String dimension : dimensions) { + var metadata = CandidateScoringExplanationCatalog.metadata(dimension); + assertNotBlank(metadata.displayName()); + assertNotBlank(metadata.whatItMeasures()); + assertNotBlank(metadata.interpretation()); + assertFalse(metadata.limitations().isEmpty()); + assertFalse(metadata.improvementActions().isEmpty()); + assertTrue(List.of("DIRECT_RULE", "SELF_REPORTED", "BEHAVIOR_PROXY", "UNVALIDATED_PROXY") + .contains(metadata.evidenceLevel())); + } + } + + @Test + void catalogUsesRequiredNeutralSafetyLanguage() { + var integrity = CandidateScoringExplanationCatalog.metadata("INTEGRITY"); + assertTrue(integrity.limitations().stream().anyMatch(s -> s.contains("不是个人征信") && s.contains("人格判断"))); + assertFalse(CandidateScoringExplanationCatalog.metadata("GROWTH").interpretation().contains("成长潜力")); + assertFalse(CandidateScoringExplanationCatalog.metadata("STABILITY").interpretation().contains("忠诚度")); + assertFalse(CandidateScoringExplanationCatalog.metadata("COMPLIANCE").interpretation().contains("服从性")); + assertFalse(CandidateScoringExplanationCatalog.metadata("ACTIVITY").interpretation().contains("工作积极性")); + assertTrue(CandidateScoringExplanationCatalog.metadata("EDUCATION").limitations().stream() + .anyMatch(s -> s.contains("不能代表智力") && s.contains("录用适合度"))); + assertTrue(CandidateScoringExplanationCatalog.metadata("ABILITY").limitations().stream() + .anyMatch(s -> s.contains("不代表确定性工作能力") && s.contains("录用结论"))); + } + + @Test + void unknownDimensionAndRuleCodeAreNotExposed() { + assertThrows(IllegalArgumentException.class, () -> CandidateScoringExplanationCatalog.metadata("UNKNOWN")); + assertFalse(CandidateScoringExplanationCatalog.isKnownRuleCode("INTERNAL_SQL_COLUMN")); + assertTrue(CandidateScoringExplanationCatalog.isKnownRuleCode("SCORE_CAP_ADJUSTMENT")); + } + + private static void assertNotBlank(String value) { + assertNotNull(value); + assertFalse(value.isBlank()); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoringExplanationIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoringExplanationIntegrationTest.java new file mode 100644 index 0000000..666ef4a --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoringExplanationIntegrationTest.java @@ -0,0 +1,68 @@ +package com.mydreamjob.candidatescore; + +import com.mydreamjob.auth.UserRepository; +import com.mydreamjob.preference.StudentPreferenceRepository; +import com.mydreamjob.profile.StudentProfile; +import com.mydreamjob.profile.StudentProfileRepository; +import jakarta.persistence.EntityManager; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.jdbc.core.JdbcTemplate; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +@ExtendWith(MockitoExtension.class) +class CandidateScoringExplanationIntegrationTest { + @Mock StudentProfileRepository profileRepository; + @Mock StudentPreferenceRepository preferenceRepository; + @Mock UserRepository userRepository; + @Mock JdbcTemplate jdbcTemplate; + @Mock EntityManager entityManager; + @Mock AbilityAssessmentScoreSource abilityScoreSource; + @Mock AbilityScoreMapper abilityScoreMapper; + @InjectMocks CandidateScoringCalculator calculator; + + @Test + void realCalculatorBranchesProduceContributionsThatReconcileToScore() { + var captured = LocalDateTime.of(2026, 9, 9, 12, 0); + var education = calculator.computeEducation( + StudentProfile.builder().education("本科").school("清华大学").build(), captured); + var stability = calculator.computeStability(captured.minusMonths(24), 3, 0, 2, captured); + var growth = calculator.computeGrowth(2, captured.minusDays(10), 5, captured); + var integrity = calculator.computeIntegrity(2, 0, 3, 4.5, true, captured); + var activity = calculator.computeActivity(6, 3, 1, captured); + + for (var result : List.of(education, stability, growth, integrity, activity)) { + assertNotNull(result.score, result.dimension + " score"); + BigDecimal sum = result.ruleContributions.stream() + .map(r -> r.contribution() == null ? BigDecimal.ZERO : r.contribution()) + .reduce(BigDecimal.ZERO, BigDecimal::add); + assertEquals(result.score, sum, result.dimension + " contribution reconciliation"); + assertTrue(result.dataSources.stream().noneMatch(source -> source.getSource().contains(".") || source.getSource().contains("/")), + result.dimension + " must use semantic source labels"); + } + assertTrue(integrity.ruleContributions.stream().anyMatch(r -> "INTEGRITY_BASE".equals(r.ruleCode()))); + assertTrue(activity.ruleContributions.stream().anyMatch(r -> "ACTIVITY_MESSAGES".equals(r.ruleCode()))); + } + + @Test + void missingAndUnavailableBranchesDoNotPretendToContributeZero() { + var missing = calculator.computeEducation(null, LocalDateTime.of(2026, 9, 9, 12, 0)); + assertEquals("INSUFFICIENT_DATA", missing.status); + assertNull(missing.score); + assertEquals("MISSING", missing.ruleContributions.get(0).outcome()); + assertNull(missing.ruleContributions.get(0).contribution()); + + var internship = calculator.computeInternship(LocalDateTime.of(2026, 9, 9, 12, 0)); + assertEquals("PENDING", internship.status); + assertEquals("UNAVAILABLE", internship.ruleContributions.get(0).outcome()); + assertNull(internship.ruleContributions.get(0).contribution()); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/governance/CandidateScoringGovernanceServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/governance/CandidateScoringGovernanceServiceTest.java index b2571eb..f363edf 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/governance/CandidateScoringGovernanceServiceTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/governance/CandidateScoringGovernanceServiceTest.java @@ -28,11 +28,15 @@ import org.springframework.dao.OptimisticLockingFailureException; import org.springframework.test.util.ReflectionTestUtils; import java.math.BigDecimal; +import java.time.Clock; +import java.time.Instant; import java.time.LocalDateTime; +import java.time.ZoneId; import java.util.HashSet; import java.util.List; import java.util.Optional; import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.*; @@ -260,7 +264,7 @@ class CandidateScoringGovernanceServiceTest { .thenReturn(List.of(before), List.of(afterSnap)); // 第一次读回滚前、第二次读回滚后 CandidateScoringCalculator.DimensionResult after = new CandidateScoringCalculator.DimensionResult(appeal.getDimension()); after.score = new BigDecimal("80.00"); after.status = "OK"; - when(calculator.recomputeAndSnapshot(eq(STUDENT_ID), eq(appeal.getDimension()), eq(appeal.getPeriod()))).thenReturn(after); + when(calculator.recomputeAndSnapshot(eq(STUDENT_ID), eq(appeal.getDimension()), eq(appeal.getPeriod()), any(LocalDateTime.class))).thenReturn(after); } @Test void rollback_notApproved_should8606() { @@ -296,6 +300,42 @@ class CandidateScoringGovernanceServiceTest { verify(auditService).log(eq(ADMIN_ID), eq("APPEAL_ROLLBACK"), eq("CANDIDATE_SCORING_APPEAL"), any(), any(), any()); } + @Test void rollback_usesOneCapturedClockForRecomputeAndState() { + CandidateScoringAppeal a = appeal(1L, "APPROVED"); + stubRollbackData(a, 1); + LocalDateTime captured = LocalDateTime.of(2026, 9, 9, 12, 0); + AdvancingClock advancing = new AdvancingClock(captured, ZoneId.of("Asia/Shanghai")); + service.setClock(advancing); + + service.rollbackAppeal(ADMIN_ID, 1L, ctx); + + org.mockito.ArgumentCaptor recomputeTime = + org.mockito.ArgumentCaptor.forClass(LocalDateTime.class); + verify(calculator).recomputeAndSnapshot(eq(STUDENT_ID), eq("EDUCATION"), eq("2026-08"), + recomputeTime.capture()); + assertEquals(captured, recomputeTime.getValue()); + assertEquals(captured, a.getRollbackAt()); + assertEquals(2026, captured.getYear()); + assertEquals(1, advancing.instantCalls.get(), "rollback entry must capture time exactly once"); + } + + private static final class AdvancingClock extends Clock { + private final LocalDateTime first; + private final ZoneId zone; + private final AtomicInteger instantCalls = new AtomicInteger(); + + private AdvancingClock(LocalDateTime first, ZoneId zone) { + this.first = first; + this.zone = zone; + } + + @Override public ZoneId getZone() { return zone; } + @Override public Clock withZone(ZoneId zone) { return new AdvancingClock(first, zone); } + @Override public Instant instant() { + return first.plusMonths(instantCalls.getAndIncrement()).atZone(zone).toInstant(); + } + } + @Test void rollback_alreadyRolledBack_shouldIdempotent() { when(appealRepository.findById(1L)).thenReturn(Optional.of(appeal(1L, "ROLLED_BACK"))); AppealResponse resp = service.rollbackAppeal(ADMIN_ID, 1L, ctx);