Browse Source

feat: deliver V34 career progress aggregation

backup/master-local-20261012
Zhang Shun 2 months ago
parent
commit
bf75fc0353
  1. 8
      .gitignore
  2. 20
      docs/backend-development-roadmap.md
  3. 181
      docs/career-progress-api.md
  4. 53
      docs/frontend-integration.md
  5. 308
      docs/openapi-rc.json
  6. 7
      docs/verify_openapi.py
  7. 7
      mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationRepository.java
  8. 73
      mydreamjob-server/src/main/java/com/mydreamjob/careerprogress/CareerProgressController.java
  9. 333
      mydreamjob-server/src/main/java/com/mydreamjob/careerprogress/CareerProgressService.java
  10. 143
      mydreamjob-server/src/main/java/com/mydreamjob/careerprogress/dto/CareerProgressResponse.java
  11. 181
      mydreamjob-server/src/main/java/com/mydreamjob/competition/CompetitionRankingCalculator.java
  12. 142
      mydreamjob-server/src/main/java/com/mydreamjob/competition/CompetitionRankingService.java
  13. 6
      mydreamjob-server/src/main/java/com/mydreamjob/interview/InterviewRepository.java
  14. 9
      mydreamjob-server/src/main/java/com/mydreamjob/interviewscore/InterviewScoreRepository.java
  15. 4
      mydreamjob-server/src/main/java/com/mydreamjob/notification/NotificationRepository.java
  16. 6
      mydreamjob-server/src/main/java/com/mydreamjob/offer/OfferRepository.java
  17. 318
      mydreamjob-server/src/test/java/com/mydreamjob/careerprogress/CareerProgressControllerTest.java
  18. 230
      mydreamjob-server/src/test/java/com/mydreamjob/careerprogress/CareerProgressPersistenceIntegrationTest.java
  19. 442
      mydreamjob-server/src/test/java/com/mydreamjob/careerprogress/CareerProgressServiceTest.java
  20. 168
      mydreamjob-server/src/test/java/com/mydreamjob/competition/CompetitionRankingCalculatorTest.java
  21. 287
      mydreamjob-server/src/test/java/com/mydreamjob/competition/CompetitionRankingServiceTest.java

8
.gitignore

@ -42,3 +42,11 @@ acceptance-*.err.log
**/logs/ **/logs/
**/*.log **/*.log
**/data-exports/ **/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/)

20
docs/backend-development-roadmap.md

@ -26,20 +26,20 @@
| 项目 | 当前状态 | | 项目 | 当前状态 |
|---|---| |---|---|
| OpenAPI | **125 paths、159 operations** | | OpenAPI | **126 paths、160 operations** |
| Flyway | **V1–V33** 已存在 | | Flyway | **V1–V33** 已存在(V34 为纯接口扩展,不含 Flyway 迁移) |
| 全量测试 | **1255/1255 通过**(本次 `mvn clean verify` 实际数量) | | 全量测试 | **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,及认证、资料、岗位、投递、面试、Offer、看板等核心闭环 | | 已完成范围 | 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 黑盒验收并正式冻结 | | 当前结论 | 已达到已实现范围的 Release Candidate;V33 Report/Block 已完成真实 MySQL/Docker 黑盒验收并正式冻结;V34 统一求职进度聚合已完成实现并通过真实 MySQL/Docker 黑盒验收(PASS=99 / FAIL=0 / SKIP=0),待建立正式 Git 检查点 |
| 本规划范围 | 代码修复、新功能开发、接口契约、数据权限和治理能力 | | 本规划范围 | 代码修复、新功能开发、接口契约、数据权限和治理能力 |
### 2.0 模块状态分类 ### 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 验收 | — | | 已完成、待真实 MySQL/Docker 验收 | — |
| 已设计未编码 | 测评系统(Holland/能力测评)、公平性检测、SHAP/规则解释、防刷审计、企业评价管理、企业人才推荐、统一求职进度聚合 | | 已设计未编码 | 测评系统(Holland/能力测评)、公平性检测、SHAP/规则解释、防刷审计、企业评价管理、企业人才推荐 |
| 未开始 | AI 简历诊断/优化、AI 模拟面试、行业技能词云、入职准备清单、薪资计算器、诚信积分、消息撤回 | | 未开始 | AI 简历诊断/优化、AI 模拟面试、行业技能词云、入职准备清单、薪资计算器、诚信积分、消息撤回 |
### 2.1 已确认的产品决策 ### 2.1 已确认的产品决策
@ -120,7 +120,7 @@
| Figma 页面/功能 | 当前实现 | 差距 | | Figma 页面/功能 | 当前实现 | 差距 |
|---|---|---| |---|---|---|
| 同岗位竞争力排名 | `GET /jobs/{jobId}/competition-ranking` 已实现 | 已交付;最终口径:WITHDRAWN/REJECTED 退出候选人池(请求 403/2002);无评分但有效投递返回 200 + null 分数;Top5 匿名。不宣称"支持隐私退出" | | 同岗位竞争力排名 | `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 字段 | 综合薪资、福利、发展空间当前由前端展示,后端不计算综合结论 | | Offer 对比 | `/offers/compare` 返回 Offer 字段 | 综合薪资、福利、发展空间当前由前端展示,后端不计算综合结论 |
| HR 评价详情 | `candidate_feedback` 已实现 | 不是结构化面试评分 | | HR 评价详情 | `candidate_feedback` 已实现 | 不是结构化面试评分 |
| 企业评价管理 | 有评价审核和管理员决策 | 缺企业端评价管理、回复和运营动作 | | 企业评价管理 | 有评价审核和管理员决策 | 缺企业端评价管理、回复和运营动作 |
@ -142,7 +142,7 @@
- SHAP/规则解释; - SHAP/规则解释;
- ADMIN 防刷审计和规则配置; - ADMIN 防刷审计和规则配置;
- 诚信积分和激励。 - 诚信积分和激励。
- 已非缺失(已实现):个人数据导出(V30)、隐私设置(V27)、账号注销(V28/V29)、举报与屏蔽(V33)、结构化面试评分(V31)、同岗位竞争力排名(V32)。 - 已非缺失(已实现):个人数据导出(V30)、隐私设置(V27)、账号注销(V28/V29)、举报与屏蔽(V33)、结构化面试评分(V31)、同岗位竞争力排名(V32)、统一求职进度聚合(V34)。
## 5. 已有代码的优先修复项 ## 5. 已有代码的优先修复项
@ -457,7 +457,7 @@ scoring_risk_actions
| P0 | 结构化面试评分 | | P0 | 结构化面试评分 |
| P0/P1 | 同岗位竞争力排名 | | P0/P1 | 同岗位竞争力排名 |
| P1 | 霍兰德和四维能力测评 | | P1 | 霍兰德和四维能力测评 |
| P1 | 统一求职进度 | | P1 | 统一求职进度(V34 已完成) |
| P1 | 企业评价管理 | | P1 | 企业评价管理 |
| P1 | 企业人才推荐 | | P1 | 企业人才推荐 |
| P1 | 公平性检测 | | P1 | 公平性检测 |

181
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>
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<CareerProgressItem> 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(黑盒阶段除外)。

53
docs/frontend-integration.md

@ -655,3 +655,56 @@ POST /admin/reports/{id}/decision # 处理决定
**脱敏**:所有举报/屏蔽响应均**不返回** phone、email、passwordHash、resumeUrl、JWT、消息全文;MESSAGE 举报仅返回脱敏 `messagePreview`;举报列表不返回举报人内部 ID。 **脱敏**:所有举报/屏蔽响应均**不返回** phone、email、passwordHash、resumeUrl、JWT、消息全文;MESSAGE 举报仅返回脱敏 `messagePreview`;举报列表不返回举报人内部 ID。
- API 详见 [docs/report-block-api.md](report-block-api.md)。 - 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)。

308
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": { "/profile/me/deletion": {
"get": { "get": {
"tags": [ "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": { "ApiResponseAccountDeletionResponse": {
"type": "object", "type": "object",
"properties": { "properties": {

7
docs/verify_openapi.py

@ -6,7 +6,7 @@ p = PROJECT_ROOT / 'docs' / 'openapi-rc.json'
d = json.load(open(p, encoding='utf-8')) d = json.load(open(p, encoding='utf-8'))
print('paths:', len(d['paths'])) 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'] paths = d['paths']
required = [ required = [
@ -55,6 +55,8 @@ required = [
'/interviews/{interviewId}/score/history', '/interviews/{interviewId}/score/history',
# 同岗位竞争力排名(1 个 path / 1 个 operation) # 同岗位竞争力排名(1 个 path / 1 个 operation)
'/jobs/{jobId}/competition-ranking', '/jobs/{jobId}/competition-ranking',
# V34 统一求职进度聚合(1 个 path / 1 个 operation)
'/profile/me/career-progress',
# V33 举报与屏蔽(8 个 path / 9 个 operation) # V33 举报与屏蔽(8 个 path / 9 个 operation)
'/reports', '/reports',
'/reports/me', '/reports/me',
@ -119,6 +121,9 @@ print(" Personal data export endpoints present (4 operations / 3 paths)")
# 同岗位竞争力排名(1 个 GET operation) # 同岗位竞争力排名(1 个 GET operation)
assert 'get' in paths['/jobs/{jobId}/competition-ranking'], "Missing GET /jobs/{jobId}/competition-ranking" assert 'get' in paths['/jobs/{jobId}/competition-ranking'], "Missing GET /jobs/{jobId}/competition-ranking"
print(" Competition ranking endpoint present (1 GET operation)") 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}) # V33 举报与屏蔽(8 个 path / 9 个 operation;PUT+DELETE 共享 /blocks/{blockedUserId})
assert 'post' in paths['/reports'], "Missing POST /reports" assert 'post' in paths['/reports'], "Missing POST /reports"
assert 'get' in paths['/reports/me'], "Missing GET /reports/me" assert 'get' in paths['/reports/me'], "Missing GET /reports/me"

7
mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationRepository.java

@ -39,4 +39,11 @@ public interface ApplicationRepository extends JpaRepository<Application, Long>
* 同岗位 rank 用:取某岗位未排除(非 WITHDRAWN/REJECTED)的投递,按投递时间正序。 * 同岗位 rank 用:取某岗位未排除(非 WITHDRAWN/REJECTED)的投递,按投递时间正序。
*/ */
List<Application> findByJobIdAndStatusNotInOrderByCreatedAtAsc(Long jobId, java.util.Collection<String> statuses); List<Application> findByJobIdAndStatusNotInOrderByCreatedAtAsc(Long jobId, java.util.Collection<String> statuses);
/**
* V34 批量:一次取出多个岗位(page 内投递涉及的去重 jobId)未排除的候选人投递,
* 按投递时间正序;用于批量计算同岗位竞争力排名,避免按岗位循环查询造成 N+1。
*/
List<Application> findByJobIdInAndStatusNotInOrderByCreatedAtAsc(java.util.Collection<Long> jobIds,
java.util.Collection<String> statuses);
} }

73
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)。
*
* <p>仅 STUDENT 可访问;身份只取 JWT,不接受 userId/studentUserId/candidateId 等参数。
* 未登录 401;COMPANY/ADMIN 403/2002;参数非法 400/1001。</p>
*/
@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<ApiResponse<CareerProgressResponse>> 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");
}
}

333
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)。
*
* <p>只读学生端接口,汇总投递分页 + 岗位 + 下一场面试 + 最近已提交评分 + 可展示 Offer +
* 同岗位竞争力排名 + 待办/通知计数。<b>纯只读:</b>@Transactional(readOnly=true),
* 不写业务表/审计表/通知表/缓存。所有附属数据批量读取,DB 查询次数固定(与分页
* items 数量无关),避免 N+1。身份仅从 JWT/SecurityContext 获取。</p>
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class CareerProgressService {
private static final Set<String> ALLOWED_STATUSES = Set.of(
"SUBMITTED", "VIEWED", "INTERVIEW", "HIRED", "REJECTED", "WITHDRAWN");
private static final Set<String> 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<Application> appPage = (status == null || status.isBlank())
? applicationRepository.findByStudentUserIdOrderByCreatedAtDesc(studentUserId, pr)
: applicationRepository.findByStudentUserIdAndStatusOrderByCreatedAtDesc(studentUserId, status, pr);
List<Application> pageApps = appPage.getContent();
// 2. 岗位与企业(批量,与 items 数量无关)
List<Long> jobIds = pageApps.stream().map(Application::getJobId).distinct().toList();
Map<Long, Job> jobs = jobIds.isEmpty() ? Map.of()
: jobRepository.findAllById(jobIds).stream()
.collect(Collectors.toMap(Job::getId, Function.identity()));
List<Long> companyIds = jobs.values().stream().map(Job::getCompanyId).distinct().toList();
Map<Long, Company> companies = companyIds.isEmpty() ? Map.of()
: companyRepository.findAllById(companyIds).stream()
.collect(Collectors.toMap(Company::getId, Function.identity()));
// 3. 面试:学生全部 SCHEDULED/CONFIRMED(todo 计数 + nextInterview 共用一个批次)
List<Interview> pendingInterviews = interviewRepository
.findByStudentUserIdAndStatusInOrderByStartAtAsc(studentUserId, NEXT_INTERVIEW_STATUSES);
// 4. 已提交评分(本页投递,批量)
List<Long> appIds = pageApps.stream().map(Application::getId).toList();
List<InterviewScore> scores = appIds.isEmpty() ? List.of()
: interviewScoreRepository.findByStudentUserIdAndApplicationIdInAndStatus(
studentUserId, appIds, InterviewScore.STATUS_SUBMITTED);
// 5. 非 DRAFT Offer(todo 有效 Offer 计数 + 每条投递 Offer 展示共用一个批次)
List<Offer> offers = offerRepository.findByCandidateIdAndStatusNotOrderByCreatedAtDesc(studentUserId, "DRAFT");
// 6. 竞争力:本页岗位的 cohort 投递与候选人快照各批量一次,再按岗位内存排序
List<Application> cohortApps = jobIds.isEmpty() ? List.of()
: applicationRepository.findByJobIdInAndStatusNotInOrderByCreatedAtAsc(
jobIds, CompetitionRankingCalculator.EXCLUDED_STATUSES);
List<Long> cohortCandidateIds = cohortApps.stream()
.map(Application::getStudentUserId).distinct().toList();
Map<Long, List<CandidateDimensionScore>> latestByStudent =
rankingCalculator.loadLatestSnapshots(cohortCandidateIds, asOf);
Map<Long, List<Application>> 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<Long, Interview> nextInterviewByApp = buildNextInterviewMap(pendingInterviews);
Map<Long, InterviewScore> latestScoreByApp = buildLatestScoreMap(scores);
Map<Long, Offer> offerByApp = buildOfferMap(offers, now);
List<CareerProgressItem> 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<Interview> interviews, List<Offer> 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<Long, Interview> buildNextInterviewMap(List<Interview> interviews) {
Map<Long, Interview> map = new LinkedHashMap<>();
// 已按 startAt ASC,putIfAbsent 保留每个 applicationId 最早一场
for (Interview iv : interviews) {
map.putIfAbsent(iv.getApplicationId(), iv);
}
return map;
}
private Map<Long, InterviewScore> buildLatestScoreMap(List<InterviewScore> scores) {
Map<Long, InterviewScore> 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<Long, Offer> buildOfferMap(List<Offer> offers, LocalDateTime now) {
Map<Long, List<Offer>> byApp = offers.stream().collect(Collectors.groupingBy(Offer::getApplicationId));
Map<Long, Offer> result = new HashMap<>();
for (Map.Entry<Long, List<Offer>> e : byApp.entrySet()) {
List<Offer> 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<Long, Company> 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<Long, List<Application>> cohortByJob,
Map<Long, List<CandidateDimensionScore>> latestByStudent,
Long meId) {
String st = app.getStatus();
if ("WITHDRAWN".equals(st) || "REJECTED".equals(st)) {
return CompetitionSummary.builder().eligible(false).build();
}
List<Application> 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());
}
}

143
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)。
*
* <p>只读学生端接口。todo 仅返回通知/待办计数,不返回任何通知标题、正文或业务详情
* (通知详情仍由既有 /notifications 提供)。applications 为当前学生投递分页,
* 每条投递附带岗位摘要、下一场面试、最近已提交评分、可展示 Offer 与同岗位竞争力摘要。
* 对所有 null 子对象省略,避免泄露 DRAFT 或他人信息。</p>
*/
@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 &gt; 当前时间 */
private long upcomingConfirmedInterviewCount;
/** 待响应 Offer 数:状态 SENT/NEGOTIATING 且 expireAt &gt; 当前时间 */
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<CareerProgressItem> 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;
}
}

181
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 复用)。
*
* <p>承载候选人池(cohort)的稳定排序、综合分、百分位与状态判定,全部为纯确定
* 规则、无数据库写入。综合分复用 {@link CandidateScoringCalculator#overallScoreFromSnapshots}
* 真实公式;排序在 Java 侧完成(有分优先 → overallScore DESC → createdAt ASC →
* studentUserId ASC → 无分最后);百分位复用企业看板公式
* {@code 严格低于本人分数的人数 / (有分候选人数 - 1) × 100}(scale=2、HALF_UP)。
* 禁止在 SQL 里重算 overallScore 或 percentile,禁止复制粘贴第二套公式。</p>
*
* <p>「读快照」与「排序计算」分离:{@link #loadLatestSnapshots} 一次批量读取,
* {@link #rankCohort} 纯计算不触碰数据库,保证 V34 按岗位批量排名时无 N+1。</p>
*/
@Component
@RequiredArgsConstructor
public class CompetitionRankingCalculator {
/** 已退出候选人池的投递状态(与 V32 cohort 口径一致) */
public static final Set<String> EXCLUDED_STATUSES = Set.of("WITHDRAWN", "REJECTED");
private final CandidateDimensionScoreRepository scoreRepository;
private final CandidateScoringCalculator scoringCalculator;
/**
* 批量读取一批学生的快照(period &lt;= asOf),Java 侧按学生取最新周期,无 N+1。
* period 为 'yyyy-MM',字典序即时间序。
*/
public Map<Long, List<CandidateDimensionScore>> loadLatestSnapshots(List<Long> studentUserIds, String asOf) {
if (studentUserIds == null || studentUserIds.isEmpty()) return Map.of();
List<CandidateDimensionScore> all = scoreRepository.findUpToPeriod(studentUserIds, asOf);
Map<Long, Map<String, List<CandidateDimensionScore>>> byStudentPeriod = new HashMap<>();
for (CandidateDimensionScore s : all) {
byStudentPeriod.computeIfAbsent(s.getStudentUserId(), k -> new HashMap<>())
.computeIfAbsent(s.getPeriod(), k -> new ArrayList<>()).add(s);
}
Map<Long, List<CandidateDimensionScore>> result = new HashMap<>();
for (Map.Entry<Long, Map<String, List<CandidateDimensionScore>>> 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<Application> cohortApps,
Map<Long, List<CandidateDimensionScore>> latestByStudent,
Long meId) {
List<Row> rows = new ArrayList<>();
for (Application app : cohortApps) {
List<CandidateDimensionScore> 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<CandidateDimensionScore> 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<CandidateDimensionScore> snaps) {
return snaps.stream().map(CandidateDimensionScore::getPeriod)
.max(String::compareTo).orElse(null);
}
private BigDecimal computePercentile(List<Row> rows, Row me) {
if (me == null || me.overallScore == null) return null;
List<BigDecimal> 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<CandidateDimensionScore> 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<Row> 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<CandidateDimensionScore> meSnaps;
CohortRanking(int cohortSize, List<Row> rows, Row me, Integer rank,
BigDecimal overallScore, BigDecimal percentile, String status,
String mySnapshotPeriod, List<CandidateDimensionScore> 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;
}
}
}

142
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.User;
import com.mydreamjob.auth.UserRepository; import com.mydreamjob.auth.UserRepository;
import com.mydreamjob.candidatescore.CandidateDimensionScore; import com.mydreamjob.candidatescore.CandidateDimensionScore;
import com.mydreamjob.candidatescore.CandidateDimensionScoreRepository;
import com.mydreamjob.candidatescore.CandidateScoringCalculator; import com.mydreamjob.candidatescore.CandidateScoringCalculator;
import com.mydreamjob.common.exception.BusinessException; import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode; import com.mydreamjob.common.exception.ErrorCode;
@ -18,29 +17,20 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.Instant; import java.time.Instant;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator; import java.util.Comparator;
import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Objects;
import java.util.stream.Collectors; import java.util.stream.Collectors;
/** /**
* 同岗位竞争力排名(V32)。 * 同岗位竞争力排名(V32)。
* *
* <p>为已投递某 PUBLISHED 岗位的学生返回该岗位候选人池内的竞争力排名。 * <p>为已投递某 PUBLISHED 岗位的学生返回该岗位候选人池内的竞争力排名。
* 纯只读聚合,无新表、无 Flyway、不写任何业务表;综合分复用 * 纯只读聚合,无新表、无 Flyway、不写任何业务表;综合分、排序、百分位与状态
* {@link CandidateScoringCalculator#overallScoreFromSnapshots}(真实公式), * 全部委托 {@link CompetitionRankingCalculator}(与 V34 共享,禁止复制公式)。</p>
* 不修改 percentile / 评分引擎 / 企业看板。</p>
*
* <p>排序在 Java 侧完成(不使用 MySQL NULLS LAST),无 N+1:cohort 投递一次批量、
* 候选人快照一次批量。</p>
*/ */
@Slf4j @Slf4j
@Service @Service
@ -48,13 +38,11 @@ import java.util.stream.Collectors;
public class CompetitionRankingService { public class CompetitionRankingService {
private static final int TOP_N = 5; private static final int TOP_N = 5;
private static final java.util.Set<String> EXCLUDED = java.util.Set.of("WITHDRAWN", "REJECTED");
private final UserRepository userRepository; private final UserRepository userRepository;
private final JobService jobService; private final JobService jobService;
private final ApplicationRepository applicationRepository; private final ApplicationRepository applicationRepository;
private final CandidateDimensionScoreRepository scoreRepository; private final CompetitionRankingCalculator rankingCalculator;
private final CandidateScoringCalculator calculator;
@Transactional(readOnly = true) @Transactional(readOnly = true)
public CompetitionRankingResponse getRanking(Long studentUserId, Long jobId) { public CompetitionRankingResponse getRanking(Long studentUserId, Long jobId) {
@ -72,7 +60,8 @@ public class CompetitionRankingService {
// 当前学生必须对本岗位存在「有效投递」(状态与 cohort 同口径:非 WITHDRAWN/REJECTED), // 当前学生必须对本岗位存在「有效投递」(状态与 cohort 同口径:非 WITHDRAWN/REJECTED),
// 否则一律 403。禁止用"是否曾投递"的宽松判断,避免已退出候选人池仍返回 200。 // 否则一律 403。禁止用"是否曾投递"的宽松判断,避免已退出候选人池仍返回 200。
boolean applied = applicationRepository boolean applied = applicationRepository
.existsByJobIdAndStudentUserIdAndStatusNotIn(jobId, studentUserId, EXCLUDED); .existsByJobIdAndStudentUserIdAndStatusNotIn(jobId, studentUserId,
CompetitionRankingCalculator.EXCLUDED_STATUSES);
if (!applied) { if (!applied) {
throw new BusinessException(ErrorCode.FORBIDDEN); throw new BusinessException(ErrorCode.FORBIDDEN);
} }
@ -85,109 +74,43 @@ public class CompetitionRankingService {
// 1. cohort 投递(排除 WITHDRAWN/REJECTED;唯一约束保证同岗位同学生仅一条) // 1. cohort 投递(排除 WITHDRAWN/REJECTED;唯一约束保证同岗位同学生仅一条)
List<Application> apps = applicationRepository List<Application> apps = applicationRepository
.findByJobIdAndStatusNotInOrderByCreatedAtAsc(jobId, EXCLUDED); .findByJobIdAndStatusNotInOrderByCreatedAtAsc(jobId, CompetitionRankingCalculator.EXCLUDED_STATUSES);
// 2. 批量读取候选人快照(period <= asOf),Java 侧取每人最新周期 —— 无 N+1 // 2. 批量读取候选人快照 + 排序/百分位(共享组件,无 N+1)
List<Long> candidateIds = apps.stream().map(Application::getStudentUserId) List<Long> candidateIds = apps.stream().map(Application::getStudentUserId)
.distinct().collect(Collectors.toList()); .distinct().collect(Collectors.toList());
Map<Long, List<CandidateDimensionScore>> latestByStudent = Map<Long, List<CandidateDimensionScore>> latestByStudent =
loadLatestSnapshots(candidateIds, asOf); rankingCalculator.loadLatestSnapshots(candidateIds, asOf);
CompetitionRankingCalculator.CohortRanking cr =
List<Row> rows = new ArrayList<>(); rankingCalculator.rankCohort(apps, latestByStudent, meId);
for (Application app : apps) {
Row r = new Row();
r.studentUserId = app.getStudentUserId();
r.createdAt = app.getCreatedAt();
List<CandidateDimensionScore> 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);
List<CandidateDimensionScore> meSnaps = me == null // 3. Top 5 匿名摘要
? List.of() : latestByStudent.getOrDefault(meId, List.of());
String myPeriod = me == null ? null : me.snapshotPeriod;
// 5. Top 5 匿名摘要
List<CompetitionRankingResponse.RankingSnapshot> top = new ArrayList<>(); List<CompetitionRankingResponse.RankingSnapshot> 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++) { for (int i = 0; i < n; i++) {
Row r = rows.get(i); CompetitionRankingCalculator.Row r = cr.rows.get(i);
top.add(CompetitionRankingResponse.RankingSnapshot.builder() top.add(CompetitionRankingResponse.RankingSnapshot.builder()
.rank(i + 1).overallScore(r.overallScore).build()); .rank(i + 1).overallScore(r.overallScore).build());
} }
return CompetitionRankingResponse.builder() return CompetitionRankingResponse.builder()
.jobId(jobId) .jobId(jobId)
.rank(rank) .rank(cr.rank)
.cohortSize(cohortSize) .cohortSize(cr.cohortSize)
.overallScore(me == null ? null : me.overallScore) .overallScore(cr.overallScore)
.percentile(percentile) .percentile(cr.percentile)
.status(overviewStatus(meSnaps)) .status(cr.status)
.asOfPeriod(asOf) .asOfPeriod(asOf)
.mySnapshotPeriod(myPeriod) .mySnapshotPeriod(cr.mySnapshotPeriod)
.dimensions(buildDimensions(meSnaps)) .dimensions(buildDimensions(cr.meSnaps))
.gaps(buildGaps(meSnaps)) .gaps(buildGaps(cr.meSnaps))
.topCandidates(top) .topCandidates(top)
.rulesVersion(CandidateScoringCalculator.RULES_VERSION) .rulesVersion(CandidateScoringCalculator.RULES_VERSION)
.generatedAt(Instant.now()) .generatedAt(Instant.now())
.build(); .build();
} }
// ── cohort 快照批量装载 ───────────────────────────────────── // ── 维度 / 缺口(复用引擎语义,禁止另造公式)──────────
private Map<Long, List<CandidateDimensionScore>> loadLatestSnapshots(List<Long> ids, String asOf) {
if (ids.isEmpty()) return Map.of();
List<CandidateDimensionScore> all = scoreRepository.findUpToPeriod(ids, asOf);
Map<Long, Map<String, List<CandidateDimensionScore>>> byStudentPeriod = new HashMap<>();
for (CandidateDimensionScore s : all) {
byStudentPeriod.computeIfAbsent(s.getStudentUserId(), k -> new HashMap<>())
.computeIfAbsent(s.getPeriod(), k -> new ArrayList<>()).add(s);
}
Map<Long, List<CandidateDimensionScore>> result = new HashMap<>();
for (Map.Entry<Long, Map<String, List<CandidateDimensionScore>>> e : byStudentPeriod.entrySet()) {
String latest = Collections.max(e.getValue().keySet());
result.put(e.getKey(), e.getValue().get(latest));
}
return result;
}
private String latestPeriod(List<CandidateDimensionScore> snaps) {
return snaps.stream().map(CandidateDimensionScore::getPeriod)
.max(String::compareTo).orElse(null);
}
// ── 百分位(严格复用企业看板公式)──────────────────────────
private BigDecimal computePercentile(List<Row> rows, Row me) {
if (me == null || me.overallScore == null) return null;
List<BigDecimal> 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<CompetitionRankingResponse.DimensionItem> buildDimensions(List<CandidateDimensionScore> snaps) { private List<CompetitionRankingResponse.DimensionItem> buildDimensions(List<CandidateDimensionScore> snaps) {
return snaps.stream() return snaps.stream()
@ -221,16 +144,6 @@ public class CompetitionRankingService {
return gaps; return gaps;
} }
private String overviewStatus(List<CandidateDimensionScore> 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) { private void assertStudentRole(Long userId) {
@ -245,15 +158,4 @@ public class CompetitionRankingService {
private String yearMonth(LocalDate d) { private String yearMonth(LocalDate d) {
return String.format("%04d-%02d", d.getYear(), d.getMonthValue()); 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; }
}
} }

6
mydreamjob-server/src/main/java/com/mydreamjob/interview/InterviewRepository.java

@ -32,4 +32,10 @@ public interface InterviewRepository extends JpaRepository<Interview, Long> {
Page<Interview> findByStudentUserIdAndStatusAndStartAtBetweenOrderByStartAtDesc( Page<Interview> findByStudentUserIdAndStatusAndStartAtBetweenOrderByStartAtDesc(
Long studentUserId, String status, LocalDateTime from, LocalDateTime to, Pageable pageable); Long studentUserId, String status, LocalDateTime from, LocalDateTime to, Pageable pageable);
/**
* V34 聚合:一次读取当前学生全部 SCHEDULED/CONFIRMED 面试(待响应 + 已确认未来),
* 按开始时间正序;同时用于 todo 计数与每条投递的 nextInterview(取最早一场),避免 N+1。
*/
List<Interview> findByStudentUserIdAndStatusInOrderByStartAtAsc(Long studentUserId, java.util.Collection<String> statuses);
} }

9
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 org.springframework.data.jpa.repository.JpaRepository;
import java.util.Collection;
import java.util.List;
import java.util.Optional; import java.util.Optional;
public interface InterviewScoreRepository extends JpaRepository<InterviewScore, Long> { public interface InterviewScoreRepository extends JpaRepository<InterviewScore, Long> {
Optional<InterviewScore> findByInterviewId(Long interviewId); Optional<InterviewScore> findByInterviewId(Long interviewId);
/**
* V34 聚合:一次读取当前学生、某批投递的已提交(SUBMITTED)结构化面试评分,
* 避免按投递循环查询;服务层再按 applicationId 取 submittedAt 最新一条。
*/
List<InterviewScore> findByStudentUserIdAndApplicationIdInAndStatus(
Long studentUserId, Collection<Long> applicationIds, String status);
} }

4
mydreamjob-server/src/main/java/com/mydreamjob/notification/NotificationRepository.java

@ -15,5 +15,9 @@ public interface NotificationRepository extends JpaRepository<Notification, Long
@Query("SELECT COUNT(n) FROM Notification n WHERE n.userId = :userId AND n.read = false") @Query("SELECT COUNT(n) FROM Notification n WHERE n.userId = :userId AND n.read = false")
long countUnreadByUserId(@Param("userId") Long userId); long countUnreadByUserId(@Param("userId") Long userId);
/** V34 聚合:按用户 + 通知类型统计未读数(用于 REPORT_DECISION 未读计数)。 */
@Query("SELECT COUNT(n) FROM Notification n WHERE n.userId = :userId AND n.read = false AND n.type = :type")
long countUnreadByUserIdAndType(@Param("userId") Long userId, @Param("type") String type);
boolean existsByIdempotencyKey(String idempotencyKey); boolean existsByIdempotencyKey(String idempotencyKey);
} }

6
mydreamjob-server/src/main/java/com/mydreamjob/offer/OfferRepository.java

@ -70,4 +70,10 @@ public interface OfferRepository extends JpaRepository<Offer, Long> {
List<Offer> findActiveExpiringBetween(@Param("statuses") Collection<String> statuses, List<Offer> findActiveExpiringBetween(@Param("statuses") Collection<String> statuses,
@Param("from") LocalDateTime from, @Param("from") LocalDateTime from,
@Param("to") LocalDateTime to); @Param("to") LocalDateTime to);
/**
* V34 聚合:一次读取候选人本人全部非 DRAFT Offer(含 SENT/NEGOTIATING/终态),
* 按创建时间倒序;同时用于 todo 的有效 Offer 计数与每条投递的 Offer 展示,避免 N+1。
*/
List<Offer> findByCandidateIdAndStatusNotOrderByCreatedAtDesc(Long candidateId, String status);
} }

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

230
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 真实表结构)。
*
* <p>证明:真实批量查询在真实 Repository 上正确返回聚合结果;CareerProgressService
* 标注 @Transactional(readOnly=true) 且不写业务表 —— 调用前后关键业务表计数不变;
* 不新增 Flyway、不删除业务数据。</p>
*/
@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<String, Object> 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<String, Object> 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();
}
}

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

168
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 复用核心)。
*
* <p>覆盖稳定排序(有分优先 → overallScore DESC → createdAt ASC → studentUserId ASC →
* 无分最后)、cohort、百分位、最新快照周期选择与 overviewStatus。综合分走真实
* CandidateScoringCalculator 语义(此处以 mock 模拟按 studentUserId 返回预设分)。</p>
*/
@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<Long, BigDecimal> 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<Long, List<CandidateDimensionScore>> 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<Long, List<CandidateDimensionScore>> 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<Long, List<CandidateDimensionScore>> 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<Long, List<CandidateDimensionScore>> 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<Long, List<CandidateDimensionScore>> 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();
}
}

287
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.User;
import com.mydreamjob.auth.UserRepository; import com.mydreamjob.auth.UserRepository;
import com.mydreamjob.candidatescore.CandidateDimensionScore; 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.BusinessException;
import com.mydreamjob.common.exception.ErrorCode; import com.mydreamjob.common.exception.ErrorCode;
import com.mydreamjob.competition.dto.CompetitionRankingResponse; import com.mydreamjob.competition.dto.CompetitionRankingResponse;
import com.mydreamjob.job.Job; import com.mydreamjob.job.Job;
import com.mydreamjob.job.JobService; import com.mydreamjob.job.JobService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks; import org.mockito.InjectMocks;
@ -22,8 +19,7 @@ import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness; import org.mockito.quality.Strictness;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDate; import java.util.ArrayList;
import java.time.LocalDateTime;
import java.util.HashSet; import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@ -35,10 +31,11 @@ import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*; import static org.mockito.Mockito.*;
/** /**
* 同岗位竞争力排名服务单测(V32)。 * 同岗位竞争力排名服务单测(V32,编排 + 响应组装 + 脱敏)。
* *
* <p>用 Mock 隔离 repo/Calculator 以精确断言排序、cohort、百分位、Top5 脱敏与周期选择; * <p>排序/百分位/快照装载等算法已下沉到 {@link CompetitionRankingCalculator}
* 排序纯 Java(有分优先、overallScore DESC、createdAt ASC、studentUserId ASC、无分最后)。</p> * (由 CompetitionRankingCalculatorTest 覆盖),本类用 mock 计算器聚焦断言
* 权限、参数、岗位状态、cohort 委托,以及 Top5 脱敏与维度/缺口的组装。</p>
*/ */
@ExtendWith(MockitoExtension.class) @ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT) @MockitoSettings(strictness = Strictness.LENIENT)
@ -50,51 +47,10 @@ class CompetitionRankingServiceTest {
@Mock private UserRepository userRepository; @Mock private UserRepository userRepository;
@Mock private JobService jobService; @Mock private JobService jobService;
@Mock private ApplicationRepository applicationRepository; @Mock private ApplicationRepository applicationRepository;
@Mock private CandidateDimensionScoreRepository scoreRepository; @Mock private CompetitionRankingCalculator rankingCalculator;
@Mock private CandidateScoringCalculator calculator;
@InjectMocks private CompetitionRankingService service; @InjectMocks private CompetitionRankingService service;
private String currentPeriod;
private Map<Long, BigDecimal> 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 @Test
@ -112,6 +68,7 @@ class CompetitionRankingServiceTest {
@Test @Test
void jobNotFound_returnsJobNotFound() { void jobNotFound_returnsJobNotFound() {
mockStudent(ME);
when(jobService.getJob(JOB)).thenThrow(new BusinessException(ErrorCode.JOB_NOT_FOUND)); when(jobService.getJob(JOB)).thenThrow(new BusinessException(ErrorCode.JOB_NOT_FOUND));
BusinessException ex = assertThrows(BusinessException.class, () -> call()); BusinessException ex = assertThrows(BusinessException.class, () -> call());
assertEquals(ErrorCode.JOB_NOT_FOUND.getCode(), ex.getErrorCode()); assertEquals(ErrorCode.JOB_NOT_FOUND.getCode(), ex.getErrorCode());
@ -119,6 +76,7 @@ class CompetitionRankingServiceTest {
@Test @Test
void nonPublishedJob_returnsJobNotFound() { void nonPublishedJob_returnsJobNotFound() {
mockStudent(ME);
Job draft = Job.builder().id(JOB).status("DRAFT").build(); Job draft = Job.builder().id(JOB).status("DRAFT").build();
when(jobService.getJob(JOB)).thenReturn(draft); when(jobService.getJob(JOB)).thenReturn(draft);
BusinessException ex = assertThrows(BusinessException.class, () -> call()); BusinessException ex = assertThrows(BusinessException.class, () -> call());
@ -133,212 +91,109 @@ class CompetitionRankingServiceTest {
assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCode()); 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 @Test
void effectiveStatus_returns200() { void effectiveStatus_returns200_andAssemblesResponse() {
mockPublishedJob(); mockPublishedJob();
when(applicationRepository.existsByJobIdAndStudentUserIdAndStatusNotIn(eq(JOB), eq(ME), anySet())).thenReturn(true); when(applicationRepository.existsByJobIdAndStudentUserIdAndStatusNotIn(eq(JOB), eq(ME), anySet())).thenReturn(true);
when(applicationRepository.findByJobIdAndStatusNotInOrderByCreatedAtAsc(eq(JOB), anyCollection())) when(applicationRepository.findByJobIdAndStatusNotInOrderByCreatedAtAsc(eq(JOB), anyCollection()))
.thenReturn(List.of(app(ME, 1, "2026-01-01T10:00:00"))); .thenReturn(List.of(app(ME, "2026-01-01T10:00:00")));
overallByStudent = Map.of(); when(rankingCalculator.loadLatestSnapshots(anyList(), anyString())).thenReturn(Map.of());
when(scoreRepository.findUpToPeriod(anyCollection(), anyString())).thenReturn(List.of()); when(rankingCalculator.rankCohort(anyList(), anyMap(), eq(ME)))
CompetitionRankingResponse resp = call(); .thenReturn(cr(1, 1, new BigDecimal("85.00"), null, "OK", "2026-07", List.of()));
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")));
CompetitionRankingResponse resp = call(); CompetitionRankingResponse resp = call();
assertEquals(JOB, resp.getJobId()); assertEquals(JOB, resp.getJobId());
assertEquals(4, resp.getCohortSize()); // 无分候选人计入 cohortSize assertEquals(1, resp.getCohortSize());
assertEquals(2, resp.getRank()); // me 排第 2 assertEquals(1, resp.getRank());
assertEquals(new BigDecimal("85.00"), resp.getOverallScore()); assertEquals(new BigDecimal("85.00"), resp.getOverallScore());
assertEquals(new BigDecimal("50.00"), resp.getPercentile()); // lower=1, denom=2, 1/2*100 assertNull(resp.getPercentile());
// Top5 匿名:rank1 A=90, rank2 me=85, rank3 B=80, rank4 C=null assertEquals("OK", resp.getStatus());
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());
assertEquals("2026-07", resp.getMySnapshotPeriod()); assertEquals("2026-07", resp.getMySnapshotPeriod());
// 无身份字段泄露:结构上仅含 rank + overallScore
resp.getTopCandidates().forEach(t -> assertNotNull(t.getRank()));
assertEquals(1, resp.getDimensions().size());
} }
// ── Top5 脱敏与上限、维度/缺口组装 ─────────────────────────
@Test @Test
void scoredFirst_noScoreLast_secondarySortStable() { void topCandidates_cappedAt5_andNoIdentityFields() {
mockPublishedJob(); mockPublishedJob();
when(applicationRepository.existsByJobIdAndStudentUserIdAndStatusNotIn(eq(JOB), eq(ME), anySet())).thenReturn(true); 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())) when(applicationRepository.findByJobIdAndStatusNotInOrderByCreatedAtAsc(eq(JOB), anyCollection()))
.thenReturn(List.of(n1, x, y, n2, app(ME, 5, "2026-03-01T10:00:00"))); .thenReturn(List.of(app(ME, "2026-01-01T10:00:00")));
overallByStudent = Map.of(x.getStudentUserId(), new BigDecimal("77.00"), when(rankingCalculator.loadLatestSnapshots(anyList(), anyString())).thenReturn(Map.of());
y.getStudentUserId(), new BigDecimal("77.00"), when(rankingCalculator.rankCohort(anyList(), anyMap(), eq(ME)))
ME, new BigDecimal("77.00")); .thenReturn(cr(7, 3, new BigDecimal("77.00"), null, "PARTIAL", "2026-07", List.of()));
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")));
CompetitionRankingResponse resp = call(); CompetitionRankingResponse resp = call();
// 有分在前、无分最后;同分按 createdAt 升序(x 更早因此 x 在 y 前);本人有分排第 3
assertEquals(3, resp.getRank()); assertEquals(5, resp.getTopCandidates().size());
assertEquals(new BigDecimal("77.00"), resp.getTopCandidates().get(0).getOverallScore()); resp.getTopCandidates().forEach(t -> {
assertEquals(new BigDecimal("77.00"), resp.getTopCandidates().get(1).getOverallScore()); assertNotNull(t.getRank());
// 无分候选人的 overallScore 为 null 且排在末尾 });
assertNull(resp.getTopCandidates().get(3).getOverallScore());
assertEquals(5, resp.getCohortSize());
} }
@Test @Test
void ownScoreSameAsOther_tieBrokenByCreatedAtThenStudentUserId() { void dimensionsAndGaps_builtFromSnapshots() {
mockPublishedJob(); mockPublishedJob();
when(applicationRepository.existsByJobIdAndStudentUserIdAndStatusNotIn(eq(JOB), eq(ME), anySet())).thenReturn(true); 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())) when(applicationRepository.findByJobIdAndStatusNotInOrderByCreatedAtAsc(eq(JOB), anyCollection()))
.thenReturn(List.of(x, mm)); .thenReturn(List.of(app(ME, "2026-01-01T10:00:00")));
overallByStudent = Map.of(x.getStudentUserId(), new BigDecimal("60.00"), when(rankingCalculator.loadLatestSnapshots(anyList(), anyString())).thenReturn(Map.of());
ME, new BigDecimal("60.00")); when(rankingCalculator.rankCohort(anyList(), anyMap(), eq(ME)))
when(scoreRepository.findUpToPeriod(anyCollection(), anyString())) .thenReturn(cr(1, 1, new BigDecimal("85.00"), null, "OK", "2026-07",
.thenReturn(List.of(dim(x.getStudentUserId(), "EDUCATION", "2026-07"), List.of(dim(ME, "EDUCATION", "OK", "85.00"), dim(ME, "ABILITY", "PENDING", null))));
dim(ME, "EDUCATION", "2026-07")));
CompetitionRankingResponse resp = call(); 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(); private CompetitionRankingResponse call() {
assertEquals(2, resp.getCohortSize()); return service.getRanking(ME, JOB);
assertNull(resp.getRank());
assertNull(resp.getOverallScore());
assertNull(resp.getPercentile());
assertNull(resp.getMySnapshotPeriod());
assertEquals("INSUFFICIENT_DATA", resp.getStatus());
} }
@Test private void mockStudent(Long uid) {
void percentile_scoredLessThan2_returnsNull() { User u = User.builder().id(uid).phone("1").role(User.Role.STUDENT).build();
mockPublishedJob(); u.setRoles(new HashSet<>(Set.of(User.Role.STUDENT)));
when(applicationRepository.existsByJobIdAndStudentUserIdAndStatusNotIn(eq(JOB), eq(ME), anySet())).thenReturn(true); when(userRepository.findById(uid)).thenReturn(Optional.of(u));
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());
} }
@Test private void mockRole(Long uid, User.Role role) {
void latestPeriodSelected_multipleMonthlySnapshots() { User u = User.builder().id(uid).phone("1").role(role).build();
mockPublishedJob(); u.setRoles(new HashSet<>(Set.of(role)));
when(applicationRepository.existsByJobIdAndStudentUserIdAndStatusNotIn(eq(JOB), eq(ME), anySet())).thenReturn(true); when(userRepository.findById(uid)).thenReturn(Optional.of(u));
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")));
CompetitionRankingResponse resp = call(); private void mockPublishedJob() {
assertEquals("2026-07", resp.getMySnapshotPeriod()); mockStudent(ME);
assertEquals(1, resp.getDimensions().size()); // 仅最新周期 1 条 Job j = Job.builder().id(JOB).status("PUBLISHED").build();
when(jobService.getJob(JOB)).thenReturn(j);
} }
@Test private CompetitionRankingCalculator.CohortRanking cr(int cohortSize, Integer rank, BigDecimal overall,
void topCandidates_cappedAt5() { BigDecimal percentile, String status,
mockPublishedJob(); String period, List<CandidateDimensionScore> snaps) {
when(applicationRepository.existsByJobIdAndStudentUserIdAndStatusNotIn(eq(JOB), eq(ME), anySet())).thenReturn(true); List<CompetitionRankingCalculator.Row> rows = new ArrayList<>();
var apps = new java.util.ArrayList<Application>(); for (int i = 0; i < cohortSize; i++) {
apps.add(app(ME, 1, "2026-01-01T10:00:00")); BigDecimal s = (i < cohortSize) ? new BigDecimal("90.00") : null;
for (long i = 2; i <= 8; i++) { rows.add(new CompetitionRankingCalculator.Row(ME + i, null, s, period));
apps.add(app(1000L + i, (int) i, "2026-01-0" + (i % 9) + "T10:00:00"));
} }
when(applicationRepository.findByJobIdAndStatusNotInOrderByCreatedAtAsc(eq(JOB), anyCollection())) CompetitionRankingCalculator.Row me = new CompetitionRankingCalculator.Row(ME, null, overall, period);
.thenReturn(apps); return new CompetitionRankingCalculator.CohortRanking(cohortSize, rows, me, rank,
overallByStudent = Map.of(); overall, percentile, status, period, snaps);
when(scoreRepository.findUpToPeriod(anyCollection(), anyString())).thenReturn(List.of());
// 全部无分场景,仅验证 Top5 上限
CompetitionRankingResponse resp = call();
assertEquals(5, resp.getTopCandidates().size());
} }
// ── 载体构造 ────────────────────────────────────────────── private Application app(Long sid, String created) {
return Application.builder().studentUserId(sid).status("SUBMITTED")
private Application app(Long sid, int idx, String created) { .createdAt(java.time.LocalDateTime.parse(created)).build();
return Application.builder().jobId(JOB).studentUserId(sid)
.status(idx % 3 == 0 ? "VIEWED" : "SUBMITTED")
.createdAt(LocalDateTime.parse(created)).build();
} }
private CandidateDimensionScore dim(Long sid, String dimension, String period) { private CandidateDimensionScore dim(Long sid, String dimension, String status, String score) {
return CandidateDimensionScore.builder().studentUserId(sid).dimension(dimension).period(period) return CandidateDimensionScore.builder().studentUserId(sid).dimension(dimension).period("2026-07")
.weight(new BigDecimal("12.50")).rulesVersion("candidate-scoring-v0.1") .score(score == null ? null : new BigDecimal(score)).status(status)
.status("OK").build(); .weight(new BigDecimal("12.50")).rulesVersion("candidate-scoring-v0.1").build();
} }
} }
Loading…
Cancel
Save