# 同岗位竞争力排名 API(V32) > 版本:V32 设计锁定版 > 状态:已编码实现(含状态口径修复) > 适用范围:学生端只读接口,不修改既有接口、百分位、评分引擎与企业看板。 > ⚠️ 状态口径(最终): > **投递已 WITHDRAWN 或 REJECTED 的学生已退出候选人池,访问排名接口返回 403/2002。** > “进入排名接口”的有效投递状态与 cohort 完全一致:仅 `SUBMITTED / VIEWED / INTERVIEW / HIRED` 允许进入。 ## 1. 定位与边界 为已投递某岗位的 STUDENT 提供该岗位候选人池内的竞争力排名。与既有接口边界: | 接口 | 是否改动 | 关系 | |---|---|---| | `GET /profile/me/candidate-percentile` | 不改 | 全局 cohort、基于 candidate_feedback 均分;本接口为单岗位、基于候选评分快照 | | `GET /profile/me/candidate-scoring` | 不改 | 复用其真实公式 `overallScoreFromSnapshots` | | `GET /company/.../scoring/dashboard/candidate-rankings` | 不改 | 企业端全公司视角(含姓名/学校);本接口学生端、匿名 | | `GET /jobs/{jobId}/match` | 不改 | 偏好确定性匹配诊断;本接口为候选人间竞争排名 | ## 2. 接口 ``` GET /api/v1/jobs/{jobId}/competition-ranking ``` - 角色:仅 STUDENT。 - 身份从 JWT 获取,不接收 `userId`/`studentUserId` 参数。 - 新增 1 条 GET 路径,OpenAPI 116 → 117。 ## 3. 权限矩阵 | 身份/场景 | HTTP | code | 说明 | |---|---|---|---| | 未登录 | 401 | 2001 | SecurityConfig `/jobs/**` 为 authenticated | | COMPANY / ADMIN | 403 | 2002 | Service 层 `assertStudentRole` | | STUDENT 已投递该岗位且状态有效(SUBMITTED/VIEWED/INTERVIEW/HIRED) | 200 | — | 正常返回 | | STUDENT 未投递该岗位 | 403 | 2002 | 岗位存在但不属于其候选人池,不泄露候选人数据 | | STUDENT 唯一投递为 WITHDRAWN 或 REJECTED | 403 | 2002 | 已退出候选人池;与 cohort 排除口径一致,不返回 200 | | jobId 为空或 <=0 | 400 | 1001 | BAD_REQUEST | | 岗位不存在 | 404 | 7001 | JOB_NOT_FOUND | | 岗位非 PUBLISHED(DRAFT/CLOSED 等) | 404 | 7001 | 不暴露岗位是否存在候选人池 | 不新增错误码,复用既有 ErrorCode。 ## 4. 响应结构 ``` ApiResponse CompetitionRankingResponse { Long jobId; Integer rank; // 本人名次(完整 cohort 内,含无分者排序);本人无分 → null Integer cohortSize; // cohort 总人数(含无分者) BigDecimal overallScore; // 本人真实综合分;无有效维度 → null BigDecimal percentile; // 百分位,仅对有分 cohort 计算;有分者<2 → null String status; // OK / PARTIAL / INSUFFICIENT_DATA / PENDING(复用引擎语义) String asOfPeriod; // 本次排名计算截止月份 yyyy-MM(当前月) String mySnapshotPeriod;// 当前学生实际使用的最新快照月份 List dimensions; // 本人各维度 {dimension,score,status,weight} List gaps; // 缺口建议(基于维度状态,简单实现) List topCandidates; // 匿名 Top 5 String rulesVersion; Instant generatedAt; } DimensionItem { String dimension; BigDecimal score; String status; BigDecimal weight; } RankingSnapshot { Integer rank; BigDecimal overallScore; } ``` ### 明确不返回字段 - 不返回 `candidateId`(本人内部标识,前端不需要)。 - `topCandidates[]` 每个元素仅 `{rank, overallScore}`。 ## 5. Cohort 口径 | 问题 | 决策 | |---|---| | 排除状态 | 排除 `WITHDRAWN`、`REJECTED` | | 计入状态 | 计入 `SUBMITTED`、`VIEWED`、`INTERVIEW`、`HIRED` | | 同学生多次投递 | 由 `applications(job_id, student_user_id)` 唯一约束天然去重,单岗位至多 1 条 | | 是否要求已投递 | 必须存在**有效投递**(状态非 `WITHDRAWN`/`REJECTED`);仅 WITHDRAWN/REJECTED → 403/2002 | | 无评分候选人 | 计入 cohortSize,排在最后,不参与 percentile | | 岗位可查范围 | 仅 `PUBLISHED`;其余 → 404/7001 | ## 6. 排名规则 候选人排序(**Java 侧排序,SQL 只负责批量查询,不使用 MySQL NULLS LAST**): ``` 1. 有分候选人优先(overallScore != null) 2. 有分者内部:overallScore DESC 3. overallScore 相同:application.createdAt ASC 4. 仍相同:student_user_id ASC 5. 无分候选人(overallScore == null)最后 ``` - 本人 `rank`:在完整 cohort 排序中的位次;本人无分 → `rank=null`。 - 本地用 `Comparator` 排序,明确把 `overallScore == null` 最后处理。 - `ownFlag` **不参与排名排序**,仅用于在排序后定位当前学生。 ## 7. 综合分与状态 - 复用 `CandidateScoringCalculator#overallScoreFromSnapshots`(真实公式,禁止 SQL 复算)。 - `status` 复用 Candidate Scoring Engine 状态语义:`OK / PARTIAL / INSUFFICIENT_DATA / PENDING`。 - 不得把 `PENDING/INSUFFICIENT_DATA` 当作 0 分。 ## 8. 百分位 严格复用企业看板公式: ``` 分母 = 有分候选人数 - 1 分子 = 综合分严格低于当前学生的有分候选人数 percentile = 分子 / 分母 * 100 ``` - 用 `BigDecimal`,`scale=2`,`RoundingMode.HALF_UP`。 - 有分候选人 < 2 时:`percentile = null`。 ## 9. 周期 - `asOfPeriod` = 当前月份 `yyyy-MM`。 - 每个候选人取自己 `<= asOfPeriod` 的最新周期快照(cohort 各候选人的最新快照月份可能不同)。 - 当前学生单独返回 `mySnapshotPeriod`。 ## 10. Gaps - 不新增另一套缺口计算规则。 - 不把 `PENDING/INSUFFICIENT_DATA` 转成 0。 - 当前阶段返回基于维度状态的简单缺口;若后续需要复杂建议另行设计。 ## 11. 匿名化硬约束 `topCandidates[]` 仅返回 `{rank, overallScore}`。禁止返回任何其他候选人的: - `studentUserId` - `candidateId` - `name` - `school` - `phone` - `email` - `resumeUrl` - `applicationId` - 其他任何身份字段 ## 12. 数据库与 Flyway - 不需要新表。 - 不新增 Flyway V32。 - 无 DDL、无数据变更。 ## 13. 查询与 N+1 风险 沿用企业看板已验收的“批量 JOIN 最新期快照”模式: 1. `applications` 按 `job_id` + 状态过滤取 cohort(1 条查询)。 2. `candidate_dimension_scores` 按 `student_user_id IN (...)` 且 `period <= asOfPeriod` 批量读取,Java 侧取每个学生最新周期(1 条查询)。 3. jobs 验存在 + 用户身份读取(各 1 条)。 无 N+1,恒定时复杂度。 ## 14. Top N 固定返回 Top 5,不增加 `top` 参数,避免通过参数批量探测候选人排名。分页需求另行设计。 ## 15. 测试(设计) ### MockMvc - 权限矩阵(未登录/COMPANY/ADMIN/已投递/未投递/岗位不存在/非 PUBLISHED/jobId<=0)。 - 本人有分:rank/cohortSize/percentile/overallScore 数值正确(HALF_UP、scale2)。 - 本人无分:score=null、rank=null、percentile=null、仍返回 cohortSize。 - 无分候选人排最后且计入 cohort;同分按 createdAt ASC 稳定排序。 - `topCandidates[]` 匿名化(断言响应不含身份字段)。 - 最新期快照读取(两期数据取一期)。 - cohort 状态过滤(排除 WITHDRAWN/REJECTED,计入 SUBMITTED/VIEWED/INTERVIEW/HIRED)。 ### Docker 黑盒 1. 起基础设施 + 最新 JAR;`/health=200`;Flyway 无新迁移;OpenAPI=117。 2. mock 登录 STUDENT;选 PUBLISHED 岗位且 ≥2 位非无效候选人。 3. 本人 GET → 校验各字段;抓体验证 topCandidates 匿名。 4. 越权:COMPANY/ADMIN→403/2002;未投递→403/2002;匿名→401;岗位不存在或非 PUBLISHED→404/7001;jobId=0→400/1001。 5. 数据库核验最新期快照、业务数据无改动、无新表。 6. 回归:`mvn test`、`mvn clean verify`、`verify_openapi.py`。 ## 16. 文档变更 - 新增:`docs/competition-ranking-api.md`(本文档)。 - 更新:`docs/openapi-rc.json`(117 paths)、`docs/verify_openapi.py`、`docs/frontend-integration.md`。 ## 17. 禁止事项(实现阶段) - 不修改 Candidate Scoring Engine。 - 不修改 percentile。 - 不修改已验收接口(评分、看板、治理、面试、通知、匹配、Offer)。 - 不新增数据库表、不新增 Flyway。 - 不启停 Docker(黑盒阶段除外)。