diff --git a/docs/backend-development-roadmap.md b/docs/backend-development-roadmap.md index 44cb88c..7e16027 100644 --- a/docs/backend-development-roadmap.md +++ b/docs/backend-development-roadmap.md @@ -1,4 +1,24 @@ -# 职来后端后续开发规划与 Figma 差距修复方案 +# 职来后端一期交付路线图 + +> 当前范围冻结:2026-09-08。当前一期范围、需求、代码入口、验收及责任的唯一明细为 [一期范围与验收清单](phase1-scope-and-acceptance.md)。下方历史规划不再用于判断当前待办或批准新接口/迁移。 + +## 当前交付状态(覆盖历史描述) + +| 范围 | 2026-09-08核对结果 | 下一动作 | +|---|---|---| +| 核心招聘与账户能力 | 已有代码与历史验收记录;本轮未重跑验收 | 按清单C01–C08做一期集成回归 | +| V39–V42 | 已核对提交55e3975、2698d06、5cc2c86、96b3113;不再标为待Git检查点 | 保留回归,不重复开发企业回复/发现/邀约 | +| V43 | 独立代码复验与真实 MySQL/Docker 验收通过,待 Git 检查点 | N01 已关闭版本内小样本补集泄露;30/300/3000 查询预算通过,EXPLAIN 扩容风险保留观察 | +| 评分规则解释与合理性 | 已有部分reasons与行为计分,统一解释及规则证据待完善 | N02,一期必做 | +| 其余六个功能组 | 技能词云、入职清单/公司知识、薪资计算器/参考、诚信积分/激励、消息撤回、简历查重 | N03–N08全部一期必做,细节设计后编码 | +| 上线保障 | 现有资产待逐项核验 | G01–G04,一期交付门槛 | +| 后续候选池 | AI简历、AI模拟面试/预测、SHAP高级解释等 | 以清单第6节为准,移出一期 | + +当前代码静态OpenAPI记录为172 paths/210 operations;已交付V42基线为167/205。最新迁移仍为V40,V43不新增迁移。此前1514测试通过属于历史执行记录,不能替代本轮验收。职责:张顺主责N01/N02/N07/N08与业务验收,小刘主责N03–N06与交付环境,互相独立复核。 + +正式路线图只维护此处;`mydreamjob-server/docs/backend-development-roadmap.md`仅为跳转页。八个新增/待闭环功能组加四个交付工作包详见新清单,不以历史“P2/后置”建议将用户本轮确认的功能排除一期。 + +## 历史规划存档(原文保留,以下状态、建议编号与范围均非当前依据) > 文档状态:已确认,作为后续开发排期依据 > @@ -463,8 +483,7 @@ scoring_risk_scan_checkpoints | P1 | 统一求职进度(V34 已完成) | | P1 | ~~企业评价管理~~(V40 自动化、`clean verify` 与真实 MySQL/Docker 黑盒已通过,待 Git 检查点) | | P1 | ~~企业人才推荐~~(V41 已实现;新增 V39 授权迁移,自动化 1461/1461、真实 MySQL/Docker 黑盒 PASS=37/FAIL=0,待 Git 检查点) | -| P1 | 公平性检测 | -| P1 | 公平性检测 | +| P1 | V43历史条目:申诉可比分母与版本内部补集泄露已修复并通过独立代码复验、真实 MySQL/Docker 与 30/300/3000 查询预算验收;待 Git 检查点,以顶部状态与一期清单N01为准 | | P2 | SHAP 高级解释 | | P2 | AI 简历、AI 模拟面试 | | P2 | 技能词云、入职清单、诚信积分 | diff --git a/docs/candidate-scoring-fairness-api.md b/docs/candidate-scoring-fairness-api.md new file mode 100644 index 0000000..3248ddd --- /dev/null +++ b/docs/candidate-scoring-fairness-api.md @@ -0,0 +1,39 @@ +# V43 候选评分公平性与稳定性监测 API + +V43 只提供 ADMIN 聚合查询。当前状态为“独立代码复验与真实 MySQL/Docker 验收通过,待 Git 检查点”。响应基于当前数据库中指定月份的评分快照,字段 `basis=CURRENT_STORED_SNAPSHOTS` 表示快照可能被同月重算覆盖,不能解读为历史时点记录。 + +## 权限与通用约束 + +- ADMIN 才能访问;匿名为 401,STUDENT/COMPANY 为 403/2002。 +- 时间范围使用 `[from,to)`。评分接口的 `period` 必填,格式为 `yyyy-MM`,不得晚于当前业务月。 +- 申诉接口默认最近 30 个完整业务日,`from`、`to` 必须成对出现,跨度最大 90 天。 +- 仅允许文档列出的参数;未知参数、非法维度和未来月份返回 400/1001。 +- 所有响应为聚合白名单,不包含用户、申请、申诉、答卷、结果、审计 ID,联系方式、简历、题目、答案、规则详情或审计原文。 + +## 接口 + +| 方法 | 路径 | 用途 | +|---|---|---| +| GET | `/admin/scoring/fairness/overview?period=yyyy-MM` | 现存快照人数、可计算比例、规则版本构成和综合分分布 | +| GET | `/admin/scoring/fairness/dimensions?period=yyyy-MM&dimension=ABILITY` | 八维状态、缺行/空分/有效覆盖率和分布 | +| GET | `/admin/scoring/fairness/drift` | 返回 `UNAVAILABLE/IMMUTABLE_SCORE_HISTORY_NOT_AVAILABLE` | +| GET | `/admin/scoring/fairness/ranking-stability` | 返回 `UNAVAILABLE/RANKING_SNAPSHOT_HISTORY_NOT_AVAILABLE` | +| GET | `/admin/scoring/fairness/appeals?from=yyyy-MM-dd&to=yyyy-MM-dd` | 申诉创建队列当前状态、审核通过、回滚和实际变分聚合 | + +所有 `sampleSize`、状态/版本/原因计数均按独立候选人计算,`appealCount` 才是事件数。每个统计组均带有明确的 `availability` 与 `reason`:`NO_DATA`、`INSUFFICIENT_SAMPLE`、`SUPPRESSED` 或 `AVAILABLE`,前端不得通过字段缺失猜测状态。0–29 人不返回均值、分位数、极值、标准差、分桶、比例或风险;30–99 标记 `LOW_CONFIDENCE`;100 及以上为 `NORMAL_CONFIDENCE`。任意细分单元或其补集为 1–29 人时整组抑制,避免从总计反推隐藏单元。 + +规则版本分布按版本单元分别做披露决策:不安全版本只返回 `availability=SUPPRESSED`/`reason=SMALL_CELL_OR_COMPLEMENT_RISK`,其 `sampleSize`、均值、分位数、极值、标准差、分桶和风险均为空;安全兄弟版本(例如70/30)仍可返回正常分布。只要存在版本级受保护单元,`versionCounts`及其可由总体反推的计数隐藏;全局总体触发抑制时,整个版本分布组隐藏。 + +总览的 `computableSnapshotRatio` 是“至少一个有效维度的候选人数 / 总体人数”,MIXED/UNKNOWN 候选不会被错误地当作无有效分;综合分布通过 `distributionsByRulesVersion` 按单一已知候选整体规则版本分别计算,MIXED/UNKNOWN 不进入版本分布。申诉的 `statusCounts/statusCandidateCounts` 是独立候选人数,`statusEventCounts` 明确保留事件数;重复回滚、空/越界分和跨版本原因均独立计数。回滚可比性按申诉粒度判断:同一 `appeal_id` 的 ROLLBACK 历史恰好一条且分数/规则版本合法才进入 COMPARABLE;同一候选人的另一份合法申诉不因其存在重复历史的申诉而被排除。`comparableRollbackCount` 与 `changedCount` 按可比/变分回滚历史行计数,`comparableRollbackCandidateCount` 与 `changedCandidateCount` 按这些行对应的独立候选人数计数;`actualScoreChangeRate=changedCount/comparableRollbackCount`,零差历史仍在分母中。 + +维度完整性异常分开返回 `inconsistentRecordCount`(状态与是否有分不一致)、`unknownStatusCount`(未知状态)和 `outOfRangeScoreCount`(非空但不在 0–100 的分数);这些计数与有效分、缺行、空分不互相替代,并受同一独立候选人/补集抑制规则约束。分布发生分桶抑制时连同 `sampleSize`、均值、分位数、极值、标准差、分桶和风险一起隐藏,只保留 `availability/reason`。 + +回滚不可比原因按互斥优先级归类:重复 `ROLLBACK` 历史优先,其次为空/越界分,最后是规则版本未知或前后不一致;每个申诉只进入一个原因集合,但同一候选人可因不同申诉同时出现在多个原因集合,不能用候选人去重抹掉真实归属。零差回滚仍保留在可比分母中。审核通过率、回滚执行率和实际变分率使用各自独立分母,任何分母或补集不足 30 名独立候选人均不返回该指标。 + +申诉接口区分 `APPROVED/ROLLED_BACK`、实际 `ROLLBACK` 执行和前后分数变化。它不计算真实曝光申诉率,因为当前系统没有可比的曝光分母。 + +V43 不写 `candidate_dimension_scores`,不触发评分重算,不修改 V32 排名。历史漂移和历史排名接口返回不可用状态是当前数据模型的真实限制。 + +实现约束:维度统计使用 `COUNT(DISTINCT student_user_id)` 与 `GROUP BY` 频数查询,分位数/标准差由频数直接计算;申诉回滚按申诉时间窗口 JOIN 查询,不拼接无限增长的 `IN` 参数。每条 JDBC 查询使用独立 5 秒超时,不修改共享 `JdbcTemplate`。真实 MySQL 已完成 30/300/3000 人查询预算与耗时验收;EXPLAIN 中的全表扫描/filesort 作为更大规模容量风险持续观察。 + +抑制实现由独立的 `FairnessDisclosureGuard` 统一执行:有效集合、每个细分单元及其补集均按独立候选人数判断;当有效集合或补集仅 1–29 人时,可能揭示其大小的综合分布字段(包括 sampleSize、分位数、分桶、均值、极值和风险)一并隐藏。 diff --git a/docs/frontend-integration.md b/docs/frontend-integration.md index 040471a..2a286b3 100644 --- a/docs/frontend-integration.md +++ b/docs/frontend-integration.md @@ -931,3 +931,11 @@ POST /admin/review-replies/{replyId}/decision - 新增公开 `GET /job-recommendation-taxonomies`;企业通过 `GET/PUT /companies/me/jobs/{jobId}/recommendation-profile` 选择平台受控行业/岗位族并填写画像。更新启用画像后状态为 `PENDING_REVIEW`,仅 ADMIN 审核通过的启用画像参与个性化推荐。 - 学生接口:`GET /profile/me/job-recommendations?page&size`;`POST /profile/me/job-recommendations/{jobId}/feedback`;`DELETE .../feedback?expectedVersion=`。`NOT_INTERESTED` 会持久化排除该岗位;解释反馈不影响首版分数。 - ADMIN:`GET /admin/job-recommendation-profiles`、`POST /admin/job-recommendation-profiles/{jobId}/decision`。企业端不得取得任何学生分数或推荐理由;学生端不得取得画像权重、测评结果 ID 或题库字段。 + +## 25. V43 候选评分公平性与稳定性监测 + +V43 仅供管理员后台使用,前端不得向学生或企业展示。当前状态为版本内补集定向修复完成、待独立复验;总览和申诉使用最终 SQL 聚合,并统一执行独立候选人数与补集抑制。`drift` 和 `ranking-stability` 在当前没有不可变评分历史与排名快照时返回 `availability=UNAVAILABLE`,页面应显示“暂无可比历史数据”。 + +可用接口、参数和小样本规则见 [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 c08fc42..35279b2 100644 --- a/docs/openapi-rc.json +++ b/docs/openapi-rc.json @@ -118,6 +118,74 @@ { "name": "企业", "description": "企业资料创建、查询与更新" + }, + { + "name": "health-controller", + "description": "health-controller 接口" + }, + { + "name": "student-review-weight-controller", + "description": "student-review-weight-controller 接口" + }, + { + "name": "同岗位竞争力排名", + "description": "同岗位竞争力排名 接口" + }, + { + "name": "RIASEC 职业兴趣测评", + "description": "RIASEC 职业兴趣测评接口" + }, + { + "name": "求职进度", + "description": "求职进度 接口" + }, + { + "name": "account-deletion-controller", + "description": "account-deletion-controller 接口" + }, + { + "name": "personal-data-export-controller", + "description": "personal-data-export-controller 接口" + }, + { + "name": "privacy-settings-controller", + "description": "privacy-settings-controller 接口" + }, + { + "name": "举报与屏蔽", + "description": "举报与屏蔽 接口" + }, + { + "name": "举报管理", + "description": "举报管理 接口" + }, + { + "name": "四维能力测评", + "description": "四维能力测评接口" + }, + { + "name": "风险治理", + "description": "评分风险治理管理接口" + }, + { + "name": "岗位推荐", + "description": "学生岗位推荐及企业推荐画像接口" + }, + { + "name": "企业评价管理", + "description": "企业评价查看及官方回复接口" + }, + { + "name": "官方回复审核", + "description": "管理员官方回复审核接口" + }, + { + "name": "企业人才发现", + "description": "企业人才发现接口" + }, + { + "name": "企业人才邀约", + "description": "企业人才邀约接口" } ], "paths": { @@ -5463,7 +5531,7 @@ "tags": [ "account-deletion-controller" ], - "operationId": "cancel", + "operationId": "cancelAccountDeletion", "parameters": [], "responses": { "200": { @@ -7733,33 +7801,6 @@ ], "operationId": "riskOverview", "summary": "风险概览(ADMIN)", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": [ - { - "name": "from", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "to", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - } - ] - } - } - }, - "required": true - }, "responses": { "200": { "description": "OK", @@ -7771,7 +7812,25 @@ } } } - } + }, + "parameters": [ + { + "name": "from", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "to", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ] } }, "/admin/scoring/risk/trend": { @@ -7781,41 +7840,6 @@ ], "operationId": "riskTrend", "summary": "风险趋势(ADMIN)", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": [ - { - "name": "bucket", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "from", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "to", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - } - ] - } - } - }, - "required": true - }, "responses": { "200": { "description": "OK", @@ -7827,7 +7851,33 @@ } } } - } + }, + "parameters": [ + { + "name": "bucket", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "from", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "to", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ] } }, "/admin/scoring/risk/distribution": { @@ -7837,33 +7887,6 @@ ], "operationId": "riskDistribution", "summary": "风险分布(ADMIN)", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": [ - { - "name": "from", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "to", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - } - ] - } - } - }, - "required": true - }, "responses": { "200": { "description": "OK", @@ -7875,7 +7898,25 @@ } } } - } + }, + "parameters": [ + { + "name": "from", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "to", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ] } }, "/admin/scoring/risk/events": { @@ -7885,73 +7926,6 @@ ], "operationId": "riskEvents", "summary": "风险事件分页(ADMIN)", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": [ - { - "name": "page", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "size", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "status", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "severity", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "ruleCode", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "from", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "to", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - } - ] - } - } - }, - "required": true - }, "responses": { "200": { "description": "OK", @@ -7963,7 +7937,65 @@ } } } - } + }, + "parameters": [ + { + "name": "page", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "size", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "status", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "severity", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "ruleCode", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "from", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "to", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ] } }, "/admin/scoring/risk/rules": { @@ -8943,23 +8975,207 @@ } } } - } - }, - "components": { - "schemas": { - "TaxonomyResponse": { - "type": "object", - "properties": { - "code": { - "type": "string" + }, + "/admin/scoring/fairness/overview": { + "get": { + "summary": "评分完整性概览(ADMIN)", + "parameters": [ + { + "name": "period", + "in": "query", + "required": true, + "schema": { + "type": "string", + "pattern": "^\\d{4}-\\d{2}$" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponseFairnessResponseOverview" + } + } + } }, - "parentCode": { - "type": "string" + "401": { + "description": "未登录" }, - "type": { - "type": "string" + "403": { + "description": "非 ADMIN" }, - "displayName": { + "400": { + "description": "非法 period" + } + } + } + }, + "/admin/scoring/fairness/dimensions": { + "get": { + "summary": "评分维度分布(ADMIN)", + "parameters": [ + { + "name": "period", + "in": "query", + "required": true, + "schema": { + "type": "string", + "pattern": "^\\d{4}-\\d{2}$" + } + }, + { + "name": "dimension", + "in": "query", + "required": false, + "schema": { + "type": "string", + "enum": [ + "EDUCATION", + "INTERNSHIP", + "ABILITY", + "COMPLIANCE", + "STABILITY", + "GROWTH", + "INTEGRITY", + "ACTIVITY" + ] + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponseFairnessResponseDimensions" + } + } + } + }, + "401": { + "description": "未登录" + }, + "403": { + "description": "非 ADMIN" + }, + "400": { + "description": "非法参数" + } + } + } + }, + "/admin/scoring/fairness/drift": { + "get": { + "summary": "评分历史漂移(ADMIN)", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponseFairnessResponseUnavailable" + } + } + } + }, + "401": { + "description": "未登录" + }, + "403": { + "description": "非 ADMIN" + } + } + } + }, + "/admin/scoring/fairness/ranking-stability": { + "get": { + "summary": "排名稳定性(ADMIN)", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponseFairnessResponseUnavailable" + } + } + } + }, + "401": { + "description": "未登录" + }, + "403": { + "description": "非 ADMIN" + } + } + } + }, + "/admin/scoring/fairness/appeals": { + "get": { + "summary": "申诉纠错聚合(ADMIN)", + "parameters": [ + { + "name": "from", + "in": "query", + "required": false, + "schema": { + "type": "string", + "format": "date" + } + }, + { + "name": "to", + "in": "query", + "required": false, + "schema": { + "type": "string", + "format": "date" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponseFairnessResponseAppeals" + } + } + } + }, + "401": { + "description": "未登录" + }, + "403": { + "description": "非 ADMIN" + }, + "400": { + "description": "非法日期范围" + } + } + } + } + }, + "components": { + "schemas": { + "TaxonomyResponse": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "parentCode": { + "type": "string" + }, + "type": { + "type": "string" + }, + "displayName": { "type": "string" } } @@ -14038,11 +14254,9 @@ "type": "string" }, "data": { - "$ref": { - "type": "array", - "items": { - "$ref": "#/components/schemas/RiskRuleResponse" - } + "type": "array", + "items": { + "$ref": "#/components/schemas/RiskRuleResponse" } }, "requestId": { @@ -14552,6 +14766,619 @@ "format": "date-time" } } + }, + "FairnessResponseDistribution": { + "type": "object", + "properties": { + "sampleSize": { + "type": "integer" + }, + "mean": { + "type": "number" + }, + "median": { + "type": "number" + }, + "p10": { + "type": "number" + }, + "p25": { + "type": "number" + }, + "p75": { + "type": "number" + }, + "p90": { + "type": "number" + }, + "min": { + "type": "number" + }, + "max": { + "type": "number" + }, + "standardDeviation": { + "type": "number" + }, + "buckets": { + "type": "object", + "additionalProperties": { + "type": "integer" + } + }, + "concentrationRate": { + "type": "number" + }, + "riskLevel": { + "type": "string" + }, + "availability": { + "type": "string" + }, + "reason": { + "type": "string" + } + } + }, + "FairnessResponseDimensionMetric": { + "type": "object", + "properties": { + "dimension": { + "type": "string" + }, + "populationSize": { + "type": "integer" + }, + "rowCount": { + "type": "integer" + }, + "missingRowCount": { + "type": "integer" + }, + "validSampleSize": { + "type": "integer" + }, + "confidence": { + "type": "string" + }, + "missingRowRate": { + "type": "number" + }, + "nullScoreRate": { + "type": "number" + }, + "validCoverage": { + "type": "number" + }, + "statusCounts": { + "type": "object", + "additionalProperties": { + "type": "integer" + } + }, + "versionCounts": { + "type": "object", + "additionalProperties": { + "type": "integer" + } + }, + "inconsistentRecordCount": { + "type": "integer" + }, + "unknownStatusCount": { + "type": "integer" + }, + "outOfRangeScoreCount": { + "type": "integer" + }, + "unknownRulesVersionCount": { + "type": "integer" + }, + "distribution": { + "$ref": "#/components/schemas/FairnessResponseDistribution" + }, + "distributionsByRulesVersion": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/FairnessResponseDistribution" + } + }, + "availability": { + "type": "string" + }, + "reason": { + "type": "string" + } + } + }, + "FairnessResponseOverview": { + "type": "object", + "properties": { + "basis": { + "type": "string" + }, + "scope": { + "type": "string" + }, + "period": { + "type": "string" + }, + "capturedAt": { + "type": "string", + "format": "date-time" + }, + "populationSize": { + "type": "integer" + }, + "computableSnapshotRatio": { + "type": "number" + }, + "confidence": { + "type": "string" + }, + "riskLevel": { + "type": "string" + }, + "versionCounts": { + "type": "object", + "additionalProperties": { + "type": "integer" + } + }, + "mixedVersionCount": { + "type": "integer" + }, + "unknownVersionCount": { + "type": "integer" + }, + "distribution": { + "$ref": "#/components/schemas/FairnessResponseDistribution" + }, + "distributionsByRulesVersion": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/FairnessResponseDistribution" + } + }, + "coherentVersionSubset": { + "type": "array", + "items": { + "type": "string" + } + }, + "availability": { + "type": "string" + }, + "reason": { + "type": "string" + } + } + }, + "FairnessResponseDimensions": { + "type": "object", + "properties": { + "basis": { + "type": "string" + }, + "scope": { + "type": "string" + }, + "period": { + "type": "string" + }, + "capturedAt": { + "type": "string", + "format": "date-time" + }, + "dimensions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FairnessResponseDimensionMetric" + } + }, + "availability": { + "type": "string" + }, + "reason": { + "type": "string" + } + } + }, + "FairnessResponseUnavailable": { + "type": "object", + "properties": { + "availability": { + "type": "string", + "enum": [ + "UNAVAILABLE" + ] + }, + "reason": { + "type": "string" + }, + "capturedAt": { + "type": "string", + "format": "date-time" + } + } + }, + "FairnessResponseAppeals": { + "type": "object", + "properties": { + "basis": { + "type": "string" + }, + "from": { + "type": "string", + "format": "date" + }, + "to": { + "type": "string", + "format": "date" + }, + "capturedAt": { + "type": "string", + "format": "date-time" + }, + "appealCount": { + "type": "integer", + "description": "申诉事件数;与按独立候选人数统计的其他字段区分" + }, + "sampleSize": { + "type": "integer" + }, + "statusCounts": { + "type": "object", + "additionalProperties": { + "type": "integer" + } + }, + "statusEventCounts": { + "type": "object", + "additionalProperties": { + "type": "integer" + } + }, + "statusCandidateCounts": { + "type": "object", + "additionalProperties": { + "type": "integer" + } + }, + "confidence": { + "type": "string" + }, + "approvedEverCount": { + "type": "integer" + }, + "approvalRate": { + "type": "number" + }, + "rollbackExecutionRate": { + "type": "number" + }, + "duplicateAppealCount": { + "type": "integer" + }, + "duplicateAppealRate": { + "type": "number" + }, + "comparableRollbackCount": { + "type": "integer", + "description": "可比 ROLLBACK 历史行数;仅唯一历史且分数/规则版本合法的申诉" + }, + "comparableRollbackCandidateCount": { + "type": "integer", + "description": "可比回滚历史对应的独立候选人数;同一候选人的多份合法申诉只计一人" + }, + "changedCount": { + "type": "integer" + }, + "changedCandidateCount": { + "type": "integer" + }, + "actualScoreChangeRate": { + "type": "number" + }, + "meanDelta": { + "type": "number" + }, + "meanAbsoluteDelta": { + "type": "number" + }, + "duplicateRollbackHistoryCount": { + "type": "integer" + }, + "invalidScoreRollbackCount": { + "type": "integer" + }, + "ruleVersionChangedRollbackCount": { + "type": "integer" + }, + "reasonCounts": { + "type": "object", + "additionalProperties": { + "type": "integer" + } + }, + "availability": { + "type": "string" + }, + "reason": { + "type": "string" + }, + "exposureAppealRateAvailability": { + "type": "string" + }, + "exposureAppealRateReason": { + "type": "string" + } + } + }, + "ApiResponseFairnessResponseOverview": { + "type": "object", + "properties": { + "code": { + "type": "integer" + }, + "message": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/FairnessResponseOverview" + }, + "requestId": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + } + }, + "ApiResponseFairnessResponseDimensions": { + "type": "object", + "properties": { + "code": { + "type": "integer" + }, + "message": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/FairnessResponseDimensions" + }, + "requestId": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + } + }, + "ApiResponseFairnessResponseUnavailable": { + "type": "object", + "properties": { + "code": { + "type": "integer" + }, + "message": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/FairnessResponseUnavailable" + }, + "requestId": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + } + }, + "ApiResponseFairnessResponseAppeals": { + "type": "object", + "properties": { + "code": { + "type": "integer" + }, + "message": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/FairnessResponseAppeals" + }, + "requestId": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + } + }, + "StudentProfileResponse": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "userId": { + "type": "integer", + "format": "int64" + }, + "realName": { + "type": "string" + }, + "bio": { + "type": "string" + }, + "location": { + "type": "string" + }, + "education": { + "type": "string" + }, + "school": { + "type": "string" + }, + "graduationYear": { + "type": "integer", + "format": "int32" + }, + "resumeUrl": { + "type": "string" + }, + "skills": { + "type": "string" + }, + "createdAt": { + "type": "string", + "format": "date-time" + } + } + }, + "CreateDataExportRequest": { + "type": "object", + "properties": { + "format": { + "type": "string" + }, + "idempotencyKey": { + "type": "string", + "maxLength": 64 + } + } + }, + "DataExportTaskResponse": { + "type": "object", + "properties": { + "taskId": { + "type": "integer", + "format": "int64" + }, + "status": { + "type": "string" + }, + "format": { + "type": "string" + }, + "requestedAt": { + "type": "string", + "format": "date-time" + }, + "startedAt": { + "type": "string", + "format": "date-time" + }, + "completedAt": { + "type": "string", + "format": "date-time" + }, + "expiresAt": { + "type": "string", + "format": "date-time" + }, + "sizeBytes": { + "type": "integer", + "format": "int64" + }, + "downloadAvailable": { + "type": "boolean" + }, + "errorCode": { + "type": "string" + } + } + }, + "ApiResponseStudentProfileResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "message": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/StudentProfileResponse" + }, + "requestId": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + } + }, + "ApiResponseDataExportTaskResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "message": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/DataExportTaskResponse" + }, + "requestId": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + } + }, + "ApiResponsePageResponseDataExportTaskResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "message": { + "type": "string" + }, + "data": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DataExportTaskResponse" + } + }, + "totalElements": { + "type": "integer", + "format": "int64" + }, + "page": { + "type": "integer", + "format": "int32" + }, + "size": { + "type": "integer", + "format": "int32" + } + } + }, + "requestId": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + } } }, "securitySchemes": { diff --git a/docs/phase1-scope-and-acceptance.md b/docs/phase1-scope-and-acceptance.md new file mode 100644 index 0000000..9955f5f --- /dev/null +++ b/docs/phase1-scope-and-acceptance.md @@ -0,0 +1,101 @@ +# 一期范围、责任与验收清单 + +冻结日期:2026-09-08。范围依据:负责人本轮明确确认。本文是一期范围与交付状态的权威清单;历史路线图与本文冲突时,以本文为准。 + +范围冻结不等于设计批准或验收通过。八个待交付功能组全部属于一期,不再以“后置建议”将其排除。新增接口、迁移、计分规则和权限变化仍需在各模块设计中明确后编码。本文不预分配版本号、迁移号或接口数量。 + +## 1. 责任与完成定义 + +- 张顺(你):业务与产品负责人,主责评分、监测、消息和简历相关能力。 +- 小刘:主责内容与数据型功能、积分独立模块及交付环境。 +- 表中负责人负责设计、实现、测试和文档;另一位负责独立审查。模块作者不能用自己的完成报告代替独立验收。 +- 前端由现有前端同学实现,后端负责人提供契约并组织联调;未指定具体前端人员,不虚构责任人。 +- 状态采用:待设计、设计待确认、实现中、待独立复验、待真库验收、待前端验收、一期验收完成。Git 已提交与功能已验收分别记录。 +- 每项完成必须具备:需求用例、权限与隐私边界、可执行自动化、适用的真实 MySQL 验证、前端流程验收、文档和可追溯提交。无需迁移的模块不强行新增迁移。 + +下面代码路径均相对项目根目录 `C:/Users/15642/Desktop/zhilai`。生产包根为 `mydreamjob-server/src/main/java/com/mydreamjob/`,测试包根为 `mydreamjob-server/src/test/java/com/mydreamjob/`。拟新增包只是定位建议,不代表已存在。 + +## 2. 已有核心能力:保留在一期,执行集成回归 + +这些能力已有代码及历史验证记录;本轮仅核查入口和 Git,不重新宣称所有历史黑盒已通过。 + +| 编号/能力 | 一期需要什么 | 现有代码入口(生产包根下) | 如何验收 | 主责 / 复核 | +|---|---|---|---|---| +| C01 认证与企业认证 | 注册登录、身份校验、企业认证与权限隔离 | auth/、verification/、config/SecurityConfig.java | 登录失败/过期/匿名/角色越权;企业审核主链路;生产短信配置验证 | 小刘 / 张顺 | +| C02 档案与岗位 | 学生资料、偏好、企业资料、岗位发布搜索收藏比较 | profile/、preference/、company/、job/ | 企业只能改本企业岗位;公开字段白名单;筛选分页与资料更新页面联调 | 小刘 / 张顺 | +| C03 招聘主链路 | 投递、面试、结构化评分、Offer、进度 | application/、interview/、interviewscore/、offer/、careerprogress/ | 从注册到投递、面试、Offer逐步操作;拒绝/撤回/过期/重复请求与并发 | 张顺 / 小刘 | +| C04 沟通与安全 | 匹配、会话、消息、通知、举报屏蔽 | messaging/、notification/、bidirectionalmatch/、safety/ | 非成员不能读写;屏蔽生效;DIRECT与邀约会话隔离;重试不重复 | 张顺 / 小刘 | +| C05 简历与账户隐私 | 简历版本导出、个人数据导出、注销与隐私授权 | resume/、privacy/、deletion/、dataexport/ | 文件下载归属;导出内容脱敏;注销状态机;保留策略和恢复验证 | 张顺 / 小刘 | +| C06 评价与企业回复 | 评价、预审、管理员决策、企业官方回复 | review/、feedback/ | 公开载荷无学生身份;仅通过回复公开;幂等及审核并发;企业归属 | 小刘 / 张顺 | +| C07 测评与评分治理 | RIASEC、四维能力、评分卡、排名、申诉回滚、防刷 | assessment/、ability/、candidatescore/、competition/、risk/ | 抽题/计时/有效期/规则开关;申诉回滚;评分读取与写入边界;结果解释 | 张顺 / 小刘 | +| C08 推荐与人才邀约 | 岗位推荐、学生授权发现、企业邀约与接受后沟通 | recommendation/、talentdiscovery/、talentinvitation/ | 默认不发现;同企业历史投递排除;限额/冷却/幂等;接受后会话隐私 | 张顺 / 小刘 | + +## 3. 一期必须补齐的八个功能组 + +以下交付边界用于控制设计范围;涉及产品参数的内容明确列为待评审,不得由实现者自行锁定。 + +| 编号/功能 | 当前状态与一期需要什么 | 代码在哪里 | 如何验收 | 主责 / 复核 | +|---|---|---|---|---| +| 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/ | 对固定样例独立手算对账;解释与实际分数同源;企业不见私人细分;边界与刷分案例评审;形成规则证据表和测评试测安排 | 张顺 / 小刘 | +| N03 行业技能词云 | 未确认有独立实现;用有授权、可追溯的数据生成技能频次,支持空数据与更新时间;不是装饰性随机词云 | 现有 job/、recommendation/ 可复用数据入口;拟新增 skillinsight/ | 同义词归一/去重/计数用已知样本对账;行业筛选与来源日期准确;无数据不编造;批量性能与页面联调 | 小刘 / 张顺 | +| N04 入职清单与公司知识介绍 | 未确认有独立实现;清单模板、个人完成进度、公司可公开介绍;公司原有介绍与新增入职知识明确区分 | 现有 company/、offer/、careerprogress/;拟新增 onboarding/ | 本人进度隔离、企业内容归属、模板变更行为、重复更新;未认证内容不得冒充平台核验;页面完整走通 | 小刘 / 张顺 | +| N05 薪资计算器与薪资参考 | 未确认有独立实现;可解释的计算明细与有来源的薪资参考;明确币种、周期、地区、规则生效期和估算性质 | 现有 job/、offer/ 可提供薪资字段;拟新增 salary/ | 经确认的规则用独立算例验证舍入与边界;来源/版本/更新时间;缺地区或参考样本时明确不可计算/不足;不伪造市场数据 | 小刘 / 张顺 | +| N06 诚信积分与激励 | 独立积分能力未完成,现有INTEGRITY不是积分账户;先确定合格行为、积分流水、撤销和展示语义,不以简单行为推断人格 | 现有 candidatescore/CandidateScoringCalculator.java#computeIntegrity、audit/;拟新增 incentive/ | 积分事件幂等、并发不重复加分、撤销可追溯、余额与流水一致、刷行为不无限获益;与候选评分默认隔离,接入必须单独评审 | 小刘 / 张顺 | +| N07 消息撤回 | 未确认有独立实现;发送者在批准窗口内撤回,列表/详情/重试响应同步显示撤回态;保留必要审计 | 现有 messaging/MessagingService.java、talentinvitation/;新增位置由设计确定 | 非本人不可撤回;时间边界、并发发送/读取/撤回、幂等;撤回后正文不从缓存或历史响应泄漏;DIRECT与邀约会话回归 | 张顺 / 小刘 | +| N08 简历查重 | 未确认有独立实现;先界定同人重复上传与跨文档相似检测,给出依据和局限;不得直接判抄袭或自动扣分 | 现有 resume/ResumeVersionService.java、profile/storage/;拟新增 resumededup/ | 重复文件/改名/模板相似/不同格式/解析失败样例;阈值误报验证;不得借查重返回他人简历、姓名或联系方式;上传性能与租户隔离 | 张顺 / 小刘 | + +### 编码前必须补齐的产品决策 + +范围已确认,以下细节未确认。负责人先交付短设计,双方评审后再实现,不因缺细节将功能移出一期。 + +- N02:各规则的业务含义、修改兼容方式、测评试测样本与证据门槛;现有分数是否继续影响企业和排名须明确,不能默默改变。 +- N03:行业受控字典、数据来源和授权、统计窗口、最小样本、更新方式。 +- N04:清单适用人群与触发点、模板维护方、公司内容发布/审核机制、进度保存方式。 +- N05:税前/税后/年包范围、支持地区、规则与市场参考数据来源、更新责任。涉及税费规则时设计阶段查验权威资料。 +- N06:积分名称、可积分事件、额度、冷却、撤销、兑换或仅展示;不能默认涉及现金/奖品。 +- N07:撤回时间窗、附件与引用、离线/缓存同步、审计及数据保留口径。 +- N08:查重范围、合法数据边界、文件类型、指纹或文本相似算法、阈值和提示对象;跨用户查重需单独隐私评审。 + +## 4. 一期交付保障,同样属于未完成工作 + +| 编号 | 需要什么 | 入口 | 如何验收 | 主责 / 复核 | +|---|---|---|---|---| +| G01 可复现构建与持续验证 | 干净环境构建、PR检查、MySQL兼容验证、精确版本产物 | mydreamjob-server/pom.xml、测试目录、backend-infra/;CI落点待设计 | 新环境按文档运行;提交触发测试;失败阻断;JAR摘要关联提交 | 小刘 / 张顺 | +| G02 部署与恢复 | 环境隔离、密钥管理、必要外部服务、文件持久化、数据库/文件备份恢复、发布回退 | backend-infra/、profile/storage/、resume/storage/、dataexport/storage/ | 隔离环境部署演练、恢复演练和回退证据;不把启动成功当恢复完成 | 小刘 / 张顺 | +| G03 监控、安全与容量 | 指标告警、权限隐私、依赖失败、重试、压测、敏感信息检查 | config/、audit/、健康检查、各模块 | 定义预计并发与SLA后实测;故障有告警、可定位;依赖中断和重复请求不破坏一致性 | 小刘 / 张顺 | +| G04 一期前端联调与用户验收 | 核心招聘链路与N01–N08对应页面可用,空态/错误态明确 | docs/frontend-integration.md、各API文档、前端仓库待登记 | 前端同学参与逐项签收;合成账号端到端录证;缺陷关闭后小范围试运营 | 张顺 / 小刘 | + +这四项是交付工作包,不代表恰好四个代码模块。已有资产可复用,但必须提供当前证据。 + +## 5. 排期与合并顺序 + +1. 张顺先收尾N01并完成N02规则评审;小刘同步搭建G01及设计N03。N01真库验收通过前不宣称V43冻结。 +2. 张顺实现N07;小刘实现N03,再实现N04。各模块先短设计、后实现、再独立验收。 +3. 张顺实现N08;小刘实现N05。数据来源、隐私与阈值未确认时先处理其他不依赖的工作。 +4. N02语义确认后,小刘实现N06,张顺复核与评分引擎的边界。 +5. G02/G03从第一阶段持续推进,最后执行G04和整体发布演练,不等所有业务代码写完才准备环境。 + +并行开发前登记各自改动路径。共享文件(ErrorCode、SecurityConfig、OpenAPI、路线图)由当次集成负责人串行合并。新Flyway号在准备合并前从实际主线分配;不得两人各自假定下一号。每个模块独立提交,可以批量推送,但不要将整个一期积累成一个巨型提交。 + +不承诺未估算的日期。每个短设计确认后再估工时、依赖和验收时间。当前是“8个待闭环功能组 + 4个交付工作包 + 既有核心回归”,不能折算成未经验证的完成百分比。 + +## 6. 明确移出一期 + +以下进入后续候选池,不占一期验收范围: + +- AI简历诊断与生成式优化; +- AI模拟面试与面试题预测; +- SHAP高级模型解释;一期仍包含确定性规则解释; +- 受保护人口属性公平性分析;一期不推断敏感属性; +- 需要新增不可变历史快照才能实现的历史分数漂移、历史排名稳定性;V43一期返回UNAVAILABLE; +- 自动调整评分/排名/权重、自动封禁; +- 超出已确认模块的现金奖励、交易/付费及新增商业化功能。 + +行业技能词云、入职清单/公司知识介绍、薪资计算器/参考、诚信积分/激励、消息撤回、简历查重均明确保留在一期。 + +## 7. 范围变更与状态证据 + +新增或删减一期项须记录负责人决定、原因、影响、验收变化。变更实现细节不得隐式改变产品范围。每次交付更新本清单状态与证据链接;不靠版本号大小代表完成度。 + +本轮仅冻结范围和责任,没有新增业务代码、迁移或测试执行。历史测试数不能视为本轮复验结果。已核实提交包括V39 55e3975、V40 2698d06、V41 5cc2c86、V42 96b3113;V43仍未提交且待整改复验。 diff --git a/docs/v43-candidate-scoring-fairness-design.md b/docs/v43-candidate-scoring-fairness-design.md new file mode 100644 index 0000000..9341993 --- /dev/null +++ b/docs/v43-candidate-scoring-fairness-design.md @@ -0,0 +1,173 @@ +# V43 候选评分公平性与稳定性监测:修订设计 + +文档类型:解释与技术参考。读者:负责人、后端开发及验收人员。 +状态:独立代码复验与真实 MySQL/Docker 验收通过,待 Git 检查点。 +编码前基线:V42,最新已核查迁移 V40,OpenAPI 167 paths / 205 operations;测试维护提交 498512d。实现后静态 OpenAPI 为 172 paths / 210 operations,自动化验证结果以本轮报告为准。 + +## 1. 第一版范围与修订结论 + +第一版只监测现存评分数据质量、分布、规则版本构成和申诉流程。仅 ADMIN 可读,无自动干预,无新 Flyway、表、调度器、feature flag 或公共错误码。不写 candidate_dimension_scores,不调用会触发评分重算/upsert的服务,不改变排名或申诉状态,不读取题目、答案、sub_scores_json、测评详情或 audit details,不接入 AI。 + +不推断性别、民族、年龄、家庭背景,不使用学校、城市、头像或姓名作为代理分组。结果不是人口统计公平性证明,也不能说明某条评分规则合理。置信标记只是运营样本等级,不是统计置信区间。 + +保留五个原提案 ADMIN GET 接口以保持监测契约;drift 和 ranking-stability 第一版明确不可用。不提供 jobId/companyId 或任意维度组合筛选;无 ACKNOWLEDGE/DISMISS,人工处置后续单独评审。实际编码前如发现与其他已批准文档冲突,先报告冲突。 + +## 2. 真实数据证据与可计算边界 + +| 来源/组件 | 实际结构或行为 | V43 口径 | +|---|---|---| +| candidate_dimension_scores / CandidateDimensionScore | UNIQUE(student_user_id,dimension,period);score/status/weight/rules_version/calculated_at;period为yyyy-MM | 指定period在本次读取时仍存在的快照 | +| CandidateScoringCalculator.upsertSnapshots | ON DUPLICATE KEY覆盖分数、状态、规则版本、calculated_at;治理可重写历史period | calculated_at不是不可变评分事件时间;不据此重建过去窗口 | +| CandidateScoringCalculator.overallScoreFromSnapshots | OK/ESTIMATED且非空分数的等权算术平均,2位HALF_UP | 只读复用纯计算语义;不是任取权重的第二套公式 | +| CandidateScoringEngineService / GovernanceService | 评分查询/重算可能写快照 | 不作为V43读取入口 | +| candidate_scoring_appeals | PENDING/APPROVED/REJECTED/CANCELLED/ROLLED_BACK;created_at、reviewed_at、rollback_at、rules_version | 申诉创建队列当前状态,不冒充窗口结束时状态 | +| candidate_scoring_appeal_history | 状态转换、操作人、原因 | 本版无需读原因或操作人 | +| candidate_scoring_snapshot_history | ROLLBACK的before/after_score和before/after_rules_version | 仅已记录回滚变分,非全部纠错历史 | +| CompetitionRankingCalculator/Service | 按学生选最新period快照,排除WITHDRAWN/REJECTED;无持久化rank历史 | 当前无分候选可以在候选池,但rank=null;不能把入池当异常排名 | +| applications/jobs/companies | 可关联岗位与企业,但会引入重复候选和切片风险 | 本版不连接作筛选 | +| V37风险治理 / 企业评分Dashboard | 安全事件与业务聚合服务 | 不复用风险事件表,不复制企业个人级DTO | +| SecurityConfig / ErrorCode / GlobalExceptionHandler | 既有ADMIN认证、403/2002、参数400/1001约定 | 后续编码精确复用,验证真实响应 | + +现有迁移索引:V24有(student_user_id,dimension,period)唯一键、(student_user_id,period)、(dimension,period);V25申诉有(candidate_user_id,status)、(status,created_at),回滚历史有(candidate_user_id,dimension,period,created_at)。这些并不保证全局period或created_at聚合高效。上线前必须做真实EXPLAIN,不能仅因存在索引宣称覆盖。 + +可以计算:指定period的现存快照质量、分布、版本构成;指定创建窗口申诉的当前流程情况;已记录回滚的变分情况。 +不可计算:过去某时刻真实分布、等长历史分布漂移、长期PENDING持续时长、系统全体学生覆盖率、评分曝光口径的真实申诉率、实际历史rank变化、Top N进出率、“无有效评分却曾获rank”的事件数。统一用UNAVAILABLE及明确原因,不以0代替。 + +匿名聚合快照未来可支持分布趋势,但不能支持同一个人的rank变化或Top N成员进出;后者需另行评审受控关联标识和候选级历史,不能声称匿名汇总可以恢复。 + +## 3. 新实施计划 + +1. 固定现存快照样本、分母和统计纯函数;先写包含手算结果的测试。 +2. 实现受控只读聚合查询、混合版本检测和小样本抑制。 +3. 实现申诉队列状态及回滚变分指标;区分审核通过与实际变分。 +4. 接入ADMIN白名单DTO、五个接口及权限。 +5. 更新OpenAPI与联调文档,跑专项、全量和clean verify。 +6. 停止,报告待真实MySQL/Docker验收;后续独立验证EXPLAIN、性能、权限与无写入,再决定Git检查点。 + +## 4. 样本与分母 + +### 4.1 评分样本 + +period必填,yyyy-MM严格有效且不得晚于capturedNow所在月。统计一个period,禁止拼接多个period充足样本。响应声明basis=CURRENT_STORED_SNAPSHOTS、capturedAt和period,历史period也表示现在所存数据,不是当时数据。 + +U为该period至少有一条快照的独立student_user_id集合;N=COUNT(DISTINCT student_user_id)。不包含完全没有快照的学生,响应scope=SCORED_SNAPSHOT_POPULATION。八维固定为EDUCATION/INTERNSHIP/ABILITY/COMPLIANCE/STABILITY/GROWTH/INTEGRITY/ACTIVITY。 + +对每维d: +- rowCount为有该维行的候选数;missingRowCount=N-rowCount。 +- 各状态占比=statusCount/N;nullScoreRate为有行且score=null人数/N;missingRowRate为missingRowCount/N,两者不得混淆。 +- 有效分:状态OK或ESTIMATED且分数在[0,100]。validCoverage=validCount/N。 +- OK或ESTIMATED但空分、不可用状态却非空分、未知状态、越界分、未知规则版本单独计数,不修正原表。 +- 统计分布的sampleSize=有效分对应的独立候选数。不能用八维行数当人数。 + +总览:至少一个有效维度的候选数/N,命名computableSnapshotRatio。缺失不当0。 +sampleSize不足30的总体/切片隐藏比例和异常明细,仅返回人数、状态与原因。 + +### 4.2 综合分和规则版本 + +按同一候选、同一period组装全部现存八维,不跨月补齐。 +规则版本统一性检查涵盖所有现存维度行,包括PENDING: +- 恰好同一已知版本:按纯函数对有效维度求平均,2位HALF_UP;至少一个有效维度即可计算。 +- 多版本:MIXED;单独聚合候选数,排除V43综合分分布,不改V32的实际计算。 +- 单一未知版本:UNKNOWN;显示经脱敏的UNKNOWN汇总,不回原始异常字符串,不生成版本比较。 +- 已知版本v0.1/v0.2分别分组。综合分响应标明coherentVersionSubset;不能声称覆盖所有实际排名候选。 +- 维度分布按该行版本独立分组;这与综合分的全候选一致性检查分开。 + +不开放rulesVersion过滤参数,返回固定两种已知版本及UNKNOWN/MIXED汇总,避免构造自由切片。dimension可选但仅允许八维白名单。 + +## 5. 统计公式与输出 + +有效分排序x1…xn: +- mean=Σx/n。 +- 分位数采用线性插值:h=(n-1)p,i=floor(h),f=h-i;Qp=x(i+1)+f*(x(min(i+2,n))-x(i+1)),p取0.1/0.25/0.5/0.75/0.9。 +- 总体标准差=sqrt(Σ(x-mean)^2/n),不是样本标准差。中间BigDecimal使用DECIMAL128;最终2位HALF_UP。极小负数舍入误差应有明确容差处理,不吞掉其他错误。 +- min/max来自有效分,空集合全部null。 +- 十个桶:[0,10)、[10,20)…[80,90)、[90,100],9.5归首桶,100归最后桶。 +- 比例=100*分子/分母,最终2位HALF_UP;分母0返回null与不可用原因,不能返回0%。 +- 风险比较使用未四舍五入的精确比例/数值,展示再舍入,避免69.995%显示70.00却触发70%边界。 +- 分桶集中率=max(bucketCount)/n。n>=30时>=70% WARNING、>=85% HIGH,优先HIGH;此为运营提示,不是歧视结论。 + +confidence:独立人数0–29 INSUFFICIENT_SAMPLE、30–99 LOW_CONFIDENCE、>=100 NORMAL_CONFIDENCE;riskLevel采用NORMAL/WARNING/HIGH/INSUFFICIENT_SAMPLE。UNAVAILABLE是availability字段,不混为风险级别。 +不适用的风险字段为null。缺失率增加15/25pp、均值差8/12分、纠正率20/35%仅保留后续提案,本版不启用,因为历史/分母尚不支持。 + +完整性异常必须分开呈现:`inconsistentRecordCount`(状态与分值是否为空不一致)、`unknownStatusCount`(未知状态)和 `outOfRangeScoreCount`(非空但不在0–100)。任何一个细分异常及其真实集合补集进入同一独立候选人抑制判断,不能用字段缺失或固定0代替。分布桶触发抑制时连同该分布的 `sampleSize`、统计量和风险一并隐藏。 + +不使用PSI或KS,不预留名为已计算但填0的假指标。 +结构异常只作完整性统计;异常计数存在不等于HIGH,第一版只对上述集中率给风险等级。 + +## 6. 申诉流程与变分 + +只允许已结束的完整业务日窗口from/to(Asia/Shanghai,日期,to不含);默认最近30个完整日,最大90日,from=2条记录的独立候选人数;重复率=该人数/此队列独立申诉人数。明确这是窗口内重复,不是终身重复。 +- actualScoreChangeRate:只关联该队列的action=ROLLBACK历史;按appeal_id预聚合后JOIN,防止状态计数被历史行放大。同一appeal多条ROLLBACK视为该申诉不可比,但不排除同一候选人的其他合法申诉;禁止任取首条或末条。 +- 有且仅一条历史、前后分数均非空且在范围内、前后规则版本相同且已知,才进入comparableRollbackCount。 +- changedCount为after_score!=before_score;actualScoreChangeRate=changedCount/comparableRollbackCount。 +- delta=after-before,展示meanDelta和meanAbsoluteDelta;0差也计入分母。前后null单列不可比,规则不同单列versionChanged,不把规则升级变分称为纠错。 +- 审核通过、回滚执行、分数变化均不能独立证明原评分错误。该指标名称不得写为“纠错准确率”。 +- 每种比率除事件分母外,必须检查其实际分母集合的独立人数>=30;同一候选人的多份合法申诉可贡献多条行,但候选人数只计一次;不足则抑制。 +- 真实申诉率需要可比的受评/曝光人群分母,本版UNAVAILABLE,不用当前快照数除历史申诉数。 + +第一版不返回申诉dimension/rulesVersion切片,避免样本稀疏和自由组合;后续另行评审。只读取scalar所需字段,不取reason、decision_reason、reviewer_id、actor_user_id、audit details。 + +## 7. 接口契约 + +| GET路径 | 参数 | 返回核心 | +|---|---|---| +| /admin/scoring/fairness/overview | period | 现存快照人数、完整性、综合分可计算比例、版本构成、综合分分布 | +| /admin/scoring/fairness/dimensions | period、可选dimension | 固定八维及已知版本的覆盖率、状态、分布 | +| /admin/scoring/fairness/drift | 无业务参数 | availability=UNAVAILABLE、reason=IMMUTABLE_SCORE_HISTORY_NOT_AVAILABLE | +| /admin/scoring/fairness/ranking-stability | 无业务参数 | availability=UNAVAILABLE、reason=RANKING_SNAPSHOT_HISTORY_NOT_AVAILABLE | +| /admin/scoring/fairness/appeals | 可选成对from/to | 创建队列当前状态、审核/回滚/变分指标 | + +接口沿用ApiResponse,缺失指标为null附reason;样本不足使用正常200,不伪造零值。非法/未知查询参数按400/1001处理(包括jobId/companyId/rulesVersion/granularity),防止“参数被忽略”误解。非法角色403/2002,匿名401。 + +固定五个path/五个operation,若基线167/205不变则目标172/210。选择保留两个能力不可用接口是为了明确监测能力缺口,不代表历史监测实现。前端需显示“暂无历史数据”,不能显示正常绿色。 + +## 8. 隐私与查询限制 + +所有切片依据独立人数做门槛检查。主群体不足30时隐藏均值、极值、标准差、分位数、桶、比例、状态/版本/异常明细。只回sampleSize、availability和原因。 + +人数足够也需单元格抑制:桶、状态、版本、异常计数为1–29时隐藏计数及比例;总计与其他公开单元格能反推隐藏单元时做互补抑制(实现可直接抑制整组明细)。为减少小数精度恢复个体信息,包含被抑制桶的分布组一并隐藏其mean/min/max/stddev/quantiles和集中风险。这是保守聚合保护,不宣称差分隐私保证。 + +版本分布按“版本总体—该版本有效集合—该版本无效补集”独立判定:某个版本的有效集合或补集不足30人时,该版本的Distribution返回`SUPPRESSED`且不含任何数值统计;其他满足门槛的版本仍可返回可用分布。只要存在版本级受保护单元,`versionCounts`及其可推算的整体版本计数一并隐藏;若全局总体本身触发抑制,则整个版本分布组隐藏。这样既不暴露99/1版本内补集,也不因一个不安全版本而无差别吞掉安全的70/30版本统计。 + +相邻窗口/月份集合仍可能差分推断。第一版限制参数组合、仅完整日窗口、复用ADMIN访问限流;不提供候选下钻、导出或自由groupBy。该方案不是任意重复查询下的匿名性证明。 + +禁止学生/企业及公开入口,禁止所有个人ID、姓名、学校、联系方式、性别/年龄、简历、测评、题目、答案、审计详情和凭据出现在DTO、响应和应用日志。内部COUNT DISTINCT可使用用户ID,但不得从Repository返回到HTTP。 + +## 9. 查询实现与性能 + +每请求同一capturedNow和同一只读事务;不得调用computeAll、getOverview或其他可能重算的既有入口。 +- 快照总体:period过滤,SQL按学生内部去重,生成N;使用固定八维集合LEFT JOIN补missingRow。 +- 维度状态:GROUP BY dimension,rules_version,status;可比得分GROUP BY dimension,rules_version,score形成频数。 +- 分位数/标准差从score频数累积计算,不展开为每候选列表;中间数据仅score+frequency。 +- 综合分可通过SQL按候选period作无身份返回的内部聚合,再输出score频数;必须与既有纯函数对账,不能改变生产排名公式。 +- 申诉:各分母集合COUNT DISTINCT,回滚历史先按appeal预聚合并形成候选级聚合记录;固定次数查询,不逐人/逐维查询,不把重复历史申诉关联的候选人从其他合法申诉中整体剔除。 +- 建议预算总览<=4、维度<=3、申诉<=5、不可用接口0条业务查询;权限查询单列计数。测试在30/300/3000人及8维下验证。 +- 对真实MySQL核查period、created_at扫描/排序/临时表、执行耗时与数据量。最大90日不等于行数上限;建议查询timeout=5秒、已有错误映射返回失败且不截断样本,不伪装无数据。性能不达标先报告,不擅自新增索引/迁移。 +- 现有索引不足的迁移建议必须另行批准;本版绝不修改V1–V40。 + +## 10. 测试与验收计划 + +1. 0/29/30/99/100独立人数;同一人8维和多period不凑30人。 +2. 缺行与空分分别统计;全无快照人不在分母且scope准确。 +3. 9.5/10/89.99/90/100分桶、分位数手算样例、总体标准差、HALF_UP。 +4. 零分母null;精确70/85%风险边界;69.995%不提前升级。 +5. OK/ESTIMATED空分、PENDING非空、越界、未知状态、未知版本。 +6. MIXED候选排除V43综合分;与生产纯函数同版本结果对账。 +7. 同月upsert后记录变化,drift仍UNAVAILABLE,不伪造之前分数;历史月也声明现存快照。 +8. APPROVED但未回滚、ROLLED_BACK但分不变、真实变分、跨规则/空分、重复回滚历史、JOIN不放大。 +9. 申诉队列状态变更后口径正确;重复申诉去重;事件数>=30但人数<30仍抑制。 +10. 小样本和互补抑制,响应不出现被抑制明细或可反推比例。 +11. ADMIN/匿名/STUDENT/COMPANY;真实HTTP JSON脱敏;不直接返回Entity。 +12. 非法period/未来period/非法dimension/未知参数/超90日/from>=to/仅一个日期参数。 +13. 固定查询次数、无答案/JSON明细表读取;无新业务查询的UNAVAILABLE接口。 +14. candidate_dimension_scores及申诉/审计/风险表前后内容与版本不变(不能只测行数)。 +15. V25/V32/V37/V38/V39/V41/V42回归;OpenAPI静态/运行时172/210对账。 +16. 真实MySQL EXPLAIN、并发只读查询一致性、超时不返回假成功。 + +四项阻断的针对性补证已完成:正式 Service 在30/300/3000人下业务SQL为2/2/1/0/0;精确60人61份申诉HTTP夹具、五接口逐一完整内容只读检查及综合分舍入/PENDING版本对账均已通过。独立代码复验、真实 MySQL/Docker、运行时 OpenAPI、EXPLAIN 和 30/300/3000 人性能验收均已执行;EXPLAIN 暴露的全表扫描/filesort 作为后续容量风险保留。历史漂移与排名稳定性继续按 UNAVAILABLE 返回。 diff --git a/docs/v43-implementation-instruction.md b/docs/v43-implementation-instruction.md new file mode 100644 index 0000000..4b4b7dc --- /dev/null +++ b/docs/v43-implementation-instruction.md @@ -0,0 +1,111 @@ +# V43 给 Luna 的编码指令(整改版) + +实施状态:实现完成,独立代码复验与真实 MySQL/Docker 验收通过,待 Git 检查点。 + +### 本轮四项阻断的可执行证据 + +- `FairnessMonitoringPersistenceIntegrationTest.realServiceJdbcBudgetAt30_300_3000WithEightDimensionsAndMultipleAppeals`:Spring 管理的真实 Service 事务代理、真实 Repository/权限仓库和 `@Primary` JDBC 计数数据源;30/300/3000 人、每人八维和两份合法回滚申诉。业务查询分别为 overview=2、dimensions=2、appeals=1、drift/ranking-stability=0;权限查询逐接口单列为2。夹具与验证查询不在计数区间。每个版本仅返回一个同分频数,申诉返回最终汇总。 +- `FairnessMonitoringHttpIntegrationTest.realHttpAppealsHidesSmallComparableChangeGroup`:60 人各一份非法分数回滚,其中一人在 GROWTH 另有合法回滚;真实 SQL 断言61份申诉、60人、60人非法分数、1条/1人可比、重复申诉人数0,再通过真实 HTTP 检查变分统计全部隐藏。 +- 同一非空申诉夹具中,五个接口各自独立比较调用前后完整表内容(包含 version),涉及评分、申诉、申诉历史、回滚历史、非空回滚限流、审计和四张风险治理表。原全局99/1测试亦在两个接口之间分别比较。 +- `FairnessMonitoringPersistenceIntegrationTest.sqlOverallMatchesPureFunctionForPartialRepeatingHalfUpAndPendingVersions`:1/2/3个有效维度、空分PENDING、10.005的HALF_UP、10.003333…及10.006666…;SQL结果逐例与既有评分纯函数对账。仅修改PENDING行版本即判MIXED,仍保留有效覆盖人数。 + +以上为 H2 自动化证据,不代表真实 MySQL EXPLAIN、运行时 OpenAPI 或性能验收通过。 + +2026-09-09 最终复验:V43专项60/60;全量 `mvn test` 与 `mvn clean verify` 均1535项,Failures=0、Errors=0、Skipped=0,构建成功。OpenAPI静态及运行时均为172 paths / 210 operations。真实MySQL完成权限、隐私、只读性、查询预算与30/300/3000人规模验收;未提交或推送。 + +文档类型:执行指南。先完整阅读同目录v43-candidate-scoring-fairness-design.md修订版;不要沿用旧版“calculated_at可恢复历史漂移”的结论。 + +## 1. 任务与基线 + +项目C:/Users/15642/Desktop/zhilai,实现V43候选评分公平性与稳定性监测。 +先读取项目适用AGENTS.md,执行git status --short、git diff --cached --name-only、git log -5 --oneline;核查最新迁移和OpenAPI。已知维护提交498512d、最新业务V42、Flyway V40、OpenAPI167/205,均须实测而非假设。 +保护现有暂存与未跟踪文件。禁止reset/restore/checkout、git add -A、commit、push、删除移动历史文件。若已有V43实现,先评审差异,不覆盖他人修改。 + +本任务授权代码、自动化、正式文档;实现完成后需停止在本版本,不启动/停止Docker,不执行真实MySQL黑盒,不结束未知Java进程,不进入V44。当前实现结果须以自动化验证报告为准,真实黑盒仍待负责人另行安排。 + +## 2. 实现边界 + +无新增Flyway、数据库表、后台任务、feature flag、公共错误码,不改V1–V40迁移。 +只允许ADMIN聚合查询,无学生/企业入口,无写入、自动调分/排名/权重/封禁/申诉处理。 +不读答案、题目、测评详情、sub_scores_json、audit details,不接AI。 +不得调用CandidateScoringEngineService.getOverview、computeAll、upsertSnapshots等可能写快照入口。只读纯函数可以复用。 +学校/城市/姓名不得作为敏感属性代理。 + +## 3. 接口范围 + +新增五个GET: +- /admin/scoring/fairness/overview:period必填。 +- /admin/scoring/fairness/dimensions:period必填,dimension可选八维白名单。 +- /admin/scoring/fairness/drift:无业务参数,固定UNAVAILABLE。 +- /admin/scoring/fairness/ranking-stability:无业务参数,固定UNAVAILABLE。 +- /admin/scoring/fairness/appeals:成对可选from/to日期,默认最近30个完整业务日,最大90日。 + +drift原因IMMUTABLE_SCORE_HISTORY_NOT_AVAILABLE;排名原因RANKING_SNAPSHOT_HISTORY_NOT_AVAILABLE。 +无数据、不可计算、小样本是不同状态,不全部返回0或NORMAL。 +匿名401,其他角色403/2002;参数400/1001。拒绝jobId/companyId/rulesVersion/granularity及其他未知业务参数。 +如果实际基线167/205,最终172/210;不得通过删路径满足数字。 + +## 4. 必须先落实的统计规则 + +以设计第4–6节为唯一口径,写测试后实现: +1. period指定一个月份。快照是“当前所存版本”,calculated_at会被覆盖,不支持历史窗口恢复。 +2. N是该period至少一行快照的独立学生数。无快照全体学生不在范围,响应明确scope。 +3. 每维缺行、空分、有效分、状态分别统计,分母和去重按设计,不把八维行数当人数。 +4. 分布有效分为OK/ESTIMATED且0–100。统计sampleSize是独立人数。 +5. 同候选所有现存维度跨规则版本即MIXED,排除V43综合分分布;单一未知版本归UNKNOWN。其计数受小样本保护。 +6. 同版本综合分对有效维度等权平均,2位HALF_UP,与现有overallScoreFromSnapshots测试对账,不改V32。 +7. 分位数线性插值、总体标准差、十个连续小数分桶、零分母null;内部DECIMAL128,输出2位HALF_UP。 +8. 风险先用未舍入值比较。只启用分桶集中70/85%运营提示。旧版缺失率差、均值差、纠正率阈值本版不启用。 +9. 不足30独立人抑制明细;30–99 LOW_CONFIDENCE,100以上NORMAL_CONFIDENCE。 +10. 1–29的桶/状态/版本/异常计数抑制整组,防止补集反推。被抑制桶的关联均值/极值/分位数/集中风险也隐藏。 +11. 分别使用availability/confidence/riskLevel,不把UNAVAILABLE放入风险等级。 +12. 维度DTO必须独立返回 `inconsistentRecordCount`、`unknownStatusCount` 和 `outOfRangeScoreCount`;分布一旦触发桶/补集抑制,`sampleSize` 与所有统计字段、分桶及风险均置空,只保留availability/reason。 + +## 5. 申诉与回滚 + +按created_at选[from,to)创建队列,展示截至capturedAt的当前状态,不叫历史日终状态。 +APPROVED+ROLLED_BACK才是审核通过数。分别实现通过率、回滚执行率、实际变分率,严禁三者互换。 +申诉主表先统计,历史先按appeal_id聚合后关联,不因JOIN重复计数。 +仅唯一ROLLBACK历史、前后分非空有效、前后同一已知规则版本的申诉进入可比变分分母;重复历史只排除该申诉,不排除同一候选人的其他合法申诉。变分率包括0差的分母;规则升级不算纠错。 +每个比率同时校验该分母人群>=30独立人。重复申诉按同人同维同period的窗口内记录判断。真实曝光申诉率UNAVAILABLE。 +不读原文、管理员身份;不提供申诉自由切片。 + +## 6. 组件与性能 + +建议新建独立scoringmonitoring包:Controller、Service、只读查询组件、纯统计计算器及白名单DTO;命名遵循仓库惯例。 +SQL参数绑定;GROUP BY聚合、score频数而非全量个人实体;固定查询次数,不能逐维或逐候选findById。 +总览<=4、维度<=3、申诉<=5条业务查询为目标预算,角色查询单计;UNAVAILABLE接口0条业务查询。同一请求capturedNow、同一只读事务。 +实现可解释超时处理,不能截断样本返回成功。真实EXPLAIN在后续黑盒验证;不擅自补索引或迁移。 +小样本统计数值不得绕过统一抑制组件从另一个 DTO 字段泄露。 +整改实现要求由独立、可测试的 `FairnessDisclosureGuard` 统一执行小样本与互补抑制;有效集合或其补集为 1–29 人时,相关整组统计字段必须一并隐藏。 +不回任何个人/申诉/答卷/结果ID、联系方式、学校、性别、简历、答案或内部JSON。完整序列化测试,不能只检查toString。 + +## 7. 自动化 + +必须完整实现设计第10节测试。尤其: +- 多行同一人不能绕过30人门槛; +- 9.5/100分桶、手算分位数和标准差、69.995%风险边界; +- 混合版本与未知版本; +- 同月覆盖不能生成历史漂移; +- APPROVED未回滚、回滚0差、跨版本、空分、重复历史和JOIN放大; +- 多次申诉不足30独立人; +- 互补抑制、非法筛选、权限和完整JSON脱敏; +- 快照和申诉表内容/版本不变,而非只测行数; +- 无N+1及既有V25/V32/V37/V38/V39/V41/V42回归。 + +## 8. 文档、验证与停止 + +更新本设计实施状态、路线图、frontend-integration.md;新增docs/candidate-scoring-fairness-api.md。编码完成后才更新openapi-rc.json和verify_openapi.py: +五个GET恰好5path/5operation;全部200响应有真实schema;查询参数准确;schema不含个人敏感字段;两个不可用接口契约准确。 + +执行: +~~~powershell +mvn -Dtest="*Fairness*Test,*ScoringMonitoring*Test" test +mvn test +mvn clean verify +python docs/verify_openapi.py +~~~ +测试通配符必须匹配实际新增类,不得以0测试成功。clean verify若被运行JAR阻塞,报告已核实PID和原因,不擅自停止。 +报告真实测试总数、Failures/Errors/Skipped、精确文件清单、无新增迁移、OpenAPI计数、统计口径及数据限制、小样本保护、无写入和查询预算证据、Git状态。 +真实MySQL/Docker未执行必须写待验收,运行时OpenAPI未启动核验不得声称通过。 +完成后停止,保持所有本轮变更未暂存,未提交未推送。 diff --git a/docs/v43-independent-mysql-acceptance-20260909.md b/docs/v43-independent-mysql-acceptance-20260909.md new file mode 100644 index 0000000..dbad280 --- /dev/null +++ b/docs/v43-independent-mysql-acceptance-20260909.md @@ -0,0 +1,147 @@ +# V43 候选评分公平性与稳定性监测:独立复验与真实环境验收 + +日期:2026-09-09 +结论:**PASS,可进入精确暂存检查点** + +## 1. 验收范围与 Git 基线 + +本轮只做独立代码复验、自动化验证和真实环境验收;未修改 V43 生产代码、测试、Flyway、OpenAPI 或既有业务数据。 + +- HEAD:`498512d test: stabilize career progress date-sensitive test` +- 最近提交:V42 `96b3113`、V41 `5cc2c86`、V40 `2698d06`、V39 `55e3975` +- 暂存区:为空 +- 当前 tracked 修改:`docs/backend-development-roadmap.md`、`docs/frontend-integration.md`、`docs/openapi-rc.json`、`docs/verify_openapi.py` +- V43 生产代码、测试和文档:未跟踪文件/未提交状态 +- 历史验收脚本、日志、请求体、临时文件和 `.env` 相关文件均保留,未删除、移动或覆盖 +- `git diff --check`:无错误,仅报告现有文件的 LF/CRLF 提示 + +验收阶段未执行 `git add -A`、commit、push、reset、restore、checkout,也未启动或停止 Docker Desktop;使用负责人已启动且健康的 MySQL、Redis、RabbitMQ 容器。最终复验时最新 Java 应用在 8080 运行且健康检查为 HTTP 200,后续是否停止由负责人决定。 + +## 2. Flyway、OpenAPI 与代码结构核查 + +- 迁移目录最新文件:`V40__enterprise_talent_invitations.sql` +- 未发现 V41/V42/V43 新 Flyway +- 静态 OpenAPI:**172 paths / 210 operations**;`python docs/verify_openapi.py` 全部通过 +- V43 保留且仅保留五个 ADMIN GET: + - `/admin/scoring/fairness/overview` + - `/admin/scoring/fairness/dimensions` + - `/admin/scoring/fairness/drift` + - `/admin/scoring/fairness/ranking-stability` + - `/admin/scoring/fairness/appeals` +- 五个接口均有非空 200 响应 schema;静态校验覆盖聚合响应结构和 V43 路径计数 + +生产包 `com.mydreamjob.scoringmonitoring` 中未发现写入、删除、候选评分重算/upsert、排名重算或 Controller 外的公开接口。V43 查询不写 `candidate_dimension_scores`,也不发送消息。 + +## 3. 阶段 A:独立代码审查 + +### A1 只读边界:PASS(静态 + H2) + +Controller 只有五个 GET。`drift` 和 `ranking-stability` 直接返回 `UNAVAILABLE`;服务不会调用评分重算/upsert。H2 HTTP 集成测试逐接口比较了相关评分、申诉、历史、审计和风险表的内容/版本,未发现 V43 写入。 + +### A2 无双轨实现:PASS(静态 + 测试) + +`FairnessMonitoringService.overview` 仅调用 `findOverviewAggregateData`,空结果由正式聚合对象处理;`appeals` 仅调用 `findAppealAggregateSummary`。未发现“聚合为空时回退明细查询”的生产分支。Repository 返回总体/版本聚合、受控分组和 score-frequency,而不是候选人或申诉明细。 + +### A3 小样本抑制:PASS(H2 独立复现) + +`FairnessDisclosureGuard` 对总体、有效集合、补集、版本内集合、状态集合、可比回滚集合、实际变分集合和不可比原因集合执行独立候选人数门槛,并同步抑制可反推的嵌套分布、均值、分位数、标准差、分桶和风险字段。保留允许公开的总体人口数,并区分 `AVAILABLE`、`INSUFFICIENT_SAMPLE`、`SUPPRESSED`、`UNAVAILABLE` 等状态。 + +`FairnessIndependentReviewTest` 两个历史缺陷复现均通过: + +- `smallComparablePopulationMustHideAllChangeStatistics` +- `versionLocalComplementMustNotLeakThroughVersionPopulationAndDistribution` + +`FairnessMonitoringHttpIntegrationTest` 还覆盖全局 99/1、版本内补集、非空申诉小样本及不可用接口。 + +### A4 版本口径:PASS(真实 MySQL 黑盒复验) + +有效覆盖率按“至少一个有效维度的独立候选人数 / period 总体人数”计算;MIXED/UNKNOWN 不被当作无效分,PENDING 维度参与整体版本一致性判断;同一已知版本的综合分布分开统计。未读取答案、题目或评分规则。 + +真实 MySQL 夹具(v0.1:100 人,99 有效/1 无效;v0.2:100 人,70 有效/30 无效;总体 200 人)复验结果:v0.1 版本组及可反推字段正确标记为 `SUPPRESSED`,不返回 sampleSize、均值、分位数、分桶或风险;v0.2 版本组保持 `AVAILABLE`,sampleSize=70、mean=83.50。顶层 `populationSize=200` 保留,`versionCounts` 等联合可反推字段隐藏。overview 与 dimensions 联合响应不能还原 v0.1 的 1 人补集。 + +### A5 申诉统计:PASS(H2) + +回滚历史按 `appeal_id` 统计,重复历史进入独立不可比分类;审核通过、回滚执行和实际变分分开计算;零差值保留在可比分母;一名候选人的不可比申诉不会排除其其他合法申诉。相关服务和持久化测试覆盖同人多申诉、重复历史、跨版本、空/越界分数及输入顺序变化。 + +### A6 SQL/性能结构:PASS(代码结构、H2、真实 MySQL 查询预算与 3000 人规模) + +Repository 使用参数绑定、数据库聚合、score-frequency 结果和每条 JDBC 查询局部 `setQueryTimeout(5)`;没有修改共享 JdbcTemplate 超时配置或生成无限 IN 参数。真实 MySQL 通过临时启用 performance_schema statement history(验收后恢复原值)测得业务 SQL 数:overview=2、dimensions=2、appeals=1、drift/ranking=0,且在 30/300/3000 人规模下保持不变。3000 人实测最慢为 appeals 358ms。`EXPLAIN FORMAT=JSON` 同时显示 dimensions 聚合以及部分回滚历史读取存在全表扫描/filesort/temporary;当前验收规模无性能阻断,但这是生产扩容风险,后续应通过新迁移评估针对性索引,本轮未改既有迁移或数据库结构。 + +## 4. 自动化复验 + +| 命令 | 实际结果 | +|---|---| +| `mvn -Dtest=FairnessIndependentReviewTest,FairnessMonitoringHttpIntegrationTest test` | 7 tests,Failures=0,Errors=0,Skipped=0,BUILD SUCCESS | +| V43 专项复验 | 60 tests,Failures=0,Errors=0,Skipped=0,BUILD SUCCESS | +| `mvn test` | 1535 tests,Failures=0,Errors=0,Skipped=0,BUILD SUCCESS | +| `mvn clean verify` | 1535 tests,Failures=0,Errors=0,Skipped=0,BUILD SUCCESS | +| `python docs/verify_openapi.py` | PASS,172 paths / 210 operations | +| `git diff --check` | 无 diff 错误 | + +H2 真实服务端集成测试包含: + +- `realHttpPathSuppresses99Of100ComplementAcrossOverviewAndDimensions` +- `realHttpFiveEndpointsUseRealAdminPermissionAndUnavailablePathsDoNotWrite` +- `realHttpSuppressesVersionLocalComplementAcrossOverviewAndDimensions` +- `realHttpAppealsHidesSmallComparableChangeGroup` + +这些测试经过真实 MockMvc → Service → Repository → H2 SQL;仅 Controller 契约测试使用 Mock Service,报告未将其冒充为真库验收。 + +## 5. Docker/MySQL 阶段:PASS + +只读检查结果: + +- Docker Desktop/Engine:可用;Client/Server 29.6.2,Docker Desktop 4.84.0 +- MySQL:`mysql:8.4`,实际版本 `8.4.11`,容器 healthy,映射 3307 +- Redis:`redis:7.4-alpine`,healthy +- RabbitMQ:`rabbitmq:3.13-management-alpine`,healthy +- Flyway:V40 `enterprise talent invitations`,`success=1`;V43 迁移行数为 0 +- 应用:最新 JAR 启动成功,`ddl-auto=validate`,8080 监听;健康检查 HTTP 200 +- 运行时 OpenAPI:`/api/v1/v3/api-docs` 为 **172 paths / 210 operations** +- RabbitMQ 队列真实存在且 ready/unacked 均为 0:`queue.sms.verification`、`queue.notification`、`queue.audit`、`queue.notification.dlq` + +真实查询预算和规模结果(权限查询、夹具写入及验收查询不计入业务 SQL): + +| 候选人数 | overview | dimensions | appeals | +|---:|---:|---:|---:| +| 30 | 2 SQL / 67ms | 2 SQL / 38ms | 1 SQL / 46ms | +| 300 | 2 SQL / 70ms | 2 SQL / 60ms | 1 SQL / 53ms | +| 3000 | 2 SQL / 99ms | 2 SQL / 177ms | 1 SQL / 358ms | + +`drift` 与 `ranking-stability` 均为 0 条业务 SQL。验收夹具已按精确前缀清理,相关用户残留数为 0;performance_schema consumer 已恢复为验收前状态。 + +## 6. 真实 HTTP 与夹具证据 + +五接口逐请求结果(真实 MySQL): + +- ADMIN:五个接口均 `200/200` +- STUDENT:五个接口均 `403/2002` +- COMPANY:五个接口均 `403/2002` +- 未登录:五个接口均 `401/401` +- 完整 JSON 敏感字段扫描:五接口均未发现禁止字段 +- 逐接口表摘要:overview、dimensions、drift、ranking-stability、appeals 均未发现接口自身写入;风险定时器关闭后摘要稳定 +- `drift` 与 `ranking-stability` 均返回 `UNAVAILABLE` + +真实夹具结果: + +- 2026-07 总体 200 人,v0.1=99/1、v0.2=70/30:v0.1 正确抑制,v0.2 保持可用,跨接口不能反推小补集 +- 2026-03 100 人 99/1:总体抑制符合预期 +- 2026-02 100 人 1/99:总体抑制符合预期 +- 2026-06 29 人:`INSUFFICIENT_SAMPLE` +- 2026-05 30 人全有效:`AVAILABLE`,分布可见 +- 2026-04 30 人全无效:无有效分布,未发现个人字段泄露 +- 申诉 2026-09-07:30 名候选中 1 名重复回滚历史,比较组 29 人,相关变分字段被抑制 +- 申诉 2026-09-08:同一候选同时有合法申诉和重复历史申诉,接口未泄露个人信息;敏感比较组因原因小单元受到抑制 + +已验证:V43 生产代码无明细回退、H2 SQL 聚合与小样本抑制、独立失败复现、真实 Service/Repository H2 HTTP、静态与运行时 OpenAPI、自动化回归、真实 MySQL 启动、真实权限和 RabbitMQ 队列。 + +真实 MySQL 查询预算、30/300/3000 性能曲线、运行时 OpenAPI、权限、只读性和 RabbitMQ 终态均已完成。EXPLAIN 暴露的全表扫描/filesort 属扩容风险,不构成本次 3000 人规模验收失败。 + +## 7. 验收判定 + +**最终结论:PASS。V43 独立代码复验与真实 MySQL/Docker 黑盒在本次范围内通过,可以进入精确暂存检查点。** + +- 功能、权限、隐私、小样本抑制、申诉集合、只读性、运行时契约和 3000 人规模查询预算均无发布阻断; +- FAIL=0,核心验收项无 SKIP; +- 不声称已证明无限数据规模性能;EXPLAIN 中的全表扫描/filesort 作为上线容量观察项和后续索引优化候选保留; +- V43 不新增 Flyway,不写 `candidate_dimension_scores`,不改变评分、排名和申诉状态机。 diff --git a/docs/verify_openapi.py b/docs/verify_openapi.py index 54438c3..9df3255 100644 --- a/docs/verify_openapi.py +++ b/docs/verify_openapi.py @@ -6,9 +6,59 @@ p = PROJECT_ROOT / 'docs' / 'openapi-rc.json' d = json.load(open(p, encoding='utf-8')) print('paths:', len(d['paths'])) -assert len(d['paths']) == 167, f"Expected 167 paths, got {len(d['paths'])}" +assert len(d['paths']) == 172, f"Expected 172 paths, got {len(d['paths'])}" paths = d['paths'] + +# Generic OpenAPI integrity checks. These catch importer-breaking structures +# before business-specific path/count assertions run. +HTTP_METHODS = {'get', 'put', 'post', 'delete', 'patch', 'options', 'head', 'trace'} + +def resolve_local_ref(document, ref): + assert isinstance(ref, str) and ref.startswith('#/'), f'Invalid local $ref: {ref!r}' + node = document + for token in ref[2:].split('/'): + token = token.replace('~1', '/').replace('~0', '~') + assert isinstance(node, dict) and token in node, f'Unresolved $ref: {ref}' + node = node[token] + return node + +def validate_refs(node, location='$'): + if isinstance(node, dict): + for key, value in node.items(): + child = f'{location}.{key}' + if key == '$ref': + assert isinstance(value, str) and value, f'$ref must be a string at {child}' + if value.startswith('#/'): + resolve_local_ref(d, value) + validate_refs(value, child) + elif isinstance(node, list): + for index, value in enumerate(node): + validate_refs(value, f'{location}[{index}]') + +validate_refs(d) +operation_ids = {} +used_tags = set() +for route, path_item in paths.items(): + for method, operation in path_item.items(): + if method.lower() not in HTTP_METHODS: + continue + assert isinstance(operation, dict), f'Invalid operation: {method.upper()} {route}' + assert not (method.lower() == 'get' and 'requestBody' in operation), \ + f'GET must not contain requestBody: {route}' + if 'parameters' in operation: + assert isinstance(operation['parameters'], list), \ + f'parameters must be an array: {method.upper()} {route}' + operation_id = operation.get('operationId') + if operation_id: + operation_ids.setdefault(operation_id, []).append(f'{method.upper()} {route}') + used_tags.update(operation.get('tags', [])) + +duplicates = {key: value for key, value in operation_ids.items() if len(value) > 1} +assert not duplicates, f'Duplicate operationId values: {duplicates}' +declared_tags = {tag['name'] for tag in d.get('tags', [])} +assert used_tags <= declared_tags, f'Undeclared operation tags: {sorted(used_tags - declared_tags)}' + required = [ '/review-weight-templates', '/profile/me/review-weights', @@ -399,10 +449,76 @@ for f in ['studentUserId', 'candidateKey', 'candidateKeyDigest', 'activeKey', 'm assert f not in company_invitation_schema, f"V42 company invitation schema exposes {f}" print(" V42 exactly 6 paths / 6 operations; invitation schemas are present and redacted") -# 统计 operation 总数并校验 V42 计数 205 +# V43 候选评分公平性与稳定性监测:5 个 ADMIN 聚合 GET,历史漂移/排名稳定性首版明确 UNAVAILABLE。 +v43 = [ + '/admin/scoring/fairness/overview', + '/admin/scoring/fairness/dimensions', + '/admin/scoring/fairness/drift', + '/admin/scoring/fairness/ranking-stability', + '/admin/scoring/fairness/appeals', +] +assert all(k in paths for k in v43), "One or more V43 fairness paths are missing" +assert len(v43) == 5, f"Expected exactly 5 V43 paths, got {len(v43)}" +assert all('get' in paths[k] for k in v43), "V43 operations must all be GET" +assert sum(len(paths[k]) for k in v43) == 5, "Expected exactly 5 V43 operations" +assert paths[v43[0]]['get'].get('parameters'), "V43 overview period parameter missing" +assert paths[v43[1]]['get'].get('parameters'), "V43 dimensions parameters missing" +for pt, schema in [ + (v43[0], 'ApiResponseFairnessResponseOverview'), + (v43[1], 'ApiResponseFairnessResponseDimensions'), + (v43[2], 'ApiResponseFairnessResponseUnavailable'), + (v43[3], 'ApiResponseFairnessResponseUnavailable'), + (v43[4], 'ApiResponseFairnessResponseAppeals'), +]: + assert response_ref(paths[pt]['get']).endswith(schema), f"GET {pt} 200 schema missing" +for name in ('FairnessResponseOverview', 'FairnessResponseDimensions', 'FairnessResponseUnavailable', 'FairnessResponseAppeals'): + assert name in d['components']['schemas'], f"Missing V43 schema {name}" +v43_forbidden = ['studentUserId', 'applicationId', 'candidateId', 'phone', 'email', 'resumeUrl', + 'answerKey', 'scoringRule', 'questionCode', 'subScoresJson', 'auditDetails', + 'token', 'password', 'ipAddress', 'userAgent', 'appealId', 'reviewerId'] +def resolve_schema(value, seen=None): + """递归展开 V43 schema 的 $ref/properties/items,避免只校验顶层漏掉嵌套字段。""" + seen = set() if seen is None else seen + if isinstance(value, dict) and '$ref' in value: + ref = value['$ref'] + if ref.startswith('#/components/schemas/'): + name = ref.rsplit('/', 1)[-1] + if name in seen: + return {} + seen.add(name) + return resolve_schema(d['components']['schemas'].get(name, {}), seen) + if isinstance(value, dict): + return {k: resolve_schema(v, seen.copy()) for k, v in value.items()} + if isinstance(value, list): + return [resolve_schema(v, seen.copy()) for v in value] + return value + +for name, sch in d['components']['schemas'].items(): + if name.startswith('FairnessResponse'): + encoded = json.dumps(resolve_schema(sch), ensure_ascii=False) + for field in v43_forbidden: + assert field not in encoded, f"V43 schema {name} exposes {field}" +for name, required_fields in { + 'FairnessResponseOverview': ['availability', 'reason', 'distributionsByRulesVersion', 'coherentVersionSubset'], + 'FairnessResponseDimensions': ['availability', 'reason'], + 'FairnessResponseDimensionMetric': ['availability', 'reason', 'unknownStatusCount', 'outOfRangeScoreCount'], + 'FairnessResponseDistribution': ['availability', 'reason'], + 'FairnessResponseAppeals': ['availability', 'reason', 'appealCount', 'statusCounts', 'statusEventCounts', 'statusCandidateCounts', 'comparableRollbackCount', 'comparableRollbackCandidateCount', + 'duplicateRollbackHistoryCount', 'invalidScoreRollbackCount', 'ruleVersionChangedRollbackCount'], +}.items(): + props = d['components']['schemas'][name].get('properties', {}) + for field in required_fields: + assert field in props, f"V43 schema {name} missing explicit field {field}" +assert d['components']['schemas']['FairnessResponseDistribution']['properties']['availability']['type'] == 'string' +assert d['components']['schemas']['FairnessResponseAppeals']['properties']['appealCount']['type'] == 'integer' +assert '独立候选人数' in d['components']['schemas']['FairnessResponseAppeals']['properties']['comparableRollbackCandidateCount'].get('description', '') +assert '/admin/scoring/fairness/overview' in paths +print(" V43 exactly 5 paths / 5 operations; aggregate fairness schemas are redacted") + +# 统计 operation 总数并校验 V43 计数 210 operations = sum(len(methods) for methods in d['paths'].values()) print('operations:', operations) -assert operations == 205, f"Expected 205 operations, got {operations}" +assert operations == 210, f"Expected 210 operations, got {operations}" print(" Offer advance endpoints all present") print(" 26 operations all present") print(" Account deletion endpoints present (3 operations)") diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/scoringmonitoring/FairnessDisclosureGuard.java b/mydreamjob-server/src/main/java/com/mydreamjob/scoringmonitoring/FairnessDisclosureGuard.java new file mode 100644 index 0000000..15f4482 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/scoringmonitoring/FairnessDisclosureGuard.java @@ -0,0 +1,67 @@ +package com.mydreamjob.scoringmonitoring; + +import java.util.Collection; +import java.util.HashSet; +import java.util.Set; + +/** + * V43 统一小样本与互补抑制规则。 + * + *

所有集合大小都必须是独立候选人数;事件数不能绕过本组件。

+ */ +public final class FairnessDisclosureGuard { + public static final int MIN_INDEPENDENT_CANDIDATES = 30; + + private FairnessDisclosureGuard() {} + + public static boolean isSmallCell(int count) { + return count > 0 && count < MIN_INDEPENDENT_CANDIDATES; + } + + public static boolean isSmallComplement(int numerator, int denominator) { + int complement = denominator - numerator; + return complement > 0 && complement < MIN_INDEPENDENT_CANDIDATES; + } + + /** 判断一个比率分母及其对应集合是否安全可披露。 */ + public static boolean suppressRatio(int denominator, int numerator) { + return denominator < MIN_INDEPENDENT_CANDIDATES + || isSmallCell(numerator) + || isSmallComplement(numerator, denominator); + } + + /** 判断多个互斥/细分集合;任一小单元即整组抑制,避免通过总计反推。 */ + public static boolean suppressCells(int denominator, Collection cellCounts, + boolean checkComplement) { + if (denominator < MIN_INDEPENDENT_CANDIDATES) return true; + if (cellCounts == null) return false; + for (Integer value : cellCounts) { + if (value == null) continue; + if (isSmallCell(value)) return true; + if (checkComplement && isSmallComplement(value, denominator)) return true; + } + return false; + } + + /** + * Set-aware variant for overlapping groups (for example, one candidate can + * have appeals in more than one status). Complements are calculated from + * the actual universe rather than {@code denominator - cellSize}; callers + * must never use arithmetic subtraction for overlapping populations. + */ + public static boolean suppressSetCells(Set universe, Collection> cells, + boolean checkComplement) { + if (universe == null || universe.size() < MIN_INDEPENDENT_CANDIDATES) return true; + if (cells == null) return false; + for (Set cell : cells) { + if (cell == null) continue; + if (isSmallCell(cell.size())) return true; + if (checkComplement) { + Set complement = new HashSet<>(universe.size()); + for (Object value : universe) if (!cell.contains(value)) complement.add(value); + if (isSmallCell(complement.size())) return true; + } + } + return false; + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/scoringmonitoring/FairnessMonitoringController.java b/mydreamjob-server/src/main/java/com/mydreamjob/scoringmonitoring/FairnessMonitoringController.java new file mode 100644 index 0000000..99d24fb --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/scoringmonitoring/FairnessMonitoringController.java @@ -0,0 +1,88 @@ +package com.mydreamjob.scoringmonitoring; + +import com.mydreamjob.common.api.ApiResponse; +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.time.LocalDate; +import java.time.format.DateTimeParseException; +import java.util.Set; + +@Tag(name = "评分公平性监测", description = "ADMIN 只读聚合监测;不返回候选人或申诉身份信息") +@RestController +@RequestMapping("/admin/scoring/fairness") +@RequiredArgsConstructor +public class FairnessMonitoringController { + private final FairnessMonitoringService service; + + @Operation(summary = "评分完整性概览", description = "仅 ADMIN;按现存月度快照聚合,样本不足时隐藏统计明细") + @GetMapping("/overview") + public ResponseEntity> overview( + @RequestParam String period, HttpServletRequest request) { + checkParams(request, Set.of("period")); + return ResponseEntity.ok(ApiResponse.ok(service.overview(userId(), period), requestId(request))); + } + + @Operation(summary = "评分维度分布", description = "仅 ADMIN;dimension 仅允许八个评分维度") + @GetMapping("/dimensions") + public ResponseEntity> dimensions( + @RequestParam String period, @RequestParam(required = false) String dimension, HttpServletRequest request) { + checkParams(request, Set.of("period", "dimension")); + return ResponseEntity.ok(ApiResponse.ok(service.dimensions(userId(), period, dimension), requestId(request))); + } + + @Operation(summary = "评分历史漂移", description = "仅 ADMIN;当前快照可覆盖,历史漂移暂不可计算") + @GetMapping("/drift") + public ResponseEntity> drift(HttpServletRequest request) { + checkParams(request, Set.of()); + return ResponseEntity.ok(ApiResponse.ok(service.drift(userId()), requestId(request))); + } + + @Operation(summary = "排名稳定性", description = "仅 ADMIN;当前没有持久化排名快照,返回 UNAVAILABLE") + @GetMapping("/ranking-stability") + public ResponseEntity> rankingStability(HttpServletRequest request) { + checkParams(request, Set.of()); + return ResponseEntity.ok(ApiResponse.ok(service.rankingStability(userId()), requestId(request))); + } + + @Operation(summary = "申诉纠错聚合", description = "仅 ADMIN;按申诉创建队列聚合,隐藏小样本明细") + @GetMapping("/appeals") + public ResponseEntity> appeals( + @RequestParam(required = false) String from, @RequestParam(required = false) String to, + HttpServletRequest request) { + checkParams(request, Set.of("from", "to")); + return ResponseEntity.ok(ApiResponse.ok(service.appeals(userId(), parseDate(from), parseDate(to)), requestId(request))); + } + + private Long userId() { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + if (auth == null || !auth.isAuthenticated()) throw new BusinessException(ErrorCode.UNAUTHORIZED); + try { return Long.valueOf(auth.getName()); } + catch (NumberFormatException e) { throw new BusinessException(ErrorCode.UNAUTHORIZED); } + } + + private LocalDate parseDate(String value) { + if (value == null) return null; + try { return LocalDate.parse(value); } + catch (DateTimeParseException e) { throw new BusinessException(ErrorCode.BAD_REQUEST, "date must be yyyy-MM-dd"); } + } + + private void checkParams(HttpServletRequest request, Set allowed) { + for (String name : request.getParameterMap().keySet()) { + if (!allowed.contains(name)) throw new BusinessException(ErrorCode.BAD_REQUEST, "unsupported parameter: " + name); + } + } + + private String requestId(HttpServletRequest request) { return request.getHeader("X-Request-Id"); } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/scoringmonitoring/FairnessMonitoringRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/scoringmonitoring/FairnessMonitoringRepository.java new file mode 100644 index 0000000..5bee6fd --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/scoringmonitoring/FairnessMonitoringRepository.java @@ -0,0 +1,243 @@ +package com.mydreamjob.scoringmonitoring; + +import lombok.RequiredArgsConstructor; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Repository; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.sql.PreparedStatement; +import java.time.LocalDateTime; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** V43 只读数据入口。SQL 只取标量并设置局部超时,不读取答案或详情 JSON。 */ +@Repository +@RequiredArgsConstructor +public class FairnessMonitoringRepository { + private final JdbcTemplate jdbcTemplate; + + /** + * 总览专用聚合入口:先在数据库按候选人归并所有现存维度,再返回总体 + * 计数和综合分频数。不会把候选人×维度明细加载到服务层。 + */ + public OverviewAggregateData findOverviewAggregateData(String period) { + String candidateSummary = "SELECT student_user_id, " + + "COUNT(DISTINCT COALESCE(rules_version, '__UNKNOWN__')) AS version_count, " + + "MIN(COALESCE(rules_version, '__UNKNOWN__')) AS one_version, " + + "MAX(CASE WHEN (status IN ('OK','ESTIMATED') AND score IS NOT NULL " + + "AND score >= 0 AND score <= 100) THEN 1 ELSE 0 END) AS has_valid " + + "FROM candidate_dimension_scores WHERE period = ? GROUP BY student_user_id"; + List summaries = queryWithTimeout( + "SELECT COUNT(*) AS population_size, " + + "COALESCE(SUM(has_valid), 0) AS computable_count, " + + "COALESCE(SUM(CASE WHEN version_count > 1 THEN 1 ELSE 0 END), 0) AS mixed_count, " + + "COALESCE(SUM(CASE WHEN version_count = 1 AND one_version NOT IN " + + "('candidate-scoring-v0.1','candidate-scoring-v0.2') THEN 1 ELSE 0 END), 0) AS unknown_count, " + + "COALESCE(SUM(CASE WHEN version_count = 1 AND one_version = 'candidate-scoring-v0.1' THEN 1 ELSE 0 END), 0) AS v01_count, " + + "COALESCE(SUM(CASE WHEN version_count = 1 AND one_version = 'candidate-scoring-v0.2' THEN 1 ELSE 0 END), 0) AS v02_count " + + "FROM (" + candidateSummary + ") candidate_summary", + (rs, rowNum) -> new OverviewSummaryRow(rs.getInt("population_size"), + rs.getInt("computable_count"), rs.getInt("mixed_count"), + rs.getInt("unknown_count"), rs.getInt("v01_count"), rs.getInt("v02_count")), period); + + String candidateScores = "SELECT student_user_id, " + + "CASE WHEN COUNT(DISTINCT COALESCE(rules_version, '__UNKNOWN__')) = 1 " + + "AND MIN(COALESCE(rules_version, '__UNKNOWN__')) IN " + + "('candidate-scoring-v0.1','candidate-scoring-v0.2') " + + "THEN MIN(COALESCE(rules_version, '__UNKNOWN__')) ELSE NULL END AS coherent_version, " + + "AVG(CASE WHEN status IN ('OK','ESTIMATED') AND score IS NOT NULL " + + "AND score >= 0 AND score <= 100 THEN score ELSE NULL END) AS overall_score " + + "FROM candidate_dimension_scores WHERE period = ? GROUP BY student_user_id"; + List frequencies = queryWithTimeout( + "SELECT coherent_version, overall_score, COUNT(*) AS candidate_count " + + "FROM (" + candidateScores + ") candidate_scores " + + "WHERE coherent_version IS NOT NULL AND overall_score IS NOT NULL " + + "GROUP BY coherent_version, overall_score", + (rs, rowNum) -> new OverviewFrequencyRow(rs.getString("coherent_version"), + scaleScore(rs.getBigDecimal("overall_score")), rs.getInt("candidate_count")), period); + + OverviewSummaryRow summary = summaries.isEmpty() + ? new OverviewSummaryRow(0, 0, 0, 0, 0, 0) : summaries.get(0); + Map> byVersion = new LinkedHashMap<>(); + byVersion.put("candidate-scoring-v0.1", new java.util.ArrayList<>()); + byVersion.put("candidate-scoring-v0.2", new java.util.ArrayList<>()); + frequencies.forEach(row -> byVersion.computeIfAbsent(row.version(), ignored -> new java.util.ArrayList<>()) + .add(new FairnessStatisticsCalculator.ScoreFrequency(row.score(), row.candidateCount()))); + Map versions = new LinkedHashMap<>(); + versions.put("candidate-scoring-v0.1", summary.v01Count()); + versions.put("candidate-scoring-v0.2", summary.v02Count()); + versions.put("UNKNOWN", summary.unknownCount()); + versions.put("MIXED", summary.mixedCount()); + return new OverviewAggregateData(summary.populationSize(), summary.computableCount(), + versions, byVersion); + } + + /** + * Final appeal aggregate. The candidate/appeal identifiers exist only in + * nested SQL relations and never cross the repository boundary. All + * denominators are COUNT(DISTINCT candidate_user_id), while rollback rows + * remain event-level for change-rate and delta statistics. + */ + public AppealAggregateSummary findAppealAggregateSummary(LocalDateTime from, LocalDateTime to) { + String sql = "WITH appeal_base AS (" + + " SELECT id, candidate_user_id, dimension, period, status FROM candidate_scoring_appeals" + + " WHERE created_at >= ? AND created_at < ?)," + + " appeal_groups AS (SELECT candidate_user_id, dimension, period, COUNT(*) AS group_count" + + " FROM appeal_base GROUP BY candidate_user_id, dimension, period)," + + " rollback_counts AS (SELECT h.appeal_id, COUNT(*) AS history_count" + + " FROM candidate_scoring_snapshot_history h JOIN appeal_base a ON a.id=h.appeal_id" + + " WHERE h.action='ROLLBACK' GROUP BY h.appeal_id)," + + " rollback_one AS (SELECT h.appeal_id, a.candidate_user_id, h.before_score, h.after_score," + + " h.before_rules_version, h.after_rules_version" + + " FROM candidate_scoring_snapshot_history h JOIN rollback_counts rc ON rc.appeal_id=h.appeal_id" + + " JOIN appeal_base a ON a.id=h.appeal_id WHERE h.action='ROLLBACK' AND rc.history_count=1)," + + " classified AS (SELECT appeal_id, candidate_user_id," + + " CASE WHEN before_score IS NULL OR after_score IS NULL OR before_score<0 OR before_score>100" + + " OR after_score<0 OR after_score>100 THEN 'INVALID_SCORE'" + + " WHEN before_rules_version IS NULL OR after_rules_version IS NULL" + + " OR before_rules_version NOT IN ('candidate-scoring-v0.1','candidate-scoring-v0.2')" + + " OR after_rules_version<>before_rules_version THEN 'RULE_VERSION_CHANGED' ELSE 'COMPARABLE' END AS reason," + + " CASE WHEN before_score IS NOT NULL AND after_score IS NOT NULL AND before_score BETWEEN 0 AND 100" + + " AND after_score BETWEEN 0 AND 100 AND before_rules_version IN ('candidate-scoring-v0.1','candidate-scoring-v0.2')" + + " AND after_rules_version=before_rules_version THEN 1 ELSE 0 END AS comparable," + + " CASE WHEN before_score IS NOT NULL AND after_score IS NOT NULL AND before_score BETWEEN 0 AND 100" + + " AND after_score BETWEEN 0 AND 100 AND before_rules_version IN ('candidate-scoring-v0.1','candidate-scoring-v0.2')" + + " AND after_rules_version=before_rules_version AND after_score<>before_score THEN 1 ELSE 0 END AS changed," + + " CASE WHEN before_score IS NOT NULL AND after_score IS NOT NULL AND before_score BETWEEN 0 AND 100" + + " AND after_score BETWEEN 0 AND 100 AND before_rules_version IN ('candidate-scoring-v0.1','candidate-scoring-v0.2')" + + " AND after_rules_version=before_rules_version THEN after_score-before_score ELSE NULL END AS delta," + + " CASE WHEN before_score IS NOT NULL AND after_score IS NOT NULL AND before_score BETWEEN 0 AND 100" + + " AND after_score BETWEEN 0 AND 100 AND before_rules_version IN ('candidate-scoring-v0.1','candidate-scoring-v0.2')" + + " AND after_rules_version=before_rules_version THEN ABS(after_score-before_score) ELSE NULL END AS abs_delta" + + " FROM rollback_one)," + + " candidate_flags AS (SELECT a.candidate_user_id," + + " MAX(CASE WHEN ag.group_count>1 THEN 1 ELSE 0 END) AS duplicate_appeal," + + " MAX(CASE WHEN rc.history_count>1 THEN 1 ELSE 0 END) AS duplicate_history," + + " MAX(CASE WHEN c.reason='INVALID_SCORE' THEN 1 ELSE 0 END) AS invalid_score," + + " MAX(CASE WHEN c.reason='RULE_VERSION_CHANGED' THEN 1 ELSE 0 END) AS version_changed," + + " MAX(CASE WHEN c.changed=1 THEN 1 ELSE 0 END) AS changed," + + " MAX(CASE WHEN c.comparable=1 AND c.changed=0 THEN 1 ELSE 0 END) AS unchanged" + + " FROM appeal_base a LEFT JOIN appeal_groups ag ON ag.candidate_user_id=a.candidate_user_id" + + " AND ag.dimension=a.dimension AND ag.period=a.period" + + " LEFT JOIN rollback_counts rc ON rc.appeal_id=a.id" + + " LEFT JOIN classified c ON c.appeal_id=a.id GROUP BY a.candidate_user_id)," + + " rollback_aggregate AS (SELECT" + + " COALESCE(SUM(c.comparable),0) AS comparable_rows," + + " COUNT(DISTINCT CASE WHEN c.comparable=1 THEN c.candidate_user_id END) AS comparable_candidates," + + " COALESCE(SUM(c.changed),0) AS changed_rows," + + " COUNT(DISTINCT CASE WHEN c.changed=1 THEN c.candidate_user_id END) AS changed_candidates," + + " COALESCE(SUM(c.delta),0) AS delta_sum, COALESCE(SUM(c.abs_delta),0) AS abs_delta_sum" + + " FROM classified c)" + + " SELECT" + + " (SELECT COUNT(*) FROM appeal_base) AS appeal_count," + + " (SELECT COUNT(DISTINCT candidate_user_id) FROM appeal_base) AS sample_size," + + " (SELECT COALESCE(SUM(CASE WHEN status='PENDING' THEN 1 ELSE 0 END),0) FROM appeal_base) AS pending_events," + + " (SELECT COALESCE(SUM(CASE WHEN status='APPROVED' THEN 1 ELSE 0 END),0) FROM appeal_base) AS approved_events," + + " (SELECT COALESCE(SUM(CASE WHEN status='REJECTED' THEN 1 ELSE 0 END),0) FROM appeal_base) AS rejected_events," + + " (SELECT COALESCE(SUM(CASE WHEN status='CANCELLED' THEN 1 ELSE 0 END),0) FROM appeal_base) AS cancelled_events," + + " (SELECT COALESCE(SUM(CASE WHEN status='ROLLED_BACK' THEN 1 ELSE 0 END),0) FROM appeal_base) AS rolled_back_events," + + " (SELECT COALESCE(SUM(CASE WHEN status NOT IN ('PENDING','APPROVED','REJECTED','CANCELLED','ROLLED_BACK') THEN 1 ELSE 0 END),0) FROM appeal_base) AS unknown_events," + + " (SELECT COUNT(DISTINCT CASE WHEN status='PENDING' THEN candidate_user_id END) FROM appeal_base) AS pending_candidates," + + " (SELECT COUNT(DISTINCT CASE WHEN status='APPROVED' THEN candidate_user_id END) FROM appeal_base) AS approved_candidates," + + " (SELECT COUNT(DISTINCT CASE WHEN status='REJECTED' THEN candidate_user_id END) FROM appeal_base) AS rejected_candidates," + + " (SELECT COUNT(DISTINCT CASE WHEN status='CANCELLED' THEN candidate_user_id END) FROM appeal_base) AS cancelled_candidates," + + " (SELECT COUNT(DISTINCT CASE WHEN status='ROLLED_BACK' THEN candidate_user_id END) FROM appeal_base) AS rolled_back_candidates," + + " (SELECT COUNT(DISTINCT CASE WHEN status NOT IN ('PENDING','APPROVED','REJECTED','CANCELLED','ROLLED_BACK') THEN candidate_user_id END) FROM appeal_base) AS unknown_candidates," + + " (SELECT COUNT(DISTINCT CASE WHEN status IN ('APPROVED','ROLLED_BACK') THEN candidate_user_id END) FROM appeal_base) AS approved_ever_candidates," + + " (SELECT COUNT(DISTINCT CASE WHEN status IN ('APPROVED','ROLLED_BACK','REJECTED') THEN candidate_user_id END) FROM appeal_base) AS approval_denominator," + + " (SELECT COUNT(DISTINCT CASE WHEN duplicate_appeal=1 THEN candidate_user_id END) FROM candidate_flags) AS duplicate_appeal_candidates," + + " (SELECT COUNT(DISTINCT CASE WHEN duplicate_history=1 THEN candidate_user_id END) FROM candidate_flags) AS duplicate_history_candidates," + + " (SELECT COUNT(DISTINCT CASE WHEN invalid_score=1 THEN candidate_user_id END) FROM candidate_flags) AS invalid_score_candidates," + + " (SELECT COUNT(DISTINCT CASE WHEN version_changed=1 THEN candidate_user_id END) FROM candidate_flags) AS version_changed_candidates," + + " (SELECT COUNT(DISTINCT CASE WHEN changed=1 THEN candidate_user_id END) FROM candidate_flags) AS changed_candidates," + + " (SELECT COUNT(DISTINCT CASE WHEN unchanged=1 THEN candidate_user_id END) FROM candidate_flags) AS unchanged_candidates," + + " (SELECT COUNT(DISTINCT CASE WHEN changed=1 AND unchanged=1 THEN candidate_user_id END) FROM candidate_flags) AS changed_unchanged_intersection," + + " ra.comparable_rows, ra.comparable_candidates, ra.changed_rows, ra.changed_candidates, ra.delta_sum, ra.abs_delta_sum" + + " FROM rollback_aggregate ra"; + List result = queryWithTimeout(sql, (rs, rowNum) -> new AppealAggregateSummary( + rs.getInt("appeal_count"), rs.getInt("sample_size"), + statusMap(rs.getInt("pending_events"), rs.getInt("approved_events"), rs.getInt("rejected_events"), + rs.getInt("cancelled_events"), rs.getInt("rolled_back_events"), rs.getInt("unknown_events")), + statusMap(rs.getInt("pending_candidates"), rs.getInt("approved_candidates"), rs.getInt("rejected_candidates"), + rs.getInt("cancelled_candidates"), rs.getInt("rolled_back_candidates"), rs.getInt("unknown_candidates")), + rs.getInt("approved_ever_candidates"), rs.getInt("approval_denominator"), + rs.getInt("rolled_back_candidates"), rs.getInt("duplicate_appeal_candidates"), + rs.getInt("comparable_rows"), rs.getInt("comparable_candidates"), rs.getInt("changed_rows"), + rs.getInt("changed_candidates"), rs.getInt("unchanged_candidates"), + rs.getInt("changed_unchanged_intersection"), rs.getBigDecimal("delta_sum"), rs.getBigDecimal("abs_delta_sum"), + rs.getInt("duplicate_history_candidates"), rs.getInt("invalid_score_candidates"), rs.getInt("version_changed_candidates")), from, to); + return result.isEmpty() ? AppealAggregateSummary.empty() : result.get(0); + } + + private static Map statusMap(int pending, int approved, int rejected, int cancelled, int rolledBack, int unknown) { + Map map = new LinkedHashMap<>(); + map.put("PENDING", pending); map.put("APPROVED", approved); map.put("REJECTED", rejected); + map.put("CANCELLED", cancelled); map.put("ROLLED_BACK", rolledBack); map.put("UNKNOWN", unknown); + return map; + } + + /** 维度/状态/规则/分数的数据库频数聚合;candidate_count 为 distinct candidate。 */ + public List findSnapshotAggregates(String period) { + return queryWithTimeout("SELECT dimension, status, rules_version, score, " + + "COUNT(DISTINCT student_user_id) AS candidate_count " + + "FROM candidate_dimension_scores WHERE period = ? " + + "GROUP BY dimension, status, rules_version, score", + (rs, rowNum) -> new SnapshotAggregateRow(rs.getString("dimension"), rs.getString("status"), + rs.getString("rules_version"), rs.getBigDecimal("score"), rs.getInt("candidate_count")), period); + } + + public int findSnapshotPopulation(String period) { + List result = queryWithTimeout( + "SELECT COUNT(DISTINCT student_user_id) AS candidate_count " + + "FROM candidate_dimension_scores WHERE period = ?", + (rs, rowNum) -> rs.getInt("candidate_count"), period); + return result.isEmpty() ? 0 : result.get(0); + } + + private List queryWithTimeout(String sql, RowMapper mapper, Object... args) { + return jdbcTemplate.query(connection -> { + PreparedStatement statement = connection.prepareStatement(sql); + statement.setQueryTimeout(5); + for (int i = 0; i < args.length; i++) statement.setObject(i + 1, args[i]); + return statement; + }, mapper); + } + + private static BigDecimal scaleScore(BigDecimal score) { + return score == null ? null : score.setScale(2, RoundingMode.HALF_UP); + } + + record SnapshotAggregateRow(String dimension, String status, String rulesVersion, BigDecimal score, int candidateCount) {} + + record OverviewAggregateData(int populationSize, int computableCount, Map versionCounts, + Map> frequenciesByVersion) { + static OverviewAggregateData empty() { + Map versions = new LinkedHashMap<>(); + versions.put("candidate-scoring-v0.1", 0); versions.put("candidate-scoring-v0.2", 0); + versions.put("UNKNOWN", 0); versions.put("MIXED", 0); + Map> frequencies = new LinkedHashMap<>(); + frequencies.put("candidate-scoring-v0.1", List.of()); frequencies.put("candidate-scoring-v0.2", List.of()); + return new OverviewAggregateData(0, 0, versions, frequencies); + } + } + record AppealAggregateSummary(int appealCount, int sampleSize, Map statusEventCounts, + Map statusCandidateCounts, int approvedEverCount, + int approvalDenominatorCount, int rolledBackCandidateCount, + int duplicateAppealCandidateCount, int comparableRollbackCount, + int comparableRollbackCandidateCount, int changedCount, int changedCandidateCount, + int unchangedCandidateCount, int changedUnchangedIntersectionCount, + BigDecimal deltaSum, BigDecimal absDeltaSum, + int duplicateRollbackHistoryCount, int invalidScoreRollbackCount, + int ruleVersionChangedRollbackCount) { + static AppealAggregateSummary empty() { + return new AppealAggregateSummary(0, 0, statusMap(0, 0, 0, 0, 0, 0), statusMap(0, 0, 0, 0, 0, 0), + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, BigDecimal.ZERO, BigDecimal.ZERO, 0, 0, 0); + } + } + private record OverviewSummaryRow(int populationSize, int computableCount, int mixedCount, int unknownCount, + int v01Count, int v02Count) {} + private record OverviewFrequencyRow(String version, BigDecimal score, int candidateCount) {} +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/scoringmonitoring/FairnessMonitoringService.java b/mydreamjob-server/src/main/java/com/mydreamjob/scoringmonitoring/FairnessMonitoringService.java new file mode 100644 index 0000000..e0d5bac --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/scoringmonitoring/FairnessMonitoringService.java @@ -0,0 +1,353 @@ +package com.mydreamjob.scoringmonitoring; + +import com.mydreamjob.auth.User; +import com.mydreamjob.auth.UserRepository; +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +@Service +@RequiredArgsConstructor +public class FairnessMonitoringService { + private static final ZoneId ZONE = ZoneId.of("Asia/Shanghai"); + private static final DateTimeFormatter PERIOD = DateTimeFormatter.ofPattern("yyyy-MM"); + private static final Set DIMENSIONS = Set.of("EDUCATION", "INTERNSHIP", "ABILITY", "COMPLIANCE", + "STABILITY", "GROWTH", "INTEGRITY", "ACTIVITY"); + private static final Set KNOWN_RULES = Set.of("candidate-scoring-v0.1", "candidate-scoring-v0.2"); + private static final Set KNOWN_STATUSES = Set.of("OK", "ESTIMATED", "PENDING", "INSUFFICIENT_DATA"); + + private final FairnessMonitoringRepository repository; + private final UserRepository userRepository; + + public void assertAdmin(Long userId) { + User user = userRepository.findById(userId).orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + if (user.getRoles() == null || user.getRoles().stream().noneMatch(r -> r == User.Role.ADMIN)) { + throw new BusinessException(ErrorCode.FORBIDDEN); + } + } + + @Transactional(readOnly = true) + public FairnessResponse.Overview overview(Long adminUserId, String period) { + assertAdmin(adminUserId); + Instant captured = Instant.now(); + String validPeriod = validatePeriod(period, captured); + FairnessMonitoringRepository.OverviewAggregateData aggregate = repository.findOverviewAggregateData(validPeriod); + if (aggregate == null) aggregate = FairnessMonitoringRepository.OverviewAggregateData.empty(); + return overviewFromAggregate(validPeriod, captured, aggregate); + } + + private FairnessResponse.Overview overviewFromAggregate(String period, Instant captured, + FairnessMonitoringRepository.OverviewAggregateData aggregate) { + int population = aggregate.populationSize(); + int computable = aggregate.computableCount(); + Map versions = new LinkedHashMap<>(aggregate.versionCounts()); + Map byVersion = new LinkedHashMap<>(); + Map suppressedVersions = new LinkedHashMap<>(); + aggregate.frequenciesByVersion().forEach((version, frequencies) -> { + int versionPopulation = versions.getOrDefault(version, 0); + int validSample = frequencyCount(frequencies); + boolean subsetSuppressed = versionPopulation > 0 + && FairnessDisclosureGuard.suppressRatio(versionPopulation, validSample); + suppressedVersions.put(version, subsetSuppressed); + FairnessResponse.Distribution distribution = FairnessStatisticsCalculator.distributionFromFrequencies(frequencies); + if (subsetSuppressed) { + byVersion.put(version, suppressedDistribution()); + } else if (distribution.getAvailability() != null && !"NO_DATA".equals(distribution.getAvailability())) { + byVersion.put(version, distribution); + } + }); + boolean ratioSuppressed = FairnessDisclosureGuard.suppressRatio(population, computable); + boolean versionSuppressed = FairnessDisclosureGuard.suppressCells(population, versions.values(), true); + // Each version is an independent disclosure universe. An unsafe + // version is suppressed as a cell, while safe sibling versions may + // still be shown when the global universe itself is safe. + boolean versionSubsetSuppressed = suppressedVersions.values().stream().anyMatch(Boolean.TRUE::equals); + boolean distributionSuppressed = byVersion.values().stream() + .anyMatch(d -> "SUPPRESSED".equals(d.getAvailability())); + boolean derivedSuppressed = ratioSuppressed || versionSuppressed || versionSubsetSuppressed; + FairnessResponse.Distribution single = null; + if (!ratioSuppressed && !versionSuppressed && !versionSubsetSuppressed && byVersion.size() == 1) { + single = byVersion.values().iterator().next(); + } + boolean insufficient = population > 0 && population < FairnessDisclosureGuard.MIN_INDEPENDENT_CANDIDATES; + String availability = population == 0 ? "NO_DATA" : insufficient ? "INSUFFICIENT_SAMPLE" + : (derivedSuppressed || distributionSuppressed ? "SUPPRESSED" : "AVAILABLE"); + String reason = population == 0 ? "NO_SNAPSHOT_POPULATION" : insufficient ? "INDEPENDENT_CANDIDATE_THRESHOLD_NOT_MET" + : (derivedSuppressed ? "SMALL_CELL_OR_COMPLEMENT_RISK" + : distributionSuppressed ? "SMALL_BUCKET_OR_COMPLEMENT_RISK" : null); + boolean anyVersion = aggregate.frequenciesByVersion().values().stream().anyMatch(v -> v != null && !v.isEmpty()); + if (!anyVersion && population >= FairnessDisclosureGuard.MIN_INDEPENDENT_CANDIDATES) reason = "NO_COHERENT_VERSION_SCORE"; + return FairnessResponse.Overview.builder().basis("CURRENT_STORED_SNAPSHOTS").scope("SCORED_SNAPSHOT_POPULATION") + .period(period).capturedAt(captured).populationSize(population) + .computableSnapshotRatio(ratioSuppressed ? null : FairnessStatisticsCalculator.ratio(computable, population)) + .confidence(FairnessStatisticsCalculator.confidence(population)) + .riskLevel(insufficient ? "INSUFFICIENT_SAMPLE" : derivedSuppressed ? null : highestRisk(byVersion.values())) + // A protected version count plus a safe sibling count can + // reconstruct the protected total; hide the version-count + // group whenever any version cell is unsafe. + .versionCounts(ratioSuppressed || versionSuppressed || versionSubsetSuppressed ? null : versions) + .mixedVersionCount(ratioSuppressed || versionSuppressed || versionSubsetSuppressed ? null : versions.getOrDefault("MIXED", 0)) + .unknownVersionCount(ratioSuppressed || versionSuppressed || versionSubsetSuppressed ? null : versions.getOrDefault("UNKNOWN", 0)) + .distribution(ratioSuppressed || versionSuppressed ? null : single) + // When only a version-local complement is unsafe, preserve + // safe sibling distributions but keep the unsafe cell + // explicitly SUPPRESSED with no numeric statistics. + .distributionsByRulesVersion(ratioSuppressed || versionSuppressed ? null : byVersion) + .coherentVersionSubset(ratioSuppressed || versionSuppressed ? null : new ArrayList<>(byVersion.keySet())) + .availability(availability).reason(reason).build(); + } + + @Transactional(readOnly = true) + public FairnessResponse.Dimensions dimensions(Long adminUserId, String period, String requestedDimension) { + assertAdmin(adminUserId); + Instant captured = Instant.now(); + String validPeriod = validatePeriod(period, captured); + if (requestedDimension != null && !DIMENSIONS.contains(requestedDimension)) { + throw new BusinessException(ErrorCode.BAD_REQUEST, "invalid dimension"); + } + List aggregates = repository.findSnapshotAggregates(validPeriod); + int population = repository.findSnapshotPopulation(validPeriod); + List targets = requestedDimension == null ? DIMENSIONS.stream().sorted().toList() : List.of(requestedDimension); + List result = new ArrayList<>(); + for (String dimension : targets) { + List items = aggregates.stream() + .filter(r -> dimension.equals(r.dimension())).toList(); + int rowCount = sum(items); + int missing = Math.max(0, population - rowCount); + int nullScore = sum(items.stream().filter(r -> r.score() == null).toList()); + int valid = sum(items.stream().filter(this::isValidAggregate).toList()); + Map statuses = groupCounts(items, true); + Map versions = groupCounts(items, false); + Map> frequencies = new LinkedHashMap<>(); + for (var item : items) if (isValidAggregate(item)) { + frequencies.computeIfAbsent(normaliseVersion(item.rulesVersion()), ignored -> new ArrayList<>()) + .add(new FairnessStatisticsCalculator.ScoreFrequency(item.score(), item.candidateCount())); + } + boolean bucketSuppressed = frequencies.values().stream() + .map(FairnessStatisticsCalculator::distributionFromFrequencies) + .anyMatch(d -> "SUPPRESSED".equals(d.getAvailability())); + Map byVersion = new LinkedHashMap<>(); + Map suppressedVersions = new LinkedHashMap<>(); + frequencies.forEach((v, f) -> { + int versionPopulation = versions.getOrDefault(v, 0); + boolean subsetSuppressed = versionPopulation > 0 + && FairnessDisclosureGuard.suppressRatio(versionPopulation, frequencyCount(f)); + suppressedVersions.put(v, subsetSuppressed); + byVersion.put(v, subsetSuppressed + ? suppressedDistribution() + : FairnessStatisticsCalculator.distributionFromFrequencies(f)); + }); + FairnessResponse.Distribution distribution = FairnessStatisticsCalculator.distributionFromFrequencies( + frequencies.values().stream().flatMap(List::stream).toList()); + int inconsistent = sum(items.stream().filter(r -> ("OK".equals(r.status()) || "ESTIMATED".equals(r.status())) != (r.score() != null)).toList()); + int unknownStatus = sum(items.stream().filter(r -> !KNOWN_STATUSES.contains(r.status())).toList()); + int outOfRange = sum(items.stream().filter(r -> r.score() != null && !validScore(r.score())).toList()); + int unknownRules = sum(items.stream().filter(r -> !KNOWN_RULES.contains(r.rulesVersion())).toList()); + boolean versionSubsetSuppressed = suppressedVersions.values().stream().anyMatch(Boolean.TRUE::equals); + boolean baseSuppress = FairnessDisclosureGuard.suppressCells(population, + List.of(rowCount, missing, nullScore, valid, inconsistent, unknownStatus, outOfRange, unknownRules), true) + || FairnessDisclosureGuard.suppressCells(population, statuses.values(), true) + || FairnessDisclosureGuard.suppressCells(population, versions.values(), true) + || "SUPPRESSED".equals(distribution.getAvailability()) + || bucketSuppressed; + // A version-local unsafe complement suppresses aggregate fields + // that could be combined with a safe sibling to recover it, but + // does not erase a safe sibling distribution. + boolean suppress = baseSuppress || versionSubsetSuppressed; + boolean insufficient = population > 0 && (population < FairnessDisclosureGuard.MIN_INDEPENDENT_CANDIDATES + || (valid > 0 && valid < FairnessDisclosureGuard.MIN_INDEPENDENT_CANDIDATES)); + String availability = population == 0 ? "NO_DATA" : insufficient ? "INSUFFICIENT_SAMPLE" + : suppress ? "SUPPRESSED" : valid == 0 ? "NO_DATA" : "AVAILABLE"; + String reason = population == 0 ? "NO_SNAPSHOT_POPULATION" : insufficient ? "INDEPENDENT_CANDIDATE_THRESHOLD_NOT_MET" + : suppress ? "SMALL_CELL_OR_COMPLEMENT_RISK" : valid == 0 ? "NO_VALID_SCORE" : null; + result.add(FairnessResponse.DimensionMetric.builder().dimension(dimension).populationSize(population) + .rowCount(suppress ? null : rowCount).missingRowCount(suppress ? null : missing) + .validSampleSize(suppress ? null : valid).confidence(suppress ? null : FairnessStatisticsCalculator.confidence(valid)) + .missingRowRate(suppress ? null : FairnessStatisticsCalculator.ratio(missing, population)) + .nullScoreRate(suppress ? null : FairnessStatisticsCalculator.ratio(nullScore, population)) + .validCoverage(suppress ? null : FairnessStatisticsCalculator.ratio(valid, population)) + .statusCounts(suppress ? null : statuses).versionCounts(suppress ? null : versions) + .inconsistentRecordCount(suppress ? null : inconsistent).unknownStatusCount(suppress ? null : unknownStatus) + .outOfRangeScoreCount(suppress ? null : outOfRange).unknownRulesVersionCount(suppress ? null : unknownRules) + .distribution(baseSuppress || versionSubsetSuppressed ? null : distribution) + .distributionsByRulesVersion(baseSuppress ? null : byVersion) + .availability(availability).reason(reason).build()); + } + boolean allNoData = result.stream().allMatch(r -> "NO_DATA".equals(r.getAvailability())); + boolean anyInsufficient = result.stream().anyMatch(r -> "INSUFFICIENT_SAMPLE".equals(r.getAvailability())); + boolean anySuppressed = result.stream().anyMatch(r -> "SUPPRESSED".equals(r.getAvailability())); + return FairnessResponse.Dimensions.builder().basis("CURRENT_STORED_SNAPSHOTS").scope("SCORED_SNAPSHOT_POPULATION") + .period(validPeriod).capturedAt(captured).dimensions(result) + .availability(allNoData ? "NO_DATA" : anySuppressed ? "SUPPRESSED" : anyInsufficient ? "INSUFFICIENT_SAMPLE" : "AVAILABLE") + .reason(allNoData ? "NO_SNAPSHOT_POPULATION" : anySuppressed ? "SMALL_CELL_OR_COMPLEMENT_RISK" + : anyInsufficient ? "INDEPENDENT_CANDIDATE_THRESHOLD_NOT_MET" : null).build(); + } + + @Transactional(readOnly = true) + public FairnessResponse.Unavailable drift(Long adminUserId) { + assertAdmin(adminUserId); + return unavailable("IMMUTABLE_SCORE_HISTORY_NOT_AVAILABLE"); + } + + @Transactional(readOnly = true) + public FairnessResponse.Unavailable rankingStability(Long adminUserId) { + assertAdmin(adminUserId); + return unavailable("RANKING_SNAPSHOT_HISTORY_NOT_AVAILABLE"); + } + + @Transactional(readOnly = true) + public FairnessResponse.Appeals appeals(Long adminUserId, LocalDate from, LocalDate to) { + assertAdmin(adminUserId); + Instant captured = Instant.now(); + LocalDate today = LocalDateTime.ofInstant(captured, ZONE).toLocalDate(); + LocalDate end = to == null ? today : to; + LocalDate start = from == null ? end.minusDays(30) : from; + if (from == null ^ to == null) throw new BusinessException(ErrorCode.BAD_REQUEST, "from and to must be provided together"); + if (!start.isBefore(end) || end.isAfter(today) || ChronoUnit.DAYS.between(start, end) > 90) { + throw new BusinessException(ErrorCode.BAD_REQUEST, "invalid appeal date range"); + } + FairnessMonitoringRepository.AppealAggregateSummary aggregate = repository.findAppealAggregateSummary( + start.atStartOfDay(ZONE).toLocalDateTime(), end.atStartOfDay(ZONE).toLocalDateTime()); + if (aggregate == null) aggregate = FairnessMonitoringRepository.AppealAggregateSummary.empty(); + return appealsFromSummary(start, end, captured, aggregate); + } + + private FairnessResponse.Appeals appealsFromSummary(LocalDate start, LocalDate end, Instant captured, + FairnessMonitoringRepository.AppealAggregateSummary a) { + int population = a.sampleSize(); + boolean enough = population >= FairnessDisclosureGuard.MIN_INDEPENDENT_CANDIDATES; + boolean comparableEnough = a.comparableRollbackCandidateCount() >= FairnessDisclosureGuard.MIN_INDEPENDENT_CANDIDATES; + boolean statusSuppressed = FairnessDisclosureGuard.suppressCells(population, a.statusCandidateCounts().values(), true); + boolean reasonSuppressed = FairnessDisclosureGuard.suppressCells(population, + List.of(a.duplicateRollbackHistoryCount(), a.invalidScoreRollbackCount(), a.ruleVersionChangedRollbackCount()), true); + boolean duplicateSuppressed = FairnessDisclosureGuard.suppressCells(population, List.of(a.duplicateAppealCandidateCount()), true); + boolean approvalSuppressed = FairnessDisclosureGuard.suppressCells(population, List.of(a.approvedEverCount()), true) + || FairnessDisclosureGuard.suppressRatio(a.approvalDenominatorCount(), a.approvedEverCount()); + boolean rollbackSuppressed = FairnessDisclosureGuard.suppressRatio(a.approvedEverCount(), a.rolledBackCandidateCount()); + // All statistics derived from comparable rollback rows share one + // disclosure decision. A small comparable population must not leave + // changed counts/rates or deltas visible beside a suppressed denominator. + boolean comparableGroupSuppressed = a.comparableRollbackCount() > 0 + && FairnessDisclosureGuard.suppressCells( + a.comparableRollbackCandidateCount(), + List.of(a.changedCandidateCount(), a.unchangedCandidateCount()), true); + boolean hidden = statusSuppressed || reasonSuppressed || duplicateSuppressed || approvalSuppressed + || rollbackSuppressed; + boolean comparableHidden = hidden || comparableGroupSuppressed; + String availability = population == 0 ? "NO_DATA" : !enough ? "INSUFFICIENT_SAMPLE" + : a.comparableRollbackCount() > 0 && !comparableEnough ? "INSUFFICIENT_SAMPLE" + : (hidden || comparableGroupSuppressed) ? "SUPPRESSED" : "AVAILABLE"; + String reason = population == 0 ? "NO_APPEAL_POPULATION" : !enough ? "INDEPENDENT_CANDIDATE_THRESHOLD_NOT_MET" + : a.comparableRollbackCount() > 0 && !comparableEnough ? "COMPARABLE_CANDIDATE_THRESHOLD_NOT_MET" + : (hidden || comparableGroupSuppressed) ? "SMALL_CELL_OR_COMPLEMENT_RISK" : null; + Map reasons = new LinkedHashMap<>(); + reasons.put("DUPLICATE_ROLLBACK_HISTORY", a.duplicateRollbackHistoryCount()); + reasons.put("INVALID_SCORE", a.invalidScoreRollbackCount()); + reasons.put("RULE_VERSION_CHANGED", a.ruleVersionChangedRollbackCount()); + BigDecimal meanDelta = a.comparableRollbackCount() == 0 ? null + : a.deltaSum().divide(BigDecimal.valueOf(a.comparableRollbackCount()), 2, RoundingMode.HALF_UP); + BigDecimal meanAbs = a.comparableRollbackCount() == 0 ? null + : a.absDeltaSum().divide(BigDecimal.valueOf(a.comparableRollbackCount()), 2, RoundingMode.HALF_UP); + boolean hideComparable = comparableHidden || !comparableEnough; + return FairnessResponse.Appeals.builder().basis("APPEAL_CREATION_QUEUE_CURRENT_STATE") + .from(start.toString()).to(end.toString()).capturedAt(captured).appealCount(a.appealCount()).sampleSize(population) + .statusCounts(hidden ? null : a.statusCandidateCounts()).statusEventCounts(hidden ? null : a.statusEventCounts()) + .statusCandidateCounts(hidden ? null : a.statusCandidateCounts()).confidence(FairnessStatisticsCalculator.confidence(population)) + .approvedEverCount(!hidden && enough ? a.approvedEverCount() : null) + .approvalRate(hidden ? null : FairnessStatisticsCalculator.ratio(a.approvedEverCount(), a.approvalDenominatorCount())) + .rollbackExecutionRate(hidden ? null : FairnessStatisticsCalculator.ratio(a.rolledBackCandidateCount(), a.approvedEverCount())) + .duplicateAppealCount(!hidden && enough ? a.duplicateAppealCandidateCount() : null) + .duplicateAppealRate(!hidden && enough ? FairnessStatisticsCalculator.ratio(a.duplicateAppealCandidateCount(), population) : null) + .comparableRollbackCount(hideComparable ? null : a.comparableRollbackCount()) + .comparableRollbackCandidateCount(hideComparable ? null : a.comparableRollbackCandidateCount()) + .changedCount(comparableHidden ? null : a.changedCount()) + .changedCandidateCount(comparableHidden ? null : a.changedCandidateCount()) + .actualScoreChangeRate(comparableHidden ? null + : FairnessStatisticsCalculator.ratio(a.changedCount(), a.comparableRollbackCount())) + .meanDelta(comparableHidden ? null : meanDelta) + .meanAbsoluteDelta(comparableHidden ? null : meanAbs) + .duplicateRollbackHistoryCount(hidden ? null : a.duplicateRollbackHistoryCount()) + .invalidScoreRollbackCount(hidden ? null : a.invalidScoreRollbackCount()) + .ruleVersionChangedRollbackCount(hidden ? null : a.ruleVersionChangedRollbackCount()) + .reasonCounts(hidden ? null : reasons).availability(availability).reason(reason) + .exposureAppealRateAvailability("UNAVAILABLE").exposureAppealRateReason("EXPOSURE_DENOMINATOR_NOT_AVAILABLE").build(); + } + + private int sum(List rows) { + return rows.stream().mapToInt(FairnessMonitoringRepository.SnapshotAggregateRow::candidateCount).sum(); + } + + private Map groupCounts(List rows, boolean status) { + Map result = new LinkedHashMap<>(); + for (var row : rows) { + String key = status ? (KNOWN_STATUSES.contains(row.status()) ? row.status() : "UNKNOWN") : normaliseVersion(row.rulesVersion()); + result.merge(key, row.candidateCount(), Integer::sum); + } + return result; + } + + private boolean isValidAggregate(FairnessMonitoringRepository.SnapshotAggregateRow row) { + return ("OK".equals(row.status()) || "ESTIMATED".equals(row.status())) && validScore(row.score()); + } + + private boolean validScore(BigDecimal value) { + return value != null && value.compareTo(BigDecimal.ZERO) >= 0 && value.compareTo(BigDecimal.valueOf(100)) <= 0; + } + + private String normaliseVersion(String value) { return KNOWN_RULES.contains(value) ? value : "UNKNOWN"; } + + private String highestRisk(Iterable distributions) { + String risk = null; + for (var d : distributions) { + if ("HIGH".equals(d.getRiskLevel())) return "HIGH"; + if ("WARNING".equals(d.getRiskLevel())) risk = "WARNING"; + if ("NORMAL".equals(d.getRiskLevel()) && risk == null) risk = "NORMAL"; + } + return risk; + } + + private int frequencyCount(List frequencies) { + return frequencies == null ? 0 : frequencies.stream().filter(f -> f != null && f.count() > 0) + .mapToInt(FairnessStatisticsCalculator.ScoreFrequency::count).sum(); + } + + private FairnessResponse.Distribution suppressedDistribution() { + return FairnessResponse.Distribution.builder() + .availability("SUPPRESSED") + .reason("SMALL_CELL_OR_COMPLEMENT_RISK") + .build(); + } + + private FairnessResponse.Unavailable unavailable(String reason) { + return FairnessResponse.Unavailable.builder().availability("UNAVAILABLE").reason(reason).capturedAt(Instant.now()).build(); + } + + private String validatePeriod(String value, Instant capturedAt) { + if (value == null || !value.matches("\\d{4}-\\d{2}")) throw new BusinessException(ErrorCode.BAD_REQUEST, "period must be yyyy-MM"); + try { + LocalDate parsed = LocalDate.parse(value + "-01", DateTimeFormatter.ISO_LOCAL_DATE); + String current = LocalDateTime.ofInstant(capturedAt, ZONE).toLocalDate().format(PERIOD); + if (value.compareTo(current) > 0) throw new BusinessException(ErrorCode.BAD_REQUEST, "period cannot be in the future"); + return parsed.format(PERIOD); + } catch (DateTimeParseException e) { + throw new BusinessException(ErrorCode.BAD_REQUEST, "invalid period"); + } + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/scoringmonitoring/FairnessResponse.java b/mydreamjob-server/src/main/java/com/mydreamjob/scoringmonitoring/FairnessResponse.java new file mode 100644 index 0000000..6976c72 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/scoringmonitoring/FairnessResponse.java @@ -0,0 +1,155 @@ +package com.mydreamjob.scoringmonitoring; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.List; +import java.util.Map; + +/** V43 只读聚合白名单响应。所有内部用户、申诉和快照 ID 均留在服务端。 */ +public final class FairnessResponse { + private FairnessResponse() {} + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class Overview { + private String basis; + private String scope; + private String period; + private Instant capturedAt; + private int populationSize; + private BigDecimal computableSnapshotRatio; + private String confidence; + private String riskLevel; + private Map versionCounts; + private Integer mixedVersionCount; + private Integer unknownVersionCount; + private Distribution distribution; + private Map distributionsByRulesVersion; + private List coherentVersionSubset; + private String availability; + private String reason; + } + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class Dimensions { + private String basis; + private String scope; + private String period; + private Instant capturedAt; + private List dimensions; + private String availability; + private String reason; + } + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class DimensionMetric { + private String dimension; + private int populationSize; + private Integer rowCount; + private Integer missingRowCount; + private Integer validSampleSize; + private String confidence; + private BigDecimal missingRowRate; + private BigDecimal nullScoreRate; + private BigDecimal validCoverage; + private Map statusCounts; + private Map versionCounts; + private Integer inconsistentRecordCount; + private Integer unknownStatusCount; + private Integer outOfRangeScoreCount; + private Integer unknownRulesVersionCount; + private Distribution distribution; + private Map distributionsByRulesVersion; + private String availability; + private String reason; + } + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class Distribution { + private Integer sampleSize; + private BigDecimal mean; + private BigDecimal median; + private BigDecimal p10; + private BigDecimal p25; + private BigDecimal p75; + private BigDecimal p90; + private BigDecimal min; + private BigDecimal max; + private BigDecimal standardDeviation; + private Map buckets; + private BigDecimal concentrationRate; + private String riskLevel; + private String availability; + private String reason; + } + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class Unavailable { + private String availability; + private String reason; + private Instant capturedAt; + } + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class Appeals { + private String basis; + private String from; + private String to; + private Instant capturedAt; + private int appealCount; + private int sampleSize; + private Map statusCounts; + private Map statusEventCounts; + private Map statusCandidateCounts; + private String confidence; + private Integer approvedEverCount; + private BigDecimal approvalRate; + private BigDecimal rollbackExecutionRate; + private Integer duplicateAppealCount; + private BigDecimal duplicateAppealRate; + private Integer comparableRollbackCount; + private Integer comparableRollbackCandidateCount; + private Integer changedCount; + private Integer changedCandidateCount; + private BigDecimal actualScoreChangeRate; + private BigDecimal meanDelta; + private BigDecimal meanAbsoluteDelta; + private Integer duplicateRollbackHistoryCount; + private Integer invalidScoreRollbackCount; + private Integer ruleVersionChangedRollbackCount; + private Map reasonCounts; + private String availability; + private String reason; + private String exposureAppealRateAvailability; + private String exposureAppealRateReason; + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/scoringmonitoring/FairnessStatisticsCalculator.java b/mydreamjob-server/src/main/java/com/mydreamjob/scoringmonitoring/FairnessStatisticsCalculator.java new file mode 100644 index 0000000..fb1cdb4 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/scoringmonitoring/FairnessStatisticsCalculator.java @@ -0,0 +1,143 @@ +package com.mydreamjob.scoringmonitoring; + +import java.math.BigDecimal; +import java.math.MathContext; +import java.math.RoundingMode; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** 无数据库的确定性统计函数。所有样本量均为独立候选人数。 */ +public final class FairnessStatisticsCalculator { + private static final MathContext MC = MathContext.DECIMAL128; + private static final BigDecimal HUNDRED = BigDecimal.valueOf(100); + + private FairnessStatisticsCalculator() {} + + /** 数据库频数聚合的最小输入,不展开为候选人列表。 */ + public record ScoreFrequency(BigDecimal score, int count) {} + + public static String confidence(int n) { + if (n < 30) return "INSUFFICIENT_SAMPLE"; + if (n < 100) return "LOW_CONFIDENCE"; + return "NORMAL_CONFIDENCE"; + } + + public static FairnessResponse.Distribution distribution(List input) { + if (input == null || input.isEmpty()) { + return FairnessResponse.Distribution.builder().sampleSize(0) + .availability("NO_DATA").reason("NO_VALID_SCORE").build(); + } + Map counts = new LinkedHashMap<>(); + input.stream().filter(v -> v != null).forEach(v -> counts.merge(v, 1, Integer::sum)); + return distributionFromFrequencies(counts.entrySet().stream() + .map(e -> new ScoreFrequency(e.getKey(), e.getValue())).toList()); + } + + public static FairnessResponse.Distribution distributionFromFrequencies(List input) { + if (input == null || input.isEmpty()) { + return FairnessResponse.Distribution.builder().sampleSize(0) + .availability("NO_DATA").reason("NO_VALID_SCORE").build(); + } + List values = input.stream() + .filter(v -> v != null && v.score() != null && v.count() > 0) + .sorted(Comparator.comparing(ScoreFrequency::score)).toList(); + int n = values.stream().mapToInt(ScoreFrequency::count).sum(); + if (n == 0) { + return FairnessResponse.Distribution.builder().sampleSize(0) + .availability("NO_DATA").reason("NO_VALID_SCORE").build(); + } + if (n < 30) { + return FairnessResponse.Distribution.builder().sampleSize(n) + .availability("INSUFFICIENT_SAMPLE") + .reason("INDEPENDENT_CANDIDATE_THRESHOLD_NOT_MET").build(); + } + Map buckets = buckets(values); + boolean suppressed = FairnessDisclosureGuard.suppressCells(n, buckets.values(), false); + if (suppressed) { + return FairnessResponse.Distribution.builder().sampleSize(null) + .availability("SUPPRESSED").reason("SMALL_BUCKET_OR_COMPLEMENT_RISK").build(); + } + BigDecimal meanExact = values.stream() + .map(v -> v.score().multiply(BigDecimal.valueOf(v.count()), MC)) + .reduce(BigDecimal.ZERO, (a, b) -> a.add(b, MC)) + .divide(BigDecimal.valueOf(n), MC); + BigDecimal variance = values.stream() + .map(v -> v.score().subtract(meanExact, MC).pow(2, MC) + .multiply(BigDecimal.valueOf(v.count()), MC)) + .reduce(BigDecimal.ZERO, (a, b) -> a.add(b, MC)) + .divide(BigDecimal.valueOf(n), MC); + BigDecimal std = variance.max(BigDecimal.ZERO).sqrt(MC) + .setScale(2, RoundingMode.HALF_UP); + int maxBucket = buckets.values().stream().mapToInt(Integer::intValue).max().orElse(0); + BigDecimal concentrationExact = BigDecimal.valueOf(maxBucket).multiply(HUNDRED, MC) + .divide(BigDecimal.valueOf(n), MC); + String risk = concentrationExact.compareTo(BigDecimal.valueOf(85)) >= 0 ? "HIGH" + : concentrationExact.compareTo(BigDecimal.valueOf(70)) >= 0 ? "WARNING" : "NORMAL"; + return FairnessResponse.Distribution.builder() + .sampleSize(n).availability("AVAILABLE") + .mean(meanExact.setScale(2, RoundingMode.HALF_UP)) + .median(weightedPercentile(values, new BigDecimal("0.5"))) + .p10(weightedPercentile(values, new BigDecimal("0.1"))) + .p25(weightedPercentile(values, new BigDecimal("0.25"))) + .p75(weightedPercentile(values, new BigDecimal("0.75"))) + .p90(weightedPercentile(values, new BigDecimal("0.9"))) + .min(values.get(0).score().setScale(2, RoundingMode.HALF_UP)) + .max(values.get(values.size() - 1).score().setScale(2, RoundingMode.HALF_UP)) + .standardDeviation(std).buckets(buckets) + .concentrationRate(concentrationExact.setScale(2, RoundingMode.HALF_UP)) + .riskLevel(risk).build(); + } + + public static BigDecimal ratio(int numerator, int denominator) { + if (denominator <= 0) return null; + return BigDecimal.valueOf(numerator).multiply(HUNDRED, MC) + .divide(BigDecimal.valueOf(denominator), MC).setScale(2, RoundingMode.HALF_UP); + } + + public static BigDecimal percentile(List sorted, BigDecimal p) { + if (sorted == null || sorted.isEmpty()) return null; + if (sorted.size() == 1) return sorted.get(0).setScale(2, RoundingMode.HALF_UP); + BigDecimal h = BigDecimal.valueOf(sorted.size() - 1).multiply(p, MC); + int i = h.setScale(0, RoundingMode.FLOOR).intValue(); + BigDecimal f = h.subtract(BigDecimal.valueOf(i), MC); + BigDecimal left = sorted.get(i); + BigDecimal right = sorted.get(Math.min(i + 1, sorted.size() - 1)); + return left.add(right.subtract(left, MC).multiply(f, MC), MC) + .setScale(2, RoundingMode.HALF_UP); + } + + private static BigDecimal weightedPercentile(List values, BigDecimal p) { + int n = values.stream().mapToInt(ScoreFrequency::count).sum(); + if (n == 1) return values.get(0).score().setScale(2, RoundingMode.HALF_UP); + BigDecimal h = BigDecimal.valueOf(n - 1).multiply(p, MC); + int lower = h.setScale(0, RoundingMode.FLOOR).intValue(); + BigDecimal fraction = h.subtract(BigDecimal.valueOf(lower), MC); + BigDecimal left = valueAt(values, lower); + BigDecimal right = valueAt(values, Math.min(lower + 1, n - 1)); + return left.add(right.subtract(left, MC).multiply(fraction, MC), MC) + .setScale(2, RoundingMode.HALF_UP); + } + + private static BigDecimal valueAt(List values, int index) { + int seen = 0; + for (ScoreFrequency value : values) { + if (index < seen + value.count()) return value.score(); + seen += value.count(); + } + return values.get(values.size() - 1).score(); + } + + private static Map buckets(List values) { + Map result = new LinkedHashMap<>(); + for (int i = 0; i < 10; i++) result.put(i * 10 + "-" + (i == 9 ? 100 : i * 10 + 9), 0); + for (ScoreFrequency value : values) { + int bucket = value.score().compareTo(BigDecimal.valueOf(100)) >= 0 ? 9 + : value.score().max(BigDecimal.ZERO).divide(BigDecimal.TEN, 0, RoundingMode.FLOOR).intValue(); + String key = bucket * 10 + "-" + (bucket == 9 ? 100 : bucket * 10 + 9); + result.computeIfPresent(key, (k, v) -> v + value.count()); + } + return result; + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/scoringmonitoring/FairnessDisclosureGuardTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/scoringmonitoring/FairnessDisclosureGuardTest.java new file mode 100644 index 0000000..3b54e70 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/scoringmonitoring/FairnessDisclosureGuardTest.java @@ -0,0 +1,31 @@ +package com.mydreamjob.scoringmonitoring; + +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class FairnessDisclosureGuardTest { + @Test + void usesIndependentCandidateThresholdAndComplementSuppression() { + assertThat(FairnessDisclosureGuard.suppressRatio(29, 29)).isTrue(); + assertThat(FairnessDisclosureGuard.suppressRatio(100, 1)).isTrue(); + assertThat(FairnessDisclosureGuard.suppressRatio(100, 99)).isTrue(); + assertThat(FairnessDisclosureGuard.suppressRatio(100, 50)).isFalse(); + } + + @Test + void anySmallCellSuppressesWholeGroup() { + assertThat(FairnessDisclosureGuard.suppressCells(100, List.of(99, 1), true)).isTrue(); + assertThat(FairnessDisclosureGuard.suppressCells(100, List.of(30, 70), true)).isFalse(); + assertThat(FairnessDisclosureGuard.suppressCells(100, List.of(30, 70), false)).isFalse(); + } + + @Test + void zeroAndFullSetsAreNotMistakenForSmallPeopleCells() { + assertThat(FairnessDisclosureGuard.isSmallCell(0)).isFalse(); + assertThat(FairnessDisclosureGuard.isSmallCell(30)).isFalse(); + assertThat(FairnessDisclosureGuard.isSmallComplement(100, 100)).isFalse(); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/scoringmonitoring/FairnessIndependentReviewTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/scoringmonitoring/FairnessIndependentReviewTest.java new file mode 100644 index 0000000..41c1e0c --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/scoringmonitoring/FairnessIndependentReviewTest.java @@ -0,0 +1,91 @@ +package com.mydreamjob.scoringmonitoring; + +import com.mydreamjob.auth.User; +import com.mydreamjob.auth.UserRepository; +import org.junit.jupiter.api.Test; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.any; + +class FairnessIndependentReviewTest { + private FairnessMonitoringService service(FairnessMonitoringRepository repository) { + UserRepository users = mock(UserRepository.class); + when(users.findById(1L)).thenReturn(Optional.of(User.builder().id(1L) + .role(User.Role.ADMIN).roles(Set.of(User.Role.ADMIN)).active(true).build())); + return new FairnessMonitoringService(repository, users); + } + + @Test + void smallComparablePopulationMustHideAllChangeStatistics() { + var repository = mock(FairnessMonitoringRepository.class); + // Sixty people have an invalid rollback; one also has a legal changed rollback + // in a different dimension (so there is no repeated-appeal group). + var aggregate = new FairnessMonitoringRepository.AppealAggregateSummary( + 61, 60, Map.of("ROLLED_BACK", 61), Map.of("ROLLED_BACK", 60), + 60, 60, 60, 0, 1, 1, 1, 1, 0, 0, + BigDecimal.TEN, BigDecimal.TEN, 0, 60, 0); + when(repository.findAppealAggregateSummary(any(), any())).thenReturn(aggregate); + var result = service(repository).appeals(1L, LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 2)); + assertThat(result.getAvailability()).isEqualTo("INSUFFICIENT_SAMPLE"); + assertThat(result.getComparableRollbackCount()).isNull(); + assertThat(result.getChangedCandidateCount()).as("one-person change group must not leak").isNull(); + assertThat(result.getChangedCount()).isNull(); + assertThat(result.getActualScoreChangeRate()).isNull(); + assertThat(result.getMeanDelta()).isNull(); + assertThat(result.getMeanAbsoluteDelta()).isNull(); + } + + @Test + void versionLocalComplementMustNotLeakThroughVersionPopulationAndDistribution() { + var repository = mock(FairnessMonitoringRepository.class); + // Global valid/invalid is 169/31, but v0.1 has a protected 99/1 split. + var aggregate = new FairnessMonitoringRepository.OverviewAggregateData(200, 169, + Map.of("candidate-scoring-v0.1", 100, "candidate-scoring-v0.2", 100, "MIXED", 0, "UNKNOWN", 0), + Map.of("candidate-scoring-v0.1", List.of(new FairnessStatisticsCalculator.ScoreFrequency(BigDecimal.valueOf(50), 99)), + "candidate-scoring-v0.2", List.of(new FairnessStatisticsCalculator.ScoreFrequency(BigDecimal.valueOf(50), 70)))); + when(repository.findOverviewAggregateData("2026-08")).thenReturn(aggregate); + var result = service(repository).overview(1L, "2026-08"); + boolean disclosedPopulation = result.getVersionCounts() != null + && Integer.valueOf(100).equals(result.getVersionCounts().get("candidate-scoring-v0.1")); + var distribution = result.getDistributionsByRulesVersion() == null ? null + : result.getDistributionsByRulesVersion().get("candidate-scoring-v0.1"); + boolean disclosedValid = distribution != null && Integer.valueOf(99).equals(distribution.getSampleSize()); + assertThat(disclosedPopulation && disclosedValid) + .as("version population 100 minus valid sample 99 reveals one protected person").isFalse(); + } + + @Test + void safeVersionCellRemainsAvailableWhenSiblingVersionIsSuppressed() { + var repository = mock(FairnessMonitoringRepository.class); + var aggregate = new FairnessMonitoringRepository.OverviewAggregateData(200, 169, + Map.of("candidate-scoring-v0.1", 100, "candidate-scoring-v0.2", 100, + "MIXED", 0, "UNKNOWN", 0), + Map.of("candidate-scoring-v0.1", List.of( + new FairnessStatisticsCalculator.ScoreFrequency(BigDecimal.valueOf(50), 99)), + "candidate-scoring-v0.2", List.of( + new FairnessStatisticsCalculator.ScoreFrequency(BigDecimal.valueOf(80), 70)))); + when(repository.findOverviewAggregateData("2026-08")).thenReturn(aggregate); + + FairnessResponse.Overview result = service(repository).overview(1L, "2026-08"); + + assertThat(result.getAvailability()).isEqualTo("SUPPRESSED"); + assertThat(result.getVersionCounts()).isNull(); + assertThat(result.getComputableSnapshotRatio()).isEqualByComparingTo("84.50"); + assertThat(result.getDistributionsByRulesVersion()).containsKeys("candidate-scoring-v0.1", "candidate-scoring-v0.2"); + assertThat(result.getDistributionsByRulesVersion().get("candidate-scoring-v0.1").getAvailability()) + .isEqualTo("SUPPRESSED"); + assertThat(result.getDistributionsByRulesVersion().get("candidate-scoring-v0.1").getSampleSize()).isNull(); + assertThat(result.getDistributionsByRulesVersion().get("candidate-scoring-v0.2").getAvailability()) + .isEqualTo("AVAILABLE"); + assertThat(result.getDistributionsByRulesVersion().get("candidate-scoring-v0.2").getSampleSize()) + .isEqualTo(70); + assertThat(result.getDistributionsByRulesVersion().get("candidate-scoring-v0.2").getMean()) + .isEqualByComparingTo("80.00"); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/scoringmonitoring/FairnessMonitoringControllerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/scoringmonitoring/FairnessMonitoringControllerTest.java new file mode 100644 index 0000000..42a7b48 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/scoringmonitoring/FairnessMonitoringControllerTest.java @@ -0,0 +1,217 @@ +package com.mydreamjob.scoringmonitoring; + +import com.mydreamjob.auth.JwtTokenProvider; +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +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.data.redis.core.ValueOperations; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; + +import java.util.Map; +import java.math.BigDecimal; + +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("integration-test") +class FairnessMonitoringControllerTest { + + @Autowired private MockMvc mockMvc; + @Autowired private JwtTokenProvider jwtTokenProvider; + @MockBean private FairnessMonitoringService service; + @MockBean private RedisTemplate redisTemplate; + + @Test + @SuppressWarnings("unchecked") + void adminOverview_returnsAggregateWhitelistOnly() throws Exception { + ValueOperations operations = mock(ValueOperations.class); + when(redisTemplate.opsForValue()).thenReturn(operations); + when(redisTemplate.hasKey(org.mockito.ArgumentMatchers.any())).thenReturn(false); + FairnessResponse.Distribution distribution = FairnessResponse.Distribution.builder() + .sampleSize(30).mean(new BigDecimal("50.00")).median(new BigDecimal("50.00")) + .p10(new BigDecimal("10.00")).p25(new BigDecimal("25.00")) + .p75(new BigDecimal("75.00")).p90(new BigDecimal("90.00")) + .min(new BigDecimal("0.00")).max(new BigDecimal("100.00")) + .standardDeviation(new BigDecimal("12.34")) + .buckets(Map.of("0-9", 1, "10-19", 2, "20-29", 3, "30-39", 4, + "40-49", 5, "50-59", 5, "60-69", 4, "70-79", 3, "80-89", 2, "90-100", 1)) + .concentrationRate(new BigDecimal("16.67")).riskLevel("NORMAL") + .availability("AVAILABLE").build(); + when(service.overview(eq(1L), eq("2026-09"))).thenReturn(FairnessResponse.Overview.builder() + .basis("CURRENT_STORED_SNAPSHOTS") + .scope("SCORED_SNAPSHOT_POPULATION") + .period("2026-09") + .populationSize(30) + .computableSnapshotRatio(new BigDecimal("90.00")) + .confidence("LOW_CONFIDENCE") + .riskLevel("NORMAL") + .versionCounts(Map.of("candidate-scoring-v0.1", 15, "candidate-scoring-v0.2", 15)) + .mixedVersionCount(0).unknownVersionCount(0).distribution(distribution) + .distributionsByRulesVersion(Map.of("candidate-scoring-v0.2", distribution)) + .coherentVersionSubset(java.util.List.of("candidate-scoring-v0.2")) + .availability("AVAILABLE") + .build()); + + String json = mockMvc.perform(get("/admin/scoring/fairness/overview") + .param("period", "2026-09") + .header("Authorization", token(1L, "ADMIN")) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.basis").value("CURRENT_STORED_SNAPSHOTS")) + .andExpect(jsonPath("$.data.distribution.sampleSize").value(30)) + .andExpect(jsonPath("$['data']['distributionsByRulesVersion']['candidate-scoring-v0.2']['mean']").value(50.00)) + .andExpect(jsonPath("$.data.computableSnapshotRatio").value(90.00)) + .andReturn().getResponse().getContentAsString(); + assertNoSensitiveFields(json); + } + + @Test + void anonymous_isUnauthorized() throws Exception { + mockMvc.perform(get("/admin/scoring/fairness/drift")) + .andExpect(status().isUnauthorized()); + } + + @Test + void nonAdmin_isForbidden() throws Exception { + doThrow(new BusinessException(ErrorCode.FORBIDDEN)) + .when(service).overview(eq(2L), eq("2026-09")); + + mockMvc.perform(get("/admin/scoring/fairness/overview") + .param("period", "2026-09") + .header("Authorization", token(2L, "STUDENT"))) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(ErrorCode.FORBIDDEN.getCode())); + } + + @Test + void unsupportedQueryParameter_isBadRequest() throws Exception { + mockMvc.perform(get("/admin/scoring/fairness/overview") + .param("period", "2026-09") + .param("jobId", "9") + .header("Authorization", token(1L, "ADMIN"))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ErrorCode.BAD_REQUEST.getCode())); + } + + @Test + void rankingStability_returnsExplicitUnavailable() throws Exception { + when(service.rankingStability(eq(1L))).thenReturn(FairnessResponse.Unavailable.builder() + .availability("UNAVAILABLE") + .reason("RANKING_SNAPSHOT_HISTORY_NOT_AVAILABLE") + .build()); + when(service.drift(eq(1L))).thenReturn(FairnessResponse.Unavailable.builder() + .availability("UNAVAILABLE") + .reason("IMMUTABLE_SCORE_HISTORY_NOT_AVAILABLE") + .build()); + + mockMvc.perform(get("/admin/scoring/fairness/ranking-stability") + .header("Authorization", token(1L, "ADMIN"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.availability").value("UNAVAILABLE")) + .andExpect(jsonPath("$.data.reason").value("RANKING_SNAPSHOT_HISTORY_NOT_AVAILABLE")); + mockMvc.perform(get("/admin/scoring/fairness/drift") + .header("Authorization", token(1L, "ADMIN"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.availability").value("UNAVAILABLE")) + .andExpect(jsonPath("$.data.reason").value("IMMUTABLE_SCORE_HISTORY_NOT_AVAILABLE")); + } + + @Test + void dimensionsAndAppealsExposeExplicitAvailabilityWithoutSensitiveFields() throws Exception { + FairnessResponse.Distribution distribution = FairnessResponse.Distribution.builder() + .sampleSize(30).mean(new BigDecimal("55.55")).median(new BigDecimal("50.00")) + .p10(new BigDecimal("10.00")).p25(new BigDecimal("25.00")) + .p75(new BigDecimal("75.00")).p90(new BigDecimal("90.00")) + .min(new BigDecimal("0.00")).max(new BigDecimal("100.00")) + .standardDeviation(new BigDecimal("15.00")).buckets(Map.of("0-9", 3)) + .concentrationRate(new BigDecimal("10.00")).riskLevel("NORMAL") + .availability("AVAILABLE").build(); + FairnessResponse.DimensionMetric metric = FairnessResponse.DimensionMetric.builder().dimension("ABILITY") + .populationSize(100).rowCount(100).missingRowCount(0).validSampleSize(90).confidence("NORMAL_CONFIDENCE") + .missingRowRate(new BigDecimal("0.00")).nullScoreRate(new BigDecimal("10.00")) + .validCoverage(new BigDecimal("90.00")).statusCounts(Map.of("OK", 90, "PENDING", 10)) + .versionCounts(Map.of("candidate-scoring-v0.2", 90)).inconsistentRecordCount(0) + .unknownStatusCount(0).outOfRangeScoreCount(0).unknownRulesVersionCount(0) + .distribution(distribution).distributionsByRulesVersion(Map.of("candidate-scoring-v0.2", distribution)) + .availability("AVAILABLE").build(); + when(service.dimensions(eq(1L), eq("2026-09"), eq("ABILITY"))).thenReturn(FairnessResponse.Dimensions.builder() + .basis("CURRENT_STORED_SNAPSHOTS").scope("SCORED_SNAPSHOT_POPULATION").period("2026-09") + .availability("AVAILABLE").dimensions(java.util.List.of(metric)).build()); + when(service.appeals(eq(1L), eq(java.time.LocalDate.of(2026, 8, 1)), eq(java.time.LocalDate.of(2026, 9, 1)))) + .thenReturn(FairnessResponse.Appeals.builder().basis("APPEAL_CREATION_QUEUE_CURRENT_STATE") + .from("2026-08-01").to("2026-09-01").appealCount(31).sampleSize(30) + .statusCounts(Map.of("APPROVED", 20, "REJECTED", 10)).statusEventCounts(Map.of("APPROVED", 21, "REJECTED", 10)) + .statusCandidateCounts(Map.of("APPROVED", 20, "REJECTED", 10)).confidence("LOW_CONFIDENCE") + .approvedEverCount(20).approvalRate(new BigDecimal("66.67")) + .rollbackExecutionRate(new BigDecimal("25.00")).duplicateAppealCount(2) + .duplicateAppealRate(new BigDecimal("6.67")).comparableRollbackCount(8) + .comparableRollbackCandidateCount(8).changedCount(3).changedCandidateCount(3) + .actualScoreChangeRate(new BigDecimal("37.50")).meanDelta(new BigDecimal("2.00")) + .meanAbsoluteDelta(new BigDecimal("2.50")).duplicateRollbackHistoryCount(1) + .invalidScoreRollbackCount(1).ruleVersionChangedRollbackCount(1) + .reasonCounts(Map.of("INVALID_SCORE", 1)).availability("AVAILABLE").build()); + String dimensionJson = mockMvc.perform(get("/admin/scoring/fairness/dimensions").param("period", "2026-09").param("dimension", "ABILITY") + .header("Authorization", token(1L, "ADMIN")).accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()).andExpect(jsonPath("$.data.availability").value("AVAILABLE")) + .andExpect(jsonPath("$.data.dimensions[0].distribution.mean").value(55.55)) + .andExpect(jsonPath("$.data.dimensions[0].unknownStatusCount").value(0)) + .andReturn().getResponse().getContentAsString(); + assertNoSensitiveFields(dimensionJson); + String appealJson = mockMvc.perform(get("/admin/scoring/fairness/appeals").param("from", "2026-08-01").param("to", "2026-09-01") + .header("Authorization", token(1L, "ADMIN")).accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()).andExpect(jsonPath("$.data.sampleSize").value(30)) + .andExpect(jsonPath("$.data.statusCandidateCounts.APPROVED").value(20)) + .andExpect(jsonPath("$.data.actualScoreChangeRate").value(37.50)) + .andReturn().getResponse().getContentAsString(); + assertNoSensitiveFields(appealJson); + } + + @Test + void overviewSuppressionOmitsNestedDerivedValuesInSerializedJson() throws Exception { + when(service.overview(eq(1L), eq("2026-09"))).thenReturn(FairnessResponse.Overview.builder() + .basis("CURRENT_STORED_SNAPSHOTS").scope("SCORED_SNAPSHOT_POPULATION") + .period("2026-09").populationSize(100) + .availability("SUPPRESSED").reason("SMALL_CELL_OR_COMPLEMENT_RISK") + .build()); + + String json = mockMvc.perform(get("/admin/scoring/fairness/overview") + .param("period", "2026-09") + .header("Authorization", token(1L, "ADMIN")) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.populationSize").value(100)) + .andExpect(jsonPath("$.data.availability").value("SUPPRESSED")) + .andExpect(jsonPath("$.data.reason").value("SMALL_CELL_OR_COMPLEMENT_RISK")) + .andReturn().getResponse().getContentAsString(); + assertThat(json).doesNotContain("computableSnapshotRatio", "riskLevel", "versionCounts", + "mixedVersionCount", "unknownVersionCount", "distribution", + "distributionsByRulesVersion", "coherentVersionSubset", "mean", "buckets"); + } + + private void assertNoSensitiveFields(String json) { + assertThat(json).doesNotContain("studentUserId", "applicationId", "candidateId", "appealId", + "phone", "email", "resumeUrl", "school", "gender", "birthDate", "answerKey", + "scoringRule", "questionCode", "auditDetails", "ipAddress", "userAgent", "token", "password"); + } + + private String token(long userId, String role) { + return "Bearer " + jwtTokenProvider.generateAccessToken(userId, "1380013" + userId, + Map.of("roles", role)); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/scoringmonitoring/FairnessMonitoringHttpIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/scoringmonitoring/FairnessMonitoringHttpIntegrationTest.java new file mode 100644 index 0000000..191e3ea --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/scoringmonitoring/FairnessMonitoringHttpIntegrationTest.java @@ -0,0 +1,307 @@ +package com.mydreamjob.scoringmonitoring; + +import com.mydreamjob.auth.JwtTokenProvider; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +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.http.MediaType; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import java.math.BigDecimal; +import java.sql.ResultSet; +import java.sql.ResultSetMetaData; +import java.time.LocalDate; +import java.time.YearMonth; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * 通过真实 Controller -> Service -> Repository 链路验证 V43 的聚合、抑制、权限与只读性。 + * 仅使用 H2 MODE=MySQL;不使用 MockBean 替代被验证的服务、仓库或权限逻辑。 + */ +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("integration-test") +class FairnessMonitoringHttpIntegrationTest { + private static final List DIMENSIONS = List.of("EDUCATION", "INTERNSHIP", "ABILITY", "COMPLIANCE", + "STABILITY", "GROWTH", "INTEGRITY", "ACTIVITY"); + private static final List READ_ONLY_TABLES = List.of( + "candidate_dimension_scores", "candidate_scoring_appeals", "candidate_scoring_snapshot_history", + "candidate_scoring_appeal_history", "candidate_scoring_rollback_limits", "audit_logs", + "scoring_risk_events", "scoring_risk_actions", + "scoring_risk_scan_checkpoints", "scoring_rule_configs"); + + @Autowired MockMvc mockMvc; + @Autowired JdbcTemplate jdbcTemplate; + @Autowired JwtTokenProvider jwtTokenProvider; + @Autowired FairnessMonitoringRepository repository; + @MockBean RedisTemplate redisTemplate; + + @Test + void realHttpPathSuppresses99Of100ComplementAcrossOverviewAndDimensions() throws Exception { + String prefix = ("v43h" + UUID.randomUUID().toString().replace("-", "")).substring(0, 10); + String period = YearMonth.now().toString(); + List userIds = new ArrayList<>(); + try { + jdbcTemplate.update("INSERT INTO users(phone, role, is_active, is_verified, created_at, updated_at) " + + "VALUES (?, 'ADMIN', 1, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)", prefix + "a"); + jdbcTemplate.batchUpdate("INSERT INTO users(phone, role, is_active, is_verified, created_at, updated_at) " + + "VALUES (?, 'STUDENT', 1, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)", + java.util.stream.IntStream.range(0, 100).mapToObj(i -> new Object[]{prefix + "s" + i}).toList()); + userIds.addAll(jdbcTemplate.query("SELECT id FROM users WHERE phone LIKE ? ORDER BY id", + (rs, rowNum) -> rs.getLong(1), prefix + "s%")); + List scores = new ArrayList<>(100 * DIMENSIONS.size()); + for (int i = 0; i < userIds.size(); i++) { + for (String dimension : DIMENSIONS) { + boolean valid = i < 99; + scores.add(new Object[]{userIds.get(i), dimension, period, + valid ? new BigDecimal("50.00") : null, valid ? "OK" : "PENDING", + "candidate-scoring-v0.2"}); + } + } + jdbcTemplate.batchUpdate("INSERT INTO candidate_dimension_scores " + + "(student_user_id, dimension, period, score, status, weight, rules_version, calculated_at) " + + "VALUES (?, ?, ?, ?, ?, 12.50, ?, CURRENT_TIMESTAMP)", scores); + + long adminId = jdbcTemplate.queryForObject("SELECT id FROM users WHERE phone = ?", Long.class, prefix + "a"); + jdbcTemplate.update("INSERT INTO user_roles(user_id, role) VALUES (?, 'ADMIN')", adminId); + Map> before = snapshotTables(); + String adminToken = token(adminId, "ADMIN"); + String overview = mockMvc.perform(get("/admin/scoring/fairness/overview") + .param("period", period).header("Authorization", adminToken).accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.populationSize").value(100)) + .andExpect(jsonPath("$.data.availability").value("SUPPRESSED")) + .andExpect(jsonPath("$.data.reason").value("SMALL_CELL_OR_COMPLEMENT_RISK")) + .andReturn().getResponse().getContentAsString(); + assertThat(overview).doesNotContain("computableSnapshotRatio", "versionCounts", "distribution", + "distributionsByRulesVersion", "coherentVersionSubset", "riskLevel", "sampleSize", "mean", + "median", "standardDeviation", "buckets"); + assertThat(snapshotTables()).isEqualTo(before); + before = snapshotTables(); + + String dimensions = mockMvc.perform(get("/admin/scoring/fairness/dimensions") + .param("period", period).header("Authorization", adminToken).accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.dimensions.length()").value(8)) + .andExpect(jsonPath("$.data.availability").value("SUPPRESSED")) + .andReturn().getResponse().getContentAsString(); + assertThat(dimensions).doesNotContain("validSampleSize", "missingRowCount", "validCoverage", + "nullScoreRate", "statusCounts", "versionCounts", "distribution", "outOfRangeScoreCount", + "unknownRulesVersionCount", "mean", "median", "standardDeviation", "buckets"); + assertThat(snapshotTables()).isEqualTo(before); + } finally { + if (!userIds.isEmpty()) { + jdbcTemplate.update("DELETE FROM candidate_dimension_scores WHERE student_user_id IN " + + "(SELECT id FROM users WHERE phone LIKE ?)", prefix + "%"); + jdbcTemplate.update("DELETE FROM users WHERE phone LIKE ?", prefix + "%"); + } + } + } + + @Test + void realHttpFiveEndpointsUseRealAdminPermissionAndUnavailablePathsDoNotWrite() throws Exception { + String prefix = ("v43p" + UUID.randomUUID().toString().replace("-", "")).substring(0, 10); + List ids = new ArrayList<>(); + try { + jdbcTemplate.batchUpdate("INSERT INTO users(phone, role, is_active, is_verified, created_at, updated_at) " + + "VALUES (?, ?, 1, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)", + List.of(new Object[]{prefix + "a", "ADMIN"}, new Object[]{prefix + "s", "STUDENT"}, + new Object[]{prefix + "c", "COMPANY"})); + ids.addAll(jdbcTemplate.query("SELECT id FROM users WHERE phone LIKE ? ORDER BY id", + (rs, rowNum) -> rs.getLong(1), prefix + "%")); + jdbcTemplate.update("INSERT INTO user_roles(user_id, role) VALUES (?, 'ADMIN')", ids.get(0)); + String adminToken = token(ids.get(0), "ADMIN"); + LocalDate to = LocalDate.now().minusDays(1); + LocalDate from = to.minusDays(1); + Map> before = snapshotTables(); + + mockMvc.perform(get("/admin/scoring/fairness/overview").param("period", "1900-01") + .header("Authorization", adminToken)).andExpect(status().isOk()) + .andExpect(jsonPath("$.data.availability").value("NO_DATA")); + mockMvc.perform(get("/admin/scoring/fairness/dimensions").param("period", "1900-01") + .header("Authorization", adminToken)).andExpect(status().isOk()); + mockMvc.perform(get("/admin/scoring/fairness/drift").header("Authorization", adminToken)) + .andExpect(status().isOk()).andExpect(jsonPath("$.data.availability").value("UNAVAILABLE")); + mockMvc.perform(get("/admin/scoring/fairness/ranking-stability").header("Authorization", adminToken)) + .andExpect(status().isOk()).andExpect(jsonPath("$.data.availability").value("UNAVAILABLE")); + mockMvc.perform(get("/admin/scoring/fairness/appeals").param("from", from.toString()).param("to", to.toString()) + .header("Authorization", adminToken)).andExpect(status().isOk()); + + String[] paths = {"/admin/scoring/fairness/overview?period=1900-01", + "/admin/scoring/fairness/dimensions?period=1900-01", "/admin/scoring/fairness/drift", + "/admin/scoring/fairness/ranking-stability", "/admin/scoring/fairness/appeals?from=1900-01-01&to=1900-01-02"}; + for (String path : paths) { + mockMvc.perform(get(path).header("Authorization", token(ids.get(1), "STUDENT"))) + .andExpect(status().isForbidden()).andExpect(jsonPath("$.code").value(2002)); + mockMvc.perform(get(path).header("Authorization", token(ids.get(2), "COMPANY"))) + .andExpect(status().isForbidden()).andExpect(jsonPath("$.code").value(2002)); + mockMvc.perform(get(path)).andExpect(status().isUnauthorized()); + } + assertThat(snapshotTables()).isEqualTo(before); + } finally { + jdbcTemplate.update("DELETE FROM users WHERE phone LIKE ?", prefix + "%"); + } + } + + @Test + void realHttpSuppressesVersionLocalComplementAcrossOverviewAndDimensions() throws Exception { + String prefix = ("v43v" + UUID.randomUUID().toString().replace("-", "")).substring(0, 10); + String period = YearMonth.now().toString(); + try { + jdbcTemplate.update("INSERT INTO users(phone, role, is_active, is_verified, created_at, updated_at) " + + "VALUES (?, 'ADMIN', 1, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)", prefix + "a"); + jdbcTemplate.batchUpdate("INSERT INTO users(phone, role, is_active, is_verified, created_at, updated_at) " + + "VALUES (?, 'STUDENT', 1, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)", + java.util.stream.IntStream.range(0, 200).mapToObj(i -> new Object[]{prefix + "s" + i}).toList()); + List ids = jdbcTemplate.query("SELECT id FROM users WHERE phone LIKE ? ORDER BY id", + (rs, rowNum) -> rs.getLong(1), prefix + "s%"); + List rows = new ArrayList<>(200); + for (int i = 0; i < ids.size(); i++) { + boolean valid = i < 99 || (i >= 100 && i < 170); + String version = i < 100 ? "candidate-scoring-v0.1" : "candidate-scoring-v0.2"; + rows.add(new Object[]{ids.get(i), "ABILITY", period, + valid ? new BigDecimal("50.00") : null, valid ? "OK" : "PENDING", version}); + } + jdbcTemplate.batchUpdate("INSERT INTO candidate_dimension_scores " + + "(student_user_id, dimension, period, score, status, weight, rules_version, calculated_at) " + + "VALUES (?, ?, ?, ?, ?, 12.50, ?, CURRENT_TIMESTAMP)", rows); + long adminId = jdbcTemplate.queryForObject("SELECT id FROM users WHERE phone = ?", Long.class, prefix + "a"); + jdbcTemplate.update("INSERT INTO user_roles(user_id, role) VALUES (?, 'ADMIN')", adminId); + String token = token(adminId, "ADMIN"); + String overview = mockMvc.perform(get("/admin/scoring/fairness/overview").param("period", period) + .header("Authorization", token).accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()).andExpect(jsonPath("$.data.populationSize").value(200)) + .andExpect(jsonPath("$.data.availability").value("SUPPRESSED")) + .andExpect(jsonPath("$.data.distributionsByRulesVersion['candidate-scoring-v0.1'].availability").value("SUPPRESSED")) + .andExpect(jsonPath("$.data.distributionsByRulesVersion['candidate-scoring-v0.1'].sampleSize").doesNotExist()) + .andExpect(jsonPath("$.data.distributionsByRulesVersion['candidate-scoring-v0.2'].availability").value("AVAILABLE")) + .andExpect(jsonPath("$.data.distributionsByRulesVersion['candidate-scoring-v0.2'].sampleSize").value(70)) + .andReturn().getResponse().getContentAsString(); + assertThat(overview).doesNotContain("\"versionCounts\"", "\"candidate-scoring-v0.1\":100"); + String dimensions = mockMvc.perform(get("/admin/scoring/fairness/dimensions").param("period", period) + .param("dimension", "ABILITY").header("Authorization", token).accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()).andExpect(jsonPath("$.data.dimensions[0].availability").value("SUPPRESSED")) + .andExpect(jsonPath("$.data.dimensions[0].distributionsByRulesVersion['candidate-scoring-v0.1'].availability").value("SUPPRESSED")) + .andExpect(jsonPath("$.data.dimensions[0].distributionsByRulesVersion['candidate-scoring-v0.1'].sampleSize").doesNotExist()) + .andExpect(jsonPath("$.data.dimensions[0].distributionsByRulesVersion['candidate-scoring-v0.2'].availability").value("AVAILABLE")) + .andExpect(jsonPath("$.data.dimensions[0].distributionsByRulesVersion['candidate-scoring-v0.2'].sampleSize").value(70)) + .andReturn().getResponse().getContentAsString(); + assertThat(dimensions).doesNotContain("\"rowCount\"", "\"validSampleSize\"", "\"versionCounts\"", "\"distribution\""); + } finally { + jdbcTemplate.update("DELETE FROM candidate_dimension_scores WHERE student_user_id IN " + + "(SELECT id FROM users WHERE phone LIKE ?)", prefix + "%"); + jdbcTemplate.update("DELETE FROM users WHERE phone LIKE ?", prefix + "%"); + } + } + + @Test + void realHttpAppealsHidesSmallComparableChangeGroup() throws Exception { + String prefix = ("v43e" + UUID.randomUUID().toString().replace("-", "")).substring(0, 10); + String reason = "v43 http appeal " + UUID.randomUUID(); + LocalDate day = LocalDate.now().minusDays(1); + List ids = new ArrayList<>(); + try { + jdbcTemplate.batchUpdate("INSERT INTO users(phone, role, is_active, is_verified, created_at, updated_at) " + + "VALUES (?, 'STUDENT', 1, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)", + java.util.stream.IntStream.range(0, 60).mapToObj(i -> new Object[]{prefix + i}).toList()); + ids.addAll(jdbcTemplate.query("SELECT id FROM users WHERE phone LIKE ? ORDER BY id", + (rs, rowNum) -> rs.getLong(1), prefix + "%")); + String appealSql = "INSERT INTO candidate_scoring_appeals " + + "(candidate_user_id, dimension, period, status, reason, rules_version, created_at, updated_at) " + + "VALUES (?, 'ABILITY', '2026-09', 'ROLLED_BACK', ?, 'candidate-scoring-v0.2', ?, CURRENT_TIMESTAMP)"; + java.sql.Timestamp created = java.sql.Timestamp.valueOf(day.atTime(10, 0)); + for (Long id : ids) jdbcTemplate.update(appealSql, id, reason, created); + List appealIds = jdbcTemplate.query("SELECT id FROM candidate_scoring_appeals WHERE reason = ? ORDER BY id", + (rs, rowNum) -> rs.getLong(1), reason); + String historySql = "INSERT INTO candidate_scoring_snapshot_history " + + "(appeal_id, candidate_user_id, dimension, period, action, before_score, after_score, " + + "before_version, after_version, before_rules_version, after_rules_version, created_at) " + + "VALUES (?, ?, 'ABILITY', '2026-09', 'ROLLBACK', ?, ?, 1, 2, 'candidate-scoring-v0.2', 'candidate-scoring-v0.2', ?)"; + for (int i = 0; i < appealIds.size(); i++) { + jdbcTemplate.update(historySql, appealIds.get(i), ids.get(i), null, + new BigDecimal("30"), java.sql.Timestamp.valueOf(day.atTime(11, 0))); + } + // Every person has an invalid rollback. One person ALSO has a legal + // rollback in another dimension; invalid-score complement is zero. + jdbcTemplate.update(appealSql.replace("'ABILITY'", "'GROWTH'"), ids.get(0), reason, created); + long legalAppeal = jdbcTemplate.queryForObject( + "SELECT id FROM candidate_scoring_appeals WHERE reason=? AND dimension='GROWTH'", Long.class, reason); + jdbcTemplate.update(historySql.replace("'ABILITY'", "'GROWTH'"), legalAppeal, ids.get(0), + new BigDecimal("20"), new BigDecimal("30"), java.sql.Timestamp.valueOf(day.atTime(11, 0))); + var aggregate = repository.findAppealAggregateSummary(day.atStartOfDay(), day.plusDays(1).atStartOfDay()); + assertThat(aggregate.sampleSize()).isEqualTo(60); + assertThat(aggregate.appealCount()).isEqualTo(61); + assertThat(aggregate.invalidScoreRollbackCount()).isEqualTo(60); + assertThat(aggregate.comparableRollbackCount()).isEqualTo(1); + assertThat(aggregate.comparableRollbackCandidateCount()).isEqualTo(1); + assertThat(aggregate.changedCount()).isEqualTo(1); + assertThat(aggregate.duplicateAppealCandidateCount()).isZero(); + long adminId; + jdbcTemplate.update("INSERT INTO users(phone, role, is_active, is_verified, created_at, updated_at) " + + "VALUES (?, 'ADMIN', 1, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)", prefix + "admin"); + adminId = jdbcTemplate.queryForObject("SELECT id FROM users WHERE phone = ?", Long.class, prefix + "admin"); + jdbcTemplate.update("INSERT INTO user_roles(user_id, role) VALUES (?, 'ADMIN')", adminId); + jdbcTemplate.update("INSERT INTO candidate_scoring_rollback_limits(student_user_id, rollback_year, rollback_count) " + + "VALUES (?, ?, 2)", ids.get(0), day.getYear()); + Map> before = snapshotTables(); + String response = mockMvc.perform(get("/admin/scoring/fairness/appeals").param("from", day.toString()) + .param("to", day.plusDays(1).toString()).header("Authorization", token(adminId, "ADMIN")) + .accept(MediaType.APPLICATION_JSON)).andExpect(status().isOk()) + .andExpect(jsonPath("$.data.sampleSize").value(60)) + .andExpect(jsonPath("$.data.availability").value("INSUFFICIENT_SAMPLE")) + .andReturn().getResponse().getContentAsString(); + assertThat(response).doesNotContain("comparableRollbackCount", "changedCount", "changedCandidateCount", + "actualScoreChangeRate", "meanDelta", "meanAbsoluteDelta"); + assertThat(snapshotTables()).isEqualTo(before); + for (String path : List.of("overview?period=" + YearMonth.now(), + "dimensions?period=" + YearMonth.now(), "drift", "ranking-stability")) { + var interfaceBefore = snapshotTables(); + mockMvc.perform(get("/admin/scoring/fairness/" + path) + .header("Authorization", token(adminId, "ADMIN"))).andExpect(status().isOk()); + assertThat(snapshotTables()).as("full contents and versions after %s", path).isEqualTo(interfaceBefore); + } + } finally { + jdbcTemplate.update("DELETE FROM candidate_scoring_snapshot_history WHERE appeal_id IN " + + "(SELECT id FROM candidate_scoring_appeals WHERE reason = ?)", reason); + jdbcTemplate.update("DELETE FROM candidate_scoring_appeals WHERE reason = ?", reason); + jdbcTemplate.update("DELETE FROM users WHERE phone LIKE ?", prefix + "%"); + } + } + + private Map> snapshotTables() { + return READ_ONLY_TABLES.stream().collect(java.util.stream.Collectors.toMap(t -> t, this::tableRows, + (a, b) -> a, java.util.LinkedHashMap::new)); + } + + private List tableRows(String table) { + return jdbcTemplate.query("SELECT * FROM " + table + " ORDER BY 1", (ResultSet rs) -> { + List rows = new ArrayList<>(); + ResultSetMetaData md = rs.getMetaData(); + while (rs.next()) { + String[] values = new String[md.getColumnCount()]; + for (int i = 1; i <= md.getColumnCount(); i++) values[i - 1] = String.valueOf(rs.getObject(i)); + rows.add(Arrays.toString(values)); + } + return rows; + }); + } + + private String token(long userId, String role) { + return "Bearer " + jwtTokenProvider.generateAccessToken(userId, "1380013" + userId, + Map.of("roles", role)); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/scoringmonitoring/FairnessMonitoringPersistenceIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/scoringmonitoring/FairnessMonitoringPersistenceIntegrationTest.java new file mode 100644 index 0000000..2d4238f --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/scoringmonitoring/FairnessMonitoringPersistenceIntegrationTest.java @@ -0,0 +1,480 @@ +package com.mydreamjob.scoringmonitoring; + +import com.mydreamjob.candidatescore.CandidateDimensionScore; +import com.mydreamjob.candidatescore.CandidateScoringCalculator; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.jdbc.DataSourceProperties; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.context.annotation.Primary; +import org.springframework.jdbc.datasource.AbstractDataSource; + +import java.math.BigDecimal; +import java.lang.reflect.Proxy; +import java.sql.Connection; +import java.sql.Statement; +import java.util.concurrent.atomic.AtomicInteger; +import javax.sql.DataSource; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +/** 在 H2 MODE=MySQL 中执行 V43 的真实只读 SQL;不使用 mock 代替被验证的查询。 */ +@SpringBootTest +@ActiveProfiles("integration-test") +@Import(FairnessMonitoringPersistenceIntegrationTest.CountingDataSourceConfiguration.class) +class FairnessMonitoringPersistenceIntegrationTest { + @Autowired FairnessMonitoringRepository repository; + @Autowired FairnessMonitoringService service; + @Autowired JdbcTemplate jdbcTemplate; + @Autowired CountingDataSource countingDataSource; + @jakarta.persistence.PersistenceContext jakarta.persistence.EntityManager entityManager; + @MockBean RedisTemplate redisTemplate; + + @Test + @org.springframework.transaction.annotation.Transactional + void realServiceJdbcBudgetAt30_300_3000WithEightDimensionsAndMultipleAppeals() { + String prefix = UUID.randomUUID().toString().substring(0, 8); + jdbcTemplate.update("INSERT INTO users(phone,role,is_active,is_verified) VALUES (?,'ADMIN',1,1)", prefix + "a"); + long admin = jdbcTemplate.queryForObject("SELECT id FROM users WHERE phone=?", Long.class, prefix + "a"); + jdbcTemplate.update("INSERT INTO user_roles(user_id,role) VALUES (?,'ADMIN')", admin); + for (int size : new int[]{30, 300, 3000}) { + String period = "2000-" + (size == 30 ? "01" : size == 300 ? "02" : "03"); + var day = java.time.LocalDate.parse(period + "-01"); + for (int i = 0; i < size; i++) { + String phone = prefix + size + "x" + i; + jdbcTemplate.update("INSERT INTO users(phone,role,is_active,is_verified) VALUES (?,'STUDENT',1,0)", phone); + long id = jdbcTemplate.queryForObject("SELECT id FROM users WHERE phone=?", Long.class, phone); + for (String dim : List.of("EDUCATION", "INTERNSHIP", "ABILITY", "COMPLIANCE", "STABILITY", "GROWTH", "INTEGRITY", "ACTIVITY")) { + jdbcTemplate.update("INSERT INTO candidate_dimension_scores(student_user_id,dimension,period,score,status,weight,rules_version,calculated_at) VALUES (?,?,?,50,'OK',12.5,'candidate-scoring-v0.2',CURRENT_TIMESTAMP)", id, dim, period); + } + for (String dim : List.of("ABILITY", "GROWTH")) { + jdbcTemplate.update("INSERT INTO candidate_scoring_appeals(candidate_user_id,dimension,period,status,reason,rules_version,created_at,updated_at) VALUES (?,?,?,'ROLLED_BACK',?,'candidate-scoring-v0.2',?,CURRENT_TIMESTAMP)", id, dim, period, prefix, java.sql.Timestamp.valueOf(day.atTime(10,0))); + long appeal = jdbcTemplate.queryForObject("SELECT id FROM candidate_scoring_appeals WHERE candidate_user_id=? AND dimension=? AND period=?", Long.class, id, dim, period); + jdbcTemplate.update("INSERT INTO candidate_scoring_snapshot_history(appeal_id,candidate_user_id,dimension,period,action,before_score,after_score,before_version,after_version,before_rules_version,after_rules_version,created_at) VALUES (?,?,?,?,'ROLLBACK',20,30,1,2,'candidate-scoring-v0.2','candidate-scoring-v0.2',?)", appeal,id,dim,period,java.sql.Timestamp.valueOf(day.atTime(11,0))); + } + } + entityManager.clear(); + countingDataSource.reset(); + assertThat(service.overview(admin, period).getPopulationSize()).isEqualTo(size); + assertThat(countingDataSource.businessStatements()).as("overview business SQL at %s",size).isEqualTo(2); + assertThat(countingDataSource.identityStatements()).as("overview permission SQL at %s",size).isEqualTo(2); + entityManager.clear(); + countingDataSource.reset(); + service.dimensions(admin, period, null); + assertThat(countingDataSource.businessStatements()).as("dimensions business SQL at %s",size).isEqualTo(2); + assertThat(countingDataSource.identityStatements()).as("dimensions permission SQL at %s",size).isEqualTo(2); + entityManager.clear(); + countingDataSource.reset(); + assertThat(service.appeals(admin, day, day.plusDays(1)).getComparableRollbackCount()).isEqualTo(size * 2); + assertThat(countingDataSource.businessStatements()).as("appeals business SQL at %s",size).isEqualTo(1); + assertThat(countingDataSource.identityStatements()).as("appeals permission SQL at %s",size).isEqualTo(2); + entityManager.clear(); + countingDataSource.reset(); + service.drift(admin); + assertThat(countingDataSource.businessStatements()).as("drift business SQL at %s", size).isZero(); + assertThat(countingDataSource.identityStatements()).as("drift permission SQL at %s", size).isEqualTo(2); + entityManager.clear(); + countingDataSource.reset(); + service.rankingStability(admin); + assertThat(countingDataSource.businessStatements()).as("ranking business SQL at %s", size).isZero(); + assertThat(countingDataSource.identityStatements()).as("ranking permission SQL at %s", size).isEqualTo(2); + var aggregate = repository.findOverviewAggregateData(period); + assertThat(aggregate.frequenciesByVersion().get("candidate-scoring-v0.2")).hasSize(1); + var appeals = repository.findAppealAggregateSummary(day.atStartOfDay(),day.plusDays(1).atStartOfDay()); + assertThat(appeals.comparableRollbackCandidateCount()).isEqualTo(size); + assertThat(appeals.comparableRollbackCount()).isEqualTo(size * 2); + } + } + + @Test + @org.springframework.transaction.annotation.Transactional + void sqlOverallMatchesPureFunctionForPartialRepeatingHalfUpAndPendingVersions() { + String prefix = UUID.randomUUID().toString().substring(0,8); + List> cases = List.of(List.of("10.00"), List.of("10.00","10.01"), + List.of("10.00","10.00","10.01"), List.of("10.00","10.01","10.01")); + var calculator = new CandidateScoringCalculator(null,null,null,null,null,null); + for (int c=0;c snapshots=new ArrayList<>(); + List dims=List.of("ABILITY","GROWTH","EDUCATION"); + for(int i=0;i periods = List.of("2091-01", "2091-02", "2091-03"); + int[] sizes = {30, 300, 3000}; + try { + for (int i = 0; i < sizes.length; i++) { + String period = periods.get(i); + String periodPrefix = prefix + i; + jdbcTemplate.batchUpdate("INSERT INTO users(phone, role, is_active, is_verified, created_at, updated_at) " + + "VALUES (?, 'STUDENT', 1, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)", + java.util.stream.IntStream.range(0, sizes[i]).mapToObj(n -> new Object[]{periodPrefix + n}).toList()); + List ids = jdbcTemplate.query("SELECT id FROM users WHERE phone LIKE ? ORDER BY id", + (rs, rowNum) -> rs.getLong(1), periodPrefix + "%"); + List scoreArgs = new ArrayList<>(sizes[i] * 8); + for (Long id : ids) { + for (String dimension : List.of("EDUCATION", "INTERNSHIP", "ABILITY", "COMPLIANCE", + "STABILITY", "GROWTH", "INTEGRITY", "ACTIVITY")) { + scoreArgs.add(new Object[]{id, dimension, period, new BigDecimal("50.00"), "OK", + "candidate-scoring-v0.2"}); + } + } + jdbcTemplate.batchUpdate("INSERT INTO candidate_dimension_scores " + + "(student_user_id, dimension, period, score, status, weight, rules_version, calculated_at) " + + "VALUES (?, ?, ?, ?, ?, 12.50, ?, CURRENT_TIMESTAMP)", scoreArgs); + int expected = sizes[i]; + assertThat(repository.findSnapshotPopulation(period)).isEqualTo(expected); + assertThat(repository.findSnapshotAggregates(period)).hasSize(8) + .allSatisfy(row -> assertThat(row.candidateCount()).isEqualTo(expected)); + FairnessMonitoringRepository.OverviewAggregateData overview = repository.findOverviewAggregateData(period); + assertThat(overview.populationSize()).isEqualTo(expected); + assertThat(overview.computableCount()).isEqualTo(expected); + assertThat(overview.versionCounts()).containsEntry("candidate-scoring-v0.2", expected) + .containsEntry("MIXED", 0).containsEntry("UNKNOWN", 0); + assertThat(overview.frequenciesByVersion().get("candidate-scoring-v0.2")) + .containsExactly(new FairnessStatisticsCalculator.ScoreFrequency(new BigDecimal("50.00"), expected)); + } + } finally { + jdbcTemplate.update("DELETE FROM candidate_dimension_scores WHERE period IN (?, ?, ?)", periods.toArray()); + jdbcTemplate.update("DELETE FROM users WHERE phone LIKE ?", prefix + "%"); + } + } + + @Test + void overviewAggregateSqlSeparatesMixedAndUnknownVersions() { + String prefix = ("v43o" + UUID.randomUUID().toString().replace("-", "")).substring(0, 12); + String period = "2091-04"; + List dimensions = List.of("EDUCATION", "INTERNSHIP", "ABILITY", "COMPLIANCE", + "STABILITY", "GROWTH", "INTEGRITY", "ACTIVITY"); + try { + jdbcTemplate.batchUpdate("INSERT INTO users(phone, role, is_active, is_verified, created_at, updated_at) " + + "VALUES (?, 'STUDENT', 1, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)", + java.util.stream.IntStream.range(0, 30).mapToObj(n -> new Object[]{prefix + n}).toList()); + List ids = jdbcTemplate.query("SELECT id FROM users WHERE phone LIKE ? ORDER BY id", + (rs, rowNum) -> rs.getLong(1), prefix + "%"); + List scoreArgs = new ArrayList<>(30 * 8); + for (int i = 0; i < ids.size(); i++) { + for (String dimension : dimensions) { + String rowVersion = i == 1 ? "unknown-rule-fixture" + : i == 0 && "ABILITY".equals(dimension) ? "candidate-scoring-v0.2" + : "candidate-scoring-v0.1"; + scoreArgs.add(new Object[]{ids.get(i), dimension, period, new BigDecimal("50.00"), "OK", rowVersion}); + } + } + // Candidate 0 has two known versions (MIXED); candidate 1 has only + // an unknown version; the other 28 are coherent v0.1. + jdbcTemplate.batchUpdate("INSERT INTO candidate_dimension_scores " + + "(student_user_id, dimension, period, score, status, weight, rules_version, calculated_at) " + + "VALUES (?, ?, ?, ?, ?, 12.50, ?, CURRENT_TIMESTAMP)", scoreArgs); + + FairnessMonitoringRepository.OverviewAggregateData aggregate = repository.findOverviewAggregateData(period); + + assertThat(aggregate.populationSize()).isEqualTo(30); + assertThat(aggregate.computableCount()).isEqualTo(30); + assertThat(aggregate.versionCounts()).containsEntry("candidate-scoring-v0.1", 28) + .containsEntry("candidate-scoring-v0.2", 0) + .containsEntry("MIXED", 1).containsEntry("UNKNOWN", 1); + assertThat(aggregate.frequenciesByVersion().get("candidate-scoring-v0.1")) + .containsExactly(new FairnessStatisticsCalculator.ScoreFrequency(new BigDecimal("50.00"), 28)); + assertThat(aggregate.frequenciesByVersion().get("candidate-scoring-v0.2")).isEmpty(); + } finally { + jdbcTemplate.update("DELETE FROM candidate_dimension_scores WHERE period = ?", period); + jdbcTemplate.update("DELETE FROM users WHERE phone LIKE ?", prefix + "%"); + } + } + + @Test + void overviewOverallFrequencyReconcilesWithExistingScoringPureFunction() { + String prefix = ("v43s" + UUID.randomUUID().toString().replace("-", "")).substring(0, 12); + String period = "2091-05"; + List dimensions = List.of("EDUCATION", "INTERNSHIP", "ABILITY", "COMPLIANCE", + "STABILITY", "GROWTH", "INTEGRITY", "ACTIVITY"); + List scores = List.of(new BigDecimal("10.01"), new BigDecimal("20.02"), + new BigDecimal("30.03"), new BigDecimal("40.04"), new BigDecimal("50.05"), + new BigDecimal("60.06"), new BigDecimal("70.07"), new BigDecimal("80.08")); + try { + jdbcTemplate.update("INSERT INTO users(phone, role, is_active, is_verified, created_at, updated_at) " + + "VALUES (?, 'STUDENT', 1, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)", prefix); + Long studentId = jdbcTemplate.queryForObject("SELECT id FROM users WHERE phone = ?", Long.class, prefix); + List args = new ArrayList<>(); + for (int i = 0; i < dimensions.size(); i++) { + args.add(new Object[]{studentId, dimensions.get(i), period, scores.get(i), "OK", "candidate-scoring-v0.2"}); + } + jdbcTemplate.batchUpdate("INSERT INTO candidate_dimension_scores " + + "(student_user_id, dimension, period, score, status, weight, rules_version, calculated_at) " + + "VALUES (?, ?, ?, ?, ?, 12.50, ?, CURRENT_TIMESTAMP)", args); + + List snapshots = new ArrayList<>(); + for (int i = 0; i < dimensions.size(); i++) { + snapshots.add(CandidateDimensionScore.builder().studentUserId(studentId).dimension(dimensions.get(i)) + .period(period).score(scores.get(i)).status("OK").rulesVersion("candidate-scoring-v0.2") + .calculatedAt(java.time.LocalDateTime.now()).build()); + } + CandidateScoringCalculator calculator = new CandidateScoringCalculator(null, null, null, null, null, null); + BigDecimal expected = calculator.overallScoreFromSnapshots(snapshots); + FairnessMonitoringRepository.OverviewAggregateData aggregate = repository.findOverviewAggregateData(period); + assertThat(expected).isEqualByComparingTo("45.05"); + assertThat(aggregate.frequenciesByVersion().get("candidate-scoring-v0.2")) + .containsExactly(new FairnessStatisticsCalculator.ScoreFrequency(expected, 1)); + } finally { + jdbcTemplate.update("DELETE FROM candidate_dimension_scores WHERE period = ?", period); + jdbcTemplate.update("DELETE FROM users WHERE phone = ?", prefix); + } + } + + @Test + void appealAggregateSqlCountsEventsAndDistinctCandidatesByNormalizedStatus() { + String prefix = ("v43a" + UUID.randomUUID().toString().replace("-", "")).substring(0, 12); + try { + jdbcTemplate.batchUpdate("INSERT INTO users(phone, role, is_active, is_verified, created_at, updated_at) " + + "VALUES (?, 'STUDENT', 1, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)", + List.of(new Object[]{prefix + "01"}, new Object[]{prefix + "02"})); + List ids = jdbcTemplate.query("SELECT id FROM users WHERE phone LIKE ? ORDER BY id", + (rs, rowNum) -> rs.getLong(1), prefix + "%"); + String sql = "INSERT INTO candidate_scoring_appeals " + + "(candidate_user_id, dimension, period, status, reason, rules_version, created_at, updated_at) " + + "VALUES (?, 'ABILITY', '2026-09', ?, 'fixture', 'candidate-scoring-v0.2', ?, CURRENT_TIMESTAMP)"; + jdbcTemplate.update(sql, ids.get(0), "PENDING", java.sql.Timestamp.valueOf("2026-09-01 10:00:00")); + jdbcTemplate.update(sql, ids.get(0), "PENDING", java.sql.Timestamp.valueOf("2026-09-01 11:00:00")); + jdbcTemplate.update(sql, ids.get(1), "REJECTED", java.sql.Timestamp.valueOf("2026-09-01 12:00:00")); + + FairnessMonitoringRepository.AppealAggregateSummary summary = repository.findAppealAggregateSummary( + java.time.LocalDateTime.of(2026, 9, 1, 0, 0), java.time.LocalDateTime.of(2026, 9, 2, 0, 0)); + + assertThat(summary.appealCount()).isEqualTo(3); + assertThat(summary.sampleSize()).isEqualTo(2); + assertThat(summary.statusEventCounts()).containsEntry("PENDING", 2).containsEntry("REJECTED", 1); + assertThat(summary.statusCandidateCounts()).containsEntry("PENDING", 1).containsEntry("REJECTED", 1); + } finally { + jdbcTemplate.update("DELETE FROM candidate_scoring_appeals WHERE reason = 'fixture'"); + jdbcTemplate.update("DELETE FROM users WHERE phone LIKE ?", prefix + "%"); + } + } + + @Test + void rollbackSqlPreservesRowsAndCountsPerAppealWithoutSelectingMaxHistory() { + String prefix = ("v43r" + UUID.randomUUID().toString().replace("-", "")).substring(0, 12); + try { + jdbcTemplate.batchUpdate("INSERT INTO users(phone, role, is_active, is_verified, created_at, updated_at) " + + "VALUES (?, 'STUDENT', 1, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)", + List.of(new Object[]{prefix + "01"}, new Object[]{prefix + "02"})); + List ids = jdbcTemplate.query("SELECT id FROM users WHERE phone LIKE ? ORDER BY id", + (rs, rowNum) -> rs.getLong(1), prefix + "%"); + String appealSql = "INSERT INTO candidate_scoring_appeals " + + "(candidate_user_id, dimension, period, status, reason, rules_version, created_at, updated_at) " + + "VALUES (?, 'ABILITY', '2026-09', 'ROLLED_BACK', 'v43 rollback fixture', 'candidate-scoring-v0.2', ?, CURRENT_TIMESTAMP)"; + jdbcTemplate.update(appealSql, ids.get(0), java.sql.Timestamp.valueOf("2026-09-03 10:00:00")); + jdbcTemplate.update(appealSql, ids.get(1), java.sql.Timestamp.valueOf("2026-09-03 11:00:00")); + List appealIds = jdbcTemplate.query( + "SELECT id FROM candidate_scoring_appeals WHERE reason = 'v43 rollback fixture' ORDER BY id", + (rs, rowNum) -> rs.getLong(1)); + String historySql = "INSERT INTO candidate_scoring_snapshot_history " + + "(appeal_id, candidate_user_id, dimension, period, action, before_score, after_score, " + + " before_version, after_version, before_rules_version, after_rules_version, created_at) " + + "VALUES (?, ?, 'ABILITY', '2026-09', 'ROLLBACK', ?, ?, 1, 2, 'candidate-scoring-v0.2', 'candidate-scoring-v0.2', ?)"; + jdbcTemplate.update(historySql, appealIds.get(0), ids.get(0), new BigDecimal("20.00"), new BigDecimal("30.00"), + java.sql.Timestamp.valueOf("2026-09-03 12:00:00")); + jdbcTemplate.update(historySql, appealIds.get(1), ids.get(1), new BigDecimal("40.00"), new BigDecimal("50.00"), + java.sql.Timestamp.valueOf("2026-09-03 13:00:00")); + jdbcTemplate.update(historySql, appealIds.get(1), ids.get(1), new BigDecimal("40.00"), new BigDecimal("60.00"), + java.sql.Timestamp.valueOf("2026-09-03 14:00:00")); + + FairnessMonitoringRepository.AppealAggregateSummary summary = repository.findAppealAggregateSummary( + java.time.LocalDateTime.of(2026, 9, 1, 0, 0), java.time.LocalDateTime.of(2026, 9, 4, 0, 0)); + assertThat(summary.appealCount()).isEqualTo(2); + assertThat(summary.sampleSize()).isEqualTo(2); + assertThat(summary.comparableRollbackCount()).isEqualTo(1); + assertThat(summary.comparableRollbackCandidateCount()).isEqualTo(1); + assertThat(summary.changedCount()).isEqualTo(1); + assertThat(summary.changedCandidateCount()).isEqualTo(1); + assertThat(summary.deltaSum()).isEqualByComparingTo("10.00"); + assertThat(summary.duplicateRollbackHistoryCount()).isEqualTo(1); + } finally { + jdbcTemplate.update("DELETE FROM candidate_scoring_snapshot_history WHERE appeal_id IN " + + "(SELECT id FROM candidate_scoring_appeals WHERE reason = 'v43 rollback fixture')"); + jdbcTemplate.update("DELETE FROM candidate_scoring_appeals WHERE reason = 'v43 rollback fixture'"); + jdbcTemplate.update("DELETE FROM users WHERE phone LIKE ?", prefix + "%"); + } + } + + @Test + void appealCandidateAggregateSqlKeepsLegalAppealsWhenSiblingAppealHasDuplicateHistory() { + String prefix = ("v43c" + UUID.randomUUID().toString().replace("-", "")).substring(0, 12); + try { + jdbcTemplate.batchUpdate("INSERT INTO users(phone, role, is_active, is_verified, created_at, updated_at) " + + "VALUES (?, 'STUDENT', 1, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)", + List.of(new Object[]{prefix + "01"}, new Object[]{prefix + "02"})); + List ids = jdbcTemplate.query("SELECT id FROM users WHERE phone LIKE ? ORDER BY id", + (rs, rowNum) -> rs.getLong(1), prefix + "%"); + String appealSql = "INSERT INTO candidate_scoring_appeals " + + "(candidate_user_id, dimension, period, status, reason, rules_version, created_at, updated_at) " + + "VALUES (?, ?, '2026-09', 'ROLLED_BACK', 'v43 candidate aggregate fixture', 'candidate-scoring-v0.2', ?, CURRENT_TIMESTAMP)"; + // Candidate 1 has two independent legal appeals (both must count). + jdbcTemplate.update(appealSql, ids.get(0), "ABILITY", java.sql.Timestamp.valueOf("2026-09-05 10:00:00")); + jdbcTemplate.update(appealSql, ids.get(0), "GROWTH", java.sql.Timestamp.valueOf("2026-09-05 11:00:00")); + // Candidate 2 has one duplicate-history appeal and one legal appeal. + jdbcTemplate.update(appealSql, ids.get(1), "ABILITY", java.sql.Timestamp.valueOf("2026-09-05 12:00:00")); + jdbcTemplate.update(appealSql, ids.get(1), "GROWTH", java.sql.Timestamp.valueOf("2026-09-05 13:00:00")); + List appealIds = jdbcTemplate.query( + "SELECT id FROM candidate_scoring_appeals WHERE reason = 'v43 candidate aggregate fixture' ORDER BY id", + (rs, rowNum) -> rs.getLong(1)); + String historySql = "INSERT INTO candidate_scoring_snapshot_history " + + "(appeal_id, candidate_user_id, dimension, period, action, before_score, after_score, " + + " before_version, after_version, before_rules_version, after_rules_version, created_at) " + + "VALUES (?, ?, ?, '2026-09', 'ROLLBACK', ?, ?, 1, 2, 'candidate-scoring-v0.2', 'candidate-scoring-v0.2', ?)"; + jdbcTemplate.update(historySql, appealIds.get(0), ids.get(0), "ABILITY", new BigDecimal("20.00"), new BigDecimal("30.00"), java.sql.Timestamp.valueOf("2026-09-05 14:00:00")); + jdbcTemplate.update(historySql, appealIds.get(1), ids.get(0), "GROWTH", new BigDecimal("40.00"), new BigDecimal("40.00"), java.sql.Timestamp.valueOf("2026-09-05 15:00:00")); + jdbcTemplate.update(historySql, appealIds.get(2), ids.get(1), "ABILITY", new BigDecimal("10.00"), new BigDecimal("20.00"), java.sql.Timestamp.valueOf("2026-09-05 16:00:00")); + jdbcTemplate.update(historySql, appealIds.get(2), ids.get(1), "ABILITY", new BigDecimal("10.00"), new BigDecimal("25.00"), java.sql.Timestamp.valueOf("2026-09-05 17:00:00")); + jdbcTemplate.update(historySql, appealIds.get(3), ids.get(1), "GROWTH", new BigDecimal("60.00"), new BigDecimal("50.00"), java.sql.Timestamp.valueOf("2026-09-05 18:00:00")); + + FairnessMonitoringRepository.AppealAggregateSummary summary = repository.findAppealAggregateSummary( + java.time.LocalDateTime.of(2026, 9, 1, 0, 0), java.time.LocalDateTime.of(2026, 9, 6, 0, 0)); + + assertThat(summary.appealCount()).isEqualTo(4); + assertThat(summary.sampleSize()).isEqualTo(2); + assertThat(summary.comparableRollbackCount()).isEqualTo(3); + assertThat(summary.comparableRollbackCandidateCount()).isEqualTo(2); + assertThat(summary.changedCount()).isEqualTo(2); + assertThat(summary.changedCandidateCount()).isEqualTo(2); + assertThat(summary.unchangedCandidateCount()).isEqualTo(1); + assertThat(summary.changedUnchangedIntersectionCount()).isEqualTo(1); + assertThat(summary.duplicateRollbackHistoryCount()).isEqualTo(1); + assertThat(FairnessMonitoringRepository.AppealAggregateSummary.class.getRecordComponents()) + .extracting(java.lang.reflect.RecordComponent::getName) + .doesNotContain("candidateUserId", "appealId", "beforeScore", "afterScore", "details"); + } finally { + jdbcTemplate.update("DELETE FROM candidate_scoring_snapshot_history WHERE appeal_id IN " + + "(SELECT id FROM candidate_scoring_appeals WHERE reason = 'v43 candidate aggregate fixture')"); + jdbcTemplate.update("DELETE FROM candidate_scoring_appeals WHERE reason = 'v43 candidate aggregate fixture'"); + jdbcTemplate.update("DELETE FROM users WHERE phone LIKE ?", prefix + "%"); + } + } + + @TestConfiguration + static class CountingDataSourceConfiguration { + @Bean + @Primary + CountingDataSource dataSource(DataSourceProperties properties) { + return new CountingDataSource(properties.initializeDataSourceBuilder().build()); + } + } + + /** Test-only datasource used by the actual Spring Repository/Service proxies. */ + static final class CountingDataSource extends AbstractDataSource { + private final DataSource delegate; + private final AtomicInteger businessStatements = new AtomicInteger(); + private final AtomicInteger identityStatements = new AtomicInteger(); + + private CountingDataSource(DataSource delegate) { this.delegate = delegate; } + private void reset() { businessStatements.set(0); identityStatements.set(0); } + private int businessStatements() { return businessStatements.get(); } + private int identityStatements() { return identityStatements.get(); } + + @Override + public Connection getConnection() throws java.sql.SQLException { return wrap(delegate.getConnection()); } + + @Override + public Connection getConnection(String username, String password) throws java.sql.SQLException { + return wrap(delegate.getConnection(username, password)); + } + + private Connection wrap(Connection connection) { + return (Connection) Proxy.newProxyInstance(Connection.class.getClassLoader(), + new Class[]{Connection.class}, (proxy, method, args) -> { + Object value = method.invoke(connection, args); + if (value instanceof Statement statement) { + String sql = args != null && args.length > 0 && args[0] instanceof String s ? s : ""; + return wrapStatement(statement, method.getReturnType(), sql); + } + return value; + }); + } + + private Object wrapStatement(Statement statement, Class returnType, String preparedSql) { + Class iface = returnType.isInterface() ? returnType : Statement.class; + return Proxy.newProxyInstance(iface.getClassLoader(), new Class[]{iface}, (proxy, method, args) -> { + Object result = method.invoke(statement, args); + if (method.getName().startsWith("execute")) { + String sql = preparedSql; + if (sql.isBlank() && args != null && args.length > 0 && args[0] instanceof String s) sql = s; + String normalized = sql.toLowerCase(java.util.Locale.ROOT); + if (normalized.contains("candidate_dimension_scores") + || normalized.contains("candidate_scoring_appeals") + || normalized.contains("candidate_scoring_snapshot_history")) { + businessStatements.incrementAndGet(); + } else if (normalized.contains(" from users") || normalized.contains(" from user_roles")) { + identityStatements.incrementAndGet(); + } + } + return result; + }); + } + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/scoringmonitoring/FairnessMonitoringServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/scoringmonitoring/FairnessMonitoringServiceTest.java new file mode 100644 index 0000000..2c1e396 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/scoringmonitoring/FairnessMonitoringServiceTest.java @@ -0,0 +1,626 @@ +package com.mydreamjob.scoringmonitoring; + +import com.mydreamjob.auth.User; +import com.mydreamjob.auth.UserRepository; +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class FairnessMonitoringServiceTest { + private record SnapshotRow(Long studentUserId, String dimension, BigDecimal score, String status, String rulesVersion) {} + private record AppealRow(Long id, Long candidateUserId, String dimension, String period, String status, String rulesVersion) {} + private record RollbackRow(Long appealId, String action, BigDecimal beforeScore, BigDecimal afterScore, + String beforeRulesVersion, String afterRulesVersion, int historyCount) { + RollbackRow(Long appealId, String action, BigDecimal beforeScore, BigDecimal afterScore, + String beforeRulesVersion, String afterRulesVersion) { + this(appealId, action, beforeScore, afterScore, beforeRulesVersion, afterRulesVersion, 1); + } + } + + @Mock FairnessMonitoringRepository repository; + @Mock UserRepository userRepository; + private FairnessMonitoringService service; + private final Map> snapshotFixtures = new HashMap<>(); + private List appealRowsFixture = List.of(); + private List rollbackRowsFixture = List.of(); + + @BeforeEach + void setUp() { + service = new FairnessMonitoringService(repository, userRepository); + User admin = User.builder().id(1L).role(User.Role.ADMIN).roles(java.util.Set.of(User.Role.ADMIN)).active(true).build(); + lenient().when(userRepository.findById(1L)).thenReturn(Optional.of(admin)); + // Unit fixtures are adapted to the formal aggregate contract at the + // test boundary. Production code has no detail-query fallback. + lenient().doAnswer(inv -> overviewFixture(snapshotFixtures.get(inv.getArgument(0)))) + .when(repository).findOverviewAggregateData(anyString()); + lenient().doAnswer(inv -> appealFixture(appealRowsFixture, rollbackRowsFixture)) + .when(repository).findAppealAggregateSummary(any(), any()); + } + + private FairnessMonitoringRepository.OverviewAggregateData overviewFixture(List rows) { + if (rows == null || rows.isEmpty()) return FairnessMonitoringRepository.OverviewAggregateData.empty(); + Map> versions = new LinkedHashMap<>(); + Map> valid = new LinkedHashMap<>(); + for (var row : rows) { + versions.computeIfAbsent(row.studentUserId(), ignored -> new LinkedHashSet<>()).add( + row.rulesVersion() == null ? "UNKNOWN" : (Set.of("candidate-scoring-v0.1", "candidate-scoring-v0.2").contains(row.rulesVersion()) ? row.rulesVersion() : "UNKNOWN")); + if (("OK".equals(row.status()) || "ESTIMATED".equals(row.status())) && row.score() != null + && row.score().compareTo(BigDecimal.ZERO) >= 0 && row.score().compareTo(BigDecimal.valueOf(100)) <= 0) { + valid.computeIfAbsent(row.studentUserId(), ignored -> new ArrayList<>()).add(row.score()); + } + } + Map counts = new LinkedHashMap<>(); + counts.put("candidate-scoring-v0.1", 0); counts.put("candidate-scoring-v0.2", 0); counts.put("UNKNOWN", 0); counts.put("MIXED", 0); + Map> freq = new LinkedHashMap<>(); + freq.put("candidate-scoring-v0.1", new LinkedHashMap<>()); freq.put("candidate-scoring-v0.2", new LinkedHashMap<>()); + int computable = 0; + for (var entry : versions.entrySet()) { + Set distinct = entry.getValue(); + if (valid.containsKey(entry.getKey())) computable++; + if (distinct.size() != 1) { counts.merge("MIXED", 1, Integer::sum); continue; } + String version = distinct.iterator().next(); counts.merge(version, 1, Integer::sum); + if (valid.containsKey(entry.getKey()) && Set.of("candidate-scoring-v0.1", "candidate-scoring-v0.2").contains(version)) { + List values = valid.get(entry.getKey()); + BigDecimal average = values.stream().reduce(BigDecimal.ZERO, BigDecimal::add).divide(BigDecimal.valueOf(values.size()), 2, java.math.RoundingMode.HALF_UP); + freq.get(version).merge(average, 1, Integer::sum); + } + } + Map> byVersion = new LinkedHashMap<>(); + for (var entry : freq.entrySet()) byVersion.put(entry.getKey(), entry.getValue().entrySet().stream() + .map(e -> new FairnessStatisticsCalculator.ScoreFrequency(e.getKey(), e.getValue())).toList()); + return new FairnessMonitoringRepository.OverviewAggregateData(versions.size(), computable, counts, byVersion); + } + + private FairnessMonitoringRepository.AppealAggregateSummary appealFixture( + List rows, + List rollbacks) { + if (rows == null) return FairnessMonitoringRepository.AppealAggregateSummary.empty(); + Map> statusPeople = new LinkedHashMap<>(); + Map statusEvents = new LinkedHashMap<>(); + Set people = new LinkedHashSet<>(); Set approved = new LinkedHashSet<>(); + Set rejected = new LinkedHashSet<>(); Set rolledBack = new LinkedHashSet<>(); + Map byId = new HashMap<>(); + Map groups = new HashMap<>(); + for (var row : rows) { people.add(row.candidateUserId()); byId.put(row.id(), row); String status = Set.of("PENDING","APPROVED","REJECTED","CANCELLED","ROLLED_BACK").contains(row.status()) ? row.status() : "UNKNOWN"; statusEvents.merge(status, 1, Integer::sum); statusPeople.computeIfAbsent(status, ignored -> new LinkedHashSet<>()).add(row.candidateUserId()); if ("APPROVED".equals(row.status()) || "ROLLED_BACK".equals(row.status())) approved.add(row.candidateUserId()); if ("REJECTED".equals(row.status())) rejected.add(row.candidateUserId()); if ("ROLLED_BACK".equals(row.status())) rolledBack.add(row.candidateUserId()); groups.merge(row.candidateUserId()+"|"+row.dimension()+"|"+row.period(),1,Integer::sum); } + Map> rb = new LinkedHashMap<>(); for (var r : rollbacks) rb.computeIfAbsent(r.appealId(), ignored -> new ArrayList<>()).add(r); + Set duplicateAppeal = new LinkedHashSet<>(); for (var r : rows) if (groups.get(r.candidateUserId()+"|"+r.dimension()+"|"+r.period()) > 1) duplicateAppeal.add(r.candidateUserId()); + Set dup = new LinkedHashSet<>(), invalid = new LinkedHashSet<>(), changedVersion = new LinkedHashSet<>(), comparablePeople = new LinkedHashSet<>(), changedPeople = new LinkedHashSet<>(), unchangedPeople = new LinkedHashSet<>(); + int comparable = 0, changed = 0; BigDecimal delta = BigDecimal.ZERO, abs = BigDecimal.ZERO; + for (var entry : rb.entrySet()) { var appeal = byId.get(entry.getKey()); if (appeal == null) continue; if (entry.getValue().size() != 1) { dup.add(appeal.candidateUserId()); continue; } var r = entry.getValue().get(0); boolean validScore = r.beforeScore()!=null && r.afterScore()!=null && r.beforeScore().compareTo(BigDecimal.ZERO)>=0 && r.beforeScore().compareTo(BigDecimal.valueOf(100))<=0 && r.afterScore().compareTo(BigDecimal.ZERO)>=0 && r.afterScore().compareTo(BigDecimal.valueOf(100))<=0; if (!validScore) { invalid.add(appeal.candidateUserId()); continue; } if (!Set.of("candidate-scoring-v0.1","candidate-scoring-v0.2").contains(r.beforeRulesVersion()) || !java.util.Objects.equals(r.beforeRulesVersion(), r.afterRulesVersion())) { changedVersion.add(appeal.candidateUserId()); continue; } comparable++; comparablePeople.add(appeal.candidateUserId()); BigDecimal d = r.afterScore().subtract(r.beforeScore()); delta = delta.add(d); abs = abs.add(d.abs()); if (d.signum()==0) unchangedPeople.add(appeal.candidateUserId()); else { changed++; changedPeople.add(appeal.candidateUserId()); } } + Map statusCandidates = new LinkedHashMap<>(); statusPeople.forEach((k,v)->statusCandidates.put(k,v.size())); + Set approvalDenominator = new LinkedHashSet<>(approved); approvalDenominator.addAll(rejected); + return new FairnessMonitoringRepository.AppealAggregateSummary(rows.size(), people.size(), statusEvents, statusCandidates, + approved.size(), approvalDenominator.size(), rolledBack.size(), duplicateAppeal.size(), comparable, comparablePeople.size(), + changed, changedPeople.size(), unchangedPeople.size(), intersection(changedPeople, unchangedPeople).size(), delta, abs, dup.size(), invalid.size(), changedVersion.size()); + } + + private Set intersection(Set a, Set b) { Set result = new HashSet<>(a); result.retainAll(b); return result; } + + @Test + void overview_countsIndependentPeopleAndSeparatesRuleVersions() { + List rows = new ArrayList<>(); + for (long i = 1; i <= 30; i++) { + rows.add(new SnapshotRow(i, "ABILITY", new BigDecimal("20"), "OK", "candidate-scoring-v0.1")); + rows.add(new SnapshotRow(i + 100, "ABILITY", new BigDecimal("80"), "OK", "candidate-scoring-v0.2")); + } + snapshotFixtures.put("2026-09", rows); + FairnessResponse.Overview response = service.overview(1L, "2026-09"); + assertThat(response.getPopulationSize()).isEqualTo(60); + assertThat(response.getComputableSnapshotRatio()).isEqualByComparingTo("100.00"); + assertThat(response.getDistributionsByRulesVersion().get("candidate-scoring-v0.1").getMean()).isEqualByComparingTo("20.00"); + assertThat(response.getDistributionsByRulesVersion().get("candidate-scoring-v0.2").getMean()).isEqualByComparingTo("80.00"); + assertThat(response.getDistribution()).isNull(); + } + + @Test + void overviewComputableRatioIncludesMixedAndUnknownWhenAnyDimensionIsValid() { + List rows = new ArrayList<>(); + for (long i = 1; i <= 30; i++) rows.add(new SnapshotRow(i, "ABILITY", new BigDecimal("50"), "OK", "candidate-scoring-v0.2")); + rows.add(new SnapshotRow(31L, "ABILITY", new BigDecimal("50"), "OK", "candidate-scoring-v0.1")); + rows.add(new SnapshotRow(31L, "EDUCATION", new BigDecimal("50"), "PENDING", "candidate-scoring-v0.2")); + snapshotFixtures.put("2026-09", rows); + FairnessResponse.Overview response = service.overview(1L, "2026-09"); + assertThat(response.getComputableSnapshotRatio()).isEqualByComparingTo("100.00"); + assertThat(response.getDistributionsByRulesVersion()).isNull(); + assertThat(response.getDistribution()).isNull(); + } + + @Test + void overviewTreatsPendingVersionMismatchAsMixedAndNeverReturnsUnknownRawValue() { + List rows = new ArrayList<>(); + for (long i = 1; i <= 30; i++) { + rows.add(new SnapshotRow(i, "ABILITY", new BigDecimal("60"), "OK", "candidate-scoring-v0.1")); + } + rows.add(new SnapshotRow(31L, "ABILITY", new BigDecimal("70"), "OK", "future-rule-secret")); + rows.add(new SnapshotRow(31L, "EDUCATION", null, "PENDING", "candidate-scoring-v0.2")); + snapshotFixtures.put("2026-09", rows); + FairnessResponse.Overview response = service.overview(1L, "2026-09"); + assertThat(response.getVersionCounts()).isNull(); + assertThat(response.getReason()).isEqualTo("SMALL_CELL_OR_COMPLEMENT_RISK"); + assertThat(String.valueOf(response)).doesNotContain("future-rule-secret"); + } + + @Test + void overviewDoesNotDefaultRiskToNormalWhenStatisticsAreUnavailable() { + List rows = new ArrayList<>(); + for (long i = 1; i <= 29; i++) { + rows.add(new SnapshotRow(i, "ABILITY", new BigDecimal("50"), "OK", "candidate-scoring-v0.2")); + } + snapshotFixtures.put("2026-09", rows); + FairnessResponse.Overview response = service.overview(1L, "2026-09"); + assertThat(response.getRiskLevel()).isEqualTo("INSUFFICIENT_SAMPLE"); + assertThat(response.getAvailability()).isEqualTo("INSUFFICIENT_SAMPLE"); + assertThat(response.getReason()).isEqualTo("INDEPENDENT_CANDIDATE_THRESHOLD_NOT_MET"); + } + + @Test + void overviewPropagatesSuppressedDistributionWithoutUnboxingNullSampleSize() { + List rows = new ArrayList<>(); + for (long i = 1; i <= 99; i++) { + rows.add(new SnapshotRow(i, "ABILITY", new BigDecimal("50"), "OK", "candidate-scoring-v0.2")); + } + rows.add(new SnapshotRow(100L, "ABILITY", new BigDecimal("99"), "OK", "candidate-scoring-v0.2")); + snapshotFixtures.put("2026-09", rows); + + FairnessResponse.Overview response = service.overview(1L, "2026-09"); + + assertThat(response.getAvailability()).isEqualTo("SUPPRESSED"); + assertThat(response.getReason()).isEqualTo("SMALL_BUCKET_OR_COMPLEMENT_RISK"); + assertThat(response.getDistribution()).isNotNull(); + assertThat(response.getDistribution().getSampleSize()).isNull(); + } + + @Test + void overviewSuppressesVersionDistributionWhenValidComplementIsSmall() { + // 100 人中 99 人有效(v0.1)、1 人不可计算(PENDING)。ratio 99/100 补集=1 已被抑制, + // 但 distributionsByRulesVersion.v0.1(sampleSize=99)会反向暴露"恰好 1 人无效"。 + List rows = new ArrayList<>(); + for (long i = 1; i <= 99; i++) { + rows.add(new SnapshotRow(i, "ABILITY", new BigDecimal("50"), "OK", "candidate-scoring-v0.1")); + } + rows.add(new SnapshotRow(100L, "ABILITY", null, "PENDING", "candidate-scoring-v0.1")); + snapshotFixtures.put("2026-09", rows); + + FairnessResponse.Overview response = service.overview(1L, "2026-09"); + + assertThat(response.getAvailability()).isEqualTo("SUPPRESSED"); + assertThat(response.getComputableSnapshotRatio()).isNull(); + // 被抑制的补集必须连同样的版本分布一并隐藏,否则 sampleSize=99 + populationSize=100 反向推出 1 人无效 + assertThat(response.getDistributionsByRulesVersion()).isNullOrEmpty(); + assertThat(response.getDistribution()).isNull(); + } + + @Test + void overviewSqlAggregatePathSuppressesVersionDistributionWhenValidComplementIsSmall() { + Map versions = new LinkedHashMap<>(); + versions.put("candidate-scoring-v0.1", 100); + versions.put("candidate-scoring-v0.2", 0); + versions.put("UNKNOWN", 0); + versions.put("MIXED", 0); + Map> frequencies = new LinkedHashMap<>(); + frequencies.put("candidate-scoring-v0.1", List.of( + new FairnessStatisticsCalculator.ScoreFrequency(new BigDecimal("50.00"), 99))); + frequencies.put("candidate-scoring-v0.2", List.of()); + doReturn(new FairnessMonitoringRepository.OverviewAggregateData(100, 99, versions, frequencies)) + .when(repository).findOverviewAggregateData("2026-09"); + + FairnessResponse.Overview response = service.overview(1L, "2026-09"); + + assertThat(response.getAvailability()).isEqualTo("SUPPRESSED"); + assertThat(response.getComputableSnapshotRatio()).isNull(); + assertThat(response.getDistribution()).isNull(); + assertThat(response.getDistributionsByRulesVersion()).isNullOrEmpty(); + assertThat(response.getRiskLevel()).isNull(); + } + + @Test + void overviewSqlAggregatePathKeepsAllValidPopulationAvailable() { + Map versions = new LinkedHashMap<>(); + versions.put("candidate-scoring-v0.1", 0); + versions.put("candidate-scoring-v0.2", 30); + versions.put("UNKNOWN", 0); + versions.put("MIXED", 0); + Map> frequencies = new LinkedHashMap<>(); + frequencies.put("candidate-scoring-v0.1", List.of()); + frequencies.put("candidate-scoring-v0.2", List.of( + new FairnessStatisticsCalculator.ScoreFrequency(new BigDecimal("20.00"), 30))); + doReturn(new FairnessMonitoringRepository.OverviewAggregateData(30, 30, versions, frequencies)) + .when(repository).findOverviewAggregateData("2026-09"); + + FairnessResponse.Overview response = service.overview(1L, "2026-09"); + + assertThat(response.getAvailability()).isEqualTo("AVAILABLE"); + assertThat(response.getComputableSnapshotRatio()).isEqualByComparingTo("100.00"); + assertThat(response.getDistribution().getSampleSize()).isEqualTo(30); + assertThat(response.getDistribution().getMean()).isEqualByComparingTo("20.00"); + } + + @Test + void invalidFuturePeriod_isRejectedWithoutQuery() { + assertThatThrownBy(() -> service.overview(1L, "2099-01")).isInstanceOf(BusinessException.class); + verifyNoInteractions(repository); + } + + @Test + void appealsRowAndCandidateDenominatorsStayConsistentWhenCandidateHasDupAndLegitHistory() { + // 60 人。p1..p30:各 1 条可比回滚;p31..p60:各有一条重复历史申诉(2 条历史) + 一条合法单历史申诉。 + // 设计按申诉粒度排除:只排除重复历史那条申诉;p31..p60 的合法申诉仍应计入, + // 因此可比行数与可比独立候选数必须一致(各 60 行 / 60 人)。 + List appeals = new ArrayList<>(); + List rollbacks = new ArrayList<>(); + long appealId = 1; + for (long i = 1; i <= 30; i++) { + long candidate = 60_000L + i; + appeals.add(new AppealRow(appealId, candidate, "ABILITY", "2026-09", "ROLLED_BACK", "candidate-scoring-v0.2")); + rollbacks.add(new RollbackRow(appealId, "ROLLBACK", new BigDecimal("50"), new BigDecimal("60"), "candidate-scoring-v0.2", "candidate-scoring-v0.2")); + appealId++; + } + for (long i = 1; i <= 30; i++) { + long candidate = 61_000L + i; + // 重复历史申诉:2 条历史 + appeals.add(new AppealRow(appealId, candidate, "ABILITY", "2026-09", "ROLLED_BACK", "candidate-scoring-v0.2")); + rollbacks.add(new RollbackRow(appealId, "ROLLBACK", new BigDecimal("50"), new BigDecimal("60"), "candidate-scoring-v0.2", "candidate-scoring-v0.2")); + rollbacks.add(new RollbackRow(appealId, "ROLLBACK", new BigDecimal("50"), new BigDecimal("80"), "candidate-scoring-v0.2", "candidate-scoring-v0.2")); + appealId++; + // 合法单历史申诉:1 条历史(应计入可比) + appeals.add(new AppealRow(appealId, candidate, "ABILITY", "2026-09", "ROLLED_BACK", "candidate-scoring-v0.2")); + rollbacks.add(new RollbackRow(appealId, "ROLLBACK", new BigDecimal("50"), new BigDecimal("60"), "candidate-scoring-v0.2", "candidate-scoring-v0.2")); + appealId++; + } + + appealRowsFixture = appeals; + rollbackRowsFixture = rollbacks; + + FairnessResponse.Appeals response = service.appeals(1L, LocalDate.of(2026, 8, 1), LocalDate.of(2026, 9, 1)); + + // 60 条可比回滚对应 60 名独立可比候选;不得出现“候选被排除、其合法回滚行仍计入”的分母错位 + assertThat(response.getComparableRollbackCount()).isEqualTo(60); + assertThat(response.getComparableRollbackCandidateCount()).isEqualTo(60); + } + + @Test + void servicePermissionRejectsStudentAndCompanyUsingRealRoleCheck() { + User student = User.builder().id(2L).role(User.Role.STUDENT).roles(java.util.Set.of(User.Role.STUDENT)).active(true).build(); + User company = User.builder().id(3L).role(User.Role.COMPANY).roles(java.util.Set.of(User.Role.COMPANY)).active(true).build(); + when(userRepository.findById(2L)).thenReturn(Optional.of(student)); + when(userRepository.findById(3L)).thenReturn(Optional.of(company)); + assertThatThrownBy(() -> service.overview(2L, "2026-09")).isInstanceOf(BusinessException.class) + .extracting(e -> ((BusinessException) e).getErrorCode()).isEqualTo(ErrorCode.FORBIDDEN.getCode()); + assertThatThrownBy(() -> service.appeals(3L, LocalDate.of(2026, 8, 1), LocalDate.of(2026, 9, 1))) + .isInstanceOf(BusinessException.class) + .extracting(e -> ((BusinessException) e).getErrorCode()).isEqualTo(ErrorCode.FORBIDDEN.getCode()); + } + + @Test + void driftAndRankingAreExplicitlyUnavailableWithoutHistory() { + assertThat(service.drift(1L).getAvailability()).isEqualTo("UNAVAILABLE"); + assertThat(service.rankingStability(1L).getAvailability()).isEqualTo("UNAVAILABLE"); + verifyNoInteractions(repository); + } + + @Test + void dimensionsUsesFrequencyAggregatesAndSuppressesComplement() { + when(repository.findSnapshotPopulation("2026-09")).thenReturn(100); + when(repository.findSnapshotAggregates("2026-09")).thenReturn(List.of( + new FairnessMonitoringRepository.SnapshotAggregateRow("ABILITY", "OK", "candidate-scoring-v0.2", new BigDecimal("80"), 99), + new FairnessMonitoringRepository.SnapshotAggregateRow("EDUCATION", "OK", "candidate-scoring-v0.2", new BigDecimal("80"), 1))); + FairnessResponse.DimensionMetric metric = service.dimensions(1L, "2026-09", "ABILITY").getDimensions().get(0); + assertThat(metric.getAvailability()).isEqualTo("SUPPRESSED"); + assertThat(metric.getReason()).isEqualTo("SMALL_CELL_OR_COMPLEMENT_RISK"); + assertThat(metric.getRowCount()).isNull(); + assertThat(metric.getDistribution()).isNull(); + verify(repository, times(1)).findSnapshotAggregates("2026-09"); + verify(repository, times(1)).findSnapshotPopulation("2026-09"); + } + + @Test + void dimensionsNoDataIsExplicit() { + when(repository.findSnapshotPopulation("2026-09")).thenReturn(0); + when(repository.findSnapshotAggregates("2026-09")).thenReturn(List.of()); + FairnessResponse.DimensionMetric metric = service.dimensions(1L, "2026-09", "ABILITY").getDimensions().get(0); + assertThat(metric.getAvailability()).isEqualTo("NO_DATA"); + assertThat(metric.getReason()).isEqualTo("NO_SNAPSHOT_POPULATION"); + assertThat(metric.getRowCount()).isNull(); + verify(repository, times(1)).findSnapshotAggregates("2026-09"); + verify(repository, times(1)).findSnapshotPopulation("2026-09"); + } + + @Test + void dimensionsUsesFixedQueryBudgetForLargePopulation() { + when(repository.findSnapshotPopulation("2026-09")).thenReturn(3000); + List aggregates = new ArrayList<>(); + for (String dimension : List.of("ABILITY", "ACTIVITY", "COMPLIANCE", "EDUCATION", "GROWTH", "INTEGRITY", "INTERNSHIP", "STABILITY")) { + aggregates.add(new FairnessMonitoringRepository.SnapshotAggregateRow(dimension, "OK", "candidate-scoring-v0.2", new BigDecimal("50"), 3000)); + } + when(repository.findSnapshotAggregates("2026-09")).thenReturn(aggregates); + FairnessResponse.Dimensions response = service.dimensions(1L, "2026-09", null); + assertThat(response.getDimensions()).hasSize(8); + verify(repository, times(1)).findSnapshotAggregates("2026-09"); + verify(repository, times(1)).findSnapshotPopulation("2026-09"); + verifyNoMoreInteractions(repository); + } + + @Test + void appealsDuplicateRollbackHistoryExcludesCandidateFromComparableDenominator() { + List appeals = new ArrayList<>(); + List rollbacks = new ArrayList<>(); + for (long i = 1; i <= 30; i++) { + appeals.add(new AppealRow(i, 10_000L + i, "ABILITY", "2026-09", "ROLLED_BACK", "candidate-scoring-v0.2")); + rollbacks.add(new RollbackRow(i, "ROLLBACK", new BigDecimal("50"), new BigDecimal("60"), "candidate-scoring-v0.2", "candidate-scoring-v0.2")); + } + rollbacks.add(new RollbackRow(1L, "ROLLBACK", new BigDecimal("50"), new BigDecimal("60"), "candidate-scoring-v0.2", "candidate-scoring-v0.2")); + appealRowsFixture = appeals; + rollbackRowsFixture = rollbacks; + FairnessResponse.Appeals response = service.appeals(1L, LocalDate.of(2026, 8, 1), LocalDate.of(2026, 9, 1)); + assertThat(response.getComparableRollbackCandidateCount()).isNull(); + verify(repository, times(1)).findAppealAggregateSummary(any(), any()); + assertThat(response.getActualScoreChangeRate()).isNull(); + assertThat(response.getAvailability()).isEqualTo("INSUFFICIENT_SAMPLE"); + assertThat(response.getReason()).isEqualTo("COMPARABLE_CANDIDATE_THRESHOLD_NOT_MET"); + assertThat(response.getComparableRollbackCandidateCount()).isNull(); + } + + @Test + void appealsRollbackHistoryOrderDoesNotChangeClassification() { + List appealRows = new ArrayList<>(); + List rollbackRows = new ArrayList<>(); + for (long i = 1; i <= 30; i++) { + appealRows.add(new AppealRow(i, 20_000L + i, "ABILITY", "2026-09", "ROLLED_BACK", "candidate-scoring-v0.2")); + rollbackRows.add(new RollbackRow(i, "ROLLBACK", new BigDecimal("50"), new BigDecimal(i == 1 ? "50" : "60"), "candidate-scoring-v0.2", "candidate-scoring-v0.2")); + } + // 同一 appeal 的第二条历史无论排在前后都必须被视为重复并整组排除。 + rollbackRows.add(new RollbackRow(1L, "ROLLBACK", new BigDecimal("40"), new BigDecimal("80"), "candidate-scoring-v0.2", "candidate-scoring-v0.2")); + appealRowsFixture = appealRows; + rollbackRowsFixture = rollbackRows; + FairnessResponse.Appeals first = service.appeals(1L, LocalDate.of(2026, 8, 1), LocalDate.of(2026, 9, 1)); + reset(repository); + lenient().doAnswer(inv -> appealFixture(appealRowsFixture, rollbackRowsFixture)).when(repository).findAppealAggregateSummary(any(), any()); + List reversed = new ArrayList<>(rollbackRows); + java.util.Collections.reverse(reversed); + appealRowsFixture = appealRows; + rollbackRowsFixture = reversed; + FairnessResponse.Appeals second = service.appeals(1L, LocalDate.of(2026, 8, 1), LocalDate.of(2026, 9, 1)); + assertThat(first.getAvailability()).isEqualTo(second.getAvailability()); + assertThat(first.getReason()).isEqualTo(second.getReason()); + assertThat(first.getComparableRollbackCandidateCount()).isEqualTo(second.getComparableRollbackCandidateCount()); + assertThat(first.getActualScoreChangeRate()).isEqualTo(second.getActualScoreChangeRate()); + } + + @Test + void appealsSuppressesStatusAndApprovalComplementWhenOneCandidateIsInComplement() { + List rows = new ArrayList<>(); + for (long i = 1; i <= 99; i++) { + rows.add(new AppealRow(i, 30_000L + i, "ABILITY", "2026-09", "APPROVED", "candidate-scoring-v0.2")); + } + rows.add(new AppealRow(100L, 30_100L, "ABILITY", "2026-09", "REJECTED", "candidate-scoring-v0.2")); + appealRowsFixture = rows; + rollbackRowsFixture = List.of(); + + FairnessResponse.Appeals response = service.appeals(1L, LocalDate.of(2026, 8, 1), LocalDate.of(2026, 9, 1)); + + assertThat(response.getAvailability()).isEqualTo("SUPPRESSED"); + assertThat(response.getStatusCandidateCounts()).isNull(); + assertThat(response.getStatusEventCounts()).isNull(); + assertThat(response.getApprovedEverCount()).isNull(); + assertThat(response.getApprovalRate()).isNull(); + } + + @Test + void appealsUsesSetComplementForOverlappingDuplicatePeople() { + List rows = new ArrayList<>(); + long appealId = 1; + for (long i = 1; i <= 99; i++) { + long candidate = 31_000L + i; + rows.add(new AppealRow(appealId++, candidate, "ABILITY", "2026-09", "PENDING", "candidate-scoring-v0.2")); + rows.add(new AppealRow(appealId++, candidate, "ABILITY", "2026-09", "PENDING", "candidate-scoring-v0.2")); + } + rows.add(new AppealRow(appealId, 31_100L, "ABILITY", "2026-09", "PENDING", "candidate-scoring-v0.2")); + appealRowsFixture = rows; + rollbackRowsFixture = List.of(); + + FairnessResponse.Appeals response = service.appeals(1L, LocalDate.of(2026, 8, 1), LocalDate.of(2026, 9, 1)); + + assertThat(response.getSampleSize()).isEqualTo(100); + assertThat(response.getDuplicateAppealCount()).isNull(); + assertThat(response.getDuplicateAppealRate()).isNull(); + assertThat(response.getAvailability()).isEqualTo("SUPPRESSED"); + } + + @Test + void dimensionsExposeIndependentUnknownStatusAndOutOfRangeCounts() { + when(repository.findSnapshotPopulation("2026-09")).thenReturn(100); + when(repository.findSnapshotAggregates("2026-09")).thenReturn(List.of( + new FairnessMonitoringRepository.SnapshotAggregateRow("ABILITY", "BROKEN_STATUS", "future-rule", new BigDecimal("150"), 100))); + + FairnessResponse.DimensionMetric metric = service.dimensions(1L, "2026-09", "ABILITY").getDimensions().get(0); + + assertThat(metric.getAvailability()).isEqualTo("NO_DATA"); + assertThat(metric.getUnknownStatusCount()).isEqualTo(100); + assertThat(metric.getOutOfRangeScoreCount()).isEqualTo(100); + assertThat(metric.getInconsistentRecordCount()).isEqualTo(100); + } + + @Test + void appealsSeparatesApprovalRollbackChangeAndAllNonComparableReasons() { + List rows = new ArrayList<>(); + List rollbackRows = new ArrayList<>(); + long appealId = 1; + // 30 APPROVED appeals with no rollback history. + for (int i = 0; i < 30; i++) { + rows.add(new AppealRow(appealId++, 40_000L + i, "ABILITY", "2026-09", "APPROVED", "candidate-scoring-v0.2")); + } + // 30 zero-delta rollbacks: they remain in the comparable denominator. + for (int i = 0; i < 30; i++) { + long id = appealId++; + rows.add(new AppealRow(id, 40_100L + i, "ABILITY", "2026-09", "ROLLED_BACK", "candidate-scoring-v0.2")); + rollbackRows.add(new RollbackRow(id, "ROLLBACK", new BigDecimal("50"), new BigDecimal("50"), "candidate-scoring-v0.2", "candidate-scoring-v0.2")); + } + // 30 genuine score changes. + for (int i = 0; i < 30; i++) { + long id = appealId++; + rows.add(new AppealRow(id, 40_200L + i, "ABILITY", "2026-09", "ROLLED_BACK", "candidate-scoring-v0.2")); + rollbackRows.add(new RollbackRow(id, "ROLLBACK", new BigDecimal("50"), new BigDecimal("60"), "candidate-scoring-v0.2", "candidate-scoring-v0.2")); + } + // 30 cross-version histories: not comparable and not counted as changes. + for (int i = 0; i < 30; i++) { + long id = appealId++; + rows.add(new AppealRow(id, 40_300L + i, "ABILITY", "2026-09", "ROLLED_BACK", "candidate-scoring-v0.2")); + rollbackRows.add(new RollbackRow(id, "ROLLBACK", new BigDecimal("50"), new BigDecimal("60"), "candidate-scoring-v0.1", "candidate-scoring-v0.2")); + } + // 30 null-score histories: not comparable. + for (int i = 0; i < 30; i++) { + long id = appealId++; + rows.add(new AppealRow(id, 40_400L + i, "ABILITY", "2026-09", "ROLLED_BACK", "candidate-scoring-v0.2")); + rollbackRows.add(new RollbackRow(id, "ROLLBACK", null, new BigDecimal("60"), "candidate-scoring-v0.2", "candidate-scoring-v0.2")); + } + // 30 appeals with duplicate rollback histories: the whole group is excluded. + for (int i = 0; i < 30; i++) { + long id = appealId++; + rows.add(new AppealRow(id, 40_500L + i, "ABILITY", "2026-09", "ROLLED_BACK", "candidate-scoring-v0.2")); + rollbackRows.add(new RollbackRow(id, "ROLLBACK", new BigDecimal("50"), new BigDecimal("60"), "candidate-scoring-v0.2", "candidate-scoring-v0.2")); + rollbackRows.add(new RollbackRow(id, "ROLLBACK", new BigDecimal("50"), new BigDecimal("70"), "candidate-scoring-v0.2", "candidate-scoring-v0.2")); + } + appealRowsFixture = rows; + rollbackRowsFixture = rollbackRows; + + FairnessResponse.Appeals response = service.appeals(1L, LocalDate.of(2026, 8, 1), LocalDate.of(2026, 9, 1)); + + assertThat(response.getSampleSize()).isEqualTo(180); + assertThat(response.getApprovedEverCount()).isEqualTo(180); + assertThat(response.getComparableRollbackCandidateCount()).isEqualTo(60); + assertThat(response.getChangedCandidateCount()).isEqualTo(30); + assertThat(response.getActualScoreChangeRate()).isEqualByComparingTo("50.00"); + assertThat(response.getDuplicateRollbackHistoryCount()).isEqualTo(30); + assertThat(response.getInvalidScoreRollbackCount()).isEqualTo(30); + assertThat(response.getRuleVersionChangedRollbackCount()).isEqualTo(30); + assertThat(response.getAvailability()).isEqualTo("AVAILABLE"); + } + + @Test + void appealsUsesRollbackRowsForChangeRateWhenOneCandidateHasMultipleComparableHistories() { + List rows = new ArrayList<>(); + List rollbackRows = new ArrayList<>(); + long appealId = 10_000; + // 30 changed-only candidates and 30 unchanged-only candidates. + for (int i = 0; i < 30; i++) { + long id = appealId++; + rows.add(new AppealRow(id, 50_000L + i, "ABILITY", "2026-09", "ROLLED_BACK", "candidate-scoring-v0.2")); + rollbackRows.add(new RollbackRow(id, "ROLLBACK", new BigDecimal("40"), new BigDecimal("50"), "candidate-scoring-v0.2", "candidate-scoring-v0.2")); + } + for (int i = 0; i < 30; i++) { + long id = appealId++; + rows.add(new AppealRow(id, 51_000L + i, "ABILITY", "2026-09", "ROLLED_BACK", "candidate-scoring-v0.2")); + rollbackRows.add(new RollbackRow(id, "ROLLBACK", new BigDecimal("40"), new BigDecimal("40"), "candidate-scoring-v0.2", "candidate-scoring-v0.2")); + } + // One candidate has two valid histories: one changed and one unchanged. + long bothChangedAndUnchanged = appealId++; + rows.add(new AppealRow(bothChangedAndUnchanged, 52_000L, "ABILITY", "2026-09", "ROLLED_BACK", "candidate-scoring-v0.2")); + rollbackRows.add(new RollbackRow(bothChangedAndUnchanged, "ROLLBACK", new BigDecimal("40"), new BigDecimal("50"), "candidate-scoring-v0.2", "candidate-scoring-v0.2")); + // A second dimension gives the same candidate another comparable appeal + // without creating duplicate history for a single appeal or duplicate + // appeal in the same dimension/period. + long secondAppeal = appealId++; + rows.add(new AppealRow(secondAppeal, 52_000L, "EXECUTION", "2026-09", "ROLLED_BACK", "candidate-scoring-v0.2")); + rollbackRows.add(new RollbackRow(secondAppeal, "ROLLBACK", new BigDecimal("40"), new BigDecimal("40"), "candidate-scoring-v0.2", "candidate-scoring-v0.2")); + appealRowsFixture = rows; + rollbackRowsFixture = rollbackRows; + + FairnessResponse.Appeals response = service.appeals(1L, LocalDate.of(2026, 8, 1), LocalDate.of(2026, 9, 1)); + + assertThat(response.getComparableRollbackCandidateCount()).isEqualTo(61); + assertThat(response.getComparableRollbackCount()).isEqualTo(62); + assertThat(response.getChangedCount()).isEqualTo(31); + assertThat(response.getChangedCandidateCount()).isEqualTo(31); + assertThat(response.getActualScoreChangeRate()).isEqualByComparingTo("50.00"); + assertThat(response.getMeanDelta()).isEqualByComparingTo("5.00"); + assertThat(response.getMeanAbsoluteDelta()).isEqualByComparingTo("5.00"); + assertThat(response.getAvailability()).isEqualTo("AVAILABLE"); + } + + @Test + void appealsPreservesOverlappingReasonSetsAcrossDifferentAppeals() { + List appealRows = new ArrayList<>(); + List rollbackRows = new ArrayList<>(); + long appealId = 70_000L; + for (int i = 0; i < 30; i++) { + long candidate = 70_100L + i; + long duplicateAppeal = appealId++; + appealRows.add(new AppealRow(duplicateAppeal, candidate, "ABILITY", "2026-09", "ROLLED_BACK", "candidate-scoring-v0.2")); + rollbackRows.add(new RollbackRow(duplicateAppeal, "ROLLBACK", new BigDecimal("50"), new BigDecimal("60"), "candidate-scoring-v0.2", "candidate-scoring-v0.2")); + rollbackRows.add(new RollbackRow(duplicateAppeal, "ROLLBACK", new BigDecimal("50"), new BigDecimal("70"), "candidate-scoring-v0.2", "candidate-scoring-v0.2")); + long invalidAppeal = appealId++; + appealRows.add(new AppealRow(invalidAppeal, candidate, "EXECUTION", "2026-09", "ROLLED_BACK", "candidate-scoring-v0.2")); + rollbackRows.add(new RollbackRow(invalidAppeal, "ROLLBACK", null, new BigDecimal("60"), "candidate-scoring-v0.2", "candidate-scoring-v0.2")); + long versionAppeal = appealId++; + appealRows.add(new AppealRow(versionAppeal, candidate, "GROWTH", "2026-09", "ROLLED_BACK", "candidate-scoring-v0.2")); + rollbackRows.add(new RollbackRow(versionAppeal, "ROLLBACK", new BigDecimal("40"), new BigDecimal("50"), "candidate-scoring-v0.1", "candidate-scoring-v0.2")); + } + appealRowsFixture = appealRows; + rollbackRowsFixture = rollbackRows; + + FairnessResponse.Appeals response = service.appeals(1L, LocalDate.of(2026, 8, 1), LocalDate.of(2026, 9, 1)); + + assertThat(response.getReasonCounts()).containsEntry("DUPLICATE_ROLLBACK_HISTORY", 30) + .containsEntry("INVALID_SCORE", 30).containsEntry("RULE_VERSION_CHANGED", 30); + } + + @Test + void appealsUsesCandidateAggregatePathWithoutLoadingAppealOrRollbackDetails() { + Map eventCounts = new LinkedHashMap<>(); eventCounts.put("ROLLED_BACK", 30); + Map candidateCounts = new LinkedHashMap<>(); candidateCounts.put("ROLLED_BACK", 30); + doReturn(new FairnessMonitoringRepository.AppealAggregateSummary(30, 30, eventCounts, candidateCounts, + 30, 30, 30, 0, 30, 30, 30, 30, 0, 0, new BigDecimal("300.00"), new BigDecimal("300.00"), 0, 0, 0)) + .when(repository).findAppealAggregateSummary(any(), any()); + + FairnessResponse.Appeals response = service.appeals(1L, LocalDate.of(2026, 8, 1), LocalDate.of(2026, 9, 1)); + + assertThat(response.getSampleSize()).isEqualTo(30); + assertThat(response.getComparableRollbackCount()).isEqualTo(30); + assertThat(response.getComparableRollbackCandidateCount()).isEqualTo(30); + assertThat(response.getChangedCount()).isEqualTo(30); + assertThat(response.getActualScoreChangeRate()).isEqualByComparingTo("100.00"); + assertThat(response.getMeanDelta()).isEqualByComparingTo("10.00"); + verify(repository, times(1)).findAppealAggregateSummary(any(), any()); + } + + @Test + void appealsCandidateAggregatePathSuppressesComparableMetricsWhenOneOfThirtyHasOnlyDuplicateHistory() { + Map eventCounts = new LinkedHashMap<>(); eventCounts.put("ROLLED_BACK", 30); + Map candidateCounts = new LinkedHashMap<>(); candidateCounts.put("ROLLED_BACK", 30); + doReturn(new FairnessMonitoringRepository.AppealAggregateSummary(30, 30, eventCounts, candidateCounts, + 30, 30, 29, 0, 29, 29, 29, 29, 0, 0, new BigDecimal("290.00"), new BigDecimal("290.00"), 1, 0, 0)) + .when(repository).findAppealAggregateSummary(any(), any()); + + FairnessResponse.Appeals response = service.appeals(1L, LocalDate.of(2026, 8, 1), LocalDate.of(2026, 9, 1)); + + assertThat(response.getSampleSize()).isEqualTo(30); + assertThat(response.getAvailability()).isEqualTo("INSUFFICIENT_SAMPLE"); + assertThat(response.getReason()).isEqualTo("COMPARABLE_CANDIDATE_THRESHOLD_NOT_MET"); + assertThat(response.getComparableRollbackCount()).isNull(); + assertThat(response.getComparableRollbackCandidateCount()).isNull(); + assertThat(response.getActualScoreChangeRate()).isNull(); + verify(repository, times(1)).findAppealAggregateSummary(any(), any()); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/scoringmonitoring/FairnessStatisticsCalculatorTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/scoringmonitoring/FairnessStatisticsCalculatorTest.java new file mode 100644 index 0000000..cb1c47a --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/scoringmonitoring/FairnessStatisticsCalculatorTest.java @@ -0,0 +1,99 @@ +package com.mydreamjob.scoringmonitoring; + +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class FairnessStatisticsCalculatorTest { + + @Test + void confidenceBoundaries_areIndependentPeopleCounts() { + assertThat(FairnessStatisticsCalculator.confidence(0)).isEqualTo("INSUFFICIENT_SAMPLE"); + assertThat(FairnessStatisticsCalculator.confidence(29)).isEqualTo("INSUFFICIENT_SAMPLE"); + assertThat(FairnessStatisticsCalculator.confidence(30)).isEqualTo("LOW_CONFIDENCE"); + assertThat(FairnessStatisticsCalculator.confidence(99)).isEqualTo("LOW_CONFIDENCE"); + assertThat(FairnessStatisticsCalculator.confidence(100)).isEqualTo("NORMAL_CONFIDENCE"); + } + + @Test + void distribution_suppressesSmallBuckets() { + List values = new ArrayList<>(); + for (int i = 0; i < 29; i++) values.add(BigDecimal.valueOf(i)); + values.add(BigDecimal.valueOf(99)); + var result = FairnessStatisticsCalculator.distribution(values); + assertThat(result.getSampleSize()).isNull(); + assertThat(result.getMean()).isNull(); + assertThat(result.getBuckets()).isNull(); + assertThat(result.getAvailability()).isEqualTo("SUPPRESSED"); + } + + @Test + void distribution_usesContinuousBucketsAndHalfUp() { + List values = new ArrayList<>(); + for (int i = 0; i < 30; i++) values.add(BigDecimal.valueOf(50)); + var result = FairnessStatisticsCalculator.distribution(values); + assertThat(result.getMean()).isEqualByComparingTo("50.00"); + assertThat(result.getMedian()).isEqualByComparingTo("50.00"); + assertThat(result.getBuckets().get("50-59")).isEqualTo(30); + assertThat(result.getConcentrationRate()).isEqualByComparingTo("100.00"); + assertThat(result.getRiskLevel()).isEqualTo("HIGH"); + assertThat(result.getAvailability()).isEqualTo("AVAILABLE"); + } + + @Test + void concentrationThreshold_usesUnroundedPercentage() { + List values = new ArrayList<>(); + for (int i = 0; i < 13_999; i++) values.add(BigDecimal.valueOf(50)); + for (int i = 0; i < 6_001; i++) values.add(BigDecimal.valueOf(60)); + + var result = FairnessStatisticsCalculator.distribution(values); + + assertThat(result.getConcentrationRate()).isEqualByComparingTo("70.00"); + assertThat(result.getRiskLevel()).isEqualTo("NORMAL"); + } + + @Test + void frequencyDistributionCalculatesWeightedStatisticsWithoutExpandingSamples() { + var frequencies = List.of( + new FairnessStatisticsCalculator.ScoreFrequency(new BigDecimal("9.5"), 30), + new FairnessStatisticsCalculator.ScoreFrequency(new BigDecimal("10"), 30), + new FairnessStatisticsCalculator.ScoreFrequency(new BigDecimal("89.99"), 30), + new FairnessStatisticsCalculator.ScoreFrequency(new BigDecimal("90"), 30), + new FairnessStatisticsCalculator.ScoreFrequency(new BigDecimal("100"), 30)); + var result = FairnessStatisticsCalculator.distributionFromFrequencies(frequencies); + assertThat(result.getSampleSize()).isEqualTo(150); + assertThat(result.getMean()).isEqualByComparingTo("59.90"); + assertThat(result.getP10()).isEqualByComparingTo("9.50"); + assertThat(result.getP90()).isEqualByComparingTo("100.00"); + assertThat(result.getBuckets().get("0-9")).isEqualTo(30); + assertThat(result.getBuckets().get("90-100")).isEqualTo(60); + } + + @Test + void emptyDistributionIsExplicitNoData() { + var result = FairnessStatisticsCalculator.distribution(List.of()); + assertThat(result.getSampleSize()).isZero(); + assertThat(result.getAvailability()).isEqualTo("NO_DATA"); + assertThat(result.getReason()).isEqualTo("NO_VALID_SCORE"); + } + + @Test + void frequencySampleThresholdsUseIndependentCandidateCounts() { + assertThat(FairnessStatisticsCalculator.distributionFromFrequencies( + List.of(new FairnessStatisticsCalculator.ScoreFrequency(new BigDecimal("50"), 29))) + .getAvailability()).isEqualTo("INSUFFICIENT_SAMPLE"); + assertThat(FairnessStatisticsCalculator.distributionFromFrequencies( + List.of(new FairnessStatisticsCalculator.ScoreFrequency(new BigDecimal("50"), 30))) + .getAvailability()).isEqualTo("AVAILABLE"); + assertThat(FairnessStatisticsCalculator.distributionFromFrequencies( + List.of(new FairnessStatisticsCalculator.ScoreFrequency(new BigDecimal("50"), 300))) + .getSampleSize()).isEqualTo(300); + assertThat(FairnessStatisticsCalculator.distributionFromFrequencies( + List.of(new FairnessStatisticsCalculator.ScoreFrequency(new BigDecimal("50"), 3000))) + .getSampleSize()).isEqualTo(3000); + } +}