diff --git a/.gitignore b/.gitignore
index 2c919b6..c6d7175 100644
--- a/.gitignore
+++ b/.gitignore
@@ -42,3 +42,11 @@ acceptance-*.err.log
**/logs/
**/*.log
**/data-exports/
+# Backend black-box scratch: runtime OpenAPI dumps / stderr / diagnostics
+backend-infra/app.log.err
+backend-infra/diag_out.txt
+backend-infra/runtime_openapi.json
+backend-infra/v34_rt_api.json
+backend-infra/v34_runtime_api.json
+backend-infra/v34_runtime_openapi.json
+# Note: do NOT add broad *.json ignores here (would hide unit-test JSON / formal OpenAPI docs under docs/)
diff --git a/docs/backend-development-roadmap.md b/docs/backend-development-roadmap.md
index 05bbe23..815194d 100644
--- a/docs/backend-development-roadmap.md
+++ b/docs/backend-development-roadmap.md
@@ -26,20 +26,20 @@
| 项目 | 当前状态 |
|---|---|
-| OpenAPI | **125 paths、159 operations** |
-| Flyway | **V1–V33** 已存在 |
-| 全量测试 | **1255/1255 通过**(本次 `mvn clean verify` 实际数量) |
-| 已完成范围 | V24 Candidate Scoring、V25 Governance、V26 Offer Advance、V27 Privacy、V28/V29 Account Deletion、V30 Data Export、V31 Interview Score、V32 Competition Ranking、V33 Report/Block,及认证、资料、岗位、投递、面试、Offer、看板等核心闭环 |
-| 当前结论 | 已达到已实现范围的 Release Candidate;V33 Report/Block 已完成真实 MySQL/Docker 黑盒验收并正式冻结 |
+| OpenAPI | **126 paths、160 operations** |
+| Flyway | **V1–V33** 已存在(V34 为纯接口扩展,不含 Flyway 迁移) |
+| 全量测试 | **1275/1275 通过**(本次 `mvn clean verify` 实际数量) |
+| 已完成范围 | V24 Candidate Scoring、V25 Governance、V26 Offer Advance、V27 Privacy、V28/V29 Account Deletion、V30 Data Export、V31 Interview Score、V32 Competition Ranking、V33 Report/Block、V34 统一求职进度聚合,及认证、资料、岗位、投递、面试、Offer、看板等核心闭环 |
+| 当前结论 | 已达到已实现范围的 Release Candidate;V33 Report/Block 已完成真实 MySQL/Docker 黑盒验收并正式冻结;V34 统一求职进度聚合已完成实现并通过真实 MySQL/Docker 黑盒验收(PASS=99 / FAIL=0 / SKIP=0),待建立正式 Git 检查点 |
| 本规划范围 | 代码修复、新功能开发、接口契约、数据权限和治理能力 |
### 2.0 模块状态分类
| 类别 | 模块 |
|---|---|
-| 已完成并已验收 | V24 Candidate Scoring、V25 Governance、V26 Offer Advance、V27 Privacy、V28/V29 Account Deletion、V30 Data Export、V31 Interview Score、V32 Competition Ranking、V33 Report/Block |
+| 已完成并已验收 | V24 Candidate Scoring、V25 Governance、V26 Offer Advance、V27 Privacy、V28/V29 Account Deletion、V30 Data Export、V31 Interview Score、V32 Competition Ranking、V33 Report/Block、V34 统一求职进度聚合 |
| 已完成、待真实 MySQL/Docker 验收 | — |
-| 已设计未编码 | 测评系统(Holland/能力测评)、公平性检测、SHAP/规则解释、防刷审计、企业评价管理、企业人才推荐、统一求职进度聚合 |
+| 已设计未编码 | 测评系统(Holland/能力测评)、公平性检测、SHAP/规则解释、防刷审计、企业评价管理、企业人才推荐 |
| 未开始 | AI 简历诊断/优化、AI 模拟面试、行业技能词云、入职准备清单、薪资计算器、诚信积分、消息撤回 |
### 2.1 已确认的产品决策
@@ -120,7 +120,7 @@
| Figma 页面/功能 | 当前实现 | 差距 |
|---|---|---|
| 同岗位竞争力排名 | `GET /jobs/{jobId}/competition-ranking` 已实现 | 已交付;最终口径:WITHDRAWN/REJECTED 退出候选人池(请求 403/2002);无评分但有效投递返回 200 + null 分数;Top5 匿名。不宣称"支持隐私退出" |
-| 求职进度 | applications、timeline、offers、interviews 可组合 | 缺统一的求职进度聚合接口 |
+| 求职进度 | `GET /profile/me/career-progress`(V34)已实现 | 已交付统一求职进度聚合接口(只读,含竞争力摘要与待办/通知计数) |
| Offer 对比 | `/offers/compare` 返回 Offer 字段 | 综合薪资、福利、发展空间当前由前端展示,后端不计算综合结论 |
| HR 评价详情 | `candidate_feedback` 已实现 | 不是结构化面试评分 |
| 企业评价管理 | 有评价审核和管理员决策 | 缺企业端评价管理、回复和运营动作 |
@@ -142,7 +142,7 @@
- SHAP/规则解释;
- ADMIN 防刷审计和规则配置;
- 诚信积分和激励。
-- 已非缺失(已实现):个人数据导出(V30)、隐私设置(V27)、账号注销(V28/V29)、举报与屏蔽(V33)、结构化面试评分(V31)、同岗位竞争力排名(V32)。
+- 已非缺失(已实现):个人数据导出(V30)、隐私设置(V27)、账号注销(V28/V29)、举报与屏蔽(V33)、结构化面试评分(V31)、同岗位竞争力排名(V32)、统一求职进度聚合(V34)。
## 5. 已有代码的优先修复项
@@ -457,7 +457,7 @@ scoring_risk_actions
| P0 | 结构化面试评分 |
| P0/P1 | 同岗位竞争力排名 |
| P1 | 霍兰德和四维能力测评 |
-| P1 | 统一求职进度 |
+| P1 | 统一求职进度(V34 已完成) |
| P1 | 企业评价管理 |
| P1 | 企业人才推荐 |
| P1 | 公平性检测 |
diff --git a/docs/career-progress-api.md b/docs/career-progress-api.md
new file mode 100644
index 0000000..710119f
--- /dev/null
+++ b/docs/career-progress-api.md
@@ -0,0 +1,181 @@
+# 统一求职进度聚合 API(V34)
+
+> 版本:V34 设计锁定版
+> 状态:已编码实现
+> 适用范围:学生端**只读**接口,为「求职进度」页聚合投递、面试、结构化面试评分、Offer、同岗位竞争力排名与通知计数。
+
+## 1. 定位与边界
+
+为 STUDENT 提供「求职进度」页所需的单次聚合数据,避免前端串行调用多个接口。与既有接口边界:
+
+| 接口 | 是否改动 | 关系 |
+|---|---|---|
+| `GET /jobs/{jobId}/competition-ranking` | 不改 | 复用同套候选人池/排序/百分位口径(共享 `CompetitionRankingCalculator`),但本接口只返回摘要,不返回 dimensions/gaps/topCandidates |
+| `GET /notifications` | 不改 | 本接口只返回通知**计数**,通知标题/正文/详情仍由 `/notifications` 提供 |
+| `GET /interviews/{id}/score` 等 | 不改 | 仅读取已提交(SUBMITTED)评分摘要,不返回 DRAFT、不返回 comment |
+| `GET /offers/...` | 不改 | 仅返回 Offer 摘要,不返回薪资/条款细节 |
+| `GET /profile/me/...`、投递、面试 | 不改 | 本接口为其只读聚合,不重写既有语义 |
+
+## 2. 接口
+
+```
+GET /api/v1/profile/me/career-progress
+```
+
+Query 参数:
+
+| 参数 | 类型 | 必填 | 默认 | 说明 |
+|---|---|---|---|---|
+| `page` | int | 否 | `0` | 页码,从 0 开始,`>= 0` |
+| `size` | int | 否 | `20` | 每页条数,`1–50` |
+| `status` | string | 否 | — | 投递状态过滤,仅 `SUBMITTED/VIEWED/INTERVIEW/HIRED/REJECTED/WITHDRAWN` |
+
+- 角色:仅 STUDENT。
+- 身份从 JWT 获取,不接收 `userId`/`studentUserId`/`candidateId` 等身份注入参数。
+- 新增 1 条 GET 路径,OpenAPI 125 → 126。
+
+## 3. 权限矩阵
+
+| 身份/场景 | HTTP | code | 说明 |
+|---|---|---|---|
+| 未登录 | 401 | 2001 | 既有安全机制 |
+| COMPANY / ADMIN | 403 | 2002 | Service 层 `assertStudentRole` |
+| STUDENT 正常 | 200 | — | 正常返回 |
+| `page < 0` / `size < 1` / `size > 50` / 非法 `status` | 400 | 1001 | BAD_REQUEST |
+
+不新增错误码,复用既有 ErrorCode。
+
+## 4. 响应结构
+
+```
+ApiResponse
+
+CareerProgressResponse {
+ ProgressTodoSummary todo;
+ PaginatedApplications applications;
+ Instant generatedAt;
+}
+
+ProgressTodoSummary {
+ long pendingInterviewResponseCount; // 状态 SCHEDULED 的面试数量
+ long upcomingConfirmedInterviewCount; // 状态 CONFIRMED 且 startAt > 当前时间
+ long pendingOfferResponseCount; // 状态 SENT/NEGOTIATING 且 expireAt > 当前时间
+ long unreadNotificationCount; // 未读通知总数
+ long unreadReportDecisionCount; // 未读 REPORT_DECISION 通知数
+}
+
+PaginatedApplications {
+ List items;
+ long total;
+ int page;
+ int size;
+ int totalPages;
+}
+
+CareerProgressItem {
+ Long applicationId;
+ String applicationStatus;
+ LocalDateTime appliedAt;
+ LocalDateTime updatedAt;
+ JobSummary job;
+ InterviewSummary nextInterview; // 可空
+ ScoreSummary latestSubmittedInterviewScore; // 可空
+ OfferSummary offer; // 可空
+ CompetitionSummary competition;
+}
+
+JobSummary { Long jobId; String title; String companyName; String city; String employmentType; String jobStatus; }
+InterviewSummary { Long interviewId; Integer roundNo; String title; String interviewType; LocalDateTime startAt; LocalDateTime endAt; String status; }
+ScoreSummary { Long interviewId; BigDecimal overallScore; String recommendation; LocalDateTime submittedAt; }
+OfferSummary { Long offerId; String status; String title; LocalDateTime expireAt; LocalDate onboardingDate; }
+CompetitionSummary {
+ boolean eligible;
+ Integer rank; // 有分才返回;WITHDRAWN/REJECTED 或本人无分 → null
+ Integer cohortSize; // 仅 eligible=true 时返回
+ BigDecimal overallScore; // 本人无分 → null
+ BigDecimal percentile; // 有分者 < 2 → null
+ String status; // OK / PARTIAL / INSUFFICIENT_DATA / PENDING
+ String mySnapshotPeriod; // 本人实际使用快照月;无快照 → null
+ String rulesVersion;
+}
+```
+
+### 明确不返回字段
+
+- 不返回 `studentUserId`、`candidateId`、`companyUserId`、`evaluatorUserId`、`phone`、`email`、`resumeUrl`、`passwordHash`、JWT。
+- 不返回通知标题/正文(`title`/`content`)、举报详情、面试评分 `comment`、Offer 薪资/条款细节(`salaryMin/salaryMax/baseSalary/housingAllowance/annualBonusMonths/benefits/description/otherAllowances`)。
+- 不返回其他候选人的任何身份信息,也不返回 V32 的 `dimensions`、`gaps`、`topCandidates`。
+
+## 5. 聚合口径
+
+### 5.1 投递
+
+- 读取当前学生全部投递,按 `createdAt DESC` 分页。
+- `status` 存在时按该状态筛选。
+- 不过滤 `WITHDRAWN/REJECTED/HIRED`,均属于进度历史。
+
+### 5.2 Todo
+
+| 字段 | 口径 |
+|---|---|
+| `pendingInterviewResponseCount` | 当前学生 `SCHEDULED` 面试数量 |
+| `upcomingConfirmedInterviewCount` | 当前学生 `CONFIRMED` 且 `startAt > now` 的面试数量 |
+| `pendingOfferResponseCount` | 当前学生 `SENT/NEGOTIATING` 且 `expireAt > now` 的 Offer 数量 |
+| `unreadNotificationCount` | 复用 `NotificationRepository` 未读总数 |
+| `unreadReportDecisionCount` | 未读且 `type='REPORT_DECISION'` 的通知数量 |
+
+### 5.3 nextInterview
+
+- 只看该投递的 `SCHEDULED / CONFIRMED` 面试,取 `startAt` 最早一场。
+- `RESCHEDULE_REQUESTED` 不算已确定;已完成/取消/拒绝不作为 nextInterview。
+
+### 5.4 latestSubmittedInterviewScore
+
+- 只读 `SUBMITTED` 状态的 `InterviewScore`,每个 `applicationId` 取 `submittedAt` 最新一条。
+- 不返回 DRAFT、不返回 `comment`。
+
+### 5.5 Offer
+
+- 仅学生本人 `candidateId` 的 Offer;`DRAFT` 永不返回。
+- 优先展示 `SENT/NEGOTIATING` 且 `expireAt > now` 的有效 Offer,按 `expireAt ASC` 取最早到期一条。
+- 无有效 Offer 时,展示该投递 `createdAt` 最新的一条非 DRAFT Offer。
+- 不返回薪资/条款细节。
+
+### 5.6 competition
+
+- 投递状态 `SUBMITTED/VIEWED/INTERVIEW/HIRED` → `eligible=true`;`WITHDRAWN/REJECTED` → `eligible=false`(不返回其它排名字段)。
+- 复用 V32 候选池口径、最新快照口径、`CandidateScoringCalculator#overallScoreFromSnapshots`、稳定排序与百分位公式。
+- `percentile = 严格低于本人分数的人数 / (有分候选人数 - 1) × 100`,`scale=2`、`HALF_UP`;有分者 `< 2` 时 `null`。
+- 不返回 V32 的 `dimensions`/`gaps`/`topCandidates`。
+
+## 6. 综合分 / 排序 / 百分位
+
+- 全部复用 `CompetitionRankingCalculator`(V32/V34 共享),禁止复制第二套公式、禁止 SQL 重算。
+- `status` 复用引擎语义:`OK / PARTIAL / INSUFFICIENT_DATA / PENDING`,不把 `PENDING/INSUFFICIENT_DATA` 折算为 0。
+
+## 7. 只读与性能
+
+- `CareerProgressService` 标注 `@Transactional(readOnly=true)`,不写业务表/审计表/通知表/缓存。
+- 所有附属数据批量读取(jobs/companies/interviews/scores/offers/cohort/快照/通知计数),查询次数与分页 `items` 数量无关,目标约 8–10 次,无 N+1。
+- `size` 上限固定 50。
+
+## 8. 数据库与 Flyway
+
+- 不需要新表。
+- 不新增 Flyway V34。
+- 无 DDL、无数据变更。
+
+## 9. 测试
+
+- `CareerProgressServiceTest`:权限/参数、空数据、完整聚合、DRAFT 不泄露、WITHDRAWN/REJECTED、无快照、批量查询无 N+1。
+- `CareerProgressControllerTest`:STUDENT 正常、COMPANY/ADMIN 403、未登录 401、参数非法 400、脱敏断言。
+- `CareerProgressPersistenceIntegrationTest`:真实仓库批量读取 + 只读性(关键表计数前后不变)。
+
+## 10. 禁止事项(实现阶段)
+
+- 不修改 Candidate Scoring Engine、Competition Ranking 对外接口/权限/状态口径。
+- 不修改 Offer、Interview、Notification、Report/Block 的既有接口语义。
+- 不新增数据库表、不新增 Flyway、不新增 Redis 缓存、不新增异步任务。
+- 不新增错误码。
+- 不做企业回复率、推荐算法、AI 分析、薪资建议、通知内容聚合。
+- 不启停 Docker(黑盒阶段除外)。
\ No newline at end of file
diff --git a/docs/frontend-integration.md b/docs/frontend-integration.md
index 3406e9e..632e707 100644
--- a/docs/frontend-integration.md
+++ b/docs/frontend-integration.md
@@ -655,3 +655,56 @@ POST /admin/reports/{id}/decision # 处理决定
**脱敏**:所有举报/屏蔽响应均**不返回** phone、email、passwordHash、resumeUrl、JWT、消息全文;MESSAGE 举报仅返回脱敏 `messagePreview`;举报列表不返回举报人内部 ID。
- API 详见 [docs/report-block-api.md](report-block-api.md)。
+
+## 20. 统一求职进度聚合(Career Progress)联调
+
+学生端「求职进度」页的**只读**聚合接口,一次返回投递分页、岗位摘要、下一场面试、最近已提交结构化面试评分、可展示 Offer、同岗位竞争力摘要,以及待办/通知计数:
+
+```text
+GET /profile/me/career-progress?page=0&size=20&status=INTERVIEW # 仅 STUDENT;page/size/status 均可选
+```
+
+- **权限矩阵**:
+ - 未登录 → `401`(安全过滤器)
+ - `COMPANY` / `ADMIN` → `403/2002`
+ - `page<0`、`size<1`、`size>50`、非法 `status` → `400/1001`
+ - 身份只取 JWT,不接受 `userId/studentUserId/candidateId` 参数
+- **查询参数**:`page`(默认 0,≥0)、`size`(默认 20,1–50)、`status`(可选,仅 `SUBMITTED/VIEWED/INTERVIEW/HIRED/REJECTED/WITHDRAWN`)。
+- **响应结构(示例,字段为 camelCase)**:
+
+```json
+{
+ "todo": {
+ "pendingInterviewResponseCount": 1,
+ "upcomingConfirmedInterviewCount": 2,
+ "pendingOfferResponseCount": 1,
+ "unreadNotificationCount": 3,
+ "unreadReportDecisionCount": 1
+ },
+ "applications": {
+ "items": [
+ {
+ "applicationId": 31, "applicationStatus": "INTERVIEW",
+ "appliedAt": "2026-08-20T10:00:00", "updatedAt": "2026-08-25T12:00:00",
+ "job": { "jobId": 9, "title": "Java 后端工程师", "companyName": "示例科技", "city": "杭州", "employmentType": "FULL_TIME", "jobStatus": "PUBLISHED" },
+ "nextInterview": { "interviewId": 23, "roundNo": 2, "title": "技术二面", "interviewType": "ONLINE", "startAt": "2026-08-30T14:00:00", "endAt": "2026-08-30T15:00:00", "status": "CONFIRMED" },
+ "latestSubmittedInterviewScore": { "interviewId": 22, "overallScore": 3.60, "recommendation": "HIRE", "submittedAt": "2026-08-25T16:00:00" },
+ "offer": { "offerId": 14, "status": "SENT", "title": "Java 后端工程师", "expireAt": "2026-09-05T23:59:59", "onboardingDate": "2026-09-15" },
+ "competition": { "eligible": true, "rank": 3, "cohortSize": 18, "overallScore": 77.50, "percentile": 82.35, "status": "PARTIAL", "mySnapshotPeriod": "2026-08", "rulesVersion": "candidate-scoring-v0.1" }
+ }
+ ],
+ "total": 8, "page": 0, "size": 20, "totalPages": 1
+ },
+ "generatedAt": "2026-08-27T10:00:00Z"
+}
+```
+
+- **空数据**:无投递 → `items=[]`、`total=0`、`todo` 全 0;无下一场面试/无已提交评分/无可展示 Offer 时,相应对象为 `null`(JSON 省略)。
+- **competition**:
+ - 有效投递(`SUBMITTED/VIEWED/INTERVIEW/HIRED`)`eligible=true`;有评分快照才返回 `rank/overallScore/percentile/mySnapshotPeriod`,无快照则这些为 `null` 但仍返回 `cohortSize`。
+ - `WITHDRAWN/REJECTED` 投递保留在历史,但 `eligible=false` 且不返回 `rank/cohortSize/overallScore/percentile/status/mySnapshotPeriod/rulesVersion`。
+ - 复用 V32 同岗位排名的同一套 cohort/排序/百分位口径,但不返回 `dimensions/gaps/topCandidates`。
+- **Todo 口径**:`pendingInterviewResponseCount`=状态 `SCHEDULED` 面试数;`upcomingConfirmedInterviewCount`=状态 `CONFIRMED` 且 `startAt>now`;`pendingOfferResponseCount`=状态 `SENT/NEGOTIATING` 且 `expireAt>now`;`unreadNotificationCount`=未读通知总数;`unreadReportDecisionCount`=未读 `REPORT_DECISION` 通知数。
+- **脱敏**:不返回 `studentUserId/candidateId/companyUserId/evaluatorUserId/phone/email/resumeUrl/passwordHash/JWT`、通知标题/正文、评分 `comment`、Offer 薪资/条款、他人任何身份字段。**本接口只返回通知计数,通知详情仍用 `/notifications`**。
+- **只读**:`GET`,不写任何业务表;`size` 上限 50,聚合查询次数与单页条数无关(无 N+1)。
+- API 详见 [docs/career-progress-api.md](career-progress-api.md)。
diff --git a/docs/openapi-rc.json b/docs/openapi-rc.json
index f046158..24c8fc5 100644
--- a/docs/openapi-rc.json
+++ b/docs/openapi-rc.json
@@ -4763,6 +4763,60 @@
}
}
},
+ "/profile/me/career-progress": {
+ "get": {
+ "tags": [
+ "求职进度"
+ ],
+ "summary": "统一求职进度聚合",
+ "description": "只读学生端接口:汇总当前学生的投递分页、岗位摘要、下一场面试、最近已提交结构化面试评分、可展示 Offer、同岗位竞争力排名,以及待办/通知计数(仅计数,不含通知标题/正文/详情)。仅 STUDENT 可访问,身份仅从 JWT/SecurityContext 获取,不接受 userId/studentUserId/candidateId 参数;COMPANY/ADMIN 返回 403/2002,未登录返回 401,参数非法返回 400/1001。通知详情仍由既有 /notifications 提供。",
+ "operationId": "getCareerProgress",
+ "parameters": [
+ {
+ "name": "page",
+ "in": "query",
+ "required": false,
+ "schema": { "type": "integer", "format": "int32", "default": 0 }
+ },
+ {
+ "name": "size",
+ "in": "query",
+ "required": false,
+ "schema": { "type": "integer", "format": "int32", "default": 20 }
+ },
+ {
+ "name": "status",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "string",
+ "enum": ["SUBMITTED", "VIEWED", "INTERVIEW", "HIRED", "REJECTED", "WITHDRAWN"]
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ApiResponseCareerProgressResponse" } } }
+ },
+ "400": {
+ "description": "page/size/status 参数非法",
+ "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ApiResponse" } } }
+ },
+ "401": {
+ "description": "未登录",
+ "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ApiResponse" } } }
+ },
+ "403": {
+ "description": "非 STUDENT 角色",
+ "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ApiResponse" } } }
+ }
+ },
+ "security": [
+ { "bearerAuth": [] }
+ ]
+ }
+ },
"/profile/me/deletion": {
"get": {
"tags": [
@@ -6337,6 +6391,260 @@
}
}
},
+ "CareerProgressResponse": {
+ "type": "object",
+ "properties": {
+ "todo": {
+ "$ref": "#/components/schemas/ProgressTodoSummary"
+ },
+ "applications": {
+ "$ref": "#/components/schemas/PaginatedApplications"
+ },
+ "generatedAt": {
+ "type": "string",
+ "format": "date-time"
+ }
+ }
+ },
+ "ProgressTodoSummary": {
+ "type": "object",
+ "properties": {
+ "pendingInterviewResponseCount": {
+ "type": "integer",
+ "format": "int64"
+ },
+ "upcomingConfirmedInterviewCount": {
+ "type": "integer",
+ "format": "int64"
+ },
+ "pendingOfferResponseCount": {
+ "type": "integer",
+ "format": "int64"
+ },
+ "unreadNotificationCount": {
+ "type": "integer",
+ "format": "int64"
+ },
+ "unreadReportDecisionCount": {
+ "type": "integer",
+ "format": "int64"
+ }
+ }
+ },
+ "PaginatedApplications": {
+ "type": "object",
+ "properties": {
+ "items": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/CareerProgressItem"
+ }
+ },
+ "total": {
+ "type": "integer",
+ "format": "int64"
+ },
+ "page": {
+ "type": "integer",
+ "format": "int32"
+ },
+ "size": {
+ "type": "integer",
+ "format": "int32"
+ },
+ "totalPages": {
+ "type": "integer",
+ "format": "int32"
+ }
+ }
+ },
+ "CareerProgressItem": {
+ "type": "object",
+ "properties": {
+ "applicationId": {
+ "type": "integer",
+ "format": "int64"
+ },
+ "applicationStatus": {
+ "type": "string"
+ },
+ "appliedAt": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "updatedAt": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "job": {
+ "$ref": "#/components/schemas/JobSummary"
+ },
+ "nextInterview": {
+ "$ref": "#/components/schemas/InterviewSummary"
+ },
+ "latestSubmittedInterviewScore": {
+ "$ref": "#/components/schemas/ScoreSummary"
+ },
+ "offer": {
+ "$ref": "#/components/schemas/OfferSummary"
+ },
+ "competition": {
+ "$ref": "#/components/schemas/CompetitionSummary"
+ }
+ }
+ },
+ "JobSummary": {
+ "type": "object",
+ "properties": {
+ "jobId": {
+ "type": "integer",
+ "format": "int64"
+ },
+ "title": {
+ "type": "string"
+ },
+ "companyName": {
+ "type": "string"
+ },
+ "city": {
+ "type": "string"
+ },
+ "employmentType": {
+ "type": "string"
+ },
+ "jobStatus": {
+ "type": "string"
+ }
+ }
+ },
+ "InterviewSummary": {
+ "type": "object",
+ "properties": {
+ "interviewId": {
+ "type": "integer",
+ "format": "int64"
+ },
+ "roundNo": {
+ "type": "integer",
+ "format": "int32"
+ },
+ "title": {
+ "type": "string"
+ },
+ "interviewType": {
+ "type": "string"
+ },
+ "startAt": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "endAt": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "status": {
+ "type": "string"
+ }
+ }
+ },
+ "ScoreSummary": {
+ "type": "object",
+ "properties": {
+ "interviewId": {
+ "type": "integer",
+ "format": "int64"
+ },
+ "overallScore": {
+ "type": "number"
+ },
+ "recommendation": {
+ "type": "string"
+ },
+ "submittedAt": {
+ "type": "string",
+ "format": "date-time"
+ }
+ }
+ },
+ "OfferSummary": {
+ "type": "object",
+ "properties": {
+ "offerId": {
+ "type": "integer",
+ "format": "int64"
+ },
+ "status": {
+ "type": "string"
+ },
+ "title": {
+ "type": "string"
+ },
+ "expireAt": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "onboardingDate": {
+ "type": "string",
+ "format": "date"
+ }
+ }
+ },
+ "CompetitionSummary": {
+ "type": "object",
+ "properties": {
+ "eligible": {
+ "type": "boolean"
+ },
+ "rank": {
+ "type": "integer",
+ "format": "int32"
+ },
+ "cohortSize": {
+ "type": "integer",
+ "format": "int32"
+ },
+ "overallScore": {
+ "type": "number"
+ },
+ "percentile": {
+ "type": "number"
+ },
+ "status": {
+ "type": "string"
+ },
+ "mySnapshotPeriod": {
+ "type": "string"
+ },
+ "rulesVersion": {
+ "type": "string"
+ }
+ }
+ },
+ "ApiResponseCareerProgressResponse": {
+ "type": "object",
+ "properties": {
+ "code": {
+ "type": "integer",
+ "format": "int32",
+ "example": 200
+ },
+ "message": {
+ "type": "string",
+ "example": "ok"
+ },
+ "data": {
+ "$ref": "#/components/schemas/CareerProgressResponse"
+ },
+ "requestId": {
+ "type": "string",
+ "example": "req-1"
+ },
+ "timestamp": {
+ "type": "string",
+ "format": "date-time"
+ }
+ }
+ },
"ApiResponseAccountDeletionResponse": {
"type": "object",
"properties": {
diff --git a/docs/verify_openapi.py b/docs/verify_openapi.py
index 4625a2a..7470085 100644
--- a/docs/verify_openapi.py
+++ b/docs/verify_openapi.py
@@ -6,7 +6,7 @@ p = PROJECT_ROOT / 'docs' / 'openapi-rc.json'
d = json.load(open(p, encoding='utf-8'))
print('paths:', len(d['paths']))
-assert len(d['paths']) == 125, f"Expected 125 paths, got {len(d['paths'])}"
+assert len(d['paths']) == 126, f"Expected 126 paths, got {len(d['paths'])}"
paths = d['paths']
required = [
@@ -55,6 +55,8 @@ required = [
'/interviews/{interviewId}/score/history',
# 同岗位竞争力排名(1 个 path / 1 个 operation)
'/jobs/{jobId}/competition-ranking',
+ # V34 统一求职进度聚合(1 个 path / 1 个 operation)
+ '/profile/me/career-progress',
# V33 举报与屏蔽(8 个 path / 9 个 operation)
'/reports',
'/reports/me',
@@ -119,6 +121,9 @@ print(" Personal data export endpoints present (4 operations / 3 paths)")
# 同岗位竞争力排名(1 个 GET operation)
assert 'get' in paths['/jobs/{jobId}/competition-ranking'], "Missing GET /jobs/{jobId}/competition-ranking"
print(" Competition ranking endpoint present (1 GET operation)")
+# V34 统一求职进度聚合(1 个 GET operation)
+assert 'get' in paths['/profile/me/career-progress'], "Missing GET /profile/me/career-progress"
+print(" Career progress endpoint present (1 GET operation)")
# V33 举报与屏蔽(8 个 path / 9 个 operation;PUT+DELETE 共享 /blocks/{blockedUserId})
assert 'post' in paths['/reports'], "Missing POST /reports"
assert 'get' in paths['/reports/me'], "Missing GET /reports/me"
diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationRepository.java
index 6c10de4..20c670c 100644
--- a/mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationRepository.java
+++ b/mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationRepository.java
@@ -39,4 +39,11 @@ public interface ApplicationRepository extends JpaRepository
* 同岗位 rank 用:取某岗位未排除(非 WITHDRAWN/REJECTED)的投递,按投递时间正序。
*/
List findByJobIdAndStatusNotInOrderByCreatedAtAsc(Long jobId, java.util.Collection statuses);
+
+ /**
+ * V34 批量:一次取出多个岗位(page 内投递涉及的去重 jobId)未排除的候选人投递,
+ * 按投递时间正序;用于批量计算同岗位竞争力排名,避免按岗位循环查询造成 N+1。
+ */
+ List findByJobIdInAndStatusNotInOrderByCreatedAtAsc(java.util.Collection jobIds,
+ java.util.Collection statuses);
}
diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/careerprogress/CareerProgressController.java b/mydreamjob-server/src/main/java/com/mydreamjob/careerprogress/CareerProgressController.java
new file mode 100644
index 0000000..891a111
--- /dev/null
+++ b/mydreamjob-server/src/main/java/com/mydreamjob/careerprogress/CareerProgressController.java
@@ -0,0 +1,73 @@
+package com.mydreamjob.careerprogress;
+
+import com.mydreamjob.careerprogress.dto.CareerProgressResponse;
+import com.mydreamjob.common.api.ApiResponse;
+import com.mydreamjob.common.exception.BusinessException;
+import com.mydreamjob.common.exception.ErrorCode;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.enums.ParameterIn;
+import io.swagger.v3.oas.annotations.responses.ApiResponses;
+import io.swagger.v3.oas.annotations.security.SecurityRequirement;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.servlet.http.HttpServletRequest;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+/**
+ * 统一求职进度聚合接口(V34)。
+ *
+ * 仅 STUDENT 可访问;身份只取 JWT,不接受 userId/studentUserId/candidateId 等参数。
+ * 未登录 401;COMPANY/ADMIN 403/2002;参数非法 400/1001。
+ */
+@Tag(name = "求职进度", description = "学生端聚合投递、面试、评分、Offer、竞争力排名与通知计数")
+@RestController
+@RequiredArgsConstructor
+public class CareerProgressController {
+
+ private final CareerProgressService careerProgressService;
+
+ @Operation(
+ summary = "统一求职进度聚合",
+ description = "只读学生端接口:汇总当前学生的投递分页、岗位、下一场面试、最近已提交结构化面试评分、"
+ + "可展示 Offer、同岗位竞争力排名,以及待办/通知计数(仅计数,不含通知详情)。"
+ + "身份仅从 JWT/SecurityContext 获取。",
+ security = @SecurityRequirement(name = "bearerAuth"))
+ @ApiResponses({
+ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "OK"),
+ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "page/size/status 参数非法"),
+ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "未登录"),
+ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "非 STUDENT 角色")
+ })
+ @GetMapping("/profile/me/career-progress")
+ public ResponseEntity> getCareerProgress(
+ @Parameter(name = "page", in = ParameterIn.QUERY, description = "页码,从 0 开始,默认 0")
+ @RequestParam(defaultValue = "0") int page,
+ @Parameter(name = "size", in = ParameterIn.QUERY, description = "每页条数,1–50,默认 20")
+ @RequestParam(defaultValue = "20") int size,
+ @Parameter(name = "status", in = ParameterIn.QUERY,
+ description = "投递状态过滤,可选 SUBMITTED/VIEWED/INTERVIEW/HIRED/REJECTED/WITHDRAWN")
+ @RequestParam(required = false) String status,
+ HttpServletRequest request) {
+ Long userId = resolveUserId(request);
+ CareerProgressResponse data = careerProgressService.getCareerProgress(userId, page, size, status);
+ return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request)));
+ }
+
+ private Long resolveUserId(HttpServletRequest request) {
+ Authentication auth = SecurityContextHolder.getContext().getAuthentication();
+ if (auth == null || !auth.isAuthenticated()) {
+ throw new BusinessException(ErrorCode.UNAUTHORIZED);
+ }
+ return Long.valueOf(auth.getName());
+ }
+
+ private String getRequestId(HttpServletRequest request) {
+ return request.getHeader("X-Request-Id");
+ }
+}
\ No newline at end of file
diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/careerprogress/CareerProgressService.java b/mydreamjob-server/src/main/java/com/mydreamjob/careerprogress/CareerProgressService.java
new file mode 100644
index 0000000..7f1a649
--- /dev/null
+++ b/mydreamjob-server/src/main/java/com/mydreamjob/careerprogress/CareerProgressService.java
@@ -0,0 +1,333 @@
+package com.mydreamjob.careerprogress;
+
+import com.mydreamjob.application.Application;
+import com.mydreamjob.application.ApplicationRepository;
+import com.mydreamjob.auth.User;
+import com.mydreamjob.auth.UserRepository;
+import com.mydreamjob.candidatescore.CandidateDimensionScore;
+import com.mydreamjob.candidatescore.CandidateScoringCalculator;
+import com.mydreamjob.careerprogress.dto.CareerProgressResponse;
+import com.mydreamjob.careerprogress.dto.CareerProgressResponse.CareerProgressItem;
+import com.mydreamjob.careerprogress.dto.CareerProgressResponse.CompetitionSummary;
+import com.mydreamjob.careerprogress.dto.CareerProgressResponse.InterviewSummary;
+import com.mydreamjob.careerprogress.dto.CareerProgressResponse.JobSummary;
+import com.mydreamjob.careerprogress.dto.CareerProgressResponse.OfferSummary;
+import com.mydreamjob.careerprogress.dto.CareerProgressResponse.PaginatedApplications;
+import com.mydreamjob.careerprogress.dto.CareerProgressResponse.ProgressTodoSummary;
+import com.mydreamjob.careerprogress.dto.CareerProgressResponse.ScoreSummary;
+import com.mydreamjob.common.exception.BusinessException;
+import com.mydreamjob.common.exception.ErrorCode;
+import com.mydreamjob.company.Company;
+import com.mydreamjob.company.CompanyRepository;
+import com.mydreamjob.competition.CompetitionRankingCalculator;
+import com.mydreamjob.interview.Interview;
+import com.mydreamjob.interview.InterviewRepository;
+import com.mydreamjob.interviewscore.InterviewScore;
+import com.mydreamjob.interviewscore.InterviewScoreRepository;
+import com.mydreamjob.job.Job;
+import com.mydreamjob.job.JobRepository;
+import com.mydreamjob.notification.NotificationRepository;
+import com.mydreamjob.offer.Offer;
+import com.mydreamjob.offer.OfferRepository;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.PageRequest;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.Instant;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+
+/**
+ * 统一求职进度聚合服务(V34)。
+ *
+ * 只读学生端接口,汇总投递分页 + 岗位 + 下一场面试 + 最近已提交评分 + 可展示 Offer +
+ * 同岗位竞争力排名 + 待办/通知计数。纯只读:@Transactional(readOnly=true),
+ * 不写业务表/审计表/通知表/缓存。所有附属数据批量读取,DB 查询次数固定(与分页
+ * items 数量无关),避免 N+1。身份仅从 JWT/SecurityContext 获取。
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class CareerProgressService {
+
+ private static final Set ALLOWED_STATUSES = Set.of(
+ "SUBMITTED", "VIEWED", "INTERVIEW", "HIRED", "REJECTED", "WITHDRAWN");
+ private static final Set NEXT_INTERVIEW_STATUSES = Set.of("SCHEDULED", "CONFIRMED");
+ private static final String REPORT_DECISION_TYPE = "REPORT_DECISION";
+
+ private final ApplicationRepository applicationRepository;
+ private final InterviewRepository interviewRepository;
+ private final InterviewScoreRepository interviewScoreRepository;
+ private final OfferRepository offerRepository;
+ private final NotificationRepository notificationRepository;
+ private final JobRepository jobRepository;
+ private final CompanyRepository companyRepository;
+ private final UserRepository userRepository;
+ private final CompetitionRankingCalculator rankingCalculator;
+
+ @Transactional(readOnly = true)
+ public CareerProgressResponse getCareerProgress(Long studentUserId, int page, int size, String status) {
+ assertStudentRole(studentUserId);
+ validateParams(page, size, status);
+
+ LocalDateTime now = LocalDateTime.now();
+ String asOf = yearMonth(LocalDate.now());
+
+ // 1. 投递分页(按 createdAt DESC;status 可选筛选)
+ PageRequest pr = PageRequest.of(page, size);
+ Page appPage = (status == null || status.isBlank())
+ ? applicationRepository.findByStudentUserIdOrderByCreatedAtDesc(studentUserId, pr)
+ : applicationRepository.findByStudentUserIdAndStatusOrderByCreatedAtDesc(studentUserId, status, pr);
+ List pageApps = appPage.getContent();
+
+ // 2. 岗位与企业(批量,与 items 数量无关)
+ List jobIds = pageApps.stream().map(Application::getJobId).distinct().toList();
+ Map jobs = jobIds.isEmpty() ? Map.of()
+ : jobRepository.findAllById(jobIds).stream()
+ .collect(Collectors.toMap(Job::getId, Function.identity()));
+ List companyIds = jobs.values().stream().map(Job::getCompanyId).distinct().toList();
+ Map companies = companyIds.isEmpty() ? Map.of()
+ : companyRepository.findAllById(companyIds).stream()
+ .collect(Collectors.toMap(Company::getId, Function.identity()));
+
+ // 3. 面试:学生全部 SCHEDULED/CONFIRMED(todo 计数 + nextInterview 共用一个批次)
+ List pendingInterviews = interviewRepository
+ .findByStudentUserIdAndStatusInOrderByStartAtAsc(studentUserId, NEXT_INTERVIEW_STATUSES);
+
+ // 4. 已提交评分(本页投递,批量)
+ List appIds = pageApps.stream().map(Application::getId).toList();
+ List scores = appIds.isEmpty() ? List.of()
+ : interviewScoreRepository.findByStudentUserIdAndApplicationIdInAndStatus(
+ studentUserId, appIds, InterviewScore.STATUS_SUBMITTED);
+
+ // 5. 非 DRAFT Offer(todo 有效 Offer 计数 + 每条投递 Offer 展示共用一个批次)
+ List offers = offerRepository.findByCandidateIdAndStatusNotOrderByCreatedAtDesc(studentUserId, "DRAFT");
+
+ // 6. 竞争力:本页岗位的 cohort 投递与候选人快照各批量一次,再按岗位内存排序
+ List cohortApps = jobIds.isEmpty() ? List.of()
+ : applicationRepository.findByJobIdInAndStatusNotInOrderByCreatedAtAsc(
+ jobIds, CompetitionRankingCalculator.EXCLUDED_STATUSES);
+ List cohortCandidateIds = cohortApps.stream()
+ .map(Application::getStudentUserId).distinct().toList();
+ Map> latestByStudent =
+ rankingCalculator.loadLatestSnapshots(cohortCandidateIds, asOf);
+ Map> cohortByJob = cohortApps.stream()
+ .collect(Collectors.groupingBy(Application::getJobId));
+
+ // 7. 通知计数(只读 count)
+ long unreadTotal = notificationRepository.countUnreadByUserId(studentUserId);
+ long unreadReportDecision = notificationRepository.countUnreadByUserIdAndType(studentUserId, REPORT_DECISION_TYPE);
+
+ // 组装 todo
+ ProgressTodoSummary todo = buildTodo(pendingInterviews, offers, unreadTotal, unreadReportDecision, now);
+
+ // 组装 items
+ Map nextInterviewByApp = buildNextInterviewMap(pendingInterviews);
+ Map latestScoreByApp = buildLatestScoreMap(scores);
+ Map offerByApp = buildOfferMap(offers, now);
+
+ List items = pageApps.stream().map(app -> {
+ Job job = jobs.get(app.getJobId());
+ return CareerProgressItem.builder()
+ .applicationId(app.getId())
+ .applicationStatus(app.getStatus())
+ .appliedAt(app.getCreatedAt())
+ .updatedAt(app.getUpdatedAt())
+ .job(job == null ? null : buildJobSummary(job, companies))
+ .nextInterview(buildInterviewSummary(nextInterviewByApp.get(app.getId())))
+ .latestSubmittedInterviewScore(buildScoreSummary(latestScoreByApp.get(app.getId())))
+ .offer(buildOfferSummary(offerByApp.get(app.getId())))
+ .competition(buildCompetition(app, cohortByJob, latestByStudent, studentUserId))
+ .build();
+ }).collect(Collectors.toList());
+
+ PaginatedApplications applications = PaginatedApplications.builder()
+ .items(items)
+ .total(appPage.getTotalElements())
+ .page(page)
+ .size(size)
+ .totalPages(appPage.getTotalPages())
+ .build();
+
+ return CareerProgressResponse.builder()
+ .todo(todo)
+ .applications(applications)
+ .generatedAt(Instant.now())
+ .build();
+ }
+
+ // ── todo ────────────────────────────────────────────────
+
+ private ProgressTodoSummary buildTodo(List interviews, List offers,
+ long unreadTotal, long unreadReportDecision, LocalDateTime now) {
+ long pendingInterview = interviews.stream()
+ .filter(i -> "SCHEDULED".equals(i.getStatus())).count();
+ long upcomingConfirmed = interviews.stream()
+ .filter(i -> "CONFIRMED".equals(i.getStatus())
+ && i.getStartAt() != null && i.getStartAt().isAfter(now)).count();
+ long pendingOffer = offers.stream()
+ .filter(o -> ("SENT".equals(o.getStatus()) || "NEGOTIATING".equals(o.getStatus()))
+ && o.getExpireAt() != null && o.getExpireAt().isAfter(now)).count();
+ return ProgressTodoSummary.builder()
+ .pendingInterviewResponseCount(pendingInterview)
+ .upcomingConfirmedInterviewCount(upcomingConfirmed)
+ .pendingOfferResponseCount(pendingOffer)
+ .unreadNotificationCount(unreadTotal)
+ .unreadReportDecisionCount(unreadReportDecision)
+ .build();
+ }
+
+ // ── 批量结果归组(内存,无额外查询)────────────────────────
+
+ private Map buildNextInterviewMap(List interviews) {
+ Map map = new LinkedHashMap<>();
+ // 已按 startAt ASC,putIfAbsent 保留每个 applicationId 最早一场
+ for (Interview iv : interviews) {
+ map.putIfAbsent(iv.getApplicationId(), iv);
+ }
+ return map;
+ }
+
+ private Map buildLatestScoreMap(List scores) {
+ Map map = new HashMap<>();
+ for (InterviewScore s : scores) {
+ InterviewScore existing = map.get(s.getApplicationId());
+ if (existing == null || isAfter(s.getSubmittedAt(), existing.getSubmittedAt())) {
+ map.put(s.getApplicationId(), s);
+ }
+ }
+ return map;
+ }
+
+ private boolean isAfter(LocalDateTime a, LocalDateTime b) {
+ if (a == null) return false;
+ if (b == null) return true;
+ return a.isAfter(b);
+ }
+
+ private Map buildOfferMap(List offers, LocalDateTime now) {
+ Map> byApp = offers.stream().collect(Collectors.groupingBy(Offer::getApplicationId));
+ Map result = new HashMap<>();
+ for (Map.Entry> e : byApp.entrySet()) {
+ List list = e.getValue();
+ Offer effective = list.stream()
+ .filter(o -> ("SENT".equals(o.getStatus()) || "NEGOTIATING".equals(o.getStatus()))
+ && o.getExpireAt() != null && o.getExpireAt().isAfter(now))
+ .min(Comparator.comparing(Offer::getExpireAt))
+ .orElse(null);
+ if (effective != null) {
+ result.put(e.getKey(), effective);
+ } else if (!list.isEmpty()) {
+ // offers 已按 createdAt DESC,第一个即该投递最新的非 DRAFT Offer
+ result.put(e.getKey(), list.get(0));
+ }
+ }
+ return result;
+ }
+
+ // ── 子对象映射 ─────────────────────────────────────────
+
+ private JobSummary buildJobSummary(Job job, Map companies) {
+ Company c = companies.get(job.getCompanyId());
+ return JobSummary.builder()
+ .jobId(job.getId())
+ .title(job.getTitle())
+ .companyName(c != null ? c.getName() : null)
+ .city(job.getCity())
+ .employmentType(job.getEmploymentType())
+ .jobStatus(job.getStatus())
+ .build();
+ }
+
+ private InterviewSummary buildInterviewSummary(Interview iv) {
+ if (iv == null) return null;
+ return InterviewSummary.builder()
+ .interviewId(iv.getId())
+ .roundNo(iv.getRoundNo())
+ .title(iv.getTitle())
+ .interviewType(iv.getInterviewType())
+ .startAt(iv.getStartAt())
+ .endAt(iv.getEndAt())
+ .status(iv.getStatus())
+ .build();
+ }
+
+ private ScoreSummary buildScoreSummary(InterviewScore s) {
+ if (s == null) return null;
+ return ScoreSummary.builder()
+ .interviewId(s.getInterviewId())
+ .overallScore(s.getOverallScore())
+ .recommendation(s.getRecommendation())
+ .submittedAt(s.getSubmittedAt())
+ .build();
+ }
+
+ private OfferSummary buildOfferSummary(Offer o) {
+ if (o == null) return null;
+ return OfferSummary.builder()
+ .offerId(o.getId())
+ .status(o.getStatus())
+ .title(o.getTitle())
+ .expireAt(o.getExpireAt())
+ .onboardingDate(o.getOnboardingDate())
+ .build();
+ }
+
+ private CompetitionSummary buildCompetition(Application app,
+ Map> cohortByJob,
+ Map> latestByStudent,
+ Long meId) {
+ String st = app.getStatus();
+ if ("WITHDRAWN".equals(st) || "REJECTED".equals(st)) {
+ return CompetitionSummary.builder().eligible(false).build();
+ }
+ List cohort = cohortByJob.getOrDefault(app.getJobId(), List.of());
+ CompetitionRankingCalculator.CohortRanking cr =
+ rankingCalculator.rankCohort(cohort, latestByStudent, meId);
+ return CompetitionSummary.builder()
+ .eligible(true)
+ .rank(cr.rank)
+ .cohortSize(cr.cohortSize)
+ .overallScore(cr.overallScore)
+ .percentile(cr.percentile)
+ .status(cr.status)
+ .mySnapshotPeriod(cr.mySnapshotPeriod)
+ .rulesVersion(CandidateScoringCalculator.RULES_VERSION)
+ .build();
+ }
+
+ // ── 权限 / 参数 / 工具 ─────────────────────────────────
+
+ private void assertStudentRole(Long userId) {
+ User user = userRepository.findById(userId)
+ .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND));
+ boolean isStudent = user.getRoles().stream().anyMatch(r -> r == User.Role.STUDENT);
+ if (!isStudent) {
+ throw new BusinessException(ErrorCode.FORBIDDEN);
+ }
+ }
+
+ private void validateParams(int page, int size, String status) {
+ if (page < 0 || size < 1 || size > 50) {
+ throw new BusinessException(ErrorCode.BAD_REQUEST);
+ }
+ if (status != null && !status.isBlank() && !ALLOWED_STATUSES.contains(status)) {
+ throw new BusinessException(ErrorCode.BAD_REQUEST);
+ }
+ }
+
+ private String yearMonth(LocalDate d) {
+ return String.format("%04d-%02d", d.getYear(), d.getMonthValue());
+ }
+}
\ No newline at end of file
diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/careerprogress/dto/CareerProgressResponse.java b/mydreamjob-server/src/main/java/com/mydreamjob/careerprogress/dto/CareerProgressResponse.java
new file mode 100644
index 0000000..3250a97
--- /dev/null
+++ b/mydreamjob-server/src/main/java/com/mydreamjob/careerprogress/dto/CareerProgressResponse.java
@@ -0,0 +1,143 @@
+package com.mydreamjob.careerprogress.dto;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import lombok.Builder;
+import lombok.Data;
+
+import java.math.BigDecimal;
+import java.time.Instant;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.util.List;
+
+/**
+ * 统一求职进度聚合响应(V34)。
+ *
+ * 只读学生端接口。todo 仅返回通知/待办计数,不返回任何通知标题、正文或业务详情
+ * (通知详情仍由既有 /notifications 提供)。applications 为当前学生投递分页,
+ * 每条投递附带岗位摘要、下一场面试、最近已提交评分、可展示 Offer 与同岗位竞争力摘要。
+ * 对所有 null 子对象省略,避免泄露 DRAFT 或他人信息。
+ */
+@Data
+@Builder
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public class CareerProgressResponse {
+
+ private ProgressTodoSummary todo;
+
+ private PaginatedApplications applications;
+
+ private Instant generatedAt;
+
+ /** 待办与通知计数明细 */
+ @Data
+ @Builder
+ @JsonInclude(JsonInclude.Include.NON_NULL)
+ public static class ProgressTodoSummary {
+ /** 待响应面试数:状态为 SCHEDULED 的面试数量 */
+ private long pendingInterviewResponseCount;
+ /** 未来已确认面试数:状态 CONFIRMED 且 startAt > 当前时间 */
+ private long upcomingConfirmedInterviewCount;
+ /** 待响应 Offer 数:状态 SENT/NEGOTIATING 且 expireAt > 当前时间 */
+ private long pendingOfferResponseCount;
+ /** 未读通知总数 */
+ private long unreadNotificationCount;
+ /** 未读举报处理决定通知数(type=REPORT_DECISION) */
+ private long unreadReportDecisionCount;
+ }
+
+ /** 投递分页容器 */
+ @Data
+ @Builder
+ @JsonInclude(JsonInclude.Include.NON_NULL)
+ public static class PaginatedApplications {
+ private List items;
+ private long total;
+ private int page;
+ private int size;
+ private int totalPages;
+ }
+
+ /** 单条投递聚合项 */
+ @Data
+ @Builder
+ @JsonInclude(JsonInclude.Include.NON_NULL)
+ public static class CareerProgressItem {
+ private Long applicationId;
+ private String applicationStatus;
+ private LocalDateTime appliedAt;
+ private LocalDateTime updatedAt;
+
+ private JobSummary job;
+ private InterviewSummary nextInterview;
+ private ScoreSummary latestSubmittedInterviewScore;
+ private OfferSummary offer;
+ private CompetitionSummary competition;
+ }
+
+ /** 岗位摘要(不含任何候选人/企业内部字段) */
+ @Data
+ @Builder
+ @JsonInclude(JsonInclude.Include.NON_NULL)
+ public static class JobSummary {
+ private Long jobId;
+ private String title;
+ private String companyName;
+ private String city;
+ private String employmentType;
+ private String jobStatus;
+ }
+
+ /** 下一场面试摘要(仅 SCHEDULED/CONFIRMED,取 startAt 最早) */
+ @Data
+ @Builder
+ @JsonInclude(JsonInclude.Include.NON_NULL)
+ public static class InterviewSummary {
+ private Long interviewId;
+ private Integer roundNo;
+ private String title;
+ private String interviewType;
+ private LocalDateTime startAt;
+ private LocalDateTime endAt;
+ private String status;
+ }
+
+ /** 最近已提交结构化面试评分摘要(不返回 DRAFT、不返回 comment) */
+ @Data
+ @Builder
+ @JsonInclude(JsonInclude.Include.NON_NULL)
+ public static class ScoreSummary {
+ private Long interviewId;
+ private BigDecimal overallScore;
+ private String recommendation;
+ private LocalDateTime submittedAt;
+ }
+
+ /** Offer 摘要(不返回薪资/条款细节) */
+ @Data
+ @Builder
+ @JsonInclude(JsonInclude.Include.NON_NULL)
+ public static class OfferSummary {
+ private Long offerId;
+ private String status;
+ private String title;
+ private LocalDateTime expireAt;
+ private LocalDate onboardingDate;
+ }
+
+ /** 同岗位竞争力摘要(复用 V32 候选池/排序/百分位口径;不含 dimensions/gaps/topCandidates) */
+ @Data
+ @Builder
+ @JsonInclude(JsonInclude.Include.NON_NULL)
+ public static class CompetitionSummary {
+ /** 是否计入竞争力(仅 SUBMITTED/VIEWED/INTERVIEW/HIRED 为 true) */
+ private boolean eligible;
+ private Integer rank;
+ private Integer cohortSize;
+ private BigDecimal overallScore;
+ private BigDecimal percentile;
+ private String status;
+ private String mySnapshotPeriod;
+ private String rulesVersion;
+ }
+}
\ No newline at end of file
diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/competition/CompetitionRankingCalculator.java b/mydreamjob-server/src/main/java/com/mydreamjob/competition/CompetitionRankingCalculator.java
new file mode 100644
index 0000000..5827ef7
--- /dev/null
+++ b/mydreamjob-server/src/main/java/com/mydreamjob/competition/CompetitionRankingCalculator.java
@@ -0,0 +1,181 @@
+package com.mydreamjob.competition;
+
+import com.mydreamjob.application.Application;
+import com.mydreamjob.candidatescore.CandidateDimensionScore;
+import com.mydreamjob.candidatescore.CandidateDimensionScoreRepository;
+import com.mydreamjob.candidatescore.CandidateScoringCalculator;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Component;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+/**
+ * 同岗位竞争力排名的共享计算组件(V32 / V34 复用)。
+ *
+ * 承载候选人池(cohort)的稳定排序、综合分、百分位与状态判定,全部为纯确定
+ * 规则、无数据库写入。综合分复用 {@link CandidateScoringCalculator#overallScoreFromSnapshots}
+ * 真实公式;排序在 Java 侧完成(有分优先 → overallScore DESC → createdAt ASC →
+ * studentUserId ASC → 无分最后);百分位复用企业看板公式
+ * {@code 严格低于本人分数的人数 / (有分候选人数 - 1) × 100}(scale=2、HALF_UP)。
+ * 禁止在 SQL 里重算 overallScore 或 percentile,禁止复制粘贴第二套公式。
+ *
+ * 「读快照」与「排序计算」分离:{@link #loadLatestSnapshots} 一次批量读取,
+ * {@link #rankCohort} 纯计算不触碰数据库,保证 V34 按岗位批量排名时无 N+1。
+ */
+@Component
+@RequiredArgsConstructor
+public class CompetitionRankingCalculator {
+
+ /** 已退出候选人池的投递状态(与 V32 cohort 口径一致) */
+ public static final Set EXCLUDED_STATUSES = Set.of("WITHDRAWN", "REJECTED");
+
+ private final CandidateDimensionScoreRepository scoreRepository;
+ private final CandidateScoringCalculator scoringCalculator;
+
+ /**
+ * 批量读取一批学生的快照(period <= asOf),Java 侧按学生取最新周期,无 N+1。
+ * period 为 'yyyy-MM',字典序即时间序。
+ */
+ public Map> loadLatestSnapshots(List studentUserIds, String asOf) {
+ if (studentUserIds == null || studentUserIds.isEmpty()) return Map.of();
+ List all = scoreRepository.findUpToPeriod(studentUserIds, asOf);
+ Map>> byStudentPeriod = new HashMap<>();
+ for (CandidateDimensionScore s : all) {
+ byStudentPeriod.computeIfAbsent(s.getStudentUserId(), k -> new HashMap<>())
+ .computeIfAbsent(s.getPeriod(), k -> new ArrayList<>()).add(s);
+ }
+ Map> result = new HashMap<>();
+ for (Map.Entry>> e : byStudentPeriod.entrySet()) {
+ String latest = Collections.max(e.getValue().keySet());
+ result.put(e.getKey(), e.getValue().get(latest));
+ }
+ return result;
+ }
+
+ /**
+ * 对单个岗位的 cohort 投递做稳定排序并返回本人排名摘要。纯计算,不读库。
+ *
+ * @param cohortApps 已排除 WITHDRAWN/REJECTED 的候选人投递(同岗位)
+ * @param latestByStudent 每个学生的最新快照(由 loadLatestSnapshots 批量产出)
+ * @param meId 当前学生 userId
+ */
+ public CohortRanking rankCohort(List cohortApps,
+ Map> latestByStudent,
+ Long meId) {
+ List rows = new ArrayList<>();
+ for (Application app : cohortApps) {
+ List snaps = latestByStudent.getOrDefault(app.getStudentUserId(), List.of());
+ BigDecimal overall = scoringCalculator.overallScoreFromSnapshots(snaps);
+ rows.add(new Row(app.getStudentUserId(), app.getCreatedAt(), overall,
+ snaps.isEmpty() ? null : latestPeriod(snaps)));
+ }
+
+ rows.sort(Comparator
+ .comparing((Row r) -> r.overallScore, Comparator.nullsLast(Comparator.reverseOrder()))
+ .thenComparing(Row::getCreatedAt)
+ .thenComparing(Row::getStudentUserId));
+
+ Row me = null;
+ Integer rank = null;
+ for (int i = 0; i < rows.size(); i++) {
+ Row r = rows.get(i);
+ if (r.studentUserId.equals(meId)) {
+ me = r;
+ if (r.overallScore != null) rank = i + 1;
+ break;
+ }
+ }
+
+ BigDecimal percentile = computePercentile(rows, me);
+ List meSnaps = latestByStudent.getOrDefault(meId, List.of());
+ String status = overviewStatus(meSnaps);
+ String myPeriod = me == null ? null : me.snapshotPeriod;
+
+ return new CohortRanking(rows.size(), rows, me, rank,
+ me == null ? null : me.overallScore, percentile, status, myPeriod, meSnaps);
+ }
+
+ private String latestPeriod(List snaps) {
+ return snaps.stream().map(CandidateDimensionScore::getPeriod)
+ .max(String::compareTo).orElse(null);
+ }
+
+ private BigDecimal computePercentile(List rows, Row me) {
+ if (me == null || me.overallScore == null) return null;
+ List scored = rows.stream().map(r -> r.overallScore)
+ .filter(Objects::nonNull).collect(Collectors.toList());
+ if (scored.size() < 2) return null;
+ long lower = scored.stream().filter(s -> s.compareTo(me.overallScore) < 0).count();
+ return BigDecimal.valueOf(lower)
+ .divide(BigDecimal.valueOf(scored.size() - 1), 10, RoundingMode.HALF_UP)
+ .multiply(BigDecimal.valueOf(100))
+ .setScale(2, RoundingMode.HALF_UP);
+ }
+
+ /** 综合卡状态:OK / PARTIAL / INSUFFICIENT_DATA / PENDING(复用引擎语义) */
+ public String overviewStatus(List snaps) {
+ if (snaps == null || snaps.isEmpty()) return "INSUFFICIENT_DATA";
+ long computable = snaps.stream()
+ .filter(s -> "OK".equals(s.getStatus()) || "ESTIMATED".equals(s.getStatus())).count();
+ if (computable == snaps.size()) return "OK";
+ if (computable > 0) return "PARTIAL";
+ boolean allPending = snaps.stream().allMatch(s -> "PENDING".equals(s.getStatus()));
+ return allPending ? "PENDING" : "INSUFFICIENT_DATA";
+ }
+
+ /** 排序载体(不参与响应序列化) */
+ public static class Row {
+ public final Long studentUserId;
+ public final LocalDateTime createdAt;
+ public final BigDecimal overallScore;
+ public final String snapshotPeriod;
+
+ Row(Long studentUserId, LocalDateTime createdAt, BigDecimal overallScore, String snapshotPeriod) {
+ this.studentUserId = studentUserId;
+ this.createdAt = createdAt;
+ this.overallScore = overallScore;
+ this.snapshotPeriod = snapshotPeriod;
+ }
+
+ LocalDateTime getCreatedAt() { return createdAt; }
+ Long getStudentUserId() { return studentUserId; }
+ }
+
+ /** cohort 排名结果(领域载体,V32/V34 各自映射为 DTO) */
+ public static class CohortRanking {
+ public final int cohortSize;
+ public final List rows;
+ public final Row me;
+ public final Integer rank;
+ public final BigDecimal overallScore;
+ public final BigDecimal percentile;
+ public final String status;
+ public final String mySnapshotPeriod;
+ public final List meSnaps;
+
+ CohortRanking(int cohortSize, List rows, Row me, Integer rank,
+ BigDecimal overallScore, BigDecimal percentile, String status,
+ String mySnapshotPeriod, List meSnaps) {
+ this.cohortSize = cohortSize;
+ this.rows = rows;
+ this.me = me;
+ this.rank = rank;
+ this.overallScore = overallScore;
+ this.percentile = percentile;
+ this.status = status;
+ this.mySnapshotPeriod = mySnapshotPeriod;
+ this.meSnaps = meSnaps;
+ }
+ }
+}
\ No newline at end of file
diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/competition/CompetitionRankingService.java b/mydreamjob-server/src/main/java/com/mydreamjob/competition/CompetitionRankingService.java
index 014f164..8bb281d 100644
--- a/mydreamjob-server/src/main/java/com/mydreamjob/competition/CompetitionRankingService.java
+++ b/mydreamjob-server/src/main/java/com/mydreamjob/competition/CompetitionRankingService.java
@@ -5,7 +5,6 @@ import com.mydreamjob.application.ApplicationRepository;
import com.mydreamjob.auth.User;
import com.mydreamjob.auth.UserRepository;
import com.mydreamjob.candidatescore.CandidateDimensionScore;
-import com.mydreamjob.candidatescore.CandidateDimensionScoreRepository;
import com.mydreamjob.candidatescore.CandidateScoringCalculator;
import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode;
@@ -18,29 +17,20 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
-import java.math.RoundingMode;
import java.time.Instant;
import java.time.LocalDate;
-import java.time.LocalDateTime;
import java.util.ArrayList;
-import java.util.Collections;
import java.util.Comparator;
-import java.util.HashMap;
import java.util.List;
import java.util.Map;
-import java.util.Objects;
import java.util.stream.Collectors;
/**
* 同岗位竞争力排名(V32)。
*
* 为已投递某 PUBLISHED 岗位的学生返回该岗位候选人池内的竞争力排名。
- * 纯只读聚合,无新表、无 Flyway、不写任何业务表;综合分复用
- * {@link CandidateScoringCalculator#overallScoreFromSnapshots}(真实公式),
- * 不修改 percentile / 评分引擎 / 企业看板。
- *
- * 排序在 Java 侧完成(不使用 MySQL NULLS LAST),无 N+1:cohort 投递一次批量、
- * 候选人快照一次批量。
+ * 纯只读聚合,无新表、无 Flyway、不写任何业务表;综合分、排序、百分位与状态
+ * 全部委托 {@link CompetitionRankingCalculator}(与 V34 共享,禁止复制公式)。
*/
@Slf4j
@Service
@@ -48,13 +38,11 @@ import java.util.stream.Collectors;
public class CompetitionRankingService {
private static final int TOP_N = 5;
- private static final java.util.Set EXCLUDED = java.util.Set.of("WITHDRAWN", "REJECTED");
private final UserRepository userRepository;
private final JobService jobService;
private final ApplicationRepository applicationRepository;
- private final CandidateDimensionScoreRepository scoreRepository;
- private final CandidateScoringCalculator calculator;
+ private final CompetitionRankingCalculator rankingCalculator;
@Transactional(readOnly = true)
public CompetitionRankingResponse getRanking(Long studentUserId, Long jobId) {
@@ -72,7 +60,8 @@ public class CompetitionRankingService {
// 当前学生必须对本岗位存在「有效投递」(状态与 cohort 同口径:非 WITHDRAWN/REJECTED),
// 否则一律 403。禁止用"是否曾投递"的宽松判断,避免已退出候选人池仍返回 200。
boolean applied = applicationRepository
- .existsByJobIdAndStudentUserIdAndStatusNotIn(jobId, studentUserId, EXCLUDED);
+ .existsByJobIdAndStudentUserIdAndStatusNotIn(jobId, studentUserId,
+ CompetitionRankingCalculator.EXCLUDED_STATUSES);
if (!applied) {
throw new BusinessException(ErrorCode.FORBIDDEN);
}
@@ -85,109 +74,43 @@ public class CompetitionRankingService {
// 1. cohort 投递(排除 WITHDRAWN/REJECTED;唯一约束保证同岗位同学生仅一条)
List apps = applicationRepository
- .findByJobIdAndStatusNotInOrderByCreatedAtAsc(jobId, EXCLUDED);
+ .findByJobIdAndStatusNotInOrderByCreatedAtAsc(jobId, CompetitionRankingCalculator.EXCLUDED_STATUSES);
- // 2. 批量读取候选人快照(period <= asOf),Java 侧取每人最新周期 —— 无 N+1
+ // 2. 批量读取候选人快照 + 排序/百分位(共享组件,无 N+1)
List candidateIds = apps.stream().map(Application::getStudentUserId)
.distinct().collect(Collectors.toList());
Map> latestByStudent =
- loadLatestSnapshots(candidateIds, asOf);
-
- List rows = new ArrayList<>();
- for (Application app : apps) {
- Row r = new Row();
- r.studentUserId = app.getStudentUserId();
- r.createdAt = app.getCreatedAt();
- List snaps = latestByStudent.getOrDefault(app.getStudentUserId(), List.of());
- r.snapshotPeriod = snaps.isEmpty() ? null : latestPeriod(snaps);
- r.overallScore = calculator.overallScoreFromSnapshots(snaps);
- rows.add(r);
- }
-
- // 3. Java 排序:有分优先 → overallScore DESC → createdAt ASC → studentUserId ASC → 无分最后
- rows.sort(Comparator
- .comparing((Row r) -> r.overallScore, Comparator.nullsLast(Comparator.reverseOrder()))
- .thenComparing(Row::getCreatedAt)
- .thenComparing(Row::getStudentUserId));
-
- // 4. 本人信息
- Row me = rows.stream().filter(r -> r.studentUserId.equals(meId)).findFirst().orElse(null);
-
- int cohortSize = rows.size();
- Integer rank = null;
- if (me != null && me.overallScore != null) {
- rank = rows.indexOf(me) + 1;
- }
- BigDecimal percentile = computePercentile(rows, me);
+ rankingCalculator.loadLatestSnapshots(candidateIds, asOf);
+ CompetitionRankingCalculator.CohortRanking cr =
+ rankingCalculator.rankCohort(apps, latestByStudent, meId);
- List meSnaps = me == null
- ? List.of() : latestByStudent.getOrDefault(meId, List.of());
- String myPeriod = me == null ? null : me.snapshotPeriod;
-
- // 5. Top 5 匿名摘要
+ // 3. Top 5 匿名摘要
List top = new ArrayList<>();
- int n = Math.min(TOP_N, rows.size());
+ int n = Math.min(TOP_N, cr.rows.size());
for (int i = 0; i < n; i++) {
- Row r = rows.get(i);
+ CompetitionRankingCalculator.Row r = cr.rows.get(i);
top.add(CompetitionRankingResponse.RankingSnapshot.builder()
.rank(i + 1).overallScore(r.overallScore).build());
}
return CompetitionRankingResponse.builder()
.jobId(jobId)
- .rank(rank)
- .cohortSize(cohortSize)
- .overallScore(me == null ? null : me.overallScore)
- .percentile(percentile)
- .status(overviewStatus(meSnaps))
+ .rank(cr.rank)
+ .cohortSize(cr.cohortSize)
+ .overallScore(cr.overallScore)
+ .percentile(cr.percentile)
+ .status(cr.status)
.asOfPeriod(asOf)
- .mySnapshotPeriod(myPeriod)
- .dimensions(buildDimensions(meSnaps))
- .gaps(buildGaps(meSnaps))
+ .mySnapshotPeriod(cr.mySnapshotPeriod)
+ .dimensions(buildDimensions(cr.meSnaps))
+ .gaps(buildGaps(cr.meSnaps))
.topCandidates(top)
.rulesVersion(CandidateScoringCalculator.RULES_VERSION)
.generatedAt(Instant.now())
.build();
}
- // ── cohort 快照批量装载 ─────────────────────────────────────
-
- private Map> loadLatestSnapshots(List ids, String asOf) {
- if (ids.isEmpty()) return Map.of();
- List all = scoreRepository.findUpToPeriod(ids, asOf);
- Map>> byStudentPeriod = new HashMap<>();
- for (CandidateDimensionScore s : all) {
- byStudentPeriod.computeIfAbsent(s.getStudentUserId(), k -> new HashMap<>())
- .computeIfAbsent(s.getPeriod(), k -> new ArrayList<>()).add(s);
- }
- Map> result = new HashMap<>();
- for (Map.Entry>> e : byStudentPeriod.entrySet()) {
- String latest = Collections.max(e.getValue().keySet());
- result.put(e.getKey(), e.getValue().get(latest));
- }
- return result;
- }
-
- private String latestPeriod(List snaps) {
- return snaps.stream().map(CandidateDimensionScore::getPeriod)
- .max(String::compareTo).orElse(null);
- }
-
- // ── 百分位(严格复用企业看板公式)──────────────────────────
-
- private BigDecimal computePercentile(List rows, Row me) {
- if (me == null || me.overallScore == null) return null;
- List scored = rows.stream().map(r -> r.overallScore)
- .filter(Objects::nonNull).collect(Collectors.toList());
- if (scored.size() < 2) return null;
- long lower = scored.stream().filter(s -> s.compareTo(me.overallScore) < 0).count();
- return BigDecimal.valueOf(lower)
- .divide(BigDecimal.valueOf(scored.size() - 1), 10, RoundingMode.HALF_UP)
- .multiply(BigDecimal.valueOf(100))
- .setScale(2, RoundingMode.HALF_UP);
- }
-
- // ── 维度 / 状态 / 缺口(复用引擎语义,禁止另造公式)──────────
+ // ── 维度 / 缺口(复用引擎语义,禁止另造公式)──────────
private List buildDimensions(List snaps) {
return snaps.stream()
@@ -221,16 +144,6 @@ public class CompetitionRankingService {
return gaps;
}
- private String overviewStatus(List snaps) {
- if (snaps == null || snaps.isEmpty()) return "INSUFFICIENT_DATA";
- long computable = snaps.stream()
- .filter(s -> "OK".equals(s.getStatus()) || "ESTIMATED".equals(s.getStatus())).count();
- if (computable == snaps.size()) return "OK";
- if (computable > 0) return "PARTIAL";
- boolean allPending = snaps.stream().allMatch(s -> "PENDING".equals(s.getStatus()));
- return allPending ? "PENDING" : "INSUFFICIENT_DATA";
- }
-
// ── 身份与工具 ─────────────────────────────────────────────
private void assertStudentRole(Long userId) {
@@ -245,15 +158,4 @@ public class CompetitionRankingService {
private String yearMonth(LocalDate d) {
return String.format("%04d-%02d", d.getYear(), d.getMonthValue());
}
-
- /** 内部排序载体(不参与响应序列化) */
- static class Row {
- Long studentUserId;
- LocalDateTime createdAt;
- BigDecimal overallScore;
- String snapshotPeriod;
-
- LocalDateTime getCreatedAt() { return createdAt; }
- Long getStudentUserId() { return studentUserId; }
- }
}
\ No newline at end of file
diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/interview/InterviewRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/interview/InterviewRepository.java
index 418ea7d..40c7619 100644
--- a/mydreamjob-server/src/main/java/com/mydreamjob/interview/InterviewRepository.java
+++ b/mydreamjob-server/src/main/java/com/mydreamjob/interview/InterviewRepository.java
@@ -32,4 +32,10 @@ public interface InterviewRepository extends JpaRepository {
Page findByStudentUserIdAndStatusAndStartAtBetweenOrderByStartAtDesc(
Long studentUserId, String status, LocalDateTime from, LocalDateTime to, Pageable pageable);
+
+ /**
+ * V34 聚合:一次读取当前学生全部 SCHEDULED/CONFIRMED 面试(待响应 + 已确认未来),
+ * 按开始时间正序;同时用于 todo 计数与每条投递的 nextInterview(取最早一场),避免 N+1。
+ */
+ List findByStudentUserIdAndStatusInOrderByStartAtAsc(Long studentUserId, java.util.Collection statuses);
}
\ No newline at end of file
diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/interviewscore/InterviewScoreRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/interviewscore/InterviewScoreRepository.java
index 74183cf..583ad19 100644
--- a/mydreamjob-server/src/main/java/com/mydreamjob/interviewscore/InterviewScoreRepository.java
+++ b/mydreamjob-server/src/main/java/com/mydreamjob/interviewscore/InterviewScoreRepository.java
@@ -2,9 +2,18 @@ package com.mydreamjob.interviewscore;
import org.springframework.data.jpa.repository.JpaRepository;
+import java.util.Collection;
+import java.util.List;
import java.util.Optional;
public interface InterviewScoreRepository extends JpaRepository {
Optional findByInterviewId(Long interviewId);
+
+ /**
+ * V34 聚合:一次读取当前学生、某批投递的已提交(SUBMITTED)结构化面试评分,
+ * 避免按投递循环查询;服务层再按 applicationId 取 submittedAt 最新一条。
+ */
+ List findByStudentUserIdAndApplicationIdInAndStatus(
+ Long studentUserId, Collection applicationIds, String status);
}
\ No newline at end of file
diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/notification/NotificationRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/notification/NotificationRepository.java
index 5e421a6..35eaf93 100644
--- a/mydreamjob-server/src/main/java/com/mydreamjob/notification/NotificationRepository.java
+++ b/mydreamjob-server/src/main/java/com/mydreamjob/notification/NotificationRepository.java
@@ -15,5 +15,9 @@ public interface NotificationRepository extends JpaRepository {
List findActiveExpiringBetween(@Param("statuses") Collection statuses,
@Param("from") LocalDateTime from,
@Param("to") LocalDateTime to);
+
+ /**
+ * V34 聚合:一次读取候选人本人全部非 DRAFT Offer(含 SENT/NEGOTIATING/终态),
+ * 按创建时间倒序;同时用于 todo 的有效 Offer 计数与每条投递的 Offer 展示,避免 N+1。
+ */
+ List findByCandidateIdAndStatusNotOrderByCreatedAtDesc(Long candidateId, String status);
}
diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/careerprogress/CareerProgressControllerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/careerprogress/CareerProgressControllerTest.java
new file mode 100644
index 0000000..e9d2ff7
--- /dev/null
+++ b/mydreamjob-server/src/test/java/com/mydreamjob/careerprogress/CareerProgressControllerTest.java
@@ -0,0 +1,318 @@
+package com.mydreamjob.careerprogress;
+
+import com.mydreamjob.application.Application;
+import com.mydreamjob.application.ApplicationRepository;
+import com.mydreamjob.auth.JwtTokenProvider;
+import com.mydreamjob.auth.User;
+import com.mydreamjob.auth.UserRepository;
+import com.mydreamjob.candidatescore.CandidateDimensionScore;
+import com.mydreamjob.candidatescore.CandidateDimensionScoreRepository;
+import com.mydreamjob.company.Company;
+import com.mydreamjob.company.CompanyRepository;
+import com.mydreamjob.interview.Interview;
+import com.mydreamjob.interview.InterviewRepository;
+import com.mydreamjob.interviewscore.InterviewScore;
+import com.mydreamjob.interviewscore.InterviewScoreRepository;
+import com.mydreamjob.job.Job;
+import com.mydreamjob.job.JobRepository;
+import com.mydreamjob.notification.Notification;
+import com.mydreamjob.notification.NotificationRepository;
+import com.mydreamjob.offer.Offer;
+import com.mydreamjob.offer.OfferRepository;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.data.redis.core.ValueOperations;
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.test.context.TestPropertySource;
+import org.springframework.test.web.servlet.MockMvc;
+
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.atomic.AtomicLong;
+
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+/**
+ * 统一求职进度聚合接口控制器集成测试(V34,真实 Service + H2)。
+ *
+ * 覆盖权限矩阵(未登录 401、COMPANY/ADMIN 403/2002)、参数校验 400/1001、空投递、
+ * 正常聚合、脱敏、通知仅计数、competition 摘要(无 dimensions/gaps/topCandidates),
+ * 以及 DRAFT 评分/Offer 不泄露。使用独立 H2 库避免共享 integrationdb 跨类外键污染。
+ */
+@SpringBootTest
+@AutoConfigureMockMvc
+@ActiveProfiles("integration-test")
+@TestPropertySource(properties =
+ "spring.datasource.url=jdbc:h2:mem:integrationdb_careerprogress;MODE=MySQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE")
+class CareerProgressControllerTest {
+
+ @Autowired private MockMvc mockMvc;
+ @Autowired private JwtTokenProvider jwtTokenProvider;
+ @Autowired private UserRepository userRepository;
+ @Autowired private CompanyRepository companyRepository;
+ @Autowired private JobRepository jobRepository;
+ @Autowired private ApplicationRepository applicationRepository;
+ @Autowired private InterviewRepository interviewRepository;
+ @Autowired private InterviewScoreRepository interviewScoreRepository;
+ @Autowired private OfferRepository offerRepository;
+ @Autowired private NotificationRepository notificationRepository;
+ @Autowired private CandidateDimensionScoreRepository dimensionScoreRepository;
+
+ @MockBean private RedisTemplate redisTemplate;
+
+ private static final AtomicLong PHONE = new AtomicLong(800);
+
+ private Long me;
+ private Long rival;
+ private Long emptyStudent;
+ private Long companyUserId;
+ private Long adminId;
+ private Long jobId;
+ private Long appId;
+ private String period;
+
+ @BeforeEach
+ @SuppressWarnings("unchecked")
+ void setUp() {
+ ValueOperations ops = mock(ValueOperations.class);
+ when(redisTemplate.opsForValue()).thenReturn(ops);
+ when(redisTemplate.hasKey(anyString())).thenReturn(false);
+
+ period = String.format("%04d-%02d", LocalDate.now().getYear(), LocalDate.now().getMonthValue());
+
+ me = createUser(User.Role.STUDENT, "me");
+ rival = createUser(User.Role.STUDENT, "rival");
+ emptyStudent = createUser(User.Role.STUDENT, "empty");
+ companyUserId = createUser(User.Role.COMPANY, "company");
+ adminId = createUser(User.Role.ADMIN, "admin");
+
+ Long companyId = companyRepository.save(
+ Company.builder().ownerUserId(companyUserId).name("示例科技").build()).getId();
+ Job job = jobRepository.save(Job.builder().companyId(companyId).title("Java 后端工程师")
+ .description("d").city("杭州").employmentType("FULL_TIME").status("PUBLISHED").build());
+ jobId = job.getId();
+
+ Application app = applicationRepository.save(Application.builder()
+ .jobId(jobId).studentUserId(me).status("INTERVIEW")
+ .createdAt(LocalDateTime.now().minusDays(1)).updatedAt(LocalDateTime.now()).build());
+ appId = app.getId();
+ applicationRepository.save(Application.builder()
+ .jobId(jobId).studentUserId(rival).status("SUBMITTED")
+ .createdAt(LocalDateTime.now().minusDays(2)).updatedAt(LocalDateTime.now()).build());
+
+ // 竞争力快照:本人 77.50,rival 90.00
+ dimensionScoreRepository.save(dim(me, "77.50"));
+ dimensionScoreRepository.save(dim(rival, "90.00"));
+
+ // 面试:SCHEDULED(nextInterview 最早) + CONFIRMED 未来
+ Interview sched = interviewRepository.save(interview("SCHEDULED", LocalDateTime.now().plusDays(1), 2, "技术二面"));
+ interviewRepository.save(interview("CONFIRMED", LocalDateTime.now().plusDays(2), 3, "技术三面"));
+
+ // 已提交评分(仅摘要,不泄 comment)
+ interviewScoreRepository.save(InterviewScore.builder()
+ .interviewId(sched.getId()).applicationId(appId).companyUserId(companyUserId)
+ .studentUserId(me).status(InterviewScore.STATUS_SUBMITTED)
+ .technicalScore(4).communicationScore(3).problemSolvingScore(4)
+ .roleFitScore(4).professionalismScore(3)
+ .overallScore(new BigDecimal("3.60")).recommendation("HIRE")
+ .comment("含敏感评语,不应返回给学生").submittedAt(LocalDateTime.now())
+ .version(0L).build());
+
+ // Offer:SENT 有效(不返回薪资/条款)
+ offerRepository.save(Offer.builder()
+ .applicationId(appId).jobId(jobId).companyId(companyId).candidateId(me)
+ .title("Java 后端工程师").description("d").expireAt(LocalDateTime.now().plusDays(9))
+ .salaryMin(20000).salaryMax(30000).baseSalary(25000).housingAllowance(3000)
+ .annualBonusMonths(3).otherAllowances("补贴").benefits("五险一金")
+ .status("SENT").version(0L).createdBy(companyUserId).build());
+
+ // 通知:未读 2 条(其中 1 条 REPORT_DECISION)
+ notificationRepository.save(Notification.builder().userId(me).type("INTERVIEW_SCHEDULED")
+ .title("面试通知").content("你有一场面试").build());
+ notificationRepository.save(Notification.builder().userId(me).type("REPORT_DECISION")
+ .title("举报处理结果").content("举报已处理").build());
+ }
+
+ @AfterEach
+ void tearDown() {
+ dimensionScoreRepository.deleteAll();
+ interviewScoreRepository.deleteAll();
+ offerRepository.deleteAll();
+ interviewRepository.deleteAll();
+ notificationRepository.deleteAll();
+ applicationRepository.deleteAll();
+ jobRepository.deleteAll();
+ companyRepository.deleteAll();
+ userRepository.deleteAll();
+ }
+
+ // ── 辅助 ────────────────────────────────────────────────
+
+ private Long createUser(User.Role role, String tag) {
+ User u = userRepository.save(User.builder()
+ .phone("177" + String.format("%08d", PHONE.incrementAndGet()))
+ .nickname(tag).role(role).roles(Set.of(role)).active(true).verified(true).build());
+ return u.getId();
+ }
+
+ private Interview interview(String status, LocalDateTime startAt, int roundNo, String title) {
+ return Interview.builder().applicationId(appId).companyUserId(companyUserId).studentUserId(me)
+ .roundNo(roundNo).title(title).interviewType("ONLINE").startAt(startAt)
+ .endAt(startAt.plusHours(1)).status(status).build();
+ }
+
+ private CandidateDimensionScore dim(Long sid, String score) {
+ return CandidateDimensionScore.builder().studentUserId(sid).dimension("EDUCATION").period(period)
+ .score(new BigDecimal(score)).status("OK").weight(new BigDecimal("12.50"))
+ .rulesVersion("candidate-scoring-v0.1").calculatedAt(LocalDateTime.now()).build();
+ }
+
+ private String token(Long userId) {
+ return "Bearer " + jwtTokenProvider.generateAccessToken(userId,
+ String.valueOf(userId), Map.of("roles", "STUDENT"));
+ }
+
+ // ── 权限矩阵 ──────────────────────────────────────────────
+
+ @Test
+ void unauthenticated_returns401() throws Exception {
+ mockMvc.perform(get("/profile/me/career-progress"))
+ .andExpect(status().isUnauthorized());
+ }
+
+ @Test
+ void company_returns403_2002() throws Exception {
+ mockMvc.perform(get("/profile/me/career-progress")
+ .header("Authorization", token(companyUserId)))
+ .andExpect(status().isForbidden())
+ .andExpect(jsonPath("$.code").value(2002));
+ }
+
+ @Test
+ void admin_returns403_2002() throws Exception {
+ mockMvc.perform(get("/profile/me/career-progress")
+ .header("Authorization", token(adminId)))
+ .andExpect(status().isForbidden())
+ .andExpect(jsonPath("$.code").value(2002));
+ }
+
+ // ── 参数校验 ──────────────────────────────────────────────
+
+ @Test
+ void negativePage_returns400_1001() throws Exception {
+ mockMvc.perform(get("/profile/me/career-progress").param("page", "-1")
+ .header("Authorization", token(me)))
+ .andExpect(status().isBadRequest())
+ .andExpect(jsonPath("$.code").value(1001));
+ }
+
+ @Test
+ void sizeOutOfRange_returns400_1001() throws Exception {
+ mockMvc.perform(get("/profile/me/career-progress").param("size", "51")
+ .header("Authorization", token(me)))
+ .andExpect(status().isBadRequest())
+ .andExpect(jsonPath("$.code").value(1001));
+ mockMvc.perform(get("/profile/me/career-progress").param("size", "0")
+ .header("Authorization", token(me)))
+ .andExpect(status().isBadRequest())
+ .andExpect(jsonPath("$.code").value(1001));
+ }
+
+ @Test
+ void invalidStatus_returns400_1001() throws Exception {
+ mockMvc.perform(get("/profile/me/career-progress").param("status", "NONEXISTENT")
+ .header("Authorization", token(me)))
+ .andExpect(status().isBadRequest())
+ .andExpect(jsonPath("$.code").value(1001));
+ }
+
+ // ── 空投递 ──────────────────────────────────────────────
+
+ @Test
+ void emptyApplications_returns200_empty_andZeroTodo() throws Exception {
+ mockMvc.perform(get("/profile/me/career-progress")
+ .header("Authorization", token(emptyStudent)))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.applications.items").isArray())
+ .andExpect(jsonPath("$.data.applications.items.length()").value(0))
+ .andExpect(jsonPath("$.data.applications.total").value(0))
+ .andExpect(jsonPath("$.data.todo.pendingInterviewResponseCount").value(0))
+ .andExpect(jsonPath("$.data.todo.upcomingConfirmedInterviewCount").value(0))
+ .andExpect(jsonPath("$.data.todo.pendingOfferResponseCount").value(0))
+ .andExpect(jsonPath("$.data.todo.unreadNotificationCount").value(0))
+ .andExpect(jsonPath("$.data.todo.unreadReportDecisionCount").value(0));
+ }
+
+ // ── 正常聚合 + 脱敏 + 通知仅计数 ────────────────────────────
+
+ @Test
+ void studentReturnsAggregatedResult_andDesensitized() throws Exception {
+ mockMvc.perform(get("/profile/me/career-progress")
+ .header("Authorization", token(me)))
+ .andExpect(status().isOk())
+ // todo 计数
+ .andExpect(jsonPath("$.data.todo.pendingInterviewResponseCount").value(1))
+ .andExpect(jsonPath("$.data.todo.upcomingConfirmedInterviewCount").value(1))
+ .andExpect(jsonPath("$.data.todo.pendingOfferResponseCount").value(1))
+ .andExpect(jsonPath("$.data.todo.unreadNotificationCount").value(2))
+ .andExpect(jsonPath("$.data.todo.unreadReportDecisionCount").value(1))
+ // 投递分页
+ .andExpect(jsonPath("$.data.applications.total").value(1))
+ .andExpect(jsonPath("$.data.applications.items.length()").value(1))
+ .andExpect(jsonPath("$.data.applications.items[0].applicationStatus").value("INTERVIEW"))
+ // job 摘要
+ .andExpect(jsonPath("$.data.applications.items[0].job.title").value("Java 后端工程师"))
+ .andExpect(jsonPath("$.data.applications.items[0].job.companyName").value("示例科技"))
+ .andExpect(jsonPath("$.data.applications.items[0].job.city").value("杭州"))
+ .andExpect(jsonPath("$.data.applications.items[0].job.employmentType").value("FULL_TIME"))
+ .andExpect(jsonPath("$.data.applications.items[0].job.jobStatus").value("PUBLISHED"))
+ // nextInterview:最早 SCHEDULED
+ .andExpect(jsonPath("$.data.applications.items[0].nextInterview.roundNo").value(2))
+ .andExpect(jsonPath("$.data.applications.items[0].nextInterview.status").value("SCHEDULED"))
+ // 已提交评分摘要(无 comment)
+ .andExpect(jsonPath("$.data.applications.items[0].latestSubmittedInterviewScore.overallScore").value(3.60))
+ .andExpect(jsonPath("$.data.applications.items[0].latestSubmittedInterviewScore.recommendation").value("HIRE"))
+ .andExpect(jsonPath("$.data.applications.items[0].latestSubmittedInterviewScore.comment").doesNotExist())
+ // offer 摘要(无薪资/条款)
+ .andExpect(jsonPath("$.data.applications.items[0].offer.status").value("SENT"))
+ .andExpect(jsonPath("$.data.applications.items[0].offer.salaryMin").doesNotExist())
+ .andExpect(jsonPath("$.data.applications.items[0].offer.salaryMax").doesNotExist())
+ .andExpect(jsonPath("$.data.applications.items[0].offer.baseSalary").doesNotExist())
+ .andExpect(jsonPath("$.data.applications.items[0].offer.benefits").doesNotExist())
+ // competition 摘要(无 dimensions/gaps/topCandidates,脱敏)
+ .andExpect(jsonPath("$.data.applications.items[0].competition.eligible").value(true))
+ .andExpect(jsonPath("$.data.applications.items[0].competition.cohortSize").value(2))
+ .andExpect(jsonPath("$.data.applications.items[0].competition.rank").value(2))
+ .andExpect(jsonPath("$.data.applications.items[0].competition.overallScore").value(77.50))
+ .andExpect(jsonPath("$.data.applications.items[0].competition.percentile").value(0.00))
+ .andExpect(jsonPath("$.data.applications.items[0].competition.dimensions").doesNotExist())
+ .andExpect(jsonPath("$.data.applications.items[0].competition.gaps").doesNotExist())
+ .andExpect(jsonPath("$.data.applications.items[0].competition.topCandidates").doesNotExist())
+ // 不泄露身份/联系/简历等字段
+ .andExpect(jsonPath("$.data.applications.items[0].studentUserId").doesNotExist())
+ .andExpect(jsonPath("$.data.applications.items[0].candidateId").doesNotExist())
+ .andExpect(jsonPath("$.data.applications.items[0].companyUserId").doesNotExist())
+ .andExpect(jsonPath("$.data.applications.items[0].evaluatorUserId").doesNotExist())
+ .andExpect(jsonPath("$.data.applications.items[0].phone").doesNotExist())
+ .andExpect(jsonPath("$.data.applications.items[0].email").doesNotExist())
+ .andExpect(jsonPath("$.data.applications.items[0].resumeUrl").doesNotExist())
+ .andExpect(jsonPath("$.data.applications.items[0].passwordHash").doesNotExist())
+ // 通知只返回计数,无 title/content 详情
+ .andExpect(jsonPath("$.data.notifications").doesNotExist())
+ .andExpect(jsonPath("$.data.notificationTitle").doesNotExist());
+ }
+}
\ No newline at end of file
diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/careerprogress/CareerProgressPersistenceIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/careerprogress/CareerProgressPersistenceIntegrationTest.java
new file mode 100644
index 0000000..7528bd8
--- /dev/null
+++ b/mydreamjob-server/src/test/java/com/mydreamjob/careerprogress/CareerProgressPersistenceIntegrationTest.java
@@ -0,0 +1,230 @@
+package com.mydreamjob.careerprogress;
+
+import com.mydreamjob.application.Application;
+import com.mydreamjob.application.ApplicationRepository;
+import com.mydreamjob.auth.User;
+import com.mydreamjob.auth.UserRepository;
+import com.mydreamjob.candidatescore.CandidateDimensionScore;
+import com.mydreamjob.candidatescore.CandidateDimensionScoreRepository;
+import com.mydreamjob.careerprogress.dto.CareerProgressResponse;
+import com.mydreamjob.company.Company;
+import com.mydreamjob.company.CompanyRepository;
+import com.mydreamjob.interview.Interview;
+import com.mydreamjob.interview.InterviewRepository;
+import com.mydreamjob.interviewscore.InterviewScore;
+import com.mydreamjob.interviewscore.InterviewScoreRepository;
+import com.mydreamjob.job.Job;
+import com.mydreamjob.job.JobRepository;
+import com.mydreamjob.notification.Notification;
+import com.mydreamjob.notification.NotificationRepository;
+import com.mydreamjob.offer.Offer;
+import com.mydreamjob.offer.OfferRepository;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.data.redis.core.ValueOperations;
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.test.context.TestPropertySource;
+
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.util.Set;
+import java.util.concurrent.atomic.AtomicLong;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * 统一求职进度聚合持久化/只读集成测试(V34,独立 H2 + Flyway 真实表结构)。
+ *
+ * 证明:真实批量查询在真实 Repository 上正确返回聚合结果;CareerProgressService
+ * 标注 @Transactional(readOnly=true) 且不写业务表 —— 调用前后关键业务表计数不变;
+ * 不新增 Flyway、不删除业务数据。
+ */
+@SpringBootTest
+@TestPropertySource(properties =
+ "spring.datasource.url=jdbc:h2:mem:integrationdb_careerprogress_persist;MODE=MySQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE")
+@ActiveProfiles("integration-test")
+class CareerProgressPersistenceIntegrationTest {
+
+ @Autowired private CareerProgressService service;
+ @Autowired private UserRepository userRepository;
+ @Autowired private CompanyRepository companyRepository;
+ @Autowired private JobRepository jobRepository;
+ @Autowired private ApplicationRepository applicationRepository;
+ @Autowired private InterviewRepository interviewRepository;
+ @Autowired private InterviewScoreRepository interviewScoreRepository;
+ @Autowired private OfferRepository offerRepository;
+ @Autowired private NotificationRepository notificationRepository;
+ @Autowired private CandidateDimensionScoreRepository dimensionScoreRepository;
+
+ @MockBean private RedisTemplate redisTemplate;
+
+ private static final AtomicLong PHONE_SEQ = new AtomicLong(600);
+ private String period;
+
+ private Long studentUserId;
+ private Long companyUserId;
+ private Long companyId;
+ private Long jobId;
+ private Long appId;
+
+ @BeforeEach
+ @SuppressWarnings("unchecked")
+ void setUp() {
+ ValueOperations ops = mock(ValueOperations.class);
+ when(redisTemplate.opsForValue()).thenReturn(ops);
+ when(redisTemplate.hasKey(anyString())).thenReturn(false);
+
+ period = String.format("%04d-%02d", LocalDate.now().getYear(), LocalDate.now().getMonthValue());
+
+ studentUserId = createUser(User.Role.STUDENT);
+ companyUserId = createUser(User.Role.COMPANY);
+ Long rivalId = createUser(User.Role.STUDENT);
+
+ companyId = companyRepository.save(
+ Company.builder().ownerUserId(companyUserId).name("示例科技").build()).getId();
+ jobId = jobRepository.save(Job.builder().companyId(companyId).title("Java 后端工程师")
+ .description("d").city("杭州").employmentType("FULL_TIME").status("PUBLISHED").build()).getId();
+
+ Application app = applicationRepository.save(Application.builder()
+ .jobId(jobId).studentUserId(studentUserId).status("INTERVIEW")
+ .createdAt(LocalDateTime.now().minusDays(1)).updatedAt(LocalDateTime.now()).build());
+ appId = app.getId();
+ applicationRepository.save(Application.builder()
+ .jobId(jobId).studentUserId(rivalId).status("SUBMITTED")
+ .createdAt(LocalDateTime.now().minusDays(2)).updatedAt(LocalDateTime.now()).build());
+
+ // 竞争力快照
+ dimensionScoreRepository.save(dim(studentUserId, "77.50"));
+ dimensionScoreRepository.save(dim(rivalId, "90.00"));
+
+ // 面试:SCHEDULED(待响应)+ CONFIRMED(未来)
+ Interview sched = interviewRepository.save(interview("SCHEDULED", LocalDateTime.now().plusDays(1), 2));
+ interviewRepository.save(interview("CONFIRMED", LocalDateTime.now().plusDays(2), 3));
+
+ // 已提交评分(摘要口径,不返回 comment)
+ interviewScoreRepository.save(InterviewScore.builder()
+ .interviewId(sched.getId()).applicationId(appId).companyUserId(companyUserId)
+ .studentUserId(studentUserId).status(InterviewScore.STATUS_SUBMITTED)
+ .technicalScore(4).communicationScore(3).problemSolvingScore(4)
+ .roleFitScore(4).professionalismScore(3)
+ .overallScore(new BigDecimal("3.60")).recommendation("HIRE")
+ .comment("不应返回给学生").submittedAt(LocalDateTime.now()).version(0L).build());
+
+ // 有效 Offer
+ offerRepository.save(Offer.builder()
+ .applicationId(appId).jobId(jobId).companyId(companyId).candidateId(studentUserId)
+ .title("Java 后端工程师").description("d").expireAt(LocalDateTime.now().plusDays(9))
+ .salaryMin(20000).salaryMax(30000).baseSalary(25000)
+ .status("SENT").version(0L).createdBy(companyUserId).build());
+
+ // 通知:未读 2 条(其中 1 条 REPORT_DECISION)
+ notificationRepository.save(Notification.builder().userId(studentUserId).type("INTERVIEW_SCHEDULED")
+ .title("面试通知").content("你有一场面试").build());
+ notificationRepository.save(Notification.builder().userId(studentUserId).type("REPORT_DECISION")
+ .title("举报处理结果").content("举报已处理").build());
+ }
+
+ @AfterEach
+ void tearDown() {
+ dimensionScoreRepository.deleteAll();
+ interviewScoreRepository.deleteAll();
+ offerRepository.deleteAll();
+ interviewRepository.deleteAll();
+ notificationRepository.deleteAll();
+ applicationRepository.deleteAll();
+ jobRepository.deleteAll();
+ companyRepository.deleteAll();
+ userRepository.deleteAll();
+ }
+
+ @Test
+ void aggregation_returnsCorrectSummaries_andIsReadOnly() {
+ long appsBefore = applicationRepository.count();
+ long interviewsBefore = interviewRepository.count();
+ long scoresBefore = interviewScoreRepository.count();
+ long offersBefore = offerRepository.count();
+ long notificationsBefore = notificationRepository.count();
+ long dimsBefore = dimensionScoreRepository.count();
+
+ CareerProgressResponse resp = service.getCareerProgress(studentUserId, 0, 20, null);
+
+ // todo 计数(真实批量查询)
+ assertEquals(1L, resp.getTodo().getPendingInterviewResponseCount());
+ assertEquals(1L, resp.getTodo().getUpcomingConfirmedInterviewCount());
+ assertEquals(1L, resp.getTodo().getPendingOfferResponseCount());
+ assertEquals(2L, resp.getTodo().getUnreadNotificationCount());
+ assertEquals(1L, resp.getTodo().getUnreadReportDecisionCount());
+
+ // 投递分页(本人仅 1 条,不包含 rival 投递)
+ assertEquals(1L, resp.getApplications().getTotal());
+ assertEquals(1, resp.getApplications().getItems().size());
+ CareerProgressResponse.CompetitionSummary comp =
+ resp.getApplications().getItems().get(0).getCompetition();
+ assertTrue(comp.isEligible());
+ assertEquals(2, comp.getCohortSize());
+ assertEquals(2, comp.getRank().intValue());
+ assertEquals(new BigDecimal("77.50"), comp.getOverallScore());
+ assertEquals(new BigDecimal("0.00"), comp.getPercentile());
+ assertEquals(period, comp.getMySnapshotPeriod());
+ assertNotNull(resp.getGeneratedAt());
+
+ // 只读:调用前后关键业务表计数完全不变(不写、不删、不新增)
+ assertEquals(appsBefore, applicationRepository.count());
+ assertEquals(interviewsBefore, interviewRepository.count());
+ assertEquals(scoresBefore, interviewScoreRepository.count());
+ assertEquals(offersBefore, offerRepository.count());
+ assertEquals(notificationsBefore, notificationRepository.count());
+ assertEquals(dimsBefore, dimensionScoreRepository.count());
+ }
+
+ @Test
+ void withdrawnApplication_keptInHistory_competitionNotEligible() {
+ // 独立岗位避免 (job_id, student_user_id) 唯一约束冲突
+ Long withdrawnJobId = jobRepository.save(Job.builder().companyId(companyId).title("已撤回岗位")
+ .description("d").city("上海").employmentType("FULL_TIME").status("PUBLISHED").build()).getId();
+ applicationRepository.save(Application.builder()
+ .jobId(withdrawnJobId).studentUserId(studentUserId).status("WITHDRAWN")
+ .createdAt(LocalDateTime.now().minusDays(3)).updatedAt(LocalDateTime.now()).build());
+
+ CareerProgressResponse resp = service.getCareerProgress(studentUserId, 0, 20, null);
+ // 现在本人有两条投递(INTERVIEW + WITHDRAWN)
+ assertEquals(2L, resp.getApplications().getTotal());
+ CareerProgressResponse.CareerProgressItem withdrawn = resp.getApplications().getItems().stream()
+ .filter(i -> "WITHDRAWN".equals(i.getApplicationStatus())).findFirst().orElseThrow();
+ assertEquals("WITHDRAWN", withdrawn.getApplicationStatus());
+ assertEquals(false, withdrawn.getCompetition().isEligible());
+ assertEquals(null, withdrawn.getCompetition().getRank());
+ assertEquals(null, withdrawn.getCompetition().getCohortSize());
+ assertEquals(null, withdrawn.getCompetition().getOverallScore());
+ assertEquals(null, withdrawn.getCompetition().getPercentile());
+ }
+
+ private Long createUser(User.Role role) {
+ return userRepository.save(User.builder()
+ .phone("168" + String.format("%08d", PHONE_SEQ.incrementAndGet()))
+ .nickname("u").role(role).roles(Set.of(role)).active(true).verified(true).build()).getId();
+ }
+
+ private Interview interview(String status, LocalDateTime startAt, int roundNo) {
+ return Interview.builder().applicationId(appId).companyUserId(companyUserId).studentUserId(studentUserId)
+ .roundNo(roundNo).title("技术面试").interviewType("ONLINE").startAt(startAt)
+ .endAt(startAt.plusHours(1)).status(status).build();
+ }
+
+ private CandidateDimensionScore dim(Long sid, String score) {
+ return CandidateDimensionScore.builder().studentUserId(sid).dimension("EDUCATION").period(period)
+ .score(new BigDecimal(score)).status("OK").weight(new BigDecimal("12.50"))
+ .rulesVersion("candidate-scoring-v0.1").calculatedAt(LocalDateTime.now()).build();
+ }
+}
\ No newline at end of file
diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/careerprogress/CareerProgressServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/careerprogress/CareerProgressServiceTest.java
new file mode 100644
index 0000000..5443ba8
--- /dev/null
+++ b/mydreamjob-server/src/test/java/com/mydreamjob/careerprogress/CareerProgressServiceTest.java
@@ -0,0 +1,442 @@
+package com.mydreamjob.careerprogress;
+
+import com.mydreamjob.application.Application;
+import com.mydreamjob.application.ApplicationRepository;
+import com.mydreamjob.auth.User;
+import com.mydreamjob.auth.UserRepository;
+import com.mydreamjob.candidatescore.CandidateDimensionScore;
+import com.mydreamjob.candidatescore.CandidateDimensionScoreRepository;
+import com.mydreamjob.candidatescore.CandidateScoringCalculator;
+import com.mydreamjob.careerprogress.dto.CareerProgressResponse;
+import com.mydreamjob.common.exception.BusinessException;
+import com.mydreamjob.common.exception.ErrorCode;
+import com.mydreamjob.company.Company;
+import com.mydreamjob.company.CompanyRepository;
+import com.mydreamjob.competition.CompetitionRankingCalculator;
+import com.mydreamjob.interview.Interview;
+import com.mydreamjob.interview.InterviewRepository;
+import com.mydreamjob.interviewscore.InterviewScore;
+import com.mydreamjob.interviewscore.InterviewScoreRepository;
+import com.mydreamjob.job.Job;
+import com.mydreamjob.job.JobRepository;
+import com.mydreamjob.notification.NotificationRepository;
+import com.mydreamjob.offer.Offer;
+import com.mydreamjob.offer.OfferRepository;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.mockito.junit.jupiter.MockitoSettings;
+import org.mockito.quality.Strictness;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.PageImpl;
+import org.springframework.data.domain.PageRequest;
+
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+/**
+ * 统一求职进度聚合服务单测(V34,编排 + 脱敏 + 聚合口径 + 无 N+1)。
+ *
+ * 排名/排序/百分位由真实 {@link CompetitionRankingCalculator}(配合 mock 的
+ * 快照仓库与综合分计算器)承载,与 V32 共用同一组件,保证口径一致。本类聚焦断言
+ * 权限、参数、投递分页、todo 计数、nextInterview、评分摘要、Offer 优先级、
+ * competition 摘要、通知仅计数与批量查询(无 N+1)。
+ */
+@ExtendWith(MockitoExtension.class)
+@MockitoSettings(strictness = Strictness.LENIENT)
+class CareerProgressServiceTest {
+
+ private static final Long ME = 100L;
+ private static final Long JOB = 9L;
+ private static final Long COMPANY = 1L;
+ private static final String PERIOD = yearMonth(LocalDate.now());
+
+ @Mock private ApplicationRepository applicationRepository;
+ @Mock private InterviewRepository interviewRepository;
+ @Mock private InterviewScoreRepository interviewScoreRepository;
+ @Mock private OfferRepository offerRepository;
+ @Mock private NotificationRepository notificationRepository;
+ @Mock private JobRepository jobRepository;
+ @Mock private CompanyRepository companyRepository;
+ @Mock private UserRepository userRepository;
+ @Mock private CandidateDimensionScoreRepository dimensionScoreRepository;
+ @Mock private CandidateScoringCalculator scoringCalculator;
+
+ private CompetitionRankingCalculator calculator;
+ private CareerProgressService service;
+
+ private Map overallByStudent = Map.of();
+
+ @BeforeEach
+ void setUp() {
+ calculator = spy(new CompetitionRankingCalculator(dimensionScoreRepository, scoringCalculator));
+ service = new CareerProgressService(applicationRepository, interviewRepository,
+ interviewScoreRepository, offerRepository, notificationRepository,
+ jobRepository, companyRepository, userRepository, calculator);
+
+ when(scoringCalculator.overallScoreFromSnapshots(anyList())).thenAnswer(inv -> {
+ List> snaps = inv.getArgument(0);
+ if (snaps == null || snaps.isEmpty()) return null;
+ Long sid = ((CandidateDimensionScore) snaps.get(0)).getStudentUserId();
+ return overallByStudent.get(sid);
+ });
+ }
+
+ // ── 权限与参数 ────────────────────────────────────────────
+
+ @Test
+ void companyRole_throwsForbidden() {
+ mockUser(ME, User.Role.COMPANY);
+ BusinessException ex = assertThrows(BusinessException.class,
+ () -> service.getCareerProgress(ME, 0, 20, null));
+ assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCode());
+ }
+
+ @Test
+ void adminRole_throwsForbidden() {
+ mockUser(ME, User.Role.ADMIN);
+ BusinessException ex = assertThrows(BusinessException.class,
+ () -> service.getCareerProgress(ME, 0, 20, null));
+ assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCode());
+ }
+
+ @Test
+ void negativePage_throwsBadRequest() {
+ mockUser(ME, User.Role.STUDENT);
+ BusinessException ex = assertThrows(BusinessException.class,
+ () -> service.getCareerProgress(ME, -1, 20, null));
+ assertEquals(ErrorCode.BAD_REQUEST.getCode(), ex.getErrorCode());
+ }
+
+ @Test
+ void sizeOutOfRange_throwsBadRequest() {
+ mockUser(ME, User.Role.STUDENT);
+ assertThrows(BusinessException.class,
+ () -> service.getCareerProgress(ME, 0, 0, null));
+ assertThrows(BusinessException.class,
+ () -> service.getCareerProgress(ME, 0, 51, null));
+ }
+
+ @Test
+ void invalidStatus_throwsBadRequest() {
+ mockUser(ME, User.Role.STUDENT);
+ BusinessException ex = assertThrows(BusinessException.class,
+ () -> service.getCareerProgress(ME, 0, 20, "NONEXISTENT"));
+ assertEquals(ErrorCode.BAD_REQUEST.getCode(), ex.getErrorCode());
+ }
+
+ // ── 空数据 ──────────────────────────────────────────────
+
+ @Test
+ void noApplications_returnsEmptyPage_andZeroTodo() {
+ mockUser(ME, User.Role.STUDENT);
+ when(applicationRepository.findByStudentUserIdOrderByCreatedAtDesc(eq(ME), any(PageRequest.class)))
+ .thenReturn(new PageImpl<>(List.of(), PageRequest.of(0, 20), 0));
+ when(interviewRepository.findByStudentUserIdAndStatusInOrderByStartAtAsc(anyLong(), anyCollection()))
+ .thenReturn(List.of());
+ when(offerRepository.findByCandidateIdAndStatusNotOrderByCreatedAtDesc(anyLong(), anyString()))
+ .thenReturn(List.of());
+ when(notificationRepository.countUnreadByUserId(ME)).thenReturn(0L);
+ when(notificationRepository.countUnreadByUserIdAndType(ME, "REPORT_DECISION")).thenReturn(0L);
+
+ CareerProgressResponse resp = service.getCareerProgress(ME, 0, 20, null);
+
+ assertEquals(0L, resp.getApplications().getItems().size());
+ assertEquals(0L, resp.getApplications().getTotal());
+ assertEquals(0L, resp.getTodo().getPendingInterviewResponseCount());
+ assertEquals(0L, resp.getTodo().getUpcomingConfirmedInterviewCount());
+ assertEquals(0L, resp.getTodo().getPendingOfferResponseCount());
+ assertEquals(0L, resp.getTodo().getUnreadNotificationCount());
+ assertEquals(0L, resp.getTodo().getUnreadReportDecisionCount());
+ }
+
+ // ── 聚合口径(完整 happy path)────────────────────────────
+
+ @Test
+ void fullAggregation_returnsCorrectSummaries() {
+ mockUser(ME, User.Role.STUDENT);
+ Application app = app(1L, ME, JOB, "SUBMITTED", "2026-08-20T10:00:00");
+ when(applicationRepository.findByStudentUserIdOrderByCreatedAtDesc(eq(ME), any(PageRequest.class)))
+ .thenReturn(new PageImpl<>(List.of(app), PageRequest.of(0, 20), 1));
+ when(jobRepository.findAllById(anyCollection()))
+ .thenReturn(List.of(Job.builder().id(JOB).companyId(COMPANY).title("Java 后端工程师")
+ .description("d").city("杭州").employmentType("FULL_TIME").status("PUBLISHED").build()));
+ when(companyRepository.findAllById(anyCollection()))
+ .thenReturn(List.of(Company.builder().id(COMPANY).ownerUserId(999L).name("示例科技").build()));
+
+ // 面试:两场 SCHEDULED(取最早为 nextInterview)+ 一场 CONFIRMED(未来)
+ Interview later = interview(2L, app.getId(), "SCHEDULED", "2026-08-30T14:00:00");
+ Interview earlier = interview(1L, app.getId(), "SCHEDULED", "2026-08-28T10:00:00");
+ Interview confirmed = interview(3L, app.getId(), "CONFIRMED", LocalDateTime.now().plusDays(3).toString());
+ when(interviewRepository.findByStudentUserIdAndStatusInOrderByStartAtAsc(anyLong(), anyCollection()))
+ .thenReturn(List.of(earlier, later, confirmed));
+
+ // 已提交评分:两条,取 submittedAt 最新
+ InterviewScore s1 = score(22L, app.getId(), "2026-08-25T16:00:00", new BigDecimal("3.60"));
+ InterviewScore s2 = score(23L, app.getId(), "2026-08-24T16:00:00", new BigDecimal("3.00"));
+ when(interviewScoreRepository.findByStudentUserIdAndApplicationIdInAndStatus(
+ anyLong(), anyCollection(), anyString())).thenReturn(List.of(s1, s2));
+
+ // Offer:一条 SENT 有效 + 一条 DRAFT(不应返回 DRAFT)
+ Offer sent = offer(14L, app.getId(), "SENT", "2026-09-05T23:59:59");
+ when(offerRepository.findByCandidateIdAndStatusNotOrderByCreatedAtDesc(anyLong(), anyString()))
+ .thenReturn(List.of(sent));
+
+ // 竞争力:同岗位 cohort 含本人 + 另一有分者
+ Application other = app(2L, 200L, JOB, "SUBMITTED", "2026-08-19T10:00:00");
+ when(applicationRepository.findByJobIdInAndStatusNotInOrderByCreatedAtAsc(anyCollection(), anyCollection()))
+ .thenReturn(List.of(other, app));
+ overallByStudent = Map.of(ME, new BigDecimal("77.50"), 200L, new BigDecimal("90.00"));
+ when(dimensionScoreRepository.findUpToPeriod(anyCollection(), anyString()))
+ .thenReturn(List.of(dim(ME, "77.50"), dim(200L, "90.00")));
+
+ when(notificationRepository.countUnreadByUserId(ME)).thenReturn(5L);
+ when(notificationRepository.countUnreadByUserIdAndType(ME, "REPORT_DECISION")).thenReturn(2L);
+
+ CareerProgressResponse resp = service.getCareerProgress(ME, 0, 20, null);
+
+ // todo
+ assertEquals(2L, resp.getTodo().getPendingInterviewResponseCount());
+ assertEquals(1L, resp.getTodo().getUpcomingConfirmedInterviewCount());
+ assertEquals(1L, resp.getTodo().getPendingOfferResponseCount());
+ assertEquals(5L, resp.getTodo().getUnreadNotificationCount());
+ assertEquals(2L, resp.getTodo().getUnreadReportDecisionCount());
+
+ // item
+ assertEquals(1L, resp.getApplications().getItems().size());
+ CareerProgressResponse.CareerProgressItem item = resp.getApplications().getItems().get(0);
+ assertEquals(1L, item.getApplicationId());
+ assertEquals("SUBMITTED", item.getApplicationStatus());
+ assertEquals("Java 后端工程师", item.getJob().getTitle());
+ assertEquals("示例科技", item.getJob().getCompanyName());
+ assertEquals("杭州", item.getJob().getCity());
+ assertEquals("FULL_TIME", item.getJob().getEmploymentType());
+ assertEquals("PUBLISHED", item.getJob().getJobStatus());
+
+ // nextInterview:取 startAt 最早 1L
+ assertEquals(1L, item.getNextInterview().getInterviewId());
+ // latest score:22(submittedAt 最新)
+ assertEquals(22L, item.getLatestSubmittedInterviewScore().getInterviewId());
+ assertEquals(new BigDecimal("3.60"), item.getLatestSubmittedInterviewScore().getOverallScore());
+ assertEquals("HIRE", item.getLatestSubmittedInterviewScore().getRecommendation());
+ // offer
+ assertEquals(14L, item.getOffer().getOfferId());
+ assertEquals("SENT", item.getOffer().getStatus());
+
+ // competition:cohort=2(other 90.00、me 77.50),me 排名 2;
+ // percentile = 严格低于 me 的人数(0) / (有分人数-1=1) * 100 = 0.00
+ assertTrue(item.getCompetition().isEligible());
+ assertEquals(2, item.getCompetition().getCohortSize());
+ assertEquals(2, item.getCompetition().getRank());
+ assertEquals(new BigDecimal("77.50"), item.getCompetition().getOverallScore());
+ assertEquals(new BigDecimal("0.00"), item.getCompetition().getPercentile());
+ assertEquals(PERIOD, item.getCompetition().getMySnapshotPeriod());
+ }
+
+ // ── DRAFT 不泄露 ────────────────────────────────────────
+
+ @Test
+ void draftScore_notReturned_andDraftOffer_notReturned() {
+ mockUser(ME, User.Role.STUDENT);
+ Application app = app(1L, ME, JOB, "SUBMITTED", "2026-08-20T10:00:00");
+ when(applicationRepository.findByStudentUserIdOrderByCreatedAtDesc(eq(ME), any(PageRequest.class)))
+ .thenReturn(new PageImpl<>(List.of(app), PageRequest.of(0, 20), 1));
+ when(jobRepository.findAllById(anyCollection()))
+ .thenReturn(List.of(Job.builder().id(JOB).companyId(COMPANY).title("t").description("d")
+ .city("c").employmentType("FULL_TIME").status("PUBLISHED").build()));
+ when(companyRepository.findAllById(anyCollection())).thenReturn(List.of());
+ when(interviewRepository.findByStudentUserIdAndStatusInOrderByStartAtAsc(anyLong(), anyCollection()))
+ .thenReturn(List.of());
+ // 只有 DRAFT 评分 → 不应出现在 latestSubmittedInterviewScore
+ InterviewScore draft = score(1L, app.getId(), null, null);
+ draft.setStatus(InterviewScore.STATUS_DRAFT);
+ when(interviewScoreRepository.findByStudentUserIdAndApplicationIdInAndStatus(
+ anyLong(), anyCollection(), anyString())).thenReturn(List.of());
+ // 只有 DRAFT offer → offer 为 null
+ when(offerRepository.findByCandidateIdAndStatusNotOrderByCreatedAtDesc(anyLong(), anyString()))
+ .thenReturn(List.of());
+ when(applicationRepository.findByJobIdInAndStatusNotInOrderByCreatedAtAsc(anyCollection(), anyCollection()))
+ .thenReturn(List.of(app));
+ when(dimensionScoreRepository.findUpToPeriod(anyCollection(), anyString())).thenReturn(List.of());
+ when(notificationRepository.countUnreadByUserId(ME)).thenReturn(0L);
+ when(notificationRepository.countUnreadByUserIdAndType(ME, "REPORT_DECISION")).thenReturn(0L);
+
+ CareerProgressResponse resp = service.getCareerProgress(ME, 0, 20, null);
+ CareerProgressResponse.CareerProgressItem item = resp.getApplications().getItems().get(0);
+ assertNull(item.getLatestSubmittedInterviewScore());
+ assertNull(item.getOffer());
+ }
+
+ // ── WITHDRAWN/REJECTED 保留历史但 eligible=false ─────────
+
+ @Test
+ void withdrawnApplication_eligibleFalse_andNoCompetitionFields() {
+ mockUser(ME, User.Role.STUDENT);
+ Application withdrawn = app(1L, ME, JOB, "WITHDRAWN", "2026-08-20T10:00:00");
+ when(applicationRepository.findByStudentUserIdOrderByCreatedAtDesc(eq(ME), any(PageRequest.class)))
+ .thenReturn(new PageImpl<>(List.of(withdrawn), PageRequest.of(0, 20), 1));
+ when(jobRepository.findAllById(anyCollection()))
+ .thenReturn(List.of(Job.builder().id(JOB).companyId(COMPANY).title("t").description("d")
+ .city("c").employmentType("FULL_TIME").status("PUBLISHED").build()));
+ when(companyRepository.findAllById(anyCollection())).thenReturn(List.of());
+ when(interviewRepository.findByStudentUserIdAndStatusInOrderByStartAtAsc(anyLong(), anyCollection()))
+ .thenReturn(List.of());
+ when(interviewScoreRepository.findByStudentUserIdAndApplicationIdInAndStatus(
+ anyLong(), anyCollection(), anyString())).thenReturn(List.of());
+ when(offerRepository.findByCandidateIdAndStatusNotOrderByCreatedAtDesc(anyLong(), anyString()))
+ .thenReturn(List.of());
+ when(notificationRepository.countUnreadByUserId(ME)).thenReturn(0L);
+ when(notificationRepository.countUnreadByUserIdAndType(ME, "REPORT_DECISION")).thenReturn(0L);
+
+ CareerProgressResponse resp = service.getCareerProgress(ME, 0, 20, null);
+ CareerProgressResponse.CareerProgressItem item = resp.getApplications().getItems().get(0);
+ // 保留历史
+ assertEquals(1L, item.getApplicationId());
+ assertEquals("WITHDRAWN", item.getApplicationStatus());
+ // competition 只返回 eligible=false,不返回其它排名字段
+ assertFalse(item.getCompetition().isEligible());
+ assertNull(item.getCompetition().getRank());
+ assertNull(item.getCompetition().getCohortSize());
+ assertNull(item.getCompetition().getOverallScore());
+ assertNull(item.getCompetition().getPercentile());
+ assertNull(item.getCompetition().getStatus());
+ assertNull(item.getCompetition().getMySnapshotPeriod());
+ assertNull(item.getCompetition().getRulesVersion());
+ }
+
+ // ── 有有效投递但无快照 → competition 分数/排名/百分位为 null ─
+
+ @Test
+ void validApplication_noSnapshot_competitionScoresNull_cohortSizeReturned() {
+ mockUser(ME, User.Role.STUDENT);
+ Application app = app(1L, ME, JOB, "SUBMITTED", "2026-08-20T10:00:00");
+ when(applicationRepository.findByStudentUserIdOrderByCreatedAtDesc(eq(ME), any(PageRequest.class)))
+ .thenReturn(new PageImpl<>(List.of(app), PageRequest.of(0, 20), 1));
+ when(jobRepository.findAllById(anyCollection()))
+ .thenReturn(List.of(Job.builder().id(JOB).companyId(COMPANY).title("t").description("d")
+ .city("c").employmentType("FULL_TIME").status("PUBLISHED").build()));
+ when(companyRepository.findAllById(anyCollection())).thenReturn(List.of());
+ when(interviewRepository.findByStudentUserIdAndStatusInOrderByStartAtAsc(anyLong(), anyCollection()))
+ .thenReturn(List.of());
+ when(interviewScoreRepository.findByStudentUserIdAndApplicationIdInAndStatus(
+ anyLong(), anyCollection(), anyString())).thenReturn(List.of());
+ when(offerRepository.findByCandidateIdAndStatusNotOrderByCreatedAtDesc(anyLong(), anyString()))
+ .thenReturn(List.of());
+ when(applicationRepository.findByJobIdInAndStatusNotInOrderByCreatedAtAsc(anyCollection(), anyCollection()))
+ .thenReturn(List.of(app));
+ when(dimensionScoreRepository.findUpToPeriod(anyCollection(), anyString())).thenReturn(List.of());
+ when(notificationRepository.countUnreadByUserId(ME)).thenReturn(0L);
+ when(notificationRepository.countUnreadByUserIdAndType(ME, "REPORT_DECISION")).thenReturn(0L);
+
+ CareerProgressResponse resp = service.getCareerProgress(ME, 0, 20, null);
+ CareerProgressResponse.CareerProgressItem item = resp.getApplications().getItems().get(0);
+ assertTrue(item.getCompetition().isEligible());
+ assertNull(item.getCompetition().getRank());
+ assertNull(item.getCompetition().getOverallScore());
+ assertNull(item.getCompetition().getPercentile());
+ assertNull(item.getCompetition().getMySnapshotPeriod());
+ assertEquals(1, item.getCompetition().getCohortSize());
+ }
+
+ // ── 无 N+1:所有批量查询各执行一次 ────────────────────────
+
+ @Test
+ void multipleApplications_noNPlusOne_batchQueriesSingleCallEach() {
+ mockUser(ME, User.Role.STUDENT);
+ Application a1 = app(1L, ME, 9L, "SUBMITTED", "2026-08-20T10:00:00");
+ Application a2 = app(2L, ME, 10L, "INTERVIEW", "2026-08-19T10:00:00");
+ Application a3 = app(3L, ME, 11L, "VIEWED", "2026-08-18T10:00:00");
+ when(applicationRepository.findByStudentUserIdOrderByCreatedAtDesc(eq(ME), any(PageRequest.class)))
+ .thenReturn(new PageImpl<>(List.of(a1, a2, a3), PageRequest.of(0, 20), 3));
+ when(jobRepository.findAllById(anyCollection())).thenReturn(List.of(
+ Job.builder().id(9L).companyId(COMPANY).title("t1").description("d").city("c")
+ .employmentType("FULL_TIME").status("PUBLISHED").build(),
+ Job.builder().id(10L).companyId(COMPANY).title("t2").description("d").city("c")
+ .employmentType("FULL_TIME").status("PUBLISHED").build(),
+ Job.builder().id(11L).companyId(COMPANY).title("t3").description("d").city("c")
+ .employmentType("FULL_TIME").status("PUBLISHED").build()));
+ when(companyRepository.findAllById(anyCollection()))
+ .thenReturn(List.of(Company.builder().id(COMPANY).ownerUserId(9L).name("c").build()));
+ when(interviewRepository.findByStudentUserIdAndStatusInOrderByStartAtAsc(anyLong(), anyCollection()))
+ .thenReturn(List.of());
+ when(interviewScoreRepository.findByStudentUserIdAndApplicationIdInAndStatus(
+ anyLong(), anyCollection(), anyString())).thenReturn(List.of());
+ when(offerRepository.findByCandidateIdAndStatusNotOrderByCreatedAtDesc(anyLong(), anyString()))
+ .thenReturn(List.of());
+ when(applicationRepository.findByJobIdInAndStatusNotInOrderByCreatedAtAsc(anyCollection(), anyCollection()))
+ .thenReturn(List.of(a1, a2, a3));
+ when(dimensionScoreRepository.findUpToPeriod(anyCollection(), anyString())).thenReturn(List.of());
+ when(notificationRepository.countUnreadByUserId(ME)).thenReturn(0L);
+ when(notificationRepository.countUnreadByUserIdAndType(ME, "REPORT_DECISION")).thenReturn(0L);
+
+ service.getCareerProgress(ME, 0, 20, null);
+
+ verify(jobRepository, times(1)).findAllById(anyCollection());
+ verify(companyRepository, times(1)).findAllById(anyCollection());
+ verify(interviewRepository, times(1)).findByStudentUserIdAndStatusInOrderByStartAtAsc(anyLong(), anyCollection());
+ verify(interviewScoreRepository, times(1)).findByStudentUserIdAndApplicationIdInAndStatus(anyLong(), anyCollection(), anyString());
+ verify(offerRepository, times(1)).findByCandidateIdAndStatusNotOrderByCreatedAtDesc(anyLong(), anyString());
+ verify(applicationRepository, times(1)).findByJobIdInAndStatusNotInOrderByCreatedAtAsc(anyCollection(), anyCollection());
+ verify(dimensionScoreRepository, times(1)).findUpToPeriod(anyCollection(), anyString());
+ verify(notificationRepository, times(1)).countUnreadByUserId(ME);
+ verify(notificationRepository, times(1)).countUnreadByUserIdAndType(ME, "REPORT_DECISION");
+ // 未按投递/面试/Offer 循环查询
+ verify(interviewRepository, never()).findByApplicationIdOrderByCreatedAtDesc(anyLong());
+ }
+
+ // ── 辅助 ────────────────────────────────────────────────
+
+ private void mockUser(Long uid, User.Role role) {
+ User u = User.builder().id(uid).phone("1").role(role).build();
+ u.setRoles(new HashSet<>(Set.of(role)));
+ when(userRepository.findById(uid)).thenReturn(Optional.of(u));
+ }
+
+ private Application app(Long id, Long sid, Long jobId, String status, String created) {
+ return Application.builder().id(id).jobId(jobId).studentUserId(sid).status(status)
+ .createdAt(LocalDateTime.parse(created)).updatedAt(LocalDateTime.parse(created)).build();
+ }
+
+ private Interview interview(Long id, Long appId, String status, String startAt) {
+ return Interview.builder().id(id).applicationId(appId).companyUserId(999L).studentUserId(ME)
+ .roundNo(1).title("技术面试").interviewType("ONLINE")
+ .startAt(LocalDateTime.parse(startAt)).endAt(LocalDateTime.parse(startAt).plusHours(1))
+ .status(status).build();
+ }
+
+ private InterviewScore score(Long interviewId, Long appId, String submittedAt, BigDecimal overall) {
+ LocalDateTime submitted = submittedAt == null ? null : LocalDateTime.parse(submittedAt);
+ return InterviewScore.builder().id(interviewId).interviewId(interviewId).applicationId(appId)
+ .companyUserId(999L).studentUserId(ME).status(InterviewScore.STATUS_SUBMITTED)
+ .overallScore(overall).recommendation("HIRE").submittedAt(submitted).version(0L).build();
+ }
+
+ private Offer offer(Long id, Long appId, String status, String expireAt) {
+ return Offer.builder().id(id).applicationId(appId).jobId(JOB).companyId(COMPANY).candidateId(ME)
+ .title("Java 后端工程师").description("d").expireAt(LocalDateTime.parse(expireAt))
+ .createdBy(999L).status(status).version(0L)
+ .createdAt(LocalDateTime.of(2026, 8, 25, 12, 0)).build();
+ }
+
+ private CandidateDimensionScore dim(Long sid, String score) {
+ return CandidateDimensionScore.builder().studentUserId(sid).dimension("EDUCATION").period(PERIOD)
+ .score(new BigDecimal(score)).status("OK").weight(new BigDecimal("12.50"))
+ .rulesVersion("candidate-scoring-v0.1").build();
+ }
+
+ private static String yearMonth(LocalDate d) {
+ return String.format("%04d-%02d", d.getYear(), d.getMonthValue());
+ }
+}
\ No newline at end of file
diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/competition/CompetitionRankingCalculatorTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/competition/CompetitionRankingCalculatorTest.java
new file mode 100644
index 0000000..e710892
--- /dev/null
+++ b/mydreamjob-server/src/test/java/com/mydreamjob/competition/CompetitionRankingCalculatorTest.java
@@ -0,0 +1,168 @@
+package com.mydreamjob.competition;
+
+import com.mydreamjob.application.Application;
+import com.mydreamjob.candidatescore.CandidateDimensionScore;
+import com.mydreamjob.candidatescore.CandidateDimensionScoreRepository;
+import com.mydreamjob.candidatescore.CandidateScoringCalculator;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.mockito.junit.jupiter.MockitoSettings;
+import org.mockito.quality.Strictness;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+/**
+ * 共享排名计算器单测(V32/V34 复用核心)。
+ *
+ * 覆盖稳定排序(有分优先 → overallScore DESC → createdAt ASC → studentUserId ASC →
+ * 无分最后)、cohort、百分位、最新快照周期选择与 overviewStatus。综合分走真实
+ * CandidateScoringCalculator 语义(此处以 mock 模拟按 studentUserId 返回预设分)。
+ */
+@ExtendWith(MockitoExtension.class)
+@MockitoSettings(strictness = Strictness.LENIENT)
+class CompetitionRankingCalculatorTest {
+
+ private static final Long ME = 100L;
+
+ @Mock private CandidateDimensionScoreRepository scoreRepository;
+ @Mock private CandidateScoringCalculator scoringCalculator;
+
+ private CompetitionRankingCalculator calculator;
+
+ private Map overallByStudent;
+
+ @BeforeEach
+ void setUp() {
+ calculator = new CompetitionRankingCalculator(scoreRepository, scoringCalculator);
+ overallByStudent = Map.of();
+ when(scoringCalculator.overallScoreFromSnapshots(anyList())).thenAnswer(inv -> {
+ List> snaps = inv.getArgument(0);
+ if (snaps == null || snaps.isEmpty()) return null;
+ Number sid = ((CandidateDimensionScore) snaps.get(0)).getStudentUserId();
+ return overallByStudent.get(sid.longValue());
+ });
+ }
+
+ @Test
+ void rankCohort_sortsScoredFirstThenScoreDescThenCreatedAtThenUserId() {
+ Application a = app(501L, "2026-01-01T10:00:00");
+ Application b = app(502L, "2026-01-02T10:00:00");
+ Application c = app(503L, "2026-01-03T10:00:00"); // 无分
+ Application me = app(ME, "2026-01-05T10:00:00");
+ Map> latest = Map.of(
+ a.getStudentUserId(), List.of(dim(a.getStudentUserId(), "2026-07")),
+ b.getStudentUserId(), List.of(dim(b.getStudentUserId(), "2026-07")),
+ ME, List.of(dim(ME, "2026-07")));
+ overallByStudent = Map.of(a.getStudentUserId(), new BigDecimal("90.00"),
+ b.getStudentUserId(), new BigDecimal("80.00"),
+ ME, new BigDecimal("85.00"));
+
+ CompetitionRankingCalculator.CohortRanking cr =
+ calculator.rankCohort(List.of(me, a, b, c), latest, ME);
+
+ assertEquals(4, cr.cohortSize);
+ assertEquals(2, cr.rank.intValue());
+ assertEquals(new BigDecimal("85.00"), cr.overallScore);
+ assertEquals(new BigDecimal("50.00"), cr.percentile); // lower=1, denom=2
+ // 排序:A(90) -> me(85) -> B(80) -> C(null)
+ assertEquals(501L, cr.rows.get(0).studentUserId.longValue());
+ assertEquals(ME.longValue(), cr.rows.get(1).studentUserId.longValue());
+ assertEquals(502L, cr.rows.get(2).studentUserId.longValue());
+ assertEquals(503L, cr.rows.get(3).studentUserId.longValue());
+ assertNull(cr.rows.get(3).overallScore);
+ assertEquals("OK", cr.status);
+ assertEquals("2026-07", cr.mySnapshotPeriod);
+ }
+
+ @Test
+ void rankCohort_tieBrokenByCreatedAtThenStudentUserId() {
+ Application x = app(900L, "2026-04-01T10:00:00");
+ Application me = app(ME, "2026-04-02T10:00:00");
+ Map> latest = Map.of(
+ x.getStudentUserId(), List.of(dim(x.getStudentUserId(), "2026-07")),
+ ME, List.of(dim(ME, "2026-07")));
+ overallByStudent = Map.of(x.getStudentUserId(), new BigDecimal("60.00"),
+ ME, new BigDecimal("60.00"));
+
+ CompetitionRankingCalculator.CohortRanking cr =
+ calculator.rankCohort(List.of(x, me), latest, ME);
+
+ assertEquals(2, cr.rank.intValue()); // x first, me second
+ assertEquals(900L, cr.rows.get(0).studentUserId.longValue());
+ }
+
+ @Test
+ void rankCohort_currentNoScore_rankScorePercentileNull() {
+ Application a = app(501L, "2026-01-01T10:00:00");
+ Application me = app(ME, "2026-01-02T10:00:00");
+ Map> latest = Map.of(
+ a.getStudentUserId(), List.of(dim(a.getStudentUserId(), "2026-07")));
+ overallByStudent = Map.of(a.getStudentUserId(), new BigDecimal("90.00"));
+
+ CompetitionRankingCalculator.CohortRanking cr =
+ calculator.rankCohort(List.of(a, me), latest, ME);
+
+ assertEquals(2, cr.cohortSize);
+ assertNull(cr.rank);
+ assertNull(cr.overallScore);
+ assertNull(cr.percentile);
+ assertNull(cr.mySnapshotPeriod);
+ assertEquals("INSUFFICIENT_DATA", cr.status);
+ }
+
+ @Test
+ void rankCohort_scoredLessThan2_percentileNull() {
+ Application a = app(501L, "2026-01-01T10:00:00");
+ Application me = app(ME, "2026-01-02T10:00:00");
+ Map> latest = Map.of(ME, List.of(dim(ME, "2026-07")));
+ overallByStudent = Map.of(ME, new BigDecimal("85.00"));
+
+ CompetitionRankingCalculator.CohortRanking cr =
+ calculator.rankCohort(List.of(a, me), latest, ME);
+
+ assertNull(cr.percentile);
+ }
+
+ @Test
+ void loadLatestSnapshots_picksLatestPeriodPerStudent() {
+ when(scoreRepository.findUpToPeriod(anyCollection(), anyString()))
+ .thenReturn(List.of(
+ dim(ME, "2026-06"),
+ dim(ME, "2026-07"),
+ dim(501L, "2026-05")));
+
+ Map> latest =
+ calculator.loadLatestSnapshots(List.of(ME, 501L), "2026-07");
+
+ assertEquals(1, latest.get(ME).size());
+ assertEquals("2026-07", latest.get(ME).get(0).getPeriod());
+ assertEquals("2026-05", latest.get(501L).get(0).getPeriod());
+ }
+
+ @Test
+ void overviewStatus_emptyIsInsufficientData() {
+ assertEquals("INSUFFICIENT_DATA", calculator.overviewStatus(List.of()));
+ }
+
+ private Application app(Long sid, String created) {
+ return Application.builder().studentUserId(sid)
+ .status("SUBMITTED")
+ .createdAt(LocalDateTime.parse(created)).build();
+ }
+
+ private CandidateDimensionScore dim(Long sid, String period) {
+ return CandidateDimensionScore.builder().studentUserId(sid).dimension("EDUCATION").period(period)
+ .score(new BigDecimal("80")).status("OK")
+ .weight(new BigDecimal("12.50")).rulesVersion("candidate-scoring-v0.1").build();
+ }
+}
\ No newline at end of file
diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/competition/CompetitionRankingServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/competition/CompetitionRankingServiceTest.java
index 4d49b91..ccfa0e9 100644
--- a/mydreamjob-server/src/test/java/com/mydreamjob/competition/CompetitionRankingServiceTest.java
+++ b/mydreamjob-server/src/test/java/com/mydreamjob/competition/CompetitionRankingServiceTest.java
@@ -5,14 +5,11 @@ import com.mydreamjob.application.ApplicationRepository;
import com.mydreamjob.auth.User;
import com.mydreamjob.auth.UserRepository;
import com.mydreamjob.candidatescore.CandidateDimensionScore;
-import com.mydreamjob.candidatescore.CandidateDimensionScoreRepository;
-import com.mydreamjob.candidatescore.CandidateScoringCalculator;
import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode;
import com.mydreamjob.competition.dto.CompetitionRankingResponse;
import com.mydreamjob.job.Job;
import com.mydreamjob.job.JobService;
-import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
@@ -22,8 +19,7 @@ import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import java.math.BigDecimal;
-import java.time.LocalDate;
-import java.time.LocalDateTime;
+import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
@@ -35,10 +31,11 @@ import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
/**
- * 同岗位竞争力排名服务单测(V32)。
+ * 同岗位竞争力排名服务单测(V32,编排 + 响应组装 + 脱敏)。
*
- * 用 Mock 隔离 repo/Calculator 以精确断言排序、cohort、百分位、Top5 脱敏与周期选择;
- * 排序纯 Java(有分优先、overallScore DESC、createdAt ASC、studentUserId ASC、无分最后)。
+ * 排序/百分位/快照装载等算法已下沉到 {@link CompetitionRankingCalculator}
+ * (由 CompetitionRankingCalculatorTest 覆盖),本类用 mock 计算器聚焦断言
+ * 权限、参数、岗位状态、cohort 委托,以及 Top5 脱敏与维度/缺口的组装。
*/
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
@@ -50,51 +47,10 @@ class CompetitionRankingServiceTest {
@Mock private UserRepository userRepository;
@Mock private JobService jobService;
@Mock private ApplicationRepository applicationRepository;
- @Mock private CandidateDimensionScoreRepository scoreRepository;
- @Mock private CandidateScoringCalculator calculator;
+ @Mock private CompetitionRankingCalculator rankingCalculator;
@InjectMocks private CompetitionRankingService service;
- private String currentPeriod;
- private Map overallByStudent;
-
- @BeforeEach
- void setUp() {
- currentPeriod = String.format("%04d-%02d", LocalDate.now().getYear(), LocalDate.now().getMonthValue());
- overallByStudent = Map.of();
- mockStudent(ME);
- // 默认按传入快照首条 studentUserId 返回预设综合分
- when(calculator.overallScoreFromSnapshots(anyList())).thenAnswer(inv -> {
- List> snaps = inv.getArgument(0);
- if (snaps == null || snaps.isEmpty()) return null;
- Number sid = ((CandidateDimensionScore) snaps.get(0)).getStudentUserId();
- return overallByStudent.get(sid.longValue());
- });
- }
-
- // ── 辅助 ────────────────────────────────────────────────
-
- private void mockStudent(Long uid) {
- User u = User.builder().id(uid).phone("1").role(User.Role.STUDENT).build();
- u.setRoles(new HashSet<>(Set.of(User.Role.STUDENT)));
- when(userRepository.findById(uid)).thenReturn(Optional.of(u));
- }
-
- private void mockRole(Long uid, User.Role role) {
- User u = User.builder().id(uid).phone("1").role(role).build();
- u.setRoles(new HashSet<>(Set.of(role)));
- when(userRepository.findById(uid)).thenReturn(Optional.of(u));
- }
-
- private void mockPublishedJob() {
- Job j = Job.builder().id(JOB).status("PUBLISHED").build();
- when(jobService.getJob(JOB)).thenReturn(j);
- }
-
- private CompetitionRankingResponse call() {
- return service.getRanking(ME, JOB);
- }
-
// ── 权限与参数校验 ─────────────────────────────────────────
@Test
@@ -112,6 +68,7 @@ class CompetitionRankingServiceTest {
@Test
void jobNotFound_returnsJobNotFound() {
+ mockStudent(ME);
when(jobService.getJob(JOB)).thenThrow(new BusinessException(ErrorCode.JOB_NOT_FOUND));
BusinessException ex = assertThrows(BusinessException.class, () -> call());
assertEquals(ErrorCode.JOB_NOT_FOUND.getCode(), ex.getErrorCode());
@@ -119,6 +76,7 @@ class CompetitionRankingServiceTest {
@Test
void nonPublishedJob_returnsJobNotFound() {
+ mockStudent(ME);
Job draft = Job.builder().id(JOB).status("DRAFT").build();
when(jobService.getJob(JOB)).thenReturn(draft);
BusinessException ex = assertThrows(BusinessException.class, () -> call());
@@ -133,212 +91,109 @@ class CompetitionRankingServiceTest {
assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCode());
}
- // 唯一投递为 WITHDRAWN / REJECTED → 已退出候选人池 → 403(与 cohort 同口径)
- @Test
- void withdrawnOnly_returnsForbidden() {
- mockPublishedJob();
- when(applicationRepository.existsByJobIdAndStudentUserIdAndStatusNotIn(eq(JOB), eq(ME), anySet())).thenReturn(false);
- BusinessException ex = assertThrows(BusinessException.class, () -> call());
- assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCode());
- }
-
- @Test
- void rejectedOnly_returnsForbidden() {
- mockPublishedJob();
- when(applicationRepository.existsByJobIdAndStudentUserIdAndStatusNotIn(eq(JOB), eq(ME), anySet())).thenReturn(false);
- BusinessException ex = assertThrows(BusinessException.class, () -> call());
- assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCode());
- }
-
- // 有效状态投递(SUBMITTED/VIEWED/INTERVIEW/HIRED)→ 200
@Test
- void effectiveStatus_returns200() {
+ void effectiveStatus_returns200_andAssemblesResponse() {
mockPublishedJob();
when(applicationRepository.existsByJobIdAndStudentUserIdAndStatusNotIn(eq(JOB), eq(ME), anySet())).thenReturn(true);
when(applicationRepository.findByJobIdAndStatusNotInOrderByCreatedAtAsc(eq(JOB), anyCollection()))
- .thenReturn(List.of(app(ME, 1, "2026-01-01T10:00:00")));
- overallByStudent = Map.of();
- when(scoreRepository.findUpToPeriod(anyCollection(), anyString())).thenReturn(List.of());
- CompetitionRankingResponse resp = call();
- assertEquals(JOB, resp.getJobId());
- assertEquals(1, resp.getCohortSize());
- }
-
- // ── 排序 / cohort / 百分位 / Top5 ──────────────────────────
-
- @Test
- void ranking_pipeline_checked() {
- mockPublishedJob();
- when(applicationRepository.existsByJobIdAndStudentUserIdAndStatusNotIn(eq(JOB), eq(ME), anySet())).thenReturn(true);
- // me 中位,A 更高,B 更低,C 无分
- Application meApp = app(ME, 1, "2026-01-05T10:00:00");
- Application a = app(501L, 2, "2026-01-01T10:00:00");
- Application b = app(502L, 3, "2026-01-02T10:00:00");
- Application c = app(503L, 4, "2026-01-03T10:00:00"); // 无分
- when(applicationRepository.findByJobIdAndStatusNotInOrderByCreatedAtAsc(eq(JOB), anyCollection()))
- .thenReturn(List.of(meApp, a, b, c));
- overallByStudent = Map.of(ME, new BigDecimal("85.00"),
- a.getStudentUserId(), new BigDecimal("90.00"),
- b.getStudentUserId(), new BigDecimal("80.00"));
- // c 无评分快照
- when(scoreRepository.findUpToPeriod(anyCollection(), anyString()))
- .thenReturn(List.of(dim(ME, "EDUCATION", "2026-07"),
- dim(a.getStudentUserId(), "EDUCATION", "2026-07"),
- dim(b.getStudentUserId(), "EDUCATION", "2026-07")));
+ .thenReturn(List.of(app(ME, "2026-01-01T10:00:00")));
+ when(rankingCalculator.loadLatestSnapshots(anyList(), anyString())).thenReturn(Map.of());
+ when(rankingCalculator.rankCohort(anyList(), anyMap(), eq(ME)))
+ .thenReturn(cr(1, 1, new BigDecimal("85.00"), null, "OK", "2026-07", List.of()));
CompetitionRankingResponse resp = call();
assertEquals(JOB, resp.getJobId());
- assertEquals(4, resp.getCohortSize()); // 无分候选人计入 cohortSize
- assertEquals(2, resp.getRank()); // me 排第 2
+ assertEquals(1, resp.getCohortSize());
+ assertEquals(1, resp.getRank());
assertEquals(new BigDecimal("85.00"), resp.getOverallScore());
- assertEquals(new BigDecimal("50.00"), resp.getPercentile()); // lower=1, denom=2, 1/2*100
- // Top5 匿名:rank1 A=90, rank2 me=85, rank3 B=80, rank4 C=null
- assertEquals(4, resp.getTopCandidates().size());
- assertEquals(1, resp.getTopCandidates().get(0).getRank());
- assertEquals(new BigDecimal("90.00"), resp.getTopCandidates().get(0).getOverallScore());
- assertEquals(4, resp.getTopCandidates().get(3).getRank());
- assertNull(resp.getTopCandidates().get(3).getOverallScore());
- assertEquals(currentPeriod, resp.getAsOfPeriod());
+ assertNull(resp.getPercentile());
+ assertEquals("OK", resp.getStatus());
assertEquals("2026-07", resp.getMySnapshotPeriod());
- // 无身份字段泄露:结构上仅含 rank + overallScore
- resp.getTopCandidates().forEach(t -> assertNotNull(t.getRank()));
- assertEquals(1, resp.getDimensions().size());
}
+ // ── Top5 脱敏与上限、维度/缺口组装 ─────────────────────────
+
@Test
- void scoredFirst_noScoreLast_secondarySortStable() {
+ void topCandidates_cappedAt5_andNoIdentityFields() {
mockPublishedJob();
when(applicationRepository.existsByJobIdAndStudentUserIdAndStatusNotIn(eq(JOB), eq(ME), anySet())).thenReturn(true);
- // 同分 77.00、不同 createdAt
- Application x = app(600L, 1, "2026-02-01T10:00:00"); // 更早
- Application y = app(601L, 2, "2026-02-02T10:00:00"); // 更晚
- // 无分学生
- Application n1 = app(700L, 3, "2026-01-01T10:00:00");
- Application n2 = app(701L, 4, "2026-01-01T10:00:00");
when(applicationRepository.findByJobIdAndStatusNotInOrderByCreatedAtAsc(eq(JOB), anyCollection()))
- .thenReturn(List.of(n1, x, y, n2, app(ME, 5, "2026-03-01T10:00:00")));
- overallByStudent = Map.of(x.getStudentUserId(), new BigDecimal("77.00"),
- y.getStudentUserId(), new BigDecimal("77.00"),
- ME, new BigDecimal("77.00"));
- when(scoreRepository.findUpToPeriod(anyCollection(), anyString()))
- .thenReturn(List.of(dim(x.getStudentUserId(), "EDUCATION", "2026-07"),
- dim(y.getStudentUserId(), "EDUCATION", "2026-07"),
- dim(ME, "EDUCATION", "2026-07")));
+ .thenReturn(List.of(app(ME, "2026-01-01T10:00:00")));
+ when(rankingCalculator.loadLatestSnapshots(anyList(), anyString())).thenReturn(Map.of());
+ when(rankingCalculator.rankCohort(anyList(), anyMap(), eq(ME)))
+ .thenReturn(cr(7, 3, new BigDecimal("77.00"), null, "PARTIAL", "2026-07", List.of()));
CompetitionRankingResponse resp = call();
- // 有分在前、无分最后;同分按 createdAt 升序(x 更早因此 x 在 y 前);本人有分排第 3
- assertEquals(3, resp.getRank());
- assertEquals(new BigDecimal("77.00"), resp.getTopCandidates().get(0).getOverallScore());
- assertEquals(new BigDecimal("77.00"), resp.getTopCandidates().get(1).getOverallScore());
- // 无分候选人的 overallScore 为 null 且排在末尾
- assertNull(resp.getTopCandidates().get(3).getOverallScore());
- assertEquals(5, resp.getCohortSize());
+
+ assertEquals(5, resp.getTopCandidates().size());
+ resp.getTopCandidates().forEach(t -> {
+ assertNotNull(t.getRank());
+ });
}
@Test
- void ownScoreSameAsOther_tieBrokenByCreatedAtThenStudentUserId() {
+ void dimensionsAndGaps_builtFromSnapshots() {
mockPublishedJob();
when(applicationRepository.existsByJobIdAndStudentUserIdAndStatusNotIn(eq(JOB), eq(ME), anySet())).thenReturn(true);
- // me 与 x 同分但 x 投递更早 → me 排后
- Application x = app(900L, 1, "2026-04-01T10:00:00");
- Application mm = app(ME, 2, "2026-04-02T10:00:00");
when(applicationRepository.findByJobIdAndStatusNotInOrderByCreatedAtAsc(eq(JOB), anyCollection()))
- .thenReturn(List.of(x, mm));
- overallByStudent = Map.of(x.getStudentUserId(), new BigDecimal("60.00"),
- ME, new BigDecimal("60.00"));
- when(scoreRepository.findUpToPeriod(anyCollection(), anyString()))
- .thenReturn(List.of(dim(x.getStudentUserId(), "EDUCATION", "2026-07"),
- dim(ME, "EDUCATION", "2026-07")));
+ .thenReturn(List.of(app(ME, "2026-01-01T10:00:00")));
+ when(rankingCalculator.loadLatestSnapshots(anyList(), anyString())).thenReturn(Map.of());
+ when(rankingCalculator.rankCohort(anyList(), anyMap(), eq(ME)))
+ .thenReturn(cr(1, 1, new BigDecimal("85.00"), null, "OK", "2026-07",
+ List.of(dim(ME, "EDUCATION", "OK", "85.00"), dim(ME, "ABILITY", "PENDING", null))));
CompetitionRankingResponse resp = call();
- // 有分 2 人 → percentile null(scored<2 分位为 null 的规则由百分位测试覆盖)
- assertEquals(2, resp.getRank()); // x first, me second
+
+ assertEquals(2, resp.getDimensions().size());
+ assertFalse(resp.getGaps().isEmpty());
}
- @Test
- void currentStudentNoScore_rankScorePercentileNull() {
- mockPublishedJob();
- when(applicationRepository.existsByJobIdAndStudentUserIdAndStatusNotIn(eq(JOB), eq(ME), anySet())).thenReturn(true);
- Application a = app(501L, 1, "2026-01-01T10:00:00");
- Application mm = app(ME, 2, "2026-01-02T10:00:00");
- when(applicationRepository.findByJobIdAndStatusNotInOrderByCreatedAtAsc(eq(JOB), anyCollection()))
- .thenReturn(List.of(a, mm));
- overallByStudent = Map.of(a.getStudentUserId(), new BigDecimal("90.00")); // me 无分
- when(scoreRepository.findUpToPeriod(anyCollection(), anyString()))
- .thenReturn(List.of(dim(a.getStudentUserId(), "EDUCATION", "2026-07")));
+ // ── 辅助 ────────────────────────────────────────────────
- CompetitionRankingResponse resp = call();
- assertEquals(2, resp.getCohortSize());
- assertNull(resp.getRank());
- assertNull(resp.getOverallScore());
- assertNull(resp.getPercentile());
- assertNull(resp.getMySnapshotPeriod());
- assertEquals("INSUFFICIENT_DATA", resp.getStatus());
+ private CompetitionRankingResponse call() {
+ return service.getRanking(ME, JOB);
}
- @Test
- void percentile_scoredLessThan2_returnsNull() {
- mockPublishedJob();
- when(applicationRepository.existsByJobIdAndStudentUserIdAndStatusNotIn(eq(JOB), eq(ME), anySet())).thenReturn(true);
- Application a = app(501L, 1, "2026-01-01T10:00:00");
- Application mm = app(ME, 2, "2026-01-02T10:00:00");
- when(applicationRepository.findByJobIdAndStatusNotInOrderByCreatedAtAsc(eq(JOB), anyCollection()))
- .thenReturn(List.of(a, mm));
- overallByStudent = Map.of(ME, new BigDecimal("85.00")); // 仅 1 个有分
- when(scoreRepository.findUpToPeriod(anyCollection(), anyString()))
- .thenReturn(List.of(dim(ME, "EDUCATION", "2026-07")));
-
- CompetitionRankingResponse resp = call();
- assertNull(resp.getPercentile());
+ private void mockStudent(Long uid) {
+ User u = User.builder().id(uid).phone("1").role(User.Role.STUDENT).build();
+ u.setRoles(new HashSet<>(Set.of(User.Role.STUDENT)));
+ when(userRepository.findById(uid)).thenReturn(Optional.of(u));
}
- @Test
- void latestPeriodSelected_multipleMonthlySnapshots() {
- mockPublishedJob();
- when(applicationRepository.existsByJobIdAndStudentUserIdAndStatusNotIn(eq(JOB), eq(ME), anySet())).thenReturn(true);
- Application mm = app(ME, 1, "2026-01-01T10:00:00");
- when(applicationRepository.findByJobIdAndStatusNotInOrderByCreatedAtAsc(eq(JOB), anyCollection()))
- .thenReturn(List.of(mm));
- overallByStudent = Map.of(ME, new BigDecimal("70.00"));
- // 仅 discord 学生有多期;用 ME 返回两期,latest=latest
- when(scoreRepository.findUpToPeriod(anyCollection(), anyString()))
- .thenReturn(List.of(dim(ME, "EDUCATION", "2026-06"), dim(ME, "EDUCATION", "2026-07")));
+ private void mockRole(Long uid, User.Role role) {
+ User u = User.builder().id(uid).phone("1").role(role).build();
+ u.setRoles(new HashSet<>(Set.of(role)));
+ when(userRepository.findById(uid)).thenReturn(Optional.of(u));
+ }
- CompetitionRankingResponse resp = call();
- assertEquals("2026-07", resp.getMySnapshotPeriod());
- assertEquals(1, resp.getDimensions().size()); // 仅最新周期 1 条
+ private void mockPublishedJob() {
+ mockStudent(ME);
+ Job j = Job.builder().id(JOB).status("PUBLISHED").build();
+ when(jobService.getJob(JOB)).thenReturn(j);
}
- @Test
- void topCandidates_cappedAt5() {
- mockPublishedJob();
- when(applicationRepository.existsByJobIdAndStudentUserIdAndStatusNotIn(eq(JOB), eq(ME), anySet())).thenReturn(true);
- var apps = new java.util.ArrayList();
- apps.add(app(ME, 1, "2026-01-01T10:00:00"));
- for (long i = 2; i <= 8; i++) {
- apps.add(app(1000L + i, (int) i, "2026-01-0" + (i % 9) + "T10:00:00"));
+ private CompetitionRankingCalculator.CohortRanking cr(int cohortSize, Integer rank, BigDecimal overall,
+ BigDecimal percentile, String status,
+ String period, List snaps) {
+ List rows = new ArrayList<>();
+ for (int i = 0; i < cohortSize; i++) {
+ BigDecimal s = (i < cohortSize) ? new BigDecimal("90.00") : null;
+ rows.add(new CompetitionRankingCalculator.Row(ME + i, null, s, period));
}
- when(applicationRepository.findByJobIdAndStatusNotInOrderByCreatedAtAsc(eq(JOB), anyCollection()))
- .thenReturn(apps);
- overallByStudent = Map.of();
- when(scoreRepository.findUpToPeriod(anyCollection(), anyString())).thenReturn(List.of());
- // 全部无分场景,仅验证 Top5 上限
- CompetitionRankingResponse resp = call();
- assertEquals(5, resp.getTopCandidates().size());
+ CompetitionRankingCalculator.Row me = new CompetitionRankingCalculator.Row(ME, null, overall, period);
+ return new CompetitionRankingCalculator.CohortRanking(cohortSize, rows, me, rank,
+ overall, percentile, status, period, snaps);
}
- // ── 载体构造 ──────────────────────────────────────────────
-
- private Application app(Long sid, int idx, String created) {
- return Application.builder().jobId(JOB).studentUserId(sid)
- .status(idx % 3 == 0 ? "VIEWED" : "SUBMITTED")
- .createdAt(LocalDateTime.parse(created)).build();
+ private Application app(Long sid, String created) {
+ return Application.builder().studentUserId(sid).status("SUBMITTED")
+ .createdAt(java.time.LocalDateTime.parse(created)).build();
}
- private CandidateDimensionScore dim(Long sid, String dimension, String period) {
- return CandidateDimensionScore.builder().studentUserId(sid).dimension(dimension).period(period)
- .weight(new BigDecimal("12.50")).rulesVersion("candidate-scoring-v0.1")
- .status("OK").build();
+ private CandidateDimensionScore dim(Long sid, String dimension, String status, String score) {
+ return CandidateDimensionScore.builder().studentUserId(sid).dimension(dimension).period("2026-07")
+ .score(score == null ? null : new BigDecimal(score)).status(status)
+ .weight(new BigDecimal("12.50")).rulesVersion("candidate-scoring-v0.1").build();
}
}
\ No newline at end of file