diff --git a/.gitignore b/.gitignore index 2c919b6..c6d7175 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,11 @@ acceptance-*.err.log **/logs/ **/*.log **/data-exports/ +# Backend black-box scratch: runtime OpenAPI dumps / stderr / diagnostics +backend-infra/app.log.err +backend-infra/diag_out.txt +backend-infra/runtime_openapi.json +backend-infra/v34_rt_api.json +backend-infra/v34_runtime_api.json +backend-infra/v34_runtime_openapi.json +# Note: do NOT add broad *.json ignores here (would hide unit-test JSON / formal OpenAPI docs under docs/) diff --git a/docs/backend-development-roadmap.md b/docs/backend-development-roadmap.md index 05bbe23..815194d 100644 --- a/docs/backend-development-roadmap.md +++ b/docs/backend-development-roadmap.md @@ -26,20 +26,20 @@ | 项目 | 当前状态 | |---|---| -| OpenAPI | **125 paths、159 operations** | -| Flyway | **V1–V33** 已存在 | -| 全量测试 | **1255/1255 通过**(本次 `mvn clean verify` 实际数量) | -| 已完成范围 | V24 Candidate Scoring、V25 Governance、V26 Offer Advance、V27 Privacy、V28/V29 Account Deletion、V30 Data Export、V31 Interview Score、V32 Competition Ranking、V33 Report/Block,及认证、资料、岗位、投递、面试、Offer、看板等核心闭环 | -| 当前结论 | 已达到已实现范围的 Release Candidate;V33 Report/Block 已完成真实 MySQL/Docker 黑盒验收并正式冻结 | +| OpenAPI | **126 paths、160 operations** | +| Flyway | **V1–V33** 已存在(V34 为纯接口扩展,不含 Flyway 迁移) | +| 全量测试 | **1275/1275 通过**(本次 `mvn clean verify` 实际数量) | +| 已完成范围 | V24 Candidate Scoring、V25 Governance、V26 Offer Advance、V27 Privacy、V28/V29 Account Deletion、V30 Data Export、V31 Interview Score、V32 Competition Ranking、V33 Report/Block、V34 统一求职进度聚合,及认证、资料、岗位、投递、面试、Offer、看板等核心闭环 | +| 当前结论 | 已达到已实现范围的 Release Candidate;V33 Report/Block 已完成真实 MySQL/Docker 黑盒验收并正式冻结;V34 统一求职进度聚合已完成实现并通过真实 MySQL/Docker 黑盒验收(PASS=99 / FAIL=0 / SKIP=0),待建立正式 Git 检查点 | | 本规划范围 | 代码修复、新功能开发、接口契约、数据权限和治理能力 | ### 2.0 模块状态分类 | 类别 | 模块 | |---|---| -| 已完成并已验收 | V24 Candidate Scoring、V25 Governance、V26 Offer Advance、V27 Privacy、V28/V29 Account Deletion、V30 Data Export、V31 Interview Score、V32 Competition Ranking、V33 Report/Block | +| 已完成并已验收 | V24 Candidate Scoring、V25 Governance、V26 Offer Advance、V27 Privacy、V28/V29 Account Deletion、V30 Data Export、V31 Interview Score、V32 Competition Ranking、V33 Report/Block、V34 统一求职进度聚合 | | 已完成、待真实 MySQL/Docker 验收 | — | -| 已设计未编码 | 测评系统(Holland/能力测评)、公平性检测、SHAP/规则解释、防刷审计、企业评价管理、企业人才推荐、统一求职进度聚合 | +| 已设计未编码 | 测评系统(Holland/能力测评)、公平性检测、SHAP/规则解释、防刷审计、企业评价管理、企业人才推荐 | | 未开始 | AI 简历诊断/优化、AI 模拟面试、行业技能词云、入职准备清单、薪资计算器、诚信积分、消息撤回 | ### 2.1 已确认的产品决策 @@ -120,7 +120,7 @@ | Figma 页面/功能 | 当前实现 | 差距 | |---|---|---| | 同岗位竞争力排名 | `GET /jobs/{jobId}/competition-ranking` 已实现 | 已交付;最终口径:WITHDRAWN/REJECTED 退出候选人池(请求 403/2002);无评分但有效投递返回 200 + null 分数;Top5 匿名。不宣称"支持隐私退出" | -| 求职进度 | applications、timeline、offers、interviews 可组合 | 缺统一的求职进度聚合接口 | +| 求职进度 | `GET /profile/me/career-progress`(V34)已实现 | 已交付统一求职进度聚合接口(只读,含竞争力摘要与待办/通知计数) | | Offer 对比 | `/offers/compare` 返回 Offer 字段 | 综合薪资、福利、发展空间当前由前端展示,后端不计算综合结论 | | HR 评价详情 | `candidate_feedback` 已实现 | 不是结构化面试评分 | | 企业评价管理 | 有评价审核和管理员决策 | 缺企业端评价管理、回复和运营动作 | @@ -142,7 +142,7 @@ - SHAP/规则解释; - ADMIN 防刷审计和规则配置; - 诚信积分和激励。 -- 已非缺失(已实现):个人数据导出(V30)、隐私设置(V27)、账号注销(V28/V29)、举报与屏蔽(V33)、结构化面试评分(V31)、同岗位竞争力排名(V32)。 +- 已非缺失(已实现):个人数据导出(V30)、隐私设置(V27)、账号注销(V28/V29)、举报与屏蔽(V33)、结构化面试评分(V31)、同岗位竞争力排名(V32)、统一求职进度聚合(V34)。 ## 5. 已有代码的优先修复项 @@ -457,7 +457,7 @@ scoring_risk_actions | P0 | 结构化面试评分 | | P0/P1 | 同岗位竞争力排名 | | P1 | 霍兰德和四维能力测评 | -| P1 | 统一求职进度 | +| P1 | 统一求职进度(V34 已完成) | | P1 | 企业评价管理 | | P1 | 企业人才推荐 | | P1 | 公平性检测 | diff --git a/docs/career-progress-api.md b/docs/career-progress-api.md new file mode 100644 index 0000000..710119f --- /dev/null +++ b/docs/career-progress-api.md @@ -0,0 +1,181 @@ +# 统一求职进度聚合 API(V34) + +> 版本:V34 设计锁定版 +> 状态:已编码实现 +> 适用范围:学生端**只读**接口,为「求职进度」页聚合投递、面试、结构化面试评分、Offer、同岗位竞争力排名与通知计数。 + +## 1. 定位与边界 + +为 STUDENT 提供「求职进度」页所需的单次聚合数据,避免前端串行调用多个接口。与既有接口边界: + +| 接口 | 是否改动 | 关系 | +|---|---|---| +| `GET /jobs/{jobId}/competition-ranking` | 不改 | 复用同套候选人池/排序/百分位口径(共享 `CompetitionRankingCalculator`),但本接口只返回摘要,不返回 dimensions/gaps/topCandidates | +| `GET /notifications` | 不改 | 本接口只返回通知**计数**,通知标题/正文/详情仍由 `/notifications` 提供 | +| `GET /interviews/{id}/score` 等 | 不改 | 仅读取已提交(SUBMITTED)评分摘要,不返回 DRAFT、不返回 comment | +| `GET /offers/...` | 不改 | 仅返回 Offer 摘要,不返回薪资/条款细节 | +| `GET /profile/me/...`、投递、面试 | 不改 | 本接口为其只读聚合,不重写既有语义 | + +## 2. 接口 + +``` +GET /api/v1/profile/me/career-progress +``` + +Query 参数: + +| 参数 | 类型 | 必填 | 默认 | 说明 | +|---|---|---|---|---| +| `page` | int | 否 | `0` | 页码,从 0 开始,`>= 0` | +| `size` | int | 否 | `20` | 每页条数,`1–50` | +| `status` | string | 否 | — | 投递状态过滤,仅 `SUBMITTED/VIEWED/INTERVIEW/HIRED/REJECTED/WITHDRAWN` | + +- 角色:仅 STUDENT。 +- 身份从 JWT 获取,不接收 `userId`/`studentUserId`/`candidateId` 等身份注入参数。 +- 新增 1 条 GET 路径,OpenAPI 125 → 126。 + +## 3. 权限矩阵 + +| 身份/场景 | HTTP | code | 说明 | +|---|---|---|---| +| 未登录 | 401 | 2001 | 既有安全机制 | +| COMPANY / ADMIN | 403 | 2002 | Service 层 `assertStudentRole` | +| STUDENT 正常 | 200 | — | 正常返回 | +| `page < 0` / `size < 1` / `size > 50` / 非法 `status` | 400 | 1001 | BAD_REQUEST | + +不新增错误码,复用既有 ErrorCode。 + +## 4. 响应结构 + +``` +ApiResponse + +CareerProgressResponse { + ProgressTodoSummary todo; + PaginatedApplications applications; + Instant generatedAt; +} + +ProgressTodoSummary { + long pendingInterviewResponseCount; // 状态 SCHEDULED 的面试数量 + long upcomingConfirmedInterviewCount; // 状态 CONFIRMED 且 startAt > 当前时间 + long pendingOfferResponseCount; // 状态 SENT/NEGOTIATING 且 expireAt > 当前时间 + long unreadNotificationCount; // 未读通知总数 + long unreadReportDecisionCount; // 未读 REPORT_DECISION 通知数 +} + +PaginatedApplications { + List items; + long total; + int page; + int size; + int totalPages; +} + +CareerProgressItem { + Long applicationId; + String applicationStatus; + LocalDateTime appliedAt; + LocalDateTime updatedAt; + JobSummary job; + InterviewSummary nextInterview; // 可空 + ScoreSummary latestSubmittedInterviewScore; // 可空 + OfferSummary offer; // 可空 + CompetitionSummary competition; +} + +JobSummary { Long jobId; String title; String companyName; String city; String employmentType; String jobStatus; } +InterviewSummary { Long interviewId; Integer roundNo; String title; String interviewType; LocalDateTime startAt; LocalDateTime endAt; String status; } +ScoreSummary { Long interviewId; BigDecimal overallScore; String recommendation; LocalDateTime submittedAt; } +OfferSummary { Long offerId; String status; String title; LocalDateTime expireAt; LocalDate onboardingDate; } +CompetitionSummary { + boolean eligible; + Integer rank; // 有分才返回;WITHDRAWN/REJECTED 或本人无分 → null + Integer cohortSize; // 仅 eligible=true 时返回 + BigDecimal overallScore; // 本人无分 → null + BigDecimal percentile; // 有分者 < 2 → null + String status; // OK / PARTIAL / INSUFFICIENT_DATA / PENDING + String mySnapshotPeriod; // 本人实际使用快照月;无快照 → null + String rulesVersion; +} +``` + +### 明确不返回字段 + +- 不返回 `studentUserId`、`candidateId`、`companyUserId`、`evaluatorUserId`、`phone`、`email`、`resumeUrl`、`passwordHash`、JWT。 +- 不返回通知标题/正文(`title`/`content`)、举报详情、面试评分 `comment`、Offer 薪资/条款细节(`salaryMin/salaryMax/baseSalary/housingAllowance/annualBonusMonths/benefits/description/otherAllowances`)。 +- 不返回其他候选人的任何身份信息,也不返回 V32 的 `dimensions`、`gaps`、`topCandidates`。 + +## 5. 聚合口径 + +### 5.1 投递 + +- 读取当前学生全部投递,按 `createdAt DESC` 分页。 +- `status` 存在时按该状态筛选。 +- 不过滤 `WITHDRAWN/REJECTED/HIRED`,均属于进度历史。 + +### 5.2 Todo + +| 字段 | 口径 | +|---|---| +| `pendingInterviewResponseCount` | 当前学生 `SCHEDULED` 面试数量 | +| `upcomingConfirmedInterviewCount` | 当前学生 `CONFIRMED` 且 `startAt > now` 的面试数量 | +| `pendingOfferResponseCount` | 当前学生 `SENT/NEGOTIATING` 且 `expireAt > now` 的 Offer 数量 | +| `unreadNotificationCount` | 复用 `NotificationRepository` 未读总数 | +| `unreadReportDecisionCount` | 未读且 `type='REPORT_DECISION'` 的通知数量 | + +### 5.3 nextInterview + +- 只看该投递的 `SCHEDULED / CONFIRMED` 面试,取 `startAt` 最早一场。 +- `RESCHEDULE_REQUESTED` 不算已确定;已完成/取消/拒绝不作为 nextInterview。 + +### 5.4 latestSubmittedInterviewScore + +- 只读 `SUBMITTED` 状态的 `InterviewScore`,每个 `applicationId` 取 `submittedAt` 最新一条。 +- 不返回 DRAFT、不返回 `comment`。 + +### 5.5 Offer + +- 仅学生本人 `candidateId` 的 Offer;`DRAFT` 永不返回。 +- 优先展示 `SENT/NEGOTIATING` 且 `expireAt > now` 的有效 Offer,按 `expireAt ASC` 取最早到期一条。 +- 无有效 Offer 时,展示该投递 `createdAt` 最新的一条非 DRAFT Offer。 +- 不返回薪资/条款细节。 + +### 5.6 competition + +- 投递状态 `SUBMITTED/VIEWED/INTERVIEW/HIRED` → `eligible=true`;`WITHDRAWN/REJECTED` → `eligible=false`(不返回其它排名字段)。 +- 复用 V32 候选池口径、最新快照口径、`CandidateScoringCalculator#overallScoreFromSnapshots`、稳定排序与百分位公式。 +- `percentile = 严格低于本人分数的人数 / (有分候选人数 - 1) × 100`,`scale=2`、`HALF_UP`;有分者 `< 2` 时 `null`。 +- 不返回 V32 的 `dimensions`/`gaps`/`topCandidates`。 + +## 6. 综合分 / 排序 / 百分位 + +- 全部复用 `CompetitionRankingCalculator`(V32/V34 共享),禁止复制第二套公式、禁止 SQL 重算。 +- `status` 复用引擎语义:`OK / PARTIAL / INSUFFICIENT_DATA / PENDING`,不把 `PENDING/INSUFFICIENT_DATA` 折算为 0。 + +## 7. 只读与性能 + +- `CareerProgressService` 标注 `@Transactional(readOnly=true)`,不写业务表/审计表/通知表/缓存。 +- 所有附属数据批量读取(jobs/companies/interviews/scores/offers/cohort/快照/通知计数),查询次数与分页 `items` 数量无关,目标约 8–10 次,无 N+1。 +- `size` 上限固定 50。 + +## 8. 数据库与 Flyway + +- 不需要新表。 +- 不新增 Flyway V34。 +- 无 DDL、无数据变更。 + +## 9. 测试 + +- `CareerProgressServiceTest`:权限/参数、空数据、完整聚合、DRAFT 不泄露、WITHDRAWN/REJECTED、无快照、批量查询无 N+1。 +- `CareerProgressControllerTest`:STUDENT 正常、COMPANY/ADMIN 403、未登录 401、参数非法 400、脱敏断言。 +- `CareerProgressPersistenceIntegrationTest`:真实仓库批量读取 + 只读性(关键表计数前后不变)。 + +## 10. 禁止事项(实现阶段) + +- 不修改 Candidate Scoring Engine、Competition Ranking 对外接口/权限/状态口径。 +- 不修改 Offer、Interview、Notification、Report/Block 的既有接口语义。 +- 不新增数据库表、不新增 Flyway、不新增 Redis 缓存、不新增异步任务。 +- 不新增错误码。 +- 不做企业回复率、推荐算法、AI 分析、薪资建议、通知内容聚合。 +- 不启停 Docker(黑盒阶段除外)。 \ No newline at end of file diff --git a/docs/frontend-integration.md b/docs/frontend-integration.md index 3406e9e..632e707 100644 --- a/docs/frontend-integration.md +++ b/docs/frontend-integration.md @@ -655,3 +655,56 @@ POST /admin/reports/{id}/decision # 处理决定 **脱敏**:所有举报/屏蔽响应均**不返回** phone、email、passwordHash、resumeUrl、JWT、消息全文;MESSAGE 举报仅返回脱敏 `messagePreview`;举报列表不返回举报人内部 ID。 - API 详见 [docs/report-block-api.md](report-block-api.md)。 + +## 20. 统一求职进度聚合(Career Progress)联调 + +学生端「求职进度」页的**只读**聚合接口,一次返回投递分页、岗位摘要、下一场面试、最近已提交结构化面试评分、可展示 Offer、同岗位竞争力摘要,以及待办/通知计数: + +```text +GET /profile/me/career-progress?page=0&size=20&status=INTERVIEW # 仅 STUDENT;page/size/status 均可选 +``` + +- **权限矩阵**: + - 未登录 → `401`(安全过滤器) + - `COMPANY` / `ADMIN` → `403/2002` + - `page<0`、`size<1`、`size>50`、非法 `status` → `400/1001` + - 身份只取 JWT,不接受 `userId/studentUserId/candidateId` 参数 +- **查询参数**:`page`(默认 0,≥0)、`size`(默认 20,1–50)、`status`(可选,仅 `SUBMITTED/VIEWED/INTERVIEW/HIRED/REJECTED/WITHDRAWN`)。 +- **响应结构(示例,字段为 camelCase)**: + +```json +{ + "todo": { + "pendingInterviewResponseCount": 1, + "upcomingConfirmedInterviewCount": 2, + "pendingOfferResponseCount": 1, + "unreadNotificationCount": 3, + "unreadReportDecisionCount": 1 + }, + "applications": { + "items": [ + { + "applicationId": 31, "applicationStatus": "INTERVIEW", + "appliedAt": "2026-08-20T10:00:00", "updatedAt": "2026-08-25T12:00:00", + "job": { "jobId": 9, "title": "Java 后端工程师", "companyName": "示例科技", "city": "杭州", "employmentType": "FULL_TIME", "jobStatus": "PUBLISHED" }, + "nextInterview": { "interviewId": 23, "roundNo": 2, "title": "技术二面", "interviewType": "ONLINE", "startAt": "2026-08-30T14:00:00", "endAt": "2026-08-30T15:00:00", "status": "CONFIRMED" }, + "latestSubmittedInterviewScore": { "interviewId": 22, "overallScore": 3.60, "recommendation": "HIRE", "submittedAt": "2026-08-25T16:00:00" }, + "offer": { "offerId": 14, "status": "SENT", "title": "Java 后端工程师", "expireAt": "2026-09-05T23:59:59", "onboardingDate": "2026-09-15" }, + "competition": { "eligible": true, "rank": 3, "cohortSize": 18, "overallScore": 77.50, "percentile": 82.35, "status": "PARTIAL", "mySnapshotPeriod": "2026-08", "rulesVersion": "candidate-scoring-v0.1" } + } + ], + "total": 8, "page": 0, "size": 20, "totalPages": 1 + }, + "generatedAt": "2026-08-27T10:00:00Z" +} +``` + +- **空数据**:无投递 → `items=[]`、`total=0`、`todo` 全 0;无下一场面试/无已提交评分/无可展示 Offer 时,相应对象为 `null`(JSON 省略)。 +- **competition**: + - 有效投递(`SUBMITTED/VIEWED/INTERVIEW/HIRED`)`eligible=true`;有评分快照才返回 `rank/overallScore/percentile/mySnapshotPeriod`,无快照则这些为 `null` 但仍返回 `cohortSize`。 + - `WITHDRAWN/REJECTED` 投递保留在历史,但 `eligible=false` 且不返回 `rank/cohortSize/overallScore/percentile/status/mySnapshotPeriod/rulesVersion`。 + - 复用 V32 同岗位排名的同一套 cohort/排序/百分位口径,但不返回 `dimensions/gaps/topCandidates`。 +- **Todo 口径**:`pendingInterviewResponseCount`=状态 `SCHEDULED` 面试数;`upcomingConfirmedInterviewCount`=状态 `CONFIRMED` 且 `startAt>now`;`pendingOfferResponseCount`=状态 `SENT/NEGOTIATING` 且 `expireAt>now`;`unreadNotificationCount`=未读通知总数;`unreadReportDecisionCount`=未读 `REPORT_DECISION` 通知数。 +- **脱敏**:不返回 `studentUserId/candidateId/companyUserId/evaluatorUserId/phone/email/resumeUrl/passwordHash/JWT`、通知标题/正文、评分 `comment`、Offer 薪资/条款、他人任何身份字段。**本接口只返回通知计数,通知详情仍用 `/notifications`**。 +- **只读**:`GET`,不写任何业务表;`size` 上限 50,聚合查询次数与单页条数无关(无 N+1)。 +- API 详见 [docs/career-progress-api.md](career-progress-api.md)。 diff --git a/docs/openapi-rc.json b/docs/openapi-rc.json index f046158..24c8fc5 100644 --- a/docs/openapi-rc.json +++ b/docs/openapi-rc.json @@ -4763,6 +4763,60 @@ } } }, + "/profile/me/career-progress": { + "get": { + "tags": [ + "求职进度" + ], + "summary": "统一求职进度聚合", + "description": "只读学生端接口:汇总当前学生的投递分页、岗位摘要、下一场面试、最近已提交结构化面试评分、可展示 Offer、同岗位竞争力排名,以及待办/通知计数(仅计数,不含通知标题/正文/详情)。仅 STUDENT 可访问,身份仅从 JWT/SecurityContext 获取,不接受 userId/studentUserId/candidateId 参数;COMPANY/ADMIN 返回 403/2002,未登录返回 401,参数非法返回 400/1001。通知详情仍由既有 /notifications 提供。", + "operationId": "getCareerProgress", + "parameters": [ + { + "name": "page", + "in": "query", + "required": false, + "schema": { "type": "integer", "format": "int32", "default": 0 } + }, + { + "name": "size", + "in": "query", + "required": false, + "schema": { "type": "integer", "format": "int32", "default": 20 } + }, + { + "name": "status", + "in": "query", + "required": false, + "schema": { + "type": "string", + "enum": ["SUBMITTED", "VIEWED", "INTERVIEW", "HIRED", "REJECTED", "WITHDRAWN"] + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ApiResponseCareerProgressResponse" } } } + }, + "400": { + "description": "page/size/status 参数非法", + "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ApiResponse" } } } + }, + "401": { + "description": "未登录", + "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ApiResponse" } } } + }, + "403": { + "description": "非 STUDENT 角色", + "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ApiResponse" } } } + } + }, + "security": [ + { "bearerAuth": [] } + ] + } + }, "/profile/me/deletion": { "get": { "tags": [ @@ -6337,6 +6391,260 @@ } } }, + "CareerProgressResponse": { + "type": "object", + "properties": { + "todo": { + "$ref": "#/components/schemas/ProgressTodoSummary" + }, + "applications": { + "$ref": "#/components/schemas/PaginatedApplications" + }, + "generatedAt": { + "type": "string", + "format": "date-time" + } + } + }, + "ProgressTodoSummary": { + "type": "object", + "properties": { + "pendingInterviewResponseCount": { + "type": "integer", + "format": "int64" + }, + "upcomingConfirmedInterviewCount": { + "type": "integer", + "format": "int64" + }, + "pendingOfferResponseCount": { + "type": "integer", + "format": "int64" + }, + "unreadNotificationCount": { + "type": "integer", + "format": "int64" + }, + "unreadReportDecisionCount": { + "type": "integer", + "format": "int64" + } + } + }, + "PaginatedApplications": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CareerProgressItem" + } + }, + "total": { + "type": "integer", + "format": "int64" + }, + "page": { + "type": "integer", + "format": "int32" + }, + "size": { + "type": "integer", + "format": "int32" + }, + "totalPages": { + "type": "integer", + "format": "int32" + } + } + }, + "CareerProgressItem": { + "type": "object", + "properties": { + "applicationId": { + "type": "integer", + "format": "int64" + }, + "applicationStatus": { + "type": "string" + }, + "appliedAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + }, + "job": { + "$ref": "#/components/schemas/JobSummary" + }, + "nextInterview": { + "$ref": "#/components/schemas/InterviewSummary" + }, + "latestSubmittedInterviewScore": { + "$ref": "#/components/schemas/ScoreSummary" + }, + "offer": { + "$ref": "#/components/schemas/OfferSummary" + }, + "competition": { + "$ref": "#/components/schemas/CompetitionSummary" + } + } + }, + "JobSummary": { + "type": "object", + "properties": { + "jobId": { + "type": "integer", + "format": "int64" + }, + "title": { + "type": "string" + }, + "companyName": { + "type": "string" + }, + "city": { + "type": "string" + }, + "employmentType": { + "type": "string" + }, + "jobStatus": { + "type": "string" + } + } + }, + "InterviewSummary": { + "type": "object", + "properties": { + "interviewId": { + "type": "integer", + "format": "int64" + }, + "roundNo": { + "type": "integer", + "format": "int32" + }, + "title": { + "type": "string" + }, + "interviewType": { + "type": "string" + }, + "startAt": { + "type": "string", + "format": "date-time" + }, + "endAt": { + "type": "string", + "format": "date-time" + }, + "status": { + "type": "string" + } + } + }, + "ScoreSummary": { + "type": "object", + "properties": { + "interviewId": { + "type": "integer", + "format": "int64" + }, + "overallScore": { + "type": "number" + }, + "recommendation": { + "type": "string" + }, + "submittedAt": { + "type": "string", + "format": "date-time" + } + } + }, + "OfferSummary": { + "type": "object", + "properties": { + "offerId": { + "type": "integer", + "format": "int64" + }, + "status": { + "type": "string" + }, + "title": { + "type": "string" + }, + "expireAt": { + "type": "string", + "format": "date-time" + }, + "onboardingDate": { + "type": "string", + "format": "date" + } + } + }, + "CompetitionSummary": { + "type": "object", + "properties": { + "eligible": { + "type": "boolean" + }, + "rank": { + "type": "integer", + "format": "int32" + }, + "cohortSize": { + "type": "integer", + "format": "int32" + }, + "overallScore": { + "type": "number" + }, + "percentile": { + "type": "number" + }, + "status": { + "type": "string" + }, + "mySnapshotPeriod": { + "type": "string" + }, + "rulesVersion": { + "type": "string" + } + } + }, + "ApiResponseCareerProgressResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32", + "example": 200 + }, + "message": { + "type": "string", + "example": "ok" + }, + "data": { + "$ref": "#/components/schemas/CareerProgressResponse" + }, + "requestId": { + "type": "string", + "example": "req-1" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + } + }, "ApiResponseAccountDeletionResponse": { "type": "object", "properties": { diff --git a/docs/verify_openapi.py b/docs/verify_openapi.py index 4625a2a..7470085 100644 --- a/docs/verify_openapi.py +++ b/docs/verify_openapi.py @@ -6,7 +6,7 @@ p = PROJECT_ROOT / 'docs' / 'openapi-rc.json' d = json.load(open(p, encoding='utf-8')) print('paths:', len(d['paths'])) -assert len(d['paths']) == 125, f"Expected 125 paths, got {len(d['paths'])}" +assert len(d['paths']) == 126, f"Expected 126 paths, got {len(d['paths'])}" paths = d['paths'] required = [ @@ -55,6 +55,8 @@ required = [ '/interviews/{interviewId}/score/history', # 同岗位竞争力排名(1 个 path / 1 个 operation) '/jobs/{jobId}/competition-ranking', + # V34 统一求职进度聚合(1 个 path / 1 个 operation) + '/profile/me/career-progress', # V33 举报与屏蔽(8 个 path / 9 个 operation) '/reports', '/reports/me', @@ -119,6 +121,9 @@ print(" Personal data export endpoints present (4 operations / 3 paths)") # 同岗位竞争力排名(1 个 GET operation) assert 'get' in paths['/jobs/{jobId}/competition-ranking'], "Missing GET /jobs/{jobId}/competition-ranking" print(" Competition ranking endpoint present (1 GET operation)") +# V34 统一求职进度聚合(1 个 GET operation) +assert 'get' in paths['/profile/me/career-progress'], "Missing GET /profile/me/career-progress" +print(" Career progress endpoint present (1 GET operation)") # V33 举报与屏蔽(8 个 path / 9 个 operation;PUT+DELETE 共享 /blocks/{blockedUserId}) assert 'post' in paths['/reports'], "Missing POST /reports" assert 'get' in paths['/reports/me'], "Missing GET /reports/me" diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationRepository.java index 6c10de4..20c670c 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationRepository.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationRepository.java @@ -39,4 +39,11 @@ public interface ApplicationRepository extends JpaRepository * 同岗位 rank 用:取某岗位未排除(非 WITHDRAWN/REJECTED)的投递,按投递时间正序。 */ List findByJobIdAndStatusNotInOrderByCreatedAtAsc(Long jobId, java.util.Collection statuses); + + /** + * V34 批量:一次取出多个岗位(page 内投递涉及的去重 jobId)未排除的候选人投递, + * 按投递时间正序;用于批量计算同岗位竞争力排名,避免按岗位循环查询造成 N+1。 + */ + List findByJobIdInAndStatusNotInOrderByCreatedAtAsc(java.util.Collection jobIds, + java.util.Collection statuses); } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/careerprogress/CareerProgressController.java b/mydreamjob-server/src/main/java/com/mydreamjob/careerprogress/CareerProgressController.java new file mode 100644 index 0000000..891a111 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/careerprogress/CareerProgressController.java @@ -0,0 +1,73 @@ +package com.mydreamjob.careerprogress; + +import com.mydreamjob.careerprogress.dto.CareerProgressResponse; +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.Parameter; +import io.swagger.v3.oas.annotations.enums.ParameterIn; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +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.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** + * 统一求职进度聚合接口(V34)。 + * + *

仅 STUDENT 可访问;身份只取 JWT,不接受 userId/studentUserId/candidateId 等参数。 + * 未登录 401;COMPANY/ADMIN 403/2002;参数非法 400/1001。

+ */ +@Tag(name = "求职进度", description = "学生端聚合投递、面试、评分、Offer、竞争力排名与通知计数") +@RestController +@RequiredArgsConstructor +public class CareerProgressController { + + private final CareerProgressService careerProgressService; + + @Operation( + summary = "统一求职进度聚合", + description = "只读学生端接口:汇总当前学生的投递分页、岗位、下一场面试、最近已提交结构化面试评分、" + + "可展示 Offer、同岗位竞争力排名,以及待办/通知计数(仅计数,不含通知详情)。" + + "身份仅从 JWT/SecurityContext 获取。", + security = @SecurityRequirement(name = "bearerAuth")) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "OK"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "page/size/status 参数非法"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "未登录"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "非 STUDENT 角色") + }) + @GetMapping("/profile/me/career-progress") + public ResponseEntity> getCareerProgress( + @Parameter(name = "page", in = ParameterIn.QUERY, description = "页码,从 0 开始,默认 0") + @RequestParam(defaultValue = "0") int page, + @Parameter(name = "size", in = ParameterIn.QUERY, description = "每页条数,1–50,默认 20") + @RequestParam(defaultValue = "20") int size, + @Parameter(name = "status", in = ParameterIn.QUERY, + description = "投递状态过滤,可选 SUBMITTED/VIEWED/INTERVIEW/HIRED/REJECTED/WITHDRAWN") + @RequestParam(required = false) String status, + HttpServletRequest request) { + Long userId = resolveUserId(request); + CareerProgressResponse data = careerProgressService.getCareerProgress(userId, page, size, status); + return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request))); + } + + private Long resolveUserId(HttpServletRequest request) { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + if (auth == null || !auth.isAuthenticated()) { + throw new BusinessException(ErrorCode.UNAUTHORIZED); + } + return Long.valueOf(auth.getName()); + } + + private String getRequestId(HttpServletRequest request) { + return request.getHeader("X-Request-Id"); + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/careerprogress/CareerProgressService.java b/mydreamjob-server/src/main/java/com/mydreamjob/careerprogress/CareerProgressService.java new file mode 100644 index 0000000..7f1a649 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/careerprogress/CareerProgressService.java @@ -0,0 +1,333 @@ +package com.mydreamjob.careerprogress; + +import com.mydreamjob.application.Application; +import com.mydreamjob.application.ApplicationRepository; +import com.mydreamjob.auth.User; +import com.mydreamjob.auth.UserRepository; +import com.mydreamjob.candidatescore.CandidateDimensionScore; +import com.mydreamjob.candidatescore.CandidateScoringCalculator; +import com.mydreamjob.careerprogress.dto.CareerProgressResponse; +import com.mydreamjob.careerprogress.dto.CareerProgressResponse.CareerProgressItem; +import com.mydreamjob.careerprogress.dto.CareerProgressResponse.CompetitionSummary; +import com.mydreamjob.careerprogress.dto.CareerProgressResponse.InterviewSummary; +import com.mydreamjob.careerprogress.dto.CareerProgressResponse.JobSummary; +import com.mydreamjob.careerprogress.dto.CareerProgressResponse.OfferSummary; +import com.mydreamjob.careerprogress.dto.CareerProgressResponse.PaginatedApplications; +import com.mydreamjob.careerprogress.dto.CareerProgressResponse.ProgressTodoSummary; +import com.mydreamjob.careerprogress.dto.CareerProgressResponse.ScoreSummary; +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.company.Company; +import com.mydreamjob.company.CompanyRepository; +import com.mydreamjob.competition.CompetitionRankingCalculator; +import com.mydreamjob.interview.Interview; +import com.mydreamjob.interview.InterviewRepository; +import com.mydreamjob.interviewscore.InterviewScore; +import com.mydreamjob.interviewscore.InterviewScoreRepository; +import com.mydreamjob.job.Job; +import com.mydreamjob.job.JobRepository; +import com.mydreamjob.notification.NotificationRepository; +import com.mydreamjob.offer.Offer; +import com.mydreamjob.offer.OfferRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.Comparator; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** + * 统一求职进度聚合服务(V34)。 + * + *

只读学生端接口,汇总投递分页 + 岗位 + 下一场面试 + 最近已提交评分 + 可展示 Offer + + * 同岗位竞争力排名 + 待办/通知计数。纯只读:@Transactional(readOnly=true), + * 不写业务表/审计表/通知表/缓存。所有附属数据批量读取,DB 查询次数固定(与分页 + * items 数量无关),避免 N+1。身份仅从 JWT/SecurityContext 获取。

+ */ +@Slf4j +@Service +@RequiredArgsConstructor +public class CareerProgressService { + + private static final Set ALLOWED_STATUSES = Set.of( + "SUBMITTED", "VIEWED", "INTERVIEW", "HIRED", "REJECTED", "WITHDRAWN"); + private static final Set NEXT_INTERVIEW_STATUSES = Set.of("SCHEDULED", "CONFIRMED"); + private static final String REPORT_DECISION_TYPE = "REPORT_DECISION"; + + private final ApplicationRepository applicationRepository; + private final InterviewRepository interviewRepository; + private final InterviewScoreRepository interviewScoreRepository; + private final OfferRepository offerRepository; + private final NotificationRepository notificationRepository; + private final JobRepository jobRepository; + private final CompanyRepository companyRepository; + private final UserRepository userRepository; + private final CompetitionRankingCalculator rankingCalculator; + + @Transactional(readOnly = true) + public CareerProgressResponse getCareerProgress(Long studentUserId, int page, int size, String status) { + assertStudentRole(studentUserId); + validateParams(page, size, status); + + LocalDateTime now = LocalDateTime.now(); + String asOf = yearMonth(LocalDate.now()); + + // 1. 投递分页(按 createdAt DESC;status 可选筛选) + PageRequest pr = PageRequest.of(page, size); + Page appPage = (status == null || status.isBlank()) + ? applicationRepository.findByStudentUserIdOrderByCreatedAtDesc(studentUserId, pr) + : applicationRepository.findByStudentUserIdAndStatusOrderByCreatedAtDesc(studentUserId, status, pr); + List pageApps = appPage.getContent(); + + // 2. 岗位与企业(批量,与 items 数量无关) + List jobIds = pageApps.stream().map(Application::getJobId).distinct().toList(); + Map jobs = jobIds.isEmpty() ? Map.of() + : jobRepository.findAllById(jobIds).stream() + .collect(Collectors.toMap(Job::getId, Function.identity())); + List companyIds = jobs.values().stream().map(Job::getCompanyId).distinct().toList(); + Map companies = companyIds.isEmpty() ? Map.of() + : companyRepository.findAllById(companyIds).stream() + .collect(Collectors.toMap(Company::getId, Function.identity())); + + // 3. 面试:学生全部 SCHEDULED/CONFIRMED(todo 计数 + nextInterview 共用一个批次) + List pendingInterviews = interviewRepository + .findByStudentUserIdAndStatusInOrderByStartAtAsc(studentUserId, NEXT_INTERVIEW_STATUSES); + + // 4. 已提交评分(本页投递,批量) + List appIds = pageApps.stream().map(Application::getId).toList(); + List scores = appIds.isEmpty() ? List.of() + : interviewScoreRepository.findByStudentUserIdAndApplicationIdInAndStatus( + studentUserId, appIds, InterviewScore.STATUS_SUBMITTED); + + // 5. 非 DRAFT Offer(todo 有效 Offer 计数 + 每条投递 Offer 展示共用一个批次) + List offers = offerRepository.findByCandidateIdAndStatusNotOrderByCreatedAtDesc(studentUserId, "DRAFT"); + + // 6. 竞争力:本页岗位的 cohort 投递与候选人快照各批量一次,再按岗位内存排序 + List cohortApps = jobIds.isEmpty() ? List.of() + : applicationRepository.findByJobIdInAndStatusNotInOrderByCreatedAtAsc( + jobIds, CompetitionRankingCalculator.EXCLUDED_STATUSES); + List cohortCandidateIds = cohortApps.stream() + .map(Application::getStudentUserId).distinct().toList(); + Map> latestByStudent = + rankingCalculator.loadLatestSnapshots(cohortCandidateIds, asOf); + Map> cohortByJob = cohortApps.stream() + .collect(Collectors.groupingBy(Application::getJobId)); + + // 7. 通知计数(只读 count) + long unreadTotal = notificationRepository.countUnreadByUserId(studentUserId); + long unreadReportDecision = notificationRepository.countUnreadByUserIdAndType(studentUserId, REPORT_DECISION_TYPE); + + // 组装 todo + ProgressTodoSummary todo = buildTodo(pendingInterviews, offers, unreadTotal, unreadReportDecision, now); + + // 组装 items + Map nextInterviewByApp = buildNextInterviewMap(pendingInterviews); + Map latestScoreByApp = buildLatestScoreMap(scores); + Map offerByApp = buildOfferMap(offers, now); + + List items = pageApps.stream().map(app -> { + Job job = jobs.get(app.getJobId()); + return CareerProgressItem.builder() + .applicationId(app.getId()) + .applicationStatus(app.getStatus()) + .appliedAt(app.getCreatedAt()) + .updatedAt(app.getUpdatedAt()) + .job(job == null ? null : buildJobSummary(job, companies)) + .nextInterview(buildInterviewSummary(nextInterviewByApp.get(app.getId()))) + .latestSubmittedInterviewScore(buildScoreSummary(latestScoreByApp.get(app.getId()))) + .offer(buildOfferSummary(offerByApp.get(app.getId()))) + .competition(buildCompetition(app, cohortByJob, latestByStudent, studentUserId)) + .build(); + }).collect(Collectors.toList()); + + PaginatedApplications applications = PaginatedApplications.builder() + .items(items) + .total(appPage.getTotalElements()) + .page(page) + .size(size) + .totalPages(appPage.getTotalPages()) + .build(); + + return CareerProgressResponse.builder() + .todo(todo) + .applications(applications) + .generatedAt(Instant.now()) + .build(); + } + + // ── todo ──────────────────────────────────────────────── + + private ProgressTodoSummary buildTodo(List interviews, List offers, + long unreadTotal, long unreadReportDecision, LocalDateTime now) { + long pendingInterview = interviews.stream() + .filter(i -> "SCHEDULED".equals(i.getStatus())).count(); + long upcomingConfirmed = interviews.stream() + .filter(i -> "CONFIRMED".equals(i.getStatus()) + && i.getStartAt() != null && i.getStartAt().isAfter(now)).count(); + long pendingOffer = offers.stream() + .filter(o -> ("SENT".equals(o.getStatus()) || "NEGOTIATING".equals(o.getStatus())) + && o.getExpireAt() != null && o.getExpireAt().isAfter(now)).count(); + return ProgressTodoSummary.builder() + .pendingInterviewResponseCount(pendingInterview) + .upcomingConfirmedInterviewCount(upcomingConfirmed) + .pendingOfferResponseCount(pendingOffer) + .unreadNotificationCount(unreadTotal) + .unreadReportDecisionCount(unreadReportDecision) + .build(); + } + + // ── 批量结果归组(内存,无额外查询)──────────────────────── + + private Map buildNextInterviewMap(List interviews) { + Map map = new LinkedHashMap<>(); + // 已按 startAt ASC,putIfAbsent 保留每个 applicationId 最早一场 + for (Interview iv : interviews) { + map.putIfAbsent(iv.getApplicationId(), iv); + } + return map; + } + + private Map buildLatestScoreMap(List scores) { + Map map = new HashMap<>(); + for (InterviewScore s : scores) { + InterviewScore existing = map.get(s.getApplicationId()); + if (existing == null || isAfter(s.getSubmittedAt(), existing.getSubmittedAt())) { + map.put(s.getApplicationId(), s); + } + } + return map; + } + + private boolean isAfter(LocalDateTime a, LocalDateTime b) { + if (a == null) return false; + if (b == null) return true; + return a.isAfter(b); + } + + private Map buildOfferMap(List offers, LocalDateTime now) { + Map> byApp = offers.stream().collect(Collectors.groupingBy(Offer::getApplicationId)); + Map result = new HashMap<>(); + for (Map.Entry> e : byApp.entrySet()) { + List list = e.getValue(); + Offer effective = list.stream() + .filter(o -> ("SENT".equals(o.getStatus()) || "NEGOTIATING".equals(o.getStatus())) + && o.getExpireAt() != null && o.getExpireAt().isAfter(now)) + .min(Comparator.comparing(Offer::getExpireAt)) + .orElse(null); + if (effective != null) { + result.put(e.getKey(), effective); + } else if (!list.isEmpty()) { + // offers 已按 createdAt DESC,第一个即该投递最新的非 DRAFT Offer + result.put(e.getKey(), list.get(0)); + } + } + return result; + } + + // ── 子对象映射 ───────────────────────────────────────── + + private JobSummary buildJobSummary(Job job, Map companies) { + Company c = companies.get(job.getCompanyId()); + return JobSummary.builder() + .jobId(job.getId()) + .title(job.getTitle()) + .companyName(c != null ? c.getName() : null) + .city(job.getCity()) + .employmentType(job.getEmploymentType()) + .jobStatus(job.getStatus()) + .build(); + } + + private InterviewSummary buildInterviewSummary(Interview iv) { + if (iv == null) return null; + return InterviewSummary.builder() + .interviewId(iv.getId()) + .roundNo(iv.getRoundNo()) + .title(iv.getTitle()) + .interviewType(iv.getInterviewType()) + .startAt(iv.getStartAt()) + .endAt(iv.getEndAt()) + .status(iv.getStatus()) + .build(); + } + + private ScoreSummary buildScoreSummary(InterviewScore s) { + if (s == null) return null; + return ScoreSummary.builder() + .interviewId(s.getInterviewId()) + .overallScore(s.getOverallScore()) + .recommendation(s.getRecommendation()) + .submittedAt(s.getSubmittedAt()) + .build(); + } + + private OfferSummary buildOfferSummary(Offer o) { + if (o == null) return null; + return OfferSummary.builder() + .offerId(o.getId()) + .status(o.getStatus()) + .title(o.getTitle()) + .expireAt(o.getExpireAt()) + .onboardingDate(o.getOnboardingDate()) + .build(); + } + + private CompetitionSummary buildCompetition(Application app, + Map> cohortByJob, + Map> latestByStudent, + Long meId) { + String st = app.getStatus(); + if ("WITHDRAWN".equals(st) || "REJECTED".equals(st)) { + return CompetitionSummary.builder().eligible(false).build(); + } + List cohort = cohortByJob.getOrDefault(app.getJobId(), List.of()); + CompetitionRankingCalculator.CohortRanking cr = + rankingCalculator.rankCohort(cohort, latestByStudent, meId); + return CompetitionSummary.builder() + .eligible(true) + .rank(cr.rank) + .cohortSize(cr.cohortSize) + .overallScore(cr.overallScore) + .percentile(cr.percentile) + .status(cr.status) + .mySnapshotPeriod(cr.mySnapshotPeriod) + .rulesVersion(CandidateScoringCalculator.RULES_VERSION) + .build(); + } + + // ── 权限 / 参数 / 工具 ───────────────────────────────── + + private void assertStudentRole(Long userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + boolean isStudent = user.getRoles().stream().anyMatch(r -> r == User.Role.STUDENT); + if (!isStudent) { + throw new BusinessException(ErrorCode.FORBIDDEN); + } + } + + private void validateParams(int page, int size, String status) { + if (page < 0 || size < 1 || size > 50) { + throw new BusinessException(ErrorCode.BAD_REQUEST); + } + if (status != null && !status.isBlank() && !ALLOWED_STATUSES.contains(status)) { + throw new BusinessException(ErrorCode.BAD_REQUEST); + } + } + + private String yearMonth(LocalDate d) { + return String.format("%04d-%02d", d.getYear(), d.getMonthValue()); + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/careerprogress/dto/CareerProgressResponse.java b/mydreamjob-server/src/main/java/com/mydreamjob/careerprogress/dto/CareerProgressResponse.java new file mode 100644 index 0000000..3250a97 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/careerprogress/dto/CareerProgressResponse.java @@ -0,0 +1,143 @@ +package com.mydreamjob.careerprogress.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Builder; +import lombok.Data; + +import java.math.BigDecimal; +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +/** + * 统一求职进度聚合响应(V34)。 + * + *

只读学生端接口。todo 仅返回通知/待办计数,不返回任何通知标题、正文或业务详情 + * (通知详情仍由既有 /notifications 提供)。applications 为当前学生投递分页, + * 每条投递附带岗位摘要、下一场面试、最近已提交评分、可展示 Offer 与同岗位竞争力摘要。 + * 对所有 null 子对象省略,避免泄露 DRAFT 或他人信息。

+ */ +@Data +@Builder +@JsonInclude(JsonInclude.Include.NON_NULL) +public class CareerProgressResponse { + + private ProgressTodoSummary todo; + + private PaginatedApplications applications; + + private Instant generatedAt; + + /** 待办与通知计数明细 */ + @Data + @Builder + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class ProgressTodoSummary { + /** 待响应面试数:状态为 SCHEDULED 的面试数量 */ + private long pendingInterviewResponseCount; + /** 未来已确认面试数:状态 CONFIRMED 且 startAt > 当前时间 */ + private long upcomingConfirmedInterviewCount; + /** 待响应 Offer 数:状态 SENT/NEGOTIATING 且 expireAt > 当前时间 */ + private long pendingOfferResponseCount; + /** 未读通知总数 */ + private long unreadNotificationCount; + /** 未读举报处理决定通知数(type=REPORT_DECISION) */ + private long unreadReportDecisionCount; + } + + /** 投递分页容器 */ + @Data + @Builder + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class PaginatedApplications { + private List items; + private long total; + private int page; + private int size; + private int totalPages; + } + + /** 单条投递聚合项 */ + @Data + @Builder + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class CareerProgressItem { + private Long applicationId; + private String applicationStatus; + private LocalDateTime appliedAt; + private LocalDateTime updatedAt; + + private JobSummary job; + private InterviewSummary nextInterview; + private ScoreSummary latestSubmittedInterviewScore; + private OfferSummary offer; + private CompetitionSummary competition; + } + + /** 岗位摘要(不含任何候选人/企业内部字段) */ + @Data + @Builder + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class JobSummary { + private Long jobId; + private String title; + private String companyName; + private String city; + private String employmentType; + private String jobStatus; + } + + /** 下一场面试摘要(仅 SCHEDULED/CONFIRMED,取 startAt 最早) */ + @Data + @Builder + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class InterviewSummary { + private Long interviewId; + private Integer roundNo; + private String title; + private String interviewType; + private LocalDateTime startAt; + private LocalDateTime endAt; + private String status; + } + + /** 最近已提交结构化面试评分摘要(不返回 DRAFT、不返回 comment) */ + @Data + @Builder + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class ScoreSummary { + private Long interviewId; + private BigDecimal overallScore; + private String recommendation; + private LocalDateTime submittedAt; + } + + /** Offer 摘要(不返回薪资/条款细节) */ + @Data + @Builder + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class OfferSummary { + private Long offerId; + private String status; + private String title; + private LocalDateTime expireAt; + private LocalDate onboardingDate; + } + + /** 同岗位竞争力摘要(复用 V32 候选池/排序/百分位口径;不含 dimensions/gaps/topCandidates) */ + @Data + @Builder + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class CompetitionSummary { + /** 是否计入竞争力(仅 SUBMITTED/VIEWED/INTERVIEW/HIRED 为 true) */ + private boolean eligible; + private Integer rank; + private Integer cohortSize; + private BigDecimal overallScore; + private BigDecimal percentile; + private String status; + private String mySnapshotPeriod; + private String rulesVersion; + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/competition/CompetitionRankingCalculator.java b/mydreamjob-server/src/main/java/com/mydreamjob/competition/CompetitionRankingCalculator.java new file mode 100644 index 0000000..5827ef7 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/competition/CompetitionRankingCalculator.java @@ -0,0 +1,181 @@ +package com.mydreamjob.competition; + +import com.mydreamjob.application.Application; +import com.mydreamjob.candidatescore.CandidateDimensionScore; +import com.mydreamjob.candidatescore.CandidateDimensionScoreRepository; +import com.mydreamjob.candidatescore.CandidateScoringCalculator; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * 同岗位竞争力排名的共享计算组件(V32 / V34 复用)。 + * + *

承载候选人池(cohort)的稳定排序、综合分、百分位与状态判定,全部为纯确定 + * 规则、无数据库写入。综合分复用 {@link CandidateScoringCalculator#overallScoreFromSnapshots} + * 真实公式;排序在 Java 侧完成(有分优先 → overallScore DESC → createdAt ASC → + * studentUserId ASC → 无分最后);百分位复用企业看板公式 + * {@code 严格低于本人分数的人数 / (有分候选人数 - 1) × 100}(scale=2、HALF_UP)。 + * 禁止在 SQL 里重算 overallScore 或 percentile,禁止复制粘贴第二套公式。

+ * + *

「读快照」与「排序计算」分离:{@link #loadLatestSnapshots} 一次批量读取, + * {@link #rankCohort} 纯计算不触碰数据库,保证 V34 按岗位批量排名时无 N+1。

+ */ +@Component +@RequiredArgsConstructor +public class CompetitionRankingCalculator { + + /** 已退出候选人池的投递状态(与 V32 cohort 口径一致) */ + public static final Set EXCLUDED_STATUSES = Set.of("WITHDRAWN", "REJECTED"); + + private final CandidateDimensionScoreRepository scoreRepository; + private final CandidateScoringCalculator scoringCalculator; + + /** + * 批量读取一批学生的快照(period <= asOf),Java 侧按学生取最新周期,无 N+1。 + * period 为 'yyyy-MM',字典序即时间序。 + */ + public Map> loadLatestSnapshots(List studentUserIds, String asOf) { + if (studentUserIds == null || studentUserIds.isEmpty()) return Map.of(); + List all = scoreRepository.findUpToPeriod(studentUserIds, asOf); + Map>> byStudentPeriod = new HashMap<>(); + for (CandidateDimensionScore s : all) { + byStudentPeriod.computeIfAbsent(s.getStudentUserId(), k -> new HashMap<>()) + .computeIfAbsent(s.getPeriod(), k -> new ArrayList<>()).add(s); + } + Map> result = new HashMap<>(); + for (Map.Entry>> e : byStudentPeriod.entrySet()) { + String latest = Collections.max(e.getValue().keySet()); + result.put(e.getKey(), e.getValue().get(latest)); + } + return result; + } + + /** + * 对单个岗位的 cohort 投递做稳定排序并返回本人排名摘要。纯计算,不读库。 + * + * @param cohortApps 已排除 WITHDRAWN/REJECTED 的候选人投递(同岗位) + * @param latestByStudent 每个学生的最新快照(由 loadLatestSnapshots 批量产出) + * @param meId 当前学生 userId + */ + public CohortRanking rankCohort(List cohortApps, + Map> latestByStudent, + Long meId) { + List rows = new ArrayList<>(); + for (Application app : cohortApps) { + List snaps = latestByStudent.getOrDefault(app.getStudentUserId(), List.of()); + BigDecimal overall = scoringCalculator.overallScoreFromSnapshots(snaps); + rows.add(new Row(app.getStudentUserId(), app.getCreatedAt(), overall, + snaps.isEmpty() ? null : latestPeriod(snaps))); + } + + rows.sort(Comparator + .comparing((Row r) -> r.overallScore, Comparator.nullsLast(Comparator.reverseOrder())) + .thenComparing(Row::getCreatedAt) + .thenComparing(Row::getStudentUserId)); + + Row me = null; + Integer rank = null; + for (int i = 0; i < rows.size(); i++) { + Row r = rows.get(i); + if (r.studentUserId.equals(meId)) { + me = r; + if (r.overallScore != null) rank = i + 1; + break; + } + } + + BigDecimal percentile = computePercentile(rows, me); + List meSnaps = latestByStudent.getOrDefault(meId, List.of()); + String status = overviewStatus(meSnaps); + String myPeriod = me == null ? null : me.snapshotPeriod; + + return new CohortRanking(rows.size(), rows, me, rank, + me == null ? null : me.overallScore, percentile, status, myPeriod, meSnaps); + } + + private String latestPeriod(List snaps) { + return snaps.stream().map(CandidateDimensionScore::getPeriod) + .max(String::compareTo).orElse(null); + } + + private BigDecimal computePercentile(List rows, Row me) { + if (me == null || me.overallScore == null) return null; + List scored = rows.stream().map(r -> r.overallScore) + .filter(Objects::nonNull).collect(Collectors.toList()); + if (scored.size() < 2) return null; + long lower = scored.stream().filter(s -> s.compareTo(me.overallScore) < 0).count(); + return BigDecimal.valueOf(lower) + .divide(BigDecimal.valueOf(scored.size() - 1), 10, RoundingMode.HALF_UP) + .multiply(BigDecimal.valueOf(100)) + .setScale(2, RoundingMode.HALF_UP); + } + + /** 综合卡状态:OK / PARTIAL / INSUFFICIENT_DATA / PENDING(复用引擎语义) */ + public String overviewStatus(List snaps) { + if (snaps == null || snaps.isEmpty()) return "INSUFFICIENT_DATA"; + long computable = snaps.stream() + .filter(s -> "OK".equals(s.getStatus()) || "ESTIMATED".equals(s.getStatus())).count(); + if (computable == snaps.size()) return "OK"; + if (computable > 0) return "PARTIAL"; + boolean allPending = snaps.stream().allMatch(s -> "PENDING".equals(s.getStatus())); + return allPending ? "PENDING" : "INSUFFICIENT_DATA"; + } + + /** 排序载体(不参与响应序列化) */ + public static class Row { + public final Long studentUserId; + public final LocalDateTime createdAt; + public final BigDecimal overallScore; + public final String snapshotPeriod; + + Row(Long studentUserId, LocalDateTime createdAt, BigDecimal overallScore, String snapshotPeriod) { + this.studentUserId = studentUserId; + this.createdAt = createdAt; + this.overallScore = overallScore; + this.snapshotPeriod = snapshotPeriod; + } + + LocalDateTime getCreatedAt() { return createdAt; } + Long getStudentUserId() { return studentUserId; } + } + + /** cohort 排名结果(领域载体,V32/V34 各自映射为 DTO) */ + public static class CohortRanking { + public final int cohortSize; + public final List rows; + public final Row me; + public final Integer rank; + public final BigDecimal overallScore; + public final BigDecimal percentile; + public final String status; + public final String mySnapshotPeriod; + public final List meSnaps; + + CohortRanking(int cohortSize, List rows, Row me, Integer rank, + BigDecimal overallScore, BigDecimal percentile, String status, + String mySnapshotPeriod, List meSnaps) { + this.cohortSize = cohortSize; + this.rows = rows; + this.me = me; + this.rank = rank; + this.overallScore = overallScore; + this.percentile = percentile; + this.status = status; + this.mySnapshotPeriod = mySnapshotPeriod; + this.meSnaps = meSnaps; + } + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/competition/CompetitionRankingService.java b/mydreamjob-server/src/main/java/com/mydreamjob/competition/CompetitionRankingService.java index 014f164..8bb281d 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/competition/CompetitionRankingService.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/competition/CompetitionRankingService.java @@ -5,7 +5,6 @@ import com.mydreamjob.application.ApplicationRepository; import com.mydreamjob.auth.User; import com.mydreamjob.auth.UserRepository; import com.mydreamjob.candidatescore.CandidateDimensionScore; -import com.mydreamjob.candidatescore.CandidateDimensionScoreRepository; import com.mydreamjob.candidatescore.CandidateScoringCalculator; import com.mydreamjob.common.exception.BusinessException; import com.mydreamjob.common.exception.ErrorCode; @@ -18,29 +17,20 @@ 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.util.ArrayList; -import java.util.Collections; import java.util.Comparator; -import java.util.HashMap; import java.util.List; import java.util.Map; -import java.util.Objects; import java.util.stream.Collectors; /** * 同岗位竞争力排名(V32)。 * *

为已投递某 PUBLISHED 岗位的学生返回该岗位候选人池内的竞争力排名。 - * 纯只读聚合,无新表、无 Flyway、不写任何业务表;综合分复用 - * {@link CandidateScoringCalculator#overallScoreFromSnapshots}(真实公式), - * 不修改 percentile / 评分引擎 / 企业看板。

- * - *

排序在 Java 侧完成(不使用 MySQL NULLS LAST),无 N+1:cohort 投递一次批量、 - * 候选人快照一次批量。

+ * 纯只读聚合,无新表、无 Flyway、不写任何业务表;综合分、排序、百分位与状态 + * 全部委托 {@link CompetitionRankingCalculator}(与 V34 共享,禁止复制公式)。

*/ @Slf4j @Service @@ -48,13 +38,11 @@ import java.util.stream.Collectors; public class CompetitionRankingService { private static final int TOP_N = 5; - private static final java.util.Set EXCLUDED = java.util.Set.of("WITHDRAWN", "REJECTED"); private final UserRepository userRepository; private final JobService jobService; private final ApplicationRepository applicationRepository; - private final CandidateDimensionScoreRepository scoreRepository; - private final CandidateScoringCalculator calculator; + private final CompetitionRankingCalculator rankingCalculator; @Transactional(readOnly = true) public CompetitionRankingResponse getRanking(Long studentUserId, Long jobId) { @@ -72,7 +60,8 @@ public class CompetitionRankingService { // 当前学生必须对本岗位存在「有效投递」(状态与 cohort 同口径:非 WITHDRAWN/REJECTED), // 否则一律 403。禁止用"是否曾投递"的宽松判断,避免已退出候选人池仍返回 200。 boolean applied = applicationRepository - .existsByJobIdAndStudentUserIdAndStatusNotIn(jobId, studentUserId, EXCLUDED); + .existsByJobIdAndStudentUserIdAndStatusNotIn(jobId, studentUserId, + CompetitionRankingCalculator.EXCLUDED_STATUSES); if (!applied) { throw new BusinessException(ErrorCode.FORBIDDEN); } @@ -85,109 +74,43 @@ public class CompetitionRankingService { // 1. cohort 投递(排除 WITHDRAWN/REJECTED;唯一约束保证同岗位同学生仅一条) List apps = applicationRepository - .findByJobIdAndStatusNotInOrderByCreatedAtAsc(jobId, EXCLUDED); + .findByJobIdAndStatusNotInOrderByCreatedAtAsc(jobId, CompetitionRankingCalculator.EXCLUDED_STATUSES); - // 2. 批量读取候选人快照(period <= asOf),Java 侧取每人最新周期 —— 无 N+1 + // 2. 批量读取候选人快照 + 排序/百分位(共享组件,无 N+1) List candidateIds = apps.stream().map(Application::getStudentUserId) .distinct().collect(Collectors.toList()); Map> latestByStudent = - loadLatestSnapshots(candidateIds, asOf); - - List rows = new ArrayList<>(); - for (Application app : apps) { - Row r = new Row(); - r.studentUserId = app.getStudentUserId(); - r.createdAt = app.getCreatedAt(); - List snaps = latestByStudent.getOrDefault(app.getStudentUserId(), List.of()); - r.snapshotPeriod = snaps.isEmpty() ? null : latestPeriod(snaps); - r.overallScore = calculator.overallScoreFromSnapshots(snaps); - rows.add(r); - } - - // 3. Java 排序:有分优先 → overallScore DESC → createdAt ASC → studentUserId ASC → 无分最后 - rows.sort(Comparator - .comparing((Row r) -> r.overallScore, Comparator.nullsLast(Comparator.reverseOrder())) - .thenComparing(Row::getCreatedAt) - .thenComparing(Row::getStudentUserId)); - - // 4. 本人信息 - Row me = rows.stream().filter(r -> r.studentUserId.equals(meId)).findFirst().orElse(null); - - int cohortSize = rows.size(); - Integer rank = null; - if (me != null && me.overallScore != null) { - rank = rows.indexOf(me) + 1; - } - BigDecimal percentile = computePercentile(rows, me); + rankingCalculator.loadLatestSnapshots(candidateIds, asOf); + CompetitionRankingCalculator.CohortRanking cr = + rankingCalculator.rankCohort(apps, latestByStudent, meId); - List meSnaps = me == null - ? List.of() : latestByStudent.getOrDefault(meId, List.of()); - String myPeriod = me == null ? null : me.snapshotPeriod; - - // 5. Top 5 匿名摘要 + // 3. Top 5 匿名摘要 List top = new ArrayList<>(); - int n = Math.min(TOP_N, rows.size()); + int n = Math.min(TOP_N, cr.rows.size()); for (int i = 0; i < n; i++) { - Row r = rows.get(i); + CompetitionRankingCalculator.Row r = cr.rows.get(i); top.add(CompetitionRankingResponse.RankingSnapshot.builder() .rank(i + 1).overallScore(r.overallScore).build()); } return CompetitionRankingResponse.builder() .jobId(jobId) - .rank(rank) - .cohortSize(cohortSize) - .overallScore(me == null ? null : me.overallScore) - .percentile(percentile) - .status(overviewStatus(meSnaps)) + .rank(cr.rank) + .cohortSize(cr.cohortSize) + .overallScore(cr.overallScore) + .percentile(cr.percentile) + .status(cr.status) .asOfPeriod(asOf) - .mySnapshotPeriod(myPeriod) - .dimensions(buildDimensions(meSnaps)) - .gaps(buildGaps(meSnaps)) + .mySnapshotPeriod(cr.mySnapshotPeriod) + .dimensions(buildDimensions(cr.meSnaps)) + .gaps(buildGaps(cr.meSnaps)) .topCandidates(top) .rulesVersion(CandidateScoringCalculator.RULES_VERSION) .generatedAt(Instant.now()) .build(); } - // ── cohort 快照批量装载 ───────────────────────────────────── - - private Map> loadLatestSnapshots(List ids, String asOf) { - if (ids.isEmpty()) return Map.of(); - List all = scoreRepository.findUpToPeriod(ids, asOf); - Map>> byStudentPeriod = new HashMap<>(); - for (CandidateDimensionScore s : all) { - byStudentPeriod.computeIfAbsent(s.getStudentUserId(), k -> new HashMap<>()) - .computeIfAbsent(s.getPeriod(), k -> new ArrayList<>()).add(s); - } - Map> result = new HashMap<>(); - for (Map.Entry>> e : byStudentPeriod.entrySet()) { - String latest = Collections.max(e.getValue().keySet()); - result.put(e.getKey(), e.getValue().get(latest)); - } - return result; - } - - private String latestPeriod(List snaps) { - return snaps.stream().map(CandidateDimensionScore::getPeriod) - .max(String::compareTo).orElse(null); - } - - // ── 百分位(严格复用企业看板公式)────────────────────────── - - private BigDecimal computePercentile(List rows, Row me) { - if (me == null || me.overallScore == null) return null; - List scored = rows.stream().map(r -> r.overallScore) - .filter(Objects::nonNull).collect(Collectors.toList()); - if (scored.size() < 2) return null; - long lower = scored.stream().filter(s -> s.compareTo(me.overallScore) < 0).count(); - return BigDecimal.valueOf(lower) - .divide(BigDecimal.valueOf(scored.size() - 1), 10, RoundingMode.HALF_UP) - .multiply(BigDecimal.valueOf(100)) - .setScale(2, RoundingMode.HALF_UP); - } - - // ── 维度 / 状态 / 缺口(复用引擎语义,禁止另造公式)────────── + // ── 维度 / 缺口(复用引擎语义,禁止另造公式)────────── private List buildDimensions(List snaps) { return snaps.stream() @@ -221,16 +144,6 @@ public class CompetitionRankingService { return gaps; } - private String overviewStatus(List snaps) { - if (snaps == null || snaps.isEmpty()) return "INSUFFICIENT_DATA"; - long computable = snaps.stream() - .filter(s -> "OK".equals(s.getStatus()) || "ESTIMATED".equals(s.getStatus())).count(); - if (computable == snaps.size()) return "OK"; - if (computable > 0) return "PARTIAL"; - boolean allPending = snaps.stream().allMatch(s -> "PENDING".equals(s.getStatus())); - return allPending ? "PENDING" : "INSUFFICIENT_DATA"; - } - // ── 身份与工具 ───────────────────────────────────────────── private void assertStudentRole(Long userId) { @@ -245,15 +158,4 @@ public class CompetitionRankingService { private String yearMonth(LocalDate d) { return String.format("%04d-%02d", d.getYear(), d.getMonthValue()); } - - /** 内部排序载体(不参与响应序列化) */ - static class Row { - Long studentUserId; - LocalDateTime createdAt; - BigDecimal overallScore; - String snapshotPeriod; - - LocalDateTime getCreatedAt() { return createdAt; } - Long getStudentUserId() { return studentUserId; } - } } \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/interview/InterviewRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/interview/InterviewRepository.java index 418ea7d..40c7619 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/interview/InterviewRepository.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/interview/InterviewRepository.java @@ -32,4 +32,10 @@ public interface InterviewRepository extends JpaRepository { Page findByStudentUserIdAndStatusAndStartAtBetweenOrderByStartAtDesc( Long studentUserId, String status, LocalDateTime from, LocalDateTime to, Pageable pageable); + + /** + * V34 聚合:一次读取当前学生全部 SCHEDULED/CONFIRMED 面试(待响应 + 已确认未来), + * 按开始时间正序;同时用于 todo 计数与每条投递的 nextInterview(取最早一场),避免 N+1。 + */ + List findByStudentUserIdAndStatusInOrderByStartAtAsc(Long studentUserId, java.util.Collection statuses); } \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/interviewscore/InterviewScoreRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/interviewscore/InterviewScoreRepository.java index 74183cf..583ad19 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/interviewscore/InterviewScoreRepository.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/interviewscore/InterviewScoreRepository.java @@ -2,9 +2,18 @@ package com.mydreamjob.interviewscore; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.Collection; +import java.util.List; import java.util.Optional; public interface InterviewScoreRepository extends JpaRepository { Optional findByInterviewId(Long interviewId); + + /** + * V34 聚合:一次读取当前学生、某批投递的已提交(SUBMITTED)结构化面试评分, + * 避免按投递循环查询;服务层再按 applicationId 取 submittedAt 最新一条。 + */ + List findByStudentUserIdAndApplicationIdInAndStatus( + Long studentUserId, Collection applicationIds, String status); } \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/notification/NotificationRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/notification/NotificationRepository.java index 5e421a6..35eaf93 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/notification/NotificationRepository.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/notification/NotificationRepository.java @@ -15,5 +15,9 @@ public interface NotificationRepository extends JpaRepository { List findActiveExpiringBetween(@Param("statuses") Collection statuses, @Param("from") LocalDateTime from, @Param("to") LocalDateTime to); + + /** + * V34 聚合:一次读取候选人本人全部非 DRAFT Offer(含 SENT/NEGOTIATING/终态), + * 按创建时间倒序;同时用于 todo 的有效 Offer 计数与每条投递的 Offer 展示,避免 N+1。 + */ + List findByCandidateIdAndStatusNotOrderByCreatedAtDesc(Long candidateId, String status); } diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/careerprogress/CareerProgressControllerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/careerprogress/CareerProgressControllerTest.java new file mode 100644 index 0000000..e9d2ff7 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/careerprogress/CareerProgressControllerTest.java @@ -0,0 +1,318 @@ +package com.mydreamjob.careerprogress; + +import com.mydreamjob.application.Application; +import com.mydreamjob.application.ApplicationRepository; +import com.mydreamjob.auth.JwtTokenProvider; +import com.mydreamjob.auth.User; +import com.mydreamjob.auth.UserRepository; +import com.mydreamjob.candidatescore.CandidateDimensionScore; +import com.mydreamjob.candidatescore.CandidateDimensionScoreRepository; +import com.mydreamjob.company.Company; +import com.mydreamjob.company.CompanyRepository; +import com.mydreamjob.interview.Interview; +import com.mydreamjob.interview.InterviewRepository; +import com.mydreamjob.interviewscore.InterviewScore; +import com.mydreamjob.interviewscore.InterviewScoreRepository; +import com.mydreamjob.job.Job; +import com.mydreamjob.job.JobRepository; +import com.mydreamjob.notification.Notification; +import com.mydreamjob.notification.NotificationRepository; +import com.mydreamjob.offer.Offer; +import com.mydreamjob.offer.OfferRepository; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.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.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.web.servlet.MockMvc; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicLong; + +import static org.mockito.ArgumentMatchers.anyString; +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; + +/** + * 统一求职进度聚合接口控制器集成测试(V34,真实 Service + H2)。 + * + *

覆盖权限矩阵(未登录 401、COMPANY/ADMIN 403/2002)、参数校验 400/1001、空投递、 + * 正常聚合、脱敏、通知仅计数、competition 摘要(无 dimensions/gaps/topCandidates), + * 以及 DRAFT 评分/Offer 不泄露。使用独立 H2 库避免共享 integrationdb 跨类外键污染。

+ */ +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("integration-test") +@TestPropertySource(properties = + "spring.datasource.url=jdbc:h2:mem:integrationdb_careerprogress;MODE=MySQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE") +class CareerProgressControllerTest { + + @Autowired private MockMvc mockMvc; + @Autowired private JwtTokenProvider jwtTokenProvider; + @Autowired private UserRepository userRepository; + @Autowired private CompanyRepository companyRepository; + @Autowired private JobRepository jobRepository; + @Autowired private ApplicationRepository applicationRepository; + @Autowired private InterviewRepository interviewRepository; + @Autowired private InterviewScoreRepository interviewScoreRepository; + @Autowired private OfferRepository offerRepository; + @Autowired private NotificationRepository notificationRepository; + @Autowired private CandidateDimensionScoreRepository dimensionScoreRepository; + + @MockBean private RedisTemplate redisTemplate; + + private static final AtomicLong PHONE = new AtomicLong(800); + + private Long me; + private Long rival; + private Long emptyStudent; + private Long companyUserId; + private Long adminId; + private Long jobId; + private Long appId; + private String period; + + @BeforeEach + @SuppressWarnings("unchecked") + void setUp() { + ValueOperations ops = mock(ValueOperations.class); + when(redisTemplate.opsForValue()).thenReturn(ops); + when(redisTemplate.hasKey(anyString())).thenReturn(false); + + period = String.format("%04d-%02d", LocalDate.now().getYear(), LocalDate.now().getMonthValue()); + + me = createUser(User.Role.STUDENT, "me"); + rival = createUser(User.Role.STUDENT, "rival"); + emptyStudent = createUser(User.Role.STUDENT, "empty"); + companyUserId = createUser(User.Role.COMPANY, "company"); + adminId = createUser(User.Role.ADMIN, "admin"); + + Long companyId = companyRepository.save( + Company.builder().ownerUserId(companyUserId).name("示例科技").build()).getId(); + Job job = jobRepository.save(Job.builder().companyId(companyId).title("Java 后端工程师") + .description("d").city("杭州").employmentType("FULL_TIME").status("PUBLISHED").build()); + jobId = job.getId(); + + Application app = applicationRepository.save(Application.builder() + .jobId(jobId).studentUserId(me).status("INTERVIEW") + .createdAt(LocalDateTime.now().minusDays(1)).updatedAt(LocalDateTime.now()).build()); + appId = app.getId(); + applicationRepository.save(Application.builder() + .jobId(jobId).studentUserId(rival).status("SUBMITTED") + .createdAt(LocalDateTime.now().minusDays(2)).updatedAt(LocalDateTime.now()).build()); + + // 竞争力快照:本人 77.50,rival 90.00 + dimensionScoreRepository.save(dim(me, "77.50")); + dimensionScoreRepository.save(dim(rival, "90.00")); + + // 面试:SCHEDULED(nextInterview 最早) + CONFIRMED 未来 + Interview sched = interviewRepository.save(interview("SCHEDULED", LocalDateTime.now().plusDays(1), 2, "技术二面")); + interviewRepository.save(interview("CONFIRMED", LocalDateTime.now().plusDays(2), 3, "技术三面")); + + // 已提交评分(仅摘要,不泄 comment) + interviewScoreRepository.save(InterviewScore.builder() + .interviewId(sched.getId()).applicationId(appId).companyUserId(companyUserId) + .studentUserId(me).status(InterviewScore.STATUS_SUBMITTED) + .technicalScore(4).communicationScore(3).problemSolvingScore(4) + .roleFitScore(4).professionalismScore(3) + .overallScore(new BigDecimal("3.60")).recommendation("HIRE") + .comment("含敏感评语,不应返回给学生").submittedAt(LocalDateTime.now()) + .version(0L).build()); + + // Offer:SENT 有效(不返回薪资/条款) + offerRepository.save(Offer.builder() + .applicationId(appId).jobId(jobId).companyId(companyId).candidateId(me) + .title("Java 后端工程师").description("d").expireAt(LocalDateTime.now().plusDays(9)) + .salaryMin(20000).salaryMax(30000).baseSalary(25000).housingAllowance(3000) + .annualBonusMonths(3).otherAllowances("补贴").benefits("五险一金") + .status("SENT").version(0L).createdBy(companyUserId).build()); + + // 通知:未读 2 条(其中 1 条 REPORT_DECISION) + notificationRepository.save(Notification.builder().userId(me).type("INTERVIEW_SCHEDULED") + .title("面试通知").content("你有一场面试").build()); + notificationRepository.save(Notification.builder().userId(me).type("REPORT_DECISION") + .title("举报处理结果").content("举报已处理").build()); + } + + @AfterEach + void tearDown() { + dimensionScoreRepository.deleteAll(); + interviewScoreRepository.deleteAll(); + offerRepository.deleteAll(); + interviewRepository.deleteAll(); + notificationRepository.deleteAll(); + applicationRepository.deleteAll(); + jobRepository.deleteAll(); + companyRepository.deleteAll(); + userRepository.deleteAll(); + } + + // ── 辅助 ──────────────────────────────────────────────── + + private Long createUser(User.Role role, String tag) { + User u = userRepository.save(User.builder() + .phone("177" + String.format("%08d", PHONE.incrementAndGet())) + .nickname(tag).role(role).roles(Set.of(role)).active(true).verified(true).build()); + return u.getId(); + } + + private Interview interview(String status, LocalDateTime startAt, int roundNo, String title) { + return Interview.builder().applicationId(appId).companyUserId(companyUserId).studentUserId(me) + .roundNo(roundNo).title(title).interviewType("ONLINE").startAt(startAt) + .endAt(startAt.plusHours(1)).status(status).build(); + } + + private CandidateDimensionScore dim(Long sid, String score) { + return CandidateDimensionScore.builder().studentUserId(sid).dimension("EDUCATION").period(period) + .score(new BigDecimal(score)).status("OK").weight(new BigDecimal("12.50")) + .rulesVersion("candidate-scoring-v0.1").calculatedAt(LocalDateTime.now()).build(); + } + + private String token(Long userId) { + return "Bearer " + jwtTokenProvider.generateAccessToken(userId, + String.valueOf(userId), Map.of("roles", "STUDENT")); + } + + // ── 权限矩阵 ────────────────────────────────────────────── + + @Test + void unauthenticated_returns401() throws Exception { + mockMvc.perform(get("/profile/me/career-progress")) + .andExpect(status().isUnauthorized()); + } + + @Test + void company_returns403_2002() throws Exception { + mockMvc.perform(get("/profile/me/career-progress") + .header("Authorization", token(companyUserId))) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(2002)); + } + + @Test + void admin_returns403_2002() throws Exception { + mockMvc.perform(get("/profile/me/career-progress") + .header("Authorization", token(adminId))) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(2002)); + } + + // ── 参数校验 ────────────────────────────────────────────── + + @Test + void negativePage_returns400_1001() throws Exception { + mockMvc.perform(get("/profile/me/career-progress").param("page", "-1") + .header("Authorization", token(me))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(1001)); + } + + @Test + void sizeOutOfRange_returns400_1001() throws Exception { + mockMvc.perform(get("/profile/me/career-progress").param("size", "51") + .header("Authorization", token(me))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(1001)); + mockMvc.perform(get("/profile/me/career-progress").param("size", "0") + .header("Authorization", token(me))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(1001)); + } + + @Test + void invalidStatus_returns400_1001() throws Exception { + mockMvc.perform(get("/profile/me/career-progress").param("status", "NONEXISTENT") + .header("Authorization", token(me))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(1001)); + } + + // ── 空投递 ────────────────────────────────────────────── + + @Test + void emptyApplications_returns200_empty_andZeroTodo() throws Exception { + mockMvc.perform(get("/profile/me/career-progress") + .header("Authorization", token(emptyStudent))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.applications.items").isArray()) + .andExpect(jsonPath("$.data.applications.items.length()").value(0)) + .andExpect(jsonPath("$.data.applications.total").value(0)) + .andExpect(jsonPath("$.data.todo.pendingInterviewResponseCount").value(0)) + .andExpect(jsonPath("$.data.todo.upcomingConfirmedInterviewCount").value(0)) + .andExpect(jsonPath("$.data.todo.pendingOfferResponseCount").value(0)) + .andExpect(jsonPath("$.data.todo.unreadNotificationCount").value(0)) + .andExpect(jsonPath("$.data.todo.unreadReportDecisionCount").value(0)); + } + + // ── 正常聚合 + 脱敏 + 通知仅计数 ──────────────────────────── + + @Test + void studentReturnsAggregatedResult_andDesensitized() throws Exception { + mockMvc.perform(get("/profile/me/career-progress") + .header("Authorization", token(me))) + .andExpect(status().isOk()) + // todo 计数 + .andExpect(jsonPath("$.data.todo.pendingInterviewResponseCount").value(1)) + .andExpect(jsonPath("$.data.todo.upcomingConfirmedInterviewCount").value(1)) + .andExpect(jsonPath("$.data.todo.pendingOfferResponseCount").value(1)) + .andExpect(jsonPath("$.data.todo.unreadNotificationCount").value(2)) + .andExpect(jsonPath("$.data.todo.unreadReportDecisionCount").value(1)) + // 投递分页 + .andExpect(jsonPath("$.data.applications.total").value(1)) + .andExpect(jsonPath("$.data.applications.items.length()").value(1)) + .andExpect(jsonPath("$.data.applications.items[0].applicationStatus").value("INTERVIEW")) + // job 摘要 + .andExpect(jsonPath("$.data.applications.items[0].job.title").value("Java 后端工程师")) + .andExpect(jsonPath("$.data.applications.items[0].job.companyName").value("示例科技")) + .andExpect(jsonPath("$.data.applications.items[0].job.city").value("杭州")) + .andExpect(jsonPath("$.data.applications.items[0].job.employmentType").value("FULL_TIME")) + .andExpect(jsonPath("$.data.applications.items[0].job.jobStatus").value("PUBLISHED")) + // nextInterview:最早 SCHEDULED + .andExpect(jsonPath("$.data.applications.items[0].nextInterview.roundNo").value(2)) + .andExpect(jsonPath("$.data.applications.items[0].nextInterview.status").value("SCHEDULED")) + // 已提交评分摘要(无 comment) + .andExpect(jsonPath("$.data.applications.items[0].latestSubmittedInterviewScore.overallScore").value(3.60)) + .andExpect(jsonPath("$.data.applications.items[0].latestSubmittedInterviewScore.recommendation").value("HIRE")) + .andExpect(jsonPath("$.data.applications.items[0].latestSubmittedInterviewScore.comment").doesNotExist()) + // offer 摘要(无薪资/条款) + .andExpect(jsonPath("$.data.applications.items[0].offer.status").value("SENT")) + .andExpect(jsonPath("$.data.applications.items[0].offer.salaryMin").doesNotExist()) + .andExpect(jsonPath("$.data.applications.items[0].offer.salaryMax").doesNotExist()) + .andExpect(jsonPath("$.data.applications.items[0].offer.baseSalary").doesNotExist()) + .andExpect(jsonPath("$.data.applications.items[0].offer.benefits").doesNotExist()) + // competition 摘要(无 dimensions/gaps/topCandidates,脱敏) + .andExpect(jsonPath("$.data.applications.items[0].competition.eligible").value(true)) + .andExpect(jsonPath("$.data.applications.items[0].competition.cohortSize").value(2)) + .andExpect(jsonPath("$.data.applications.items[0].competition.rank").value(2)) + .andExpect(jsonPath("$.data.applications.items[0].competition.overallScore").value(77.50)) + .andExpect(jsonPath("$.data.applications.items[0].competition.percentile").value(0.00)) + .andExpect(jsonPath("$.data.applications.items[0].competition.dimensions").doesNotExist()) + .andExpect(jsonPath("$.data.applications.items[0].competition.gaps").doesNotExist()) + .andExpect(jsonPath("$.data.applications.items[0].competition.topCandidates").doesNotExist()) + // 不泄露身份/联系/简历等字段 + .andExpect(jsonPath("$.data.applications.items[0].studentUserId").doesNotExist()) + .andExpect(jsonPath("$.data.applications.items[0].candidateId").doesNotExist()) + .andExpect(jsonPath("$.data.applications.items[0].companyUserId").doesNotExist()) + .andExpect(jsonPath("$.data.applications.items[0].evaluatorUserId").doesNotExist()) + .andExpect(jsonPath("$.data.applications.items[0].phone").doesNotExist()) + .andExpect(jsonPath("$.data.applications.items[0].email").doesNotExist()) + .andExpect(jsonPath("$.data.applications.items[0].resumeUrl").doesNotExist()) + .andExpect(jsonPath("$.data.applications.items[0].passwordHash").doesNotExist()) + // 通知只返回计数,无 title/content 详情 + .andExpect(jsonPath("$.data.notifications").doesNotExist()) + .andExpect(jsonPath("$.data.notificationTitle").doesNotExist()); + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/careerprogress/CareerProgressPersistenceIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/careerprogress/CareerProgressPersistenceIntegrationTest.java new file mode 100644 index 0000000..7528bd8 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/careerprogress/CareerProgressPersistenceIntegrationTest.java @@ -0,0 +1,230 @@ +package com.mydreamjob.careerprogress; + +import com.mydreamjob.application.Application; +import com.mydreamjob.application.ApplicationRepository; +import com.mydreamjob.auth.User; +import com.mydreamjob.auth.UserRepository; +import com.mydreamjob.candidatescore.CandidateDimensionScore; +import com.mydreamjob.candidatescore.CandidateDimensionScoreRepository; +import com.mydreamjob.careerprogress.dto.CareerProgressResponse; +import com.mydreamjob.company.Company; +import com.mydreamjob.company.CompanyRepository; +import com.mydreamjob.interview.Interview; +import com.mydreamjob.interview.InterviewRepository; +import com.mydreamjob.interviewscore.InterviewScore; +import com.mydreamjob.interviewscore.InterviewScoreRepository; +import com.mydreamjob.job.Job; +import com.mydreamjob.job.JobRepository; +import com.mydreamjob.notification.Notification; +import com.mydreamjob.notification.NotificationRepository; +import com.mydreamjob.offer.Offer; +import com.mydreamjob.offer.OfferRepository; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.Set; +import java.util.concurrent.atomic.AtomicLong; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * 统一求职进度聚合持久化/只读集成测试(V34,独立 H2 + Flyway 真实表结构)。 + * + *

证明:真实批量查询在真实 Repository 上正确返回聚合结果;CareerProgressService + * 标注 @Transactional(readOnly=true) 且不写业务表 —— 调用前后关键业务表计数不变; + * 不新增 Flyway、不删除业务数据。

+ */ +@SpringBootTest +@TestPropertySource(properties = + "spring.datasource.url=jdbc:h2:mem:integrationdb_careerprogress_persist;MODE=MySQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE") +@ActiveProfiles("integration-test") +class CareerProgressPersistenceIntegrationTest { + + @Autowired private CareerProgressService service; + @Autowired private UserRepository userRepository; + @Autowired private CompanyRepository companyRepository; + @Autowired private JobRepository jobRepository; + @Autowired private ApplicationRepository applicationRepository; + @Autowired private InterviewRepository interviewRepository; + @Autowired private InterviewScoreRepository interviewScoreRepository; + @Autowired private OfferRepository offerRepository; + @Autowired private NotificationRepository notificationRepository; + @Autowired private CandidateDimensionScoreRepository dimensionScoreRepository; + + @MockBean private RedisTemplate redisTemplate; + + private static final AtomicLong PHONE_SEQ = new AtomicLong(600); + private String period; + + private Long studentUserId; + private Long companyUserId; + private Long companyId; + private Long jobId; + private Long appId; + + @BeforeEach + @SuppressWarnings("unchecked") + void setUp() { + ValueOperations ops = mock(ValueOperations.class); + when(redisTemplate.opsForValue()).thenReturn(ops); + when(redisTemplate.hasKey(anyString())).thenReturn(false); + + period = String.format("%04d-%02d", LocalDate.now().getYear(), LocalDate.now().getMonthValue()); + + studentUserId = createUser(User.Role.STUDENT); + companyUserId = createUser(User.Role.COMPANY); + Long rivalId = createUser(User.Role.STUDENT); + + companyId = companyRepository.save( + Company.builder().ownerUserId(companyUserId).name("示例科技").build()).getId(); + jobId = jobRepository.save(Job.builder().companyId(companyId).title("Java 后端工程师") + .description("d").city("杭州").employmentType("FULL_TIME").status("PUBLISHED").build()).getId(); + + Application app = applicationRepository.save(Application.builder() + .jobId(jobId).studentUserId(studentUserId).status("INTERVIEW") + .createdAt(LocalDateTime.now().minusDays(1)).updatedAt(LocalDateTime.now()).build()); + appId = app.getId(); + applicationRepository.save(Application.builder() + .jobId(jobId).studentUserId(rivalId).status("SUBMITTED") + .createdAt(LocalDateTime.now().minusDays(2)).updatedAt(LocalDateTime.now()).build()); + + // 竞争力快照 + dimensionScoreRepository.save(dim(studentUserId, "77.50")); + dimensionScoreRepository.save(dim(rivalId, "90.00")); + + // 面试:SCHEDULED(待响应)+ CONFIRMED(未来) + Interview sched = interviewRepository.save(interview("SCHEDULED", LocalDateTime.now().plusDays(1), 2)); + interviewRepository.save(interview("CONFIRMED", LocalDateTime.now().plusDays(2), 3)); + + // 已提交评分(摘要口径,不返回 comment) + interviewScoreRepository.save(InterviewScore.builder() + .interviewId(sched.getId()).applicationId(appId).companyUserId(companyUserId) + .studentUserId(studentUserId).status(InterviewScore.STATUS_SUBMITTED) + .technicalScore(4).communicationScore(3).problemSolvingScore(4) + .roleFitScore(4).professionalismScore(3) + .overallScore(new BigDecimal("3.60")).recommendation("HIRE") + .comment("不应返回给学生").submittedAt(LocalDateTime.now()).version(0L).build()); + + // 有效 Offer + offerRepository.save(Offer.builder() + .applicationId(appId).jobId(jobId).companyId(companyId).candidateId(studentUserId) + .title("Java 后端工程师").description("d").expireAt(LocalDateTime.now().plusDays(9)) + .salaryMin(20000).salaryMax(30000).baseSalary(25000) + .status("SENT").version(0L).createdBy(companyUserId).build()); + + // 通知:未读 2 条(其中 1 条 REPORT_DECISION) + notificationRepository.save(Notification.builder().userId(studentUserId).type("INTERVIEW_SCHEDULED") + .title("面试通知").content("你有一场面试").build()); + notificationRepository.save(Notification.builder().userId(studentUserId).type("REPORT_DECISION") + .title("举报处理结果").content("举报已处理").build()); + } + + @AfterEach + void tearDown() { + dimensionScoreRepository.deleteAll(); + interviewScoreRepository.deleteAll(); + offerRepository.deleteAll(); + interviewRepository.deleteAll(); + notificationRepository.deleteAll(); + applicationRepository.deleteAll(); + jobRepository.deleteAll(); + companyRepository.deleteAll(); + userRepository.deleteAll(); + } + + @Test + void aggregation_returnsCorrectSummaries_andIsReadOnly() { + long appsBefore = applicationRepository.count(); + long interviewsBefore = interviewRepository.count(); + long scoresBefore = interviewScoreRepository.count(); + long offersBefore = offerRepository.count(); + long notificationsBefore = notificationRepository.count(); + long dimsBefore = dimensionScoreRepository.count(); + + CareerProgressResponse resp = service.getCareerProgress(studentUserId, 0, 20, null); + + // todo 计数(真实批量查询) + assertEquals(1L, resp.getTodo().getPendingInterviewResponseCount()); + assertEquals(1L, resp.getTodo().getUpcomingConfirmedInterviewCount()); + assertEquals(1L, resp.getTodo().getPendingOfferResponseCount()); + assertEquals(2L, resp.getTodo().getUnreadNotificationCount()); + assertEquals(1L, resp.getTodo().getUnreadReportDecisionCount()); + + // 投递分页(本人仅 1 条,不包含 rival 投递) + assertEquals(1L, resp.getApplications().getTotal()); + assertEquals(1, resp.getApplications().getItems().size()); + CareerProgressResponse.CompetitionSummary comp = + resp.getApplications().getItems().get(0).getCompetition(); + assertTrue(comp.isEligible()); + assertEquals(2, comp.getCohortSize()); + assertEquals(2, comp.getRank().intValue()); + assertEquals(new BigDecimal("77.50"), comp.getOverallScore()); + assertEquals(new BigDecimal("0.00"), comp.getPercentile()); + assertEquals(period, comp.getMySnapshotPeriod()); + assertNotNull(resp.getGeneratedAt()); + + // 只读:调用前后关键业务表计数完全不变(不写、不删、不新增) + assertEquals(appsBefore, applicationRepository.count()); + assertEquals(interviewsBefore, interviewRepository.count()); + assertEquals(scoresBefore, interviewScoreRepository.count()); + assertEquals(offersBefore, offerRepository.count()); + assertEquals(notificationsBefore, notificationRepository.count()); + assertEquals(dimsBefore, dimensionScoreRepository.count()); + } + + @Test + void withdrawnApplication_keptInHistory_competitionNotEligible() { + // 独立岗位避免 (job_id, student_user_id) 唯一约束冲突 + Long withdrawnJobId = jobRepository.save(Job.builder().companyId(companyId).title("已撤回岗位") + .description("d").city("上海").employmentType("FULL_TIME").status("PUBLISHED").build()).getId(); + applicationRepository.save(Application.builder() + .jobId(withdrawnJobId).studentUserId(studentUserId).status("WITHDRAWN") + .createdAt(LocalDateTime.now().minusDays(3)).updatedAt(LocalDateTime.now()).build()); + + CareerProgressResponse resp = service.getCareerProgress(studentUserId, 0, 20, null); + // 现在本人有两条投递(INTERVIEW + WITHDRAWN) + assertEquals(2L, resp.getApplications().getTotal()); + CareerProgressResponse.CareerProgressItem withdrawn = resp.getApplications().getItems().stream() + .filter(i -> "WITHDRAWN".equals(i.getApplicationStatus())).findFirst().orElseThrow(); + assertEquals("WITHDRAWN", withdrawn.getApplicationStatus()); + assertEquals(false, withdrawn.getCompetition().isEligible()); + assertEquals(null, withdrawn.getCompetition().getRank()); + assertEquals(null, withdrawn.getCompetition().getCohortSize()); + assertEquals(null, withdrawn.getCompetition().getOverallScore()); + assertEquals(null, withdrawn.getCompetition().getPercentile()); + } + + private Long createUser(User.Role role) { + return userRepository.save(User.builder() + .phone("168" + String.format("%08d", PHONE_SEQ.incrementAndGet())) + .nickname("u").role(role).roles(Set.of(role)).active(true).verified(true).build()).getId(); + } + + private Interview interview(String status, LocalDateTime startAt, int roundNo) { + return Interview.builder().applicationId(appId).companyUserId(companyUserId).studentUserId(studentUserId) + .roundNo(roundNo).title("技术面试").interviewType("ONLINE").startAt(startAt) + .endAt(startAt.plusHours(1)).status(status).build(); + } + + private CandidateDimensionScore dim(Long sid, String score) { + return CandidateDimensionScore.builder().studentUserId(sid).dimension("EDUCATION").period(period) + .score(new BigDecimal(score)).status("OK").weight(new BigDecimal("12.50")) + .rulesVersion("candidate-scoring-v0.1").calculatedAt(LocalDateTime.now()).build(); + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/careerprogress/CareerProgressServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/careerprogress/CareerProgressServiceTest.java new file mode 100644 index 0000000..5443ba8 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/careerprogress/CareerProgressServiceTest.java @@ -0,0 +1,442 @@ +package com.mydreamjob.careerprogress; + +import com.mydreamjob.application.Application; +import com.mydreamjob.application.ApplicationRepository; +import com.mydreamjob.auth.User; +import com.mydreamjob.auth.UserRepository; +import com.mydreamjob.candidatescore.CandidateDimensionScore; +import com.mydreamjob.candidatescore.CandidateDimensionScoreRepository; +import com.mydreamjob.candidatescore.CandidateScoringCalculator; +import com.mydreamjob.careerprogress.dto.CareerProgressResponse; +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.company.Company; +import com.mydreamjob.company.CompanyRepository; +import com.mydreamjob.competition.CompetitionRankingCalculator; +import com.mydreamjob.interview.Interview; +import com.mydreamjob.interview.InterviewRepository; +import com.mydreamjob.interviewscore.InterviewScore; +import com.mydreamjob.interviewscore.InterviewScoreRepository; +import com.mydreamjob.job.Job; +import com.mydreamjob.job.JobRepository; +import com.mydreamjob.notification.NotificationRepository; +import com.mydreamjob.offer.Offer; +import com.mydreamjob.offer.OfferRepository; +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 org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * 统一求职进度聚合服务单测(V34,编排 + 脱敏 + 聚合口径 + 无 N+1)。 + * + *

排名/排序/百分位由真实 {@link CompetitionRankingCalculator}(配合 mock 的 + * 快照仓库与综合分计算器)承载,与 V32 共用同一组件,保证口径一致。本类聚焦断言 + * 权限、参数、投递分页、todo 计数、nextInterview、评分摘要、Offer 优先级、 + * competition 摘要、通知仅计数与批量查询(无 N+1)。

+ */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class CareerProgressServiceTest { + + private static final Long ME = 100L; + private static final Long JOB = 9L; + private static final Long COMPANY = 1L; + private static final String PERIOD = yearMonth(LocalDate.now()); + + @Mock private ApplicationRepository applicationRepository; + @Mock private InterviewRepository interviewRepository; + @Mock private InterviewScoreRepository interviewScoreRepository; + @Mock private OfferRepository offerRepository; + @Mock private NotificationRepository notificationRepository; + @Mock private JobRepository jobRepository; + @Mock private CompanyRepository companyRepository; + @Mock private UserRepository userRepository; + @Mock private CandidateDimensionScoreRepository dimensionScoreRepository; + @Mock private CandidateScoringCalculator scoringCalculator; + + private CompetitionRankingCalculator calculator; + private CareerProgressService service; + + private Map overallByStudent = Map.of(); + + @BeforeEach + void setUp() { + calculator = spy(new CompetitionRankingCalculator(dimensionScoreRepository, scoringCalculator)); + service = new CareerProgressService(applicationRepository, interviewRepository, + interviewScoreRepository, offerRepository, notificationRepository, + jobRepository, companyRepository, userRepository, calculator); + + when(scoringCalculator.overallScoreFromSnapshots(anyList())).thenAnswer(inv -> { + List snaps = inv.getArgument(0); + if (snaps == null || snaps.isEmpty()) return null; + Long sid = ((CandidateDimensionScore) snaps.get(0)).getStudentUserId(); + return overallByStudent.get(sid); + }); + } + + // ── 权限与参数 ──────────────────────────────────────────── + + @Test + void companyRole_throwsForbidden() { + mockUser(ME, User.Role.COMPANY); + BusinessException ex = assertThrows(BusinessException.class, + () -> service.getCareerProgress(ME, 0, 20, null)); + assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCode()); + } + + @Test + void adminRole_throwsForbidden() { + mockUser(ME, User.Role.ADMIN); + BusinessException ex = assertThrows(BusinessException.class, + () -> service.getCareerProgress(ME, 0, 20, null)); + assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCode()); + } + + @Test + void negativePage_throwsBadRequest() { + mockUser(ME, User.Role.STUDENT); + BusinessException ex = assertThrows(BusinessException.class, + () -> service.getCareerProgress(ME, -1, 20, null)); + assertEquals(ErrorCode.BAD_REQUEST.getCode(), ex.getErrorCode()); + } + + @Test + void sizeOutOfRange_throwsBadRequest() { + mockUser(ME, User.Role.STUDENT); + assertThrows(BusinessException.class, + () -> service.getCareerProgress(ME, 0, 0, null)); + assertThrows(BusinessException.class, + () -> service.getCareerProgress(ME, 0, 51, null)); + } + + @Test + void invalidStatus_throwsBadRequest() { + mockUser(ME, User.Role.STUDENT); + BusinessException ex = assertThrows(BusinessException.class, + () -> service.getCareerProgress(ME, 0, 20, "NONEXISTENT")); + assertEquals(ErrorCode.BAD_REQUEST.getCode(), ex.getErrorCode()); + } + + // ── 空数据 ────────────────────────────────────────────── + + @Test + void noApplications_returnsEmptyPage_andZeroTodo() { + mockUser(ME, User.Role.STUDENT); + when(applicationRepository.findByStudentUserIdOrderByCreatedAtDesc(eq(ME), any(PageRequest.class))) + .thenReturn(new PageImpl<>(List.of(), PageRequest.of(0, 20), 0)); + when(interviewRepository.findByStudentUserIdAndStatusInOrderByStartAtAsc(anyLong(), anyCollection())) + .thenReturn(List.of()); + when(offerRepository.findByCandidateIdAndStatusNotOrderByCreatedAtDesc(anyLong(), anyString())) + .thenReturn(List.of()); + when(notificationRepository.countUnreadByUserId(ME)).thenReturn(0L); + when(notificationRepository.countUnreadByUserIdAndType(ME, "REPORT_DECISION")).thenReturn(0L); + + CareerProgressResponse resp = service.getCareerProgress(ME, 0, 20, null); + + assertEquals(0L, resp.getApplications().getItems().size()); + assertEquals(0L, resp.getApplications().getTotal()); + assertEquals(0L, resp.getTodo().getPendingInterviewResponseCount()); + assertEquals(0L, resp.getTodo().getUpcomingConfirmedInterviewCount()); + assertEquals(0L, resp.getTodo().getPendingOfferResponseCount()); + assertEquals(0L, resp.getTodo().getUnreadNotificationCount()); + assertEquals(0L, resp.getTodo().getUnreadReportDecisionCount()); + } + + // ── 聚合口径(完整 happy path)──────────────────────────── + + @Test + void fullAggregation_returnsCorrectSummaries() { + mockUser(ME, User.Role.STUDENT); + Application app = app(1L, ME, JOB, "SUBMITTED", "2026-08-20T10:00:00"); + when(applicationRepository.findByStudentUserIdOrderByCreatedAtDesc(eq(ME), any(PageRequest.class))) + .thenReturn(new PageImpl<>(List.of(app), PageRequest.of(0, 20), 1)); + when(jobRepository.findAllById(anyCollection())) + .thenReturn(List.of(Job.builder().id(JOB).companyId(COMPANY).title("Java 后端工程师") + .description("d").city("杭州").employmentType("FULL_TIME").status("PUBLISHED").build())); + when(companyRepository.findAllById(anyCollection())) + .thenReturn(List.of(Company.builder().id(COMPANY).ownerUserId(999L).name("示例科技").build())); + + // 面试:两场 SCHEDULED(取最早为 nextInterview)+ 一场 CONFIRMED(未来) + Interview later = interview(2L, app.getId(), "SCHEDULED", "2026-08-30T14:00:00"); + Interview earlier = interview(1L, app.getId(), "SCHEDULED", "2026-08-28T10:00:00"); + Interview confirmed = interview(3L, app.getId(), "CONFIRMED", LocalDateTime.now().plusDays(3).toString()); + when(interviewRepository.findByStudentUserIdAndStatusInOrderByStartAtAsc(anyLong(), anyCollection())) + .thenReturn(List.of(earlier, later, confirmed)); + + // 已提交评分:两条,取 submittedAt 最新 + InterviewScore s1 = score(22L, app.getId(), "2026-08-25T16:00:00", new BigDecimal("3.60")); + InterviewScore s2 = score(23L, app.getId(), "2026-08-24T16:00:00", new BigDecimal("3.00")); + when(interviewScoreRepository.findByStudentUserIdAndApplicationIdInAndStatus( + anyLong(), anyCollection(), anyString())).thenReturn(List.of(s1, s2)); + + // Offer:一条 SENT 有效 + 一条 DRAFT(不应返回 DRAFT) + Offer sent = offer(14L, app.getId(), "SENT", "2026-09-05T23:59:59"); + when(offerRepository.findByCandidateIdAndStatusNotOrderByCreatedAtDesc(anyLong(), anyString())) + .thenReturn(List.of(sent)); + + // 竞争力:同岗位 cohort 含本人 + 另一有分者 + Application other = app(2L, 200L, JOB, "SUBMITTED", "2026-08-19T10:00:00"); + when(applicationRepository.findByJobIdInAndStatusNotInOrderByCreatedAtAsc(anyCollection(), anyCollection())) + .thenReturn(List.of(other, app)); + overallByStudent = Map.of(ME, new BigDecimal("77.50"), 200L, new BigDecimal("90.00")); + when(dimensionScoreRepository.findUpToPeriod(anyCollection(), anyString())) + .thenReturn(List.of(dim(ME, "77.50"), dim(200L, "90.00"))); + + when(notificationRepository.countUnreadByUserId(ME)).thenReturn(5L); + when(notificationRepository.countUnreadByUserIdAndType(ME, "REPORT_DECISION")).thenReturn(2L); + + CareerProgressResponse resp = service.getCareerProgress(ME, 0, 20, null); + + // todo + assertEquals(2L, resp.getTodo().getPendingInterviewResponseCount()); + assertEquals(1L, resp.getTodo().getUpcomingConfirmedInterviewCount()); + assertEquals(1L, resp.getTodo().getPendingOfferResponseCount()); + assertEquals(5L, resp.getTodo().getUnreadNotificationCount()); + assertEquals(2L, resp.getTodo().getUnreadReportDecisionCount()); + + // item + assertEquals(1L, resp.getApplications().getItems().size()); + CareerProgressResponse.CareerProgressItem item = resp.getApplications().getItems().get(0); + assertEquals(1L, item.getApplicationId()); + assertEquals("SUBMITTED", item.getApplicationStatus()); + assertEquals("Java 后端工程师", item.getJob().getTitle()); + assertEquals("示例科技", item.getJob().getCompanyName()); + assertEquals("杭州", item.getJob().getCity()); + assertEquals("FULL_TIME", item.getJob().getEmploymentType()); + assertEquals("PUBLISHED", item.getJob().getJobStatus()); + + // nextInterview:取 startAt 最早 1L + assertEquals(1L, item.getNextInterview().getInterviewId()); + // latest score:22(submittedAt 最新) + assertEquals(22L, item.getLatestSubmittedInterviewScore().getInterviewId()); + assertEquals(new BigDecimal("3.60"), item.getLatestSubmittedInterviewScore().getOverallScore()); + assertEquals("HIRE", item.getLatestSubmittedInterviewScore().getRecommendation()); + // offer + assertEquals(14L, item.getOffer().getOfferId()); + assertEquals("SENT", item.getOffer().getStatus()); + + // competition:cohort=2(other 90.00、me 77.50),me 排名 2; + // percentile = 严格低于 me 的人数(0) / (有分人数-1=1) * 100 = 0.00 + assertTrue(item.getCompetition().isEligible()); + assertEquals(2, item.getCompetition().getCohortSize()); + assertEquals(2, item.getCompetition().getRank()); + assertEquals(new BigDecimal("77.50"), item.getCompetition().getOverallScore()); + assertEquals(new BigDecimal("0.00"), item.getCompetition().getPercentile()); + assertEquals(PERIOD, item.getCompetition().getMySnapshotPeriod()); + } + + // ── DRAFT 不泄露 ──────────────────────────────────────── + + @Test + void draftScore_notReturned_andDraftOffer_notReturned() { + mockUser(ME, User.Role.STUDENT); + Application app = app(1L, ME, JOB, "SUBMITTED", "2026-08-20T10:00:00"); + when(applicationRepository.findByStudentUserIdOrderByCreatedAtDesc(eq(ME), any(PageRequest.class))) + .thenReturn(new PageImpl<>(List.of(app), PageRequest.of(0, 20), 1)); + when(jobRepository.findAllById(anyCollection())) + .thenReturn(List.of(Job.builder().id(JOB).companyId(COMPANY).title("t").description("d") + .city("c").employmentType("FULL_TIME").status("PUBLISHED").build())); + when(companyRepository.findAllById(anyCollection())).thenReturn(List.of()); + when(interviewRepository.findByStudentUserIdAndStatusInOrderByStartAtAsc(anyLong(), anyCollection())) + .thenReturn(List.of()); + // 只有 DRAFT 评分 → 不应出现在 latestSubmittedInterviewScore + InterviewScore draft = score(1L, app.getId(), null, null); + draft.setStatus(InterviewScore.STATUS_DRAFT); + when(interviewScoreRepository.findByStudentUserIdAndApplicationIdInAndStatus( + anyLong(), anyCollection(), anyString())).thenReturn(List.of()); + // 只有 DRAFT offer → offer 为 null + when(offerRepository.findByCandidateIdAndStatusNotOrderByCreatedAtDesc(anyLong(), anyString())) + .thenReturn(List.of()); + when(applicationRepository.findByJobIdInAndStatusNotInOrderByCreatedAtAsc(anyCollection(), anyCollection())) + .thenReturn(List.of(app)); + when(dimensionScoreRepository.findUpToPeriod(anyCollection(), anyString())).thenReturn(List.of()); + when(notificationRepository.countUnreadByUserId(ME)).thenReturn(0L); + when(notificationRepository.countUnreadByUserIdAndType(ME, "REPORT_DECISION")).thenReturn(0L); + + CareerProgressResponse resp = service.getCareerProgress(ME, 0, 20, null); + CareerProgressResponse.CareerProgressItem item = resp.getApplications().getItems().get(0); + assertNull(item.getLatestSubmittedInterviewScore()); + assertNull(item.getOffer()); + } + + // ── WITHDRAWN/REJECTED 保留历史但 eligible=false ───────── + + @Test + void withdrawnApplication_eligibleFalse_andNoCompetitionFields() { + mockUser(ME, User.Role.STUDENT); + Application withdrawn = app(1L, ME, JOB, "WITHDRAWN", "2026-08-20T10:00:00"); + when(applicationRepository.findByStudentUserIdOrderByCreatedAtDesc(eq(ME), any(PageRequest.class))) + .thenReturn(new PageImpl<>(List.of(withdrawn), PageRequest.of(0, 20), 1)); + when(jobRepository.findAllById(anyCollection())) + .thenReturn(List.of(Job.builder().id(JOB).companyId(COMPANY).title("t").description("d") + .city("c").employmentType("FULL_TIME").status("PUBLISHED").build())); + when(companyRepository.findAllById(anyCollection())).thenReturn(List.of()); + when(interviewRepository.findByStudentUserIdAndStatusInOrderByStartAtAsc(anyLong(), anyCollection())) + .thenReturn(List.of()); + when(interviewScoreRepository.findByStudentUserIdAndApplicationIdInAndStatus( + anyLong(), anyCollection(), anyString())).thenReturn(List.of()); + when(offerRepository.findByCandidateIdAndStatusNotOrderByCreatedAtDesc(anyLong(), anyString())) + .thenReturn(List.of()); + when(notificationRepository.countUnreadByUserId(ME)).thenReturn(0L); + when(notificationRepository.countUnreadByUserIdAndType(ME, "REPORT_DECISION")).thenReturn(0L); + + CareerProgressResponse resp = service.getCareerProgress(ME, 0, 20, null); + CareerProgressResponse.CareerProgressItem item = resp.getApplications().getItems().get(0); + // 保留历史 + assertEquals(1L, item.getApplicationId()); + assertEquals("WITHDRAWN", item.getApplicationStatus()); + // competition 只返回 eligible=false,不返回其它排名字段 + assertFalse(item.getCompetition().isEligible()); + assertNull(item.getCompetition().getRank()); + assertNull(item.getCompetition().getCohortSize()); + assertNull(item.getCompetition().getOverallScore()); + assertNull(item.getCompetition().getPercentile()); + assertNull(item.getCompetition().getStatus()); + assertNull(item.getCompetition().getMySnapshotPeriod()); + assertNull(item.getCompetition().getRulesVersion()); + } + + // ── 有有效投递但无快照 → competition 分数/排名/百分位为 null ─ + + @Test + void validApplication_noSnapshot_competitionScoresNull_cohortSizeReturned() { + mockUser(ME, User.Role.STUDENT); + Application app = app(1L, ME, JOB, "SUBMITTED", "2026-08-20T10:00:00"); + when(applicationRepository.findByStudentUserIdOrderByCreatedAtDesc(eq(ME), any(PageRequest.class))) + .thenReturn(new PageImpl<>(List.of(app), PageRequest.of(0, 20), 1)); + when(jobRepository.findAllById(anyCollection())) + .thenReturn(List.of(Job.builder().id(JOB).companyId(COMPANY).title("t").description("d") + .city("c").employmentType("FULL_TIME").status("PUBLISHED").build())); + when(companyRepository.findAllById(anyCollection())).thenReturn(List.of()); + when(interviewRepository.findByStudentUserIdAndStatusInOrderByStartAtAsc(anyLong(), anyCollection())) + .thenReturn(List.of()); + when(interviewScoreRepository.findByStudentUserIdAndApplicationIdInAndStatus( + anyLong(), anyCollection(), anyString())).thenReturn(List.of()); + when(offerRepository.findByCandidateIdAndStatusNotOrderByCreatedAtDesc(anyLong(), anyString())) + .thenReturn(List.of()); + when(applicationRepository.findByJobIdInAndStatusNotInOrderByCreatedAtAsc(anyCollection(), anyCollection())) + .thenReturn(List.of(app)); + when(dimensionScoreRepository.findUpToPeriod(anyCollection(), anyString())).thenReturn(List.of()); + when(notificationRepository.countUnreadByUserId(ME)).thenReturn(0L); + when(notificationRepository.countUnreadByUserIdAndType(ME, "REPORT_DECISION")).thenReturn(0L); + + CareerProgressResponse resp = service.getCareerProgress(ME, 0, 20, null); + CareerProgressResponse.CareerProgressItem item = resp.getApplications().getItems().get(0); + assertTrue(item.getCompetition().isEligible()); + assertNull(item.getCompetition().getRank()); + assertNull(item.getCompetition().getOverallScore()); + assertNull(item.getCompetition().getPercentile()); + assertNull(item.getCompetition().getMySnapshotPeriod()); + assertEquals(1, item.getCompetition().getCohortSize()); + } + + // ── 无 N+1:所有批量查询各执行一次 ──────────────────────── + + @Test + void multipleApplications_noNPlusOne_batchQueriesSingleCallEach() { + mockUser(ME, User.Role.STUDENT); + Application a1 = app(1L, ME, 9L, "SUBMITTED", "2026-08-20T10:00:00"); + Application a2 = app(2L, ME, 10L, "INTERVIEW", "2026-08-19T10:00:00"); + Application a3 = app(3L, ME, 11L, "VIEWED", "2026-08-18T10:00:00"); + when(applicationRepository.findByStudentUserIdOrderByCreatedAtDesc(eq(ME), any(PageRequest.class))) + .thenReturn(new PageImpl<>(List.of(a1, a2, a3), PageRequest.of(0, 20), 3)); + when(jobRepository.findAllById(anyCollection())).thenReturn(List.of( + Job.builder().id(9L).companyId(COMPANY).title("t1").description("d").city("c") + .employmentType("FULL_TIME").status("PUBLISHED").build(), + Job.builder().id(10L).companyId(COMPANY).title("t2").description("d").city("c") + .employmentType("FULL_TIME").status("PUBLISHED").build(), + Job.builder().id(11L).companyId(COMPANY).title("t3").description("d").city("c") + .employmentType("FULL_TIME").status("PUBLISHED").build())); + when(companyRepository.findAllById(anyCollection())) + .thenReturn(List.of(Company.builder().id(COMPANY).ownerUserId(9L).name("c").build())); + when(interviewRepository.findByStudentUserIdAndStatusInOrderByStartAtAsc(anyLong(), anyCollection())) + .thenReturn(List.of()); + when(interviewScoreRepository.findByStudentUserIdAndApplicationIdInAndStatus( + anyLong(), anyCollection(), anyString())).thenReturn(List.of()); + when(offerRepository.findByCandidateIdAndStatusNotOrderByCreatedAtDesc(anyLong(), anyString())) + .thenReturn(List.of()); + when(applicationRepository.findByJobIdInAndStatusNotInOrderByCreatedAtAsc(anyCollection(), anyCollection())) + .thenReturn(List.of(a1, a2, a3)); + when(dimensionScoreRepository.findUpToPeriod(anyCollection(), anyString())).thenReturn(List.of()); + when(notificationRepository.countUnreadByUserId(ME)).thenReturn(0L); + when(notificationRepository.countUnreadByUserIdAndType(ME, "REPORT_DECISION")).thenReturn(0L); + + service.getCareerProgress(ME, 0, 20, null); + + verify(jobRepository, times(1)).findAllById(anyCollection()); + verify(companyRepository, times(1)).findAllById(anyCollection()); + verify(interviewRepository, times(1)).findByStudentUserIdAndStatusInOrderByStartAtAsc(anyLong(), anyCollection()); + verify(interviewScoreRepository, times(1)).findByStudentUserIdAndApplicationIdInAndStatus(anyLong(), anyCollection(), anyString()); + verify(offerRepository, times(1)).findByCandidateIdAndStatusNotOrderByCreatedAtDesc(anyLong(), anyString()); + verify(applicationRepository, times(1)).findByJobIdInAndStatusNotInOrderByCreatedAtAsc(anyCollection(), anyCollection()); + verify(dimensionScoreRepository, times(1)).findUpToPeriod(anyCollection(), anyString()); + verify(notificationRepository, times(1)).countUnreadByUserId(ME); + verify(notificationRepository, times(1)).countUnreadByUserIdAndType(ME, "REPORT_DECISION"); + // 未按投递/面试/Offer 循环查询 + verify(interviewRepository, never()).findByApplicationIdOrderByCreatedAtDesc(anyLong()); + } + + // ── 辅助 ──────────────────────────────────────────────── + + private void mockUser(Long uid, User.Role role) { + User u = User.builder().id(uid).phone("1").role(role).build(); + u.setRoles(new HashSet<>(Set.of(role))); + when(userRepository.findById(uid)).thenReturn(Optional.of(u)); + } + + private Application app(Long id, Long sid, Long jobId, String status, String created) { + return Application.builder().id(id).jobId(jobId).studentUserId(sid).status(status) + .createdAt(LocalDateTime.parse(created)).updatedAt(LocalDateTime.parse(created)).build(); + } + + private Interview interview(Long id, Long appId, String status, String startAt) { + return Interview.builder().id(id).applicationId(appId).companyUserId(999L).studentUserId(ME) + .roundNo(1).title("技术面试").interviewType("ONLINE") + .startAt(LocalDateTime.parse(startAt)).endAt(LocalDateTime.parse(startAt).plusHours(1)) + .status(status).build(); + } + + private InterviewScore score(Long interviewId, Long appId, String submittedAt, BigDecimal overall) { + LocalDateTime submitted = submittedAt == null ? null : LocalDateTime.parse(submittedAt); + return InterviewScore.builder().id(interviewId).interviewId(interviewId).applicationId(appId) + .companyUserId(999L).studentUserId(ME).status(InterviewScore.STATUS_SUBMITTED) + .overallScore(overall).recommendation("HIRE").submittedAt(submitted).version(0L).build(); + } + + private Offer offer(Long id, Long appId, String status, String expireAt) { + return Offer.builder().id(id).applicationId(appId).jobId(JOB).companyId(COMPANY).candidateId(ME) + .title("Java 后端工程师").description("d").expireAt(LocalDateTime.parse(expireAt)) + .createdBy(999L).status(status).version(0L) + .createdAt(LocalDateTime.of(2026, 8, 25, 12, 0)).build(); + } + + private CandidateDimensionScore dim(Long sid, String score) { + return CandidateDimensionScore.builder().studentUserId(sid).dimension("EDUCATION").period(PERIOD) + .score(new BigDecimal(score)).status("OK").weight(new BigDecimal("12.50")) + .rulesVersion("candidate-scoring-v0.1").build(); + } + + private static String yearMonth(LocalDate d) { + return String.format("%04d-%02d", d.getYear(), d.getMonthValue()); + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/competition/CompetitionRankingCalculatorTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/competition/CompetitionRankingCalculatorTest.java new file mode 100644 index 0000000..e710892 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/competition/CompetitionRankingCalculatorTest.java @@ -0,0 +1,168 @@ +package com.mydreamjob.competition; + +import com.mydreamjob.application.Application; +import com.mydreamjob.candidatescore.CandidateDimensionScore; +import com.mydreamjob.candidatescore.CandidateDimensionScoreRepository; +import com.mydreamjob.candidatescore.CandidateScoringCalculator; +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 org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * 共享排名计算器单测(V32/V34 复用核心)。 + * + *

覆盖稳定排序(有分优先 → overallScore DESC → createdAt ASC → studentUserId ASC → + * 无分最后)、cohort、百分位、最新快照周期选择与 overviewStatus。综合分走真实 + * CandidateScoringCalculator 语义(此处以 mock 模拟按 studentUserId 返回预设分)。

+ */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class CompetitionRankingCalculatorTest { + + private static final Long ME = 100L; + + @Mock private CandidateDimensionScoreRepository scoreRepository; + @Mock private CandidateScoringCalculator scoringCalculator; + + private CompetitionRankingCalculator calculator; + + private Map overallByStudent; + + @BeforeEach + void setUp() { + calculator = new CompetitionRankingCalculator(scoreRepository, scoringCalculator); + overallByStudent = Map.of(); + when(scoringCalculator.overallScoreFromSnapshots(anyList())).thenAnswer(inv -> { + List snaps = inv.getArgument(0); + if (snaps == null || snaps.isEmpty()) return null; + Number sid = ((CandidateDimensionScore) snaps.get(0)).getStudentUserId(); + return overallByStudent.get(sid.longValue()); + }); + } + + @Test + void rankCohort_sortsScoredFirstThenScoreDescThenCreatedAtThenUserId() { + Application a = app(501L, "2026-01-01T10:00:00"); + Application b = app(502L, "2026-01-02T10:00:00"); + Application c = app(503L, "2026-01-03T10:00:00"); // 无分 + Application me = app(ME, "2026-01-05T10:00:00"); + Map> latest = Map.of( + a.getStudentUserId(), List.of(dim(a.getStudentUserId(), "2026-07")), + b.getStudentUserId(), List.of(dim(b.getStudentUserId(), "2026-07")), + ME, List.of(dim(ME, "2026-07"))); + overallByStudent = Map.of(a.getStudentUserId(), new BigDecimal("90.00"), + b.getStudentUserId(), new BigDecimal("80.00"), + ME, new BigDecimal("85.00")); + + CompetitionRankingCalculator.CohortRanking cr = + calculator.rankCohort(List.of(me, a, b, c), latest, ME); + + assertEquals(4, cr.cohortSize); + assertEquals(2, cr.rank.intValue()); + assertEquals(new BigDecimal("85.00"), cr.overallScore); + assertEquals(new BigDecimal("50.00"), cr.percentile); // lower=1, denom=2 + // 排序:A(90) -> me(85) -> B(80) -> C(null) + assertEquals(501L, cr.rows.get(0).studentUserId.longValue()); + assertEquals(ME.longValue(), cr.rows.get(1).studentUserId.longValue()); + assertEquals(502L, cr.rows.get(2).studentUserId.longValue()); + assertEquals(503L, cr.rows.get(3).studentUserId.longValue()); + assertNull(cr.rows.get(3).overallScore); + assertEquals("OK", cr.status); + assertEquals("2026-07", cr.mySnapshotPeriod); + } + + @Test + void rankCohort_tieBrokenByCreatedAtThenStudentUserId() { + Application x = app(900L, "2026-04-01T10:00:00"); + Application me = app(ME, "2026-04-02T10:00:00"); + Map> latest = Map.of( + x.getStudentUserId(), List.of(dim(x.getStudentUserId(), "2026-07")), + ME, List.of(dim(ME, "2026-07"))); + overallByStudent = Map.of(x.getStudentUserId(), new BigDecimal("60.00"), + ME, new BigDecimal("60.00")); + + CompetitionRankingCalculator.CohortRanking cr = + calculator.rankCohort(List.of(x, me), latest, ME); + + assertEquals(2, cr.rank.intValue()); // x first, me second + assertEquals(900L, cr.rows.get(0).studentUserId.longValue()); + } + + @Test + void rankCohort_currentNoScore_rankScorePercentileNull() { + Application a = app(501L, "2026-01-01T10:00:00"); + Application me = app(ME, "2026-01-02T10:00:00"); + Map> latest = Map.of( + a.getStudentUserId(), List.of(dim(a.getStudentUserId(), "2026-07"))); + overallByStudent = Map.of(a.getStudentUserId(), new BigDecimal("90.00")); + + CompetitionRankingCalculator.CohortRanking cr = + calculator.rankCohort(List.of(a, me), latest, ME); + + assertEquals(2, cr.cohortSize); + assertNull(cr.rank); + assertNull(cr.overallScore); + assertNull(cr.percentile); + assertNull(cr.mySnapshotPeriod); + assertEquals("INSUFFICIENT_DATA", cr.status); + } + + @Test + void rankCohort_scoredLessThan2_percentileNull() { + Application a = app(501L, "2026-01-01T10:00:00"); + Application me = app(ME, "2026-01-02T10:00:00"); + Map> latest = Map.of(ME, List.of(dim(ME, "2026-07"))); + overallByStudent = Map.of(ME, new BigDecimal("85.00")); + + CompetitionRankingCalculator.CohortRanking cr = + calculator.rankCohort(List.of(a, me), latest, ME); + + assertNull(cr.percentile); + } + + @Test + void loadLatestSnapshots_picksLatestPeriodPerStudent() { + when(scoreRepository.findUpToPeriod(anyCollection(), anyString())) + .thenReturn(List.of( + dim(ME, "2026-06"), + dim(ME, "2026-07"), + dim(501L, "2026-05"))); + + Map> latest = + calculator.loadLatestSnapshots(List.of(ME, 501L), "2026-07"); + + assertEquals(1, latest.get(ME).size()); + assertEquals("2026-07", latest.get(ME).get(0).getPeriod()); + assertEquals("2026-05", latest.get(501L).get(0).getPeriod()); + } + + @Test + void overviewStatus_emptyIsInsufficientData() { + assertEquals("INSUFFICIENT_DATA", calculator.overviewStatus(List.of())); + } + + private Application app(Long sid, String created) { + return Application.builder().studentUserId(sid) + .status("SUBMITTED") + .createdAt(LocalDateTime.parse(created)).build(); + } + + private CandidateDimensionScore dim(Long sid, String period) { + return CandidateDimensionScore.builder().studentUserId(sid).dimension("EDUCATION").period(period) + .score(new BigDecimal("80")).status("OK") + .weight(new BigDecimal("12.50")).rulesVersion("candidate-scoring-v0.1").build(); + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/competition/CompetitionRankingServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/competition/CompetitionRankingServiceTest.java index 4d49b91..ccfa0e9 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/competition/CompetitionRankingServiceTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/competition/CompetitionRankingServiceTest.java @@ -5,14 +5,11 @@ import com.mydreamjob.application.ApplicationRepository; import com.mydreamjob.auth.User; import com.mydreamjob.auth.UserRepository; import com.mydreamjob.candidatescore.CandidateDimensionScore; -import com.mydreamjob.candidatescore.CandidateDimensionScoreRepository; -import com.mydreamjob.candidatescore.CandidateScoringCalculator; import com.mydreamjob.common.exception.BusinessException; import com.mydreamjob.common.exception.ErrorCode; import com.mydreamjob.competition.dto.CompetitionRankingResponse; import com.mydreamjob.job.Job; import com.mydreamjob.job.JobService; -import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; @@ -22,8 +19,7 @@ import org.mockito.junit.jupiter.MockitoSettings; import org.mockito.quality.Strictness; import java.math.BigDecimal; -import java.time.LocalDate; -import java.time.LocalDateTime; +import java.util.ArrayList; import java.util.HashSet; import java.util.List; import java.util.Map; @@ -35,10 +31,11 @@ import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; /** - * 同岗位竞争力排名服务单测(V32)。 + * 同岗位竞争力排名服务单测(V32,编排 + 响应组装 + 脱敏)。 * - *

用 Mock 隔离 repo/Calculator 以精确断言排序、cohort、百分位、Top5 脱敏与周期选择; - * 排序纯 Java(有分优先、overallScore DESC、createdAt ASC、studentUserId ASC、无分最后)。

+ *

排序/百分位/快照装载等算法已下沉到 {@link CompetitionRankingCalculator} + * (由 CompetitionRankingCalculatorTest 覆盖),本类用 mock 计算器聚焦断言 + * 权限、参数、岗位状态、cohort 委托,以及 Top5 脱敏与维度/缺口的组装。

*/ @ExtendWith(MockitoExtension.class) @MockitoSettings(strictness = Strictness.LENIENT) @@ -50,51 +47,10 @@ class CompetitionRankingServiceTest { @Mock private UserRepository userRepository; @Mock private JobService jobService; @Mock private ApplicationRepository applicationRepository; - @Mock private CandidateDimensionScoreRepository scoreRepository; - @Mock private CandidateScoringCalculator calculator; + @Mock private CompetitionRankingCalculator rankingCalculator; @InjectMocks private CompetitionRankingService service; - private String currentPeriod; - private Map overallByStudent; - - @BeforeEach - void setUp() { - currentPeriod = String.format("%04d-%02d", LocalDate.now().getYear(), LocalDate.now().getMonthValue()); - overallByStudent = Map.of(); - mockStudent(ME); - // 默认按传入快照首条 studentUserId 返回预设综合分 - when(calculator.overallScoreFromSnapshots(anyList())).thenAnswer(inv -> { - List snaps = inv.getArgument(0); - if (snaps == null || snaps.isEmpty()) return null; - Number sid = ((CandidateDimensionScore) snaps.get(0)).getStudentUserId(); - return overallByStudent.get(sid.longValue()); - }); - } - - // ── 辅助 ──────────────────────────────────────────────── - - private void mockStudent(Long uid) { - User u = User.builder().id(uid).phone("1").role(User.Role.STUDENT).build(); - u.setRoles(new HashSet<>(Set.of(User.Role.STUDENT))); - when(userRepository.findById(uid)).thenReturn(Optional.of(u)); - } - - private void mockRole(Long uid, User.Role role) { - User u = User.builder().id(uid).phone("1").role(role).build(); - u.setRoles(new HashSet<>(Set.of(role))); - when(userRepository.findById(uid)).thenReturn(Optional.of(u)); - } - - private void mockPublishedJob() { - Job j = Job.builder().id(JOB).status("PUBLISHED").build(); - when(jobService.getJob(JOB)).thenReturn(j); - } - - private CompetitionRankingResponse call() { - return service.getRanking(ME, JOB); - } - // ── 权限与参数校验 ───────────────────────────────────────── @Test @@ -112,6 +68,7 @@ class CompetitionRankingServiceTest { @Test void jobNotFound_returnsJobNotFound() { + mockStudent(ME); when(jobService.getJob(JOB)).thenThrow(new BusinessException(ErrorCode.JOB_NOT_FOUND)); BusinessException ex = assertThrows(BusinessException.class, () -> call()); assertEquals(ErrorCode.JOB_NOT_FOUND.getCode(), ex.getErrorCode()); @@ -119,6 +76,7 @@ class CompetitionRankingServiceTest { @Test void nonPublishedJob_returnsJobNotFound() { + mockStudent(ME); Job draft = Job.builder().id(JOB).status("DRAFT").build(); when(jobService.getJob(JOB)).thenReturn(draft); BusinessException ex = assertThrows(BusinessException.class, () -> call()); @@ -133,212 +91,109 @@ class CompetitionRankingServiceTest { assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCode()); } - // 唯一投递为 WITHDRAWN / REJECTED → 已退出候选人池 → 403(与 cohort 同口径) - @Test - void withdrawnOnly_returnsForbidden() { - mockPublishedJob(); - when(applicationRepository.existsByJobIdAndStudentUserIdAndStatusNotIn(eq(JOB), eq(ME), anySet())).thenReturn(false); - BusinessException ex = assertThrows(BusinessException.class, () -> call()); - assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCode()); - } - - @Test - void rejectedOnly_returnsForbidden() { - mockPublishedJob(); - when(applicationRepository.existsByJobIdAndStudentUserIdAndStatusNotIn(eq(JOB), eq(ME), anySet())).thenReturn(false); - BusinessException ex = assertThrows(BusinessException.class, () -> call()); - assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCode()); - } - - // 有效状态投递(SUBMITTED/VIEWED/INTERVIEW/HIRED)→ 200 @Test - void effectiveStatus_returns200() { + void effectiveStatus_returns200_andAssemblesResponse() { mockPublishedJob(); when(applicationRepository.existsByJobIdAndStudentUserIdAndStatusNotIn(eq(JOB), eq(ME), anySet())).thenReturn(true); when(applicationRepository.findByJobIdAndStatusNotInOrderByCreatedAtAsc(eq(JOB), anyCollection())) - .thenReturn(List.of(app(ME, 1, "2026-01-01T10:00:00"))); - overallByStudent = Map.of(); - when(scoreRepository.findUpToPeriod(anyCollection(), anyString())).thenReturn(List.of()); - CompetitionRankingResponse resp = call(); - assertEquals(JOB, resp.getJobId()); - assertEquals(1, resp.getCohortSize()); - } - - // ── 排序 / cohort / 百分位 / Top5 ────────────────────────── - - @Test - void ranking_pipeline_checked() { - mockPublishedJob(); - when(applicationRepository.existsByJobIdAndStudentUserIdAndStatusNotIn(eq(JOB), eq(ME), anySet())).thenReturn(true); - // me 中位,A 更高,B 更低,C 无分 - Application meApp = app(ME, 1, "2026-01-05T10:00:00"); - Application a = app(501L, 2, "2026-01-01T10:00:00"); - Application b = app(502L, 3, "2026-01-02T10:00:00"); - Application c = app(503L, 4, "2026-01-03T10:00:00"); // 无分 - when(applicationRepository.findByJobIdAndStatusNotInOrderByCreatedAtAsc(eq(JOB), anyCollection())) - .thenReturn(List.of(meApp, a, b, c)); - overallByStudent = Map.of(ME, new BigDecimal("85.00"), - a.getStudentUserId(), new BigDecimal("90.00"), - b.getStudentUserId(), new BigDecimal("80.00")); - // c 无评分快照 - when(scoreRepository.findUpToPeriod(anyCollection(), anyString())) - .thenReturn(List.of(dim(ME, "EDUCATION", "2026-07"), - dim(a.getStudentUserId(), "EDUCATION", "2026-07"), - dim(b.getStudentUserId(), "EDUCATION", "2026-07"))); + .thenReturn(List.of(app(ME, "2026-01-01T10:00:00"))); + when(rankingCalculator.loadLatestSnapshots(anyList(), anyString())).thenReturn(Map.of()); + when(rankingCalculator.rankCohort(anyList(), anyMap(), eq(ME))) + .thenReturn(cr(1, 1, new BigDecimal("85.00"), null, "OK", "2026-07", List.of())); CompetitionRankingResponse resp = call(); assertEquals(JOB, resp.getJobId()); - assertEquals(4, resp.getCohortSize()); // 无分候选人计入 cohortSize - assertEquals(2, resp.getRank()); // me 排第 2 + assertEquals(1, resp.getCohortSize()); + assertEquals(1, resp.getRank()); assertEquals(new BigDecimal("85.00"), resp.getOverallScore()); - assertEquals(new BigDecimal("50.00"), resp.getPercentile()); // lower=1, denom=2, 1/2*100 - // Top5 匿名:rank1 A=90, rank2 me=85, rank3 B=80, rank4 C=null - assertEquals(4, resp.getTopCandidates().size()); - assertEquals(1, resp.getTopCandidates().get(0).getRank()); - assertEquals(new BigDecimal("90.00"), resp.getTopCandidates().get(0).getOverallScore()); - assertEquals(4, resp.getTopCandidates().get(3).getRank()); - assertNull(resp.getTopCandidates().get(3).getOverallScore()); - assertEquals(currentPeriod, resp.getAsOfPeriod()); + assertNull(resp.getPercentile()); + assertEquals("OK", resp.getStatus()); assertEquals("2026-07", resp.getMySnapshotPeriod()); - // 无身份字段泄露:结构上仅含 rank + overallScore - resp.getTopCandidates().forEach(t -> assertNotNull(t.getRank())); - assertEquals(1, resp.getDimensions().size()); } + // ── Top5 脱敏与上限、维度/缺口组装 ───────────────────────── + @Test - void scoredFirst_noScoreLast_secondarySortStable() { + void topCandidates_cappedAt5_andNoIdentityFields() { mockPublishedJob(); when(applicationRepository.existsByJobIdAndStudentUserIdAndStatusNotIn(eq(JOB), eq(ME), anySet())).thenReturn(true); - // 同分 77.00、不同 createdAt - Application x = app(600L, 1, "2026-02-01T10:00:00"); // 更早 - Application y = app(601L, 2, "2026-02-02T10:00:00"); // 更晚 - // 无分学生 - Application n1 = app(700L, 3, "2026-01-01T10:00:00"); - Application n2 = app(701L, 4, "2026-01-01T10:00:00"); when(applicationRepository.findByJobIdAndStatusNotInOrderByCreatedAtAsc(eq(JOB), anyCollection())) - .thenReturn(List.of(n1, x, y, n2, app(ME, 5, "2026-03-01T10:00:00"))); - overallByStudent = Map.of(x.getStudentUserId(), new BigDecimal("77.00"), - y.getStudentUserId(), new BigDecimal("77.00"), - ME, new BigDecimal("77.00")); - when(scoreRepository.findUpToPeriod(anyCollection(), anyString())) - .thenReturn(List.of(dim(x.getStudentUserId(), "EDUCATION", "2026-07"), - dim(y.getStudentUserId(), "EDUCATION", "2026-07"), - dim(ME, "EDUCATION", "2026-07"))); + .thenReturn(List.of(app(ME, "2026-01-01T10:00:00"))); + when(rankingCalculator.loadLatestSnapshots(anyList(), anyString())).thenReturn(Map.of()); + when(rankingCalculator.rankCohort(anyList(), anyMap(), eq(ME))) + .thenReturn(cr(7, 3, new BigDecimal("77.00"), null, "PARTIAL", "2026-07", List.of())); CompetitionRankingResponse resp = call(); - // 有分在前、无分最后;同分按 createdAt 升序(x 更早因此 x 在 y 前);本人有分排第 3 - assertEquals(3, resp.getRank()); - assertEquals(new BigDecimal("77.00"), resp.getTopCandidates().get(0).getOverallScore()); - assertEquals(new BigDecimal("77.00"), resp.getTopCandidates().get(1).getOverallScore()); - // 无分候选人的 overallScore 为 null 且排在末尾 - assertNull(resp.getTopCandidates().get(3).getOverallScore()); - assertEquals(5, resp.getCohortSize()); + + assertEquals(5, resp.getTopCandidates().size()); + resp.getTopCandidates().forEach(t -> { + assertNotNull(t.getRank()); + }); } @Test - void ownScoreSameAsOther_tieBrokenByCreatedAtThenStudentUserId() { + void dimensionsAndGaps_builtFromSnapshots() { mockPublishedJob(); when(applicationRepository.existsByJobIdAndStudentUserIdAndStatusNotIn(eq(JOB), eq(ME), anySet())).thenReturn(true); - // me 与 x 同分但 x 投递更早 → me 排后 - Application x = app(900L, 1, "2026-04-01T10:00:00"); - Application mm = app(ME, 2, "2026-04-02T10:00:00"); when(applicationRepository.findByJobIdAndStatusNotInOrderByCreatedAtAsc(eq(JOB), anyCollection())) - .thenReturn(List.of(x, mm)); - overallByStudent = Map.of(x.getStudentUserId(), new BigDecimal("60.00"), - ME, new BigDecimal("60.00")); - when(scoreRepository.findUpToPeriod(anyCollection(), anyString())) - .thenReturn(List.of(dim(x.getStudentUserId(), "EDUCATION", "2026-07"), - dim(ME, "EDUCATION", "2026-07"))); + .thenReturn(List.of(app(ME, "2026-01-01T10:00:00"))); + when(rankingCalculator.loadLatestSnapshots(anyList(), anyString())).thenReturn(Map.of()); + when(rankingCalculator.rankCohort(anyList(), anyMap(), eq(ME))) + .thenReturn(cr(1, 1, new BigDecimal("85.00"), null, "OK", "2026-07", + List.of(dim(ME, "EDUCATION", "OK", "85.00"), dim(ME, "ABILITY", "PENDING", null)))); CompetitionRankingResponse resp = call(); - // 有分 2 人 → percentile null(scored<2 分位为 null 的规则由百分位测试覆盖) - assertEquals(2, resp.getRank()); // x first, me second + + assertEquals(2, resp.getDimensions().size()); + assertFalse(resp.getGaps().isEmpty()); } - @Test - void currentStudentNoScore_rankScorePercentileNull() { - mockPublishedJob(); - when(applicationRepository.existsByJobIdAndStudentUserIdAndStatusNotIn(eq(JOB), eq(ME), anySet())).thenReturn(true); - Application a = app(501L, 1, "2026-01-01T10:00:00"); - Application mm = app(ME, 2, "2026-01-02T10:00:00"); - when(applicationRepository.findByJobIdAndStatusNotInOrderByCreatedAtAsc(eq(JOB), anyCollection())) - .thenReturn(List.of(a, mm)); - overallByStudent = Map.of(a.getStudentUserId(), new BigDecimal("90.00")); // me 无分 - when(scoreRepository.findUpToPeriod(anyCollection(), anyString())) - .thenReturn(List.of(dim(a.getStudentUserId(), "EDUCATION", "2026-07"))); + // ── 辅助 ──────────────────────────────────────────────── - CompetitionRankingResponse resp = call(); - assertEquals(2, resp.getCohortSize()); - assertNull(resp.getRank()); - assertNull(resp.getOverallScore()); - assertNull(resp.getPercentile()); - assertNull(resp.getMySnapshotPeriod()); - assertEquals("INSUFFICIENT_DATA", resp.getStatus()); + private CompetitionRankingResponse call() { + return service.getRanking(ME, JOB); } - @Test - void percentile_scoredLessThan2_returnsNull() { - mockPublishedJob(); - when(applicationRepository.existsByJobIdAndStudentUserIdAndStatusNotIn(eq(JOB), eq(ME), anySet())).thenReturn(true); - Application a = app(501L, 1, "2026-01-01T10:00:00"); - Application mm = app(ME, 2, "2026-01-02T10:00:00"); - when(applicationRepository.findByJobIdAndStatusNotInOrderByCreatedAtAsc(eq(JOB), anyCollection())) - .thenReturn(List.of(a, mm)); - overallByStudent = Map.of(ME, new BigDecimal("85.00")); // 仅 1 个有分 - when(scoreRepository.findUpToPeriod(anyCollection(), anyString())) - .thenReturn(List.of(dim(ME, "EDUCATION", "2026-07"))); - - CompetitionRankingResponse resp = call(); - assertNull(resp.getPercentile()); + private void mockStudent(Long uid) { + User u = User.builder().id(uid).phone("1").role(User.Role.STUDENT).build(); + u.setRoles(new HashSet<>(Set.of(User.Role.STUDENT))); + when(userRepository.findById(uid)).thenReturn(Optional.of(u)); } - @Test - void latestPeriodSelected_multipleMonthlySnapshots() { - mockPublishedJob(); - when(applicationRepository.existsByJobIdAndStudentUserIdAndStatusNotIn(eq(JOB), eq(ME), anySet())).thenReturn(true); - Application mm = app(ME, 1, "2026-01-01T10:00:00"); - when(applicationRepository.findByJobIdAndStatusNotInOrderByCreatedAtAsc(eq(JOB), anyCollection())) - .thenReturn(List.of(mm)); - overallByStudent = Map.of(ME, new BigDecimal("70.00")); - // 仅 discord 学生有多期;用 ME 返回两期,latest=latest - when(scoreRepository.findUpToPeriod(anyCollection(), anyString())) - .thenReturn(List.of(dim(ME, "EDUCATION", "2026-06"), dim(ME, "EDUCATION", "2026-07"))); + private void mockRole(Long uid, User.Role role) { + User u = User.builder().id(uid).phone("1").role(role).build(); + u.setRoles(new HashSet<>(Set.of(role))); + when(userRepository.findById(uid)).thenReturn(Optional.of(u)); + } - CompetitionRankingResponse resp = call(); - assertEquals("2026-07", resp.getMySnapshotPeriod()); - assertEquals(1, resp.getDimensions().size()); // 仅最新周期 1 条 + private void mockPublishedJob() { + mockStudent(ME); + Job j = Job.builder().id(JOB).status("PUBLISHED").build(); + when(jobService.getJob(JOB)).thenReturn(j); } - @Test - void topCandidates_cappedAt5() { - mockPublishedJob(); - when(applicationRepository.existsByJobIdAndStudentUserIdAndStatusNotIn(eq(JOB), eq(ME), anySet())).thenReturn(true); - var apps = new java.util.ArrayList(); - apps.add(app(ME, 1, "2026-01-01T10:00:00")); - for (long i = 2; i <= 8; i++) { - apps.add(app(1000L + i, (int) i, "2026-01-0" + (i % 9) + "T10:00:00")); + private CompetitionRankingCalculator.CohortRanking cr(int cohortSize, Integer rank, BigDecimal overall, + BigDecimal percentile, String status, + String period, List snaps) { + List rows = new ArrayList<>(); + for (int i = 0; i < cohortSize; i++) { + BigDecimal s = (i < cohortSize) ? new BigDecimal("90.00") : null; + rows.add(new CompetitionRankingCalculator.Row(ME + i, null, s, period)); } - when(applicationRepository.findByJobIdAndStatusNotInOrderByCreatedAtAsc(eq(JOB), anyCollection())) - .thenReturn(apps); - overallByStudent = Map.of(); - when(scoreRepository.findUpToPeriod(anyCollection(), anyString())).thenReturn(List.of()); - // 全部无分场景,仅验证 Top5 上限 - CompetitionRankingResponse resp = call(); - assertEquals(5, resp.getTopCandidates().size()); + CompetitionRankingCalculator.Row me = new CompetitionRankingCalculator.Row(ME, null, overall, period); + return new CompetitionRankingCalculator.CohortRanking(cohortSize, rows, me, rank, + overall, percentile, status, period, snaps); } - // ── 载体构造 ────────────────────────────────────────────── - - private Application app(Long sid, int idx, String created) { - return Application.builder().jobId(JOB).studentUserId(sid) - .status(idx % 3 == 0 ? "VIEWED" : "SUBMITTED") - .createdAt(LocalDateTime.parse(created)).build(); + private Application app(Long sid, String created) { + return Application.builder().studentUserId(sid).status("SUBMITTED") + .createdAt(java.time.LocalDateTime.parse(created)).build(); } - private CandidateDimensionScore dim(Long sid, String dimension, String period) { - return CandidateDimensionScore.builder().studentUserId(sid).dimension(dimension).period(period) - .weight(new BigDecimal("12.50")).rulesVersion("candidate-scoring-v0.1") - .status("OK").build(); + private CandidateDimensionScore dim(Long sid, String dimension, String status, String score) { + return CandidateDimensionScore.builder().studentUserId(sid).dimension(dimension).period("2026-07") + .score(score == null ? null : new BigDecimal(score)).status(status) + .weight(new BigDecimal("12.50")).rulesVersion("candidate-scoring-v0.1").build(); } } \ No newline at end of file