21 changed files with 2579 additions and 347 deletions
@ -0,0 +1,181 @@ |
|||||
|
# 统一求职进度聚合 API(V34) |
||||
|
|
||||
|
> 版本:V34 设计锁定版 |
||||
|
> 状态:已编码实现 |
||||
|
> 适用范围:学生端**只读**接口,为「求职进度」页聚合投递、面试、结构化面试评分、Offer、同岗位竞争力排名与通知计数。 |
||||
|
|
||||
|
## 1. 定位与边界 |
||||
|
|
||||
|
为 STUDENT 提供「求职进度」页所需的单次聚合数据,避免前端串行调用多个接口。与既有接口边界: |
||||
|
|
||||
|
| 接口 | 是否改动 | 关系 | |
||||
|
|---|---|---| |
||||
|
| `GET /jobs/{jobId}/competition-ranking` | 不改 | 复用同套候选人池/排序/百分位口径(共享 `CompetitionRankingCalculator`),但本接口只返回摘要,不返回 dimensions/gaps/topCandidates | |
||||
|
| `GET /notifications` | 不改 | 本接口只返回通知**计数**,通知标题/正文/详情仍由 `/notifications` 提供 | |
||||
|
| `GET /interviews/{id}/score` 等 | 不改 | 仅读取已提交(SUBMITTED)评分摘要,不返回 DRAFT、不返回 comment | |
||||
|
| `GET /offers/...` | 不改 | 仅返回 Offer 摘要,不返回薪资/条款细节 | |
||||
|
| `GET /profile/me/...`、投递、面试 | 不改 | 本接口为其只读聚合,不重写既有语义 | |
||||
|
|
||||
|
## 2. 接口 |
||||
|
|
||||
|
``` |
||||
|
GET /api/v1/profile/me/career-progress |
||||
|
``` |
||||
|
|
||||
|
Query 参数: |
||||
|
|
||||
|
| 参数 | 类型 | 必填 | 默认 | 说明 | |
||||
|
|---|---|---|---|---| |
||||
|
| `page` | int | 否 | `0` | 页码,从 0 开始,`>= 0` | |
||||
|
| `size` | int | 否 | `20` | 每页条数,`1–50` | |
||||
|
| `status` | string | 否 | — | 投递状态过滤,仅 `SUBMITTED/VIEWED/INTERVIEW/HIRED/REJECTED/WITHDRAWN` | |
||||
|
|
||||
|
- 角色:仅 STUDENT。 |
||||
|
- 身份从 JWT 获取,不接收 `userId`/`studentUserId`/`candidateId` 等身份注入参数。 |
||||
|
- 新增 1 条 GET 路径,OpenAPI 125 → 126。 |
||||
|
|
||||
|
## 3. 权限矩阵 |
||||
|
|
||||
|
| 身份/场景 | HTTP | code | 说明 | |
||||
|
|---|---|---|---| |
||||
|
| 未登录 | 401 | 2001 | 既有安全机制 | |
||||
|
| COMPANY / ADMIN | 403 | 2002 | Service 层 `assertStudentRole` | |
||||
|
| STUDENT 正常 | 200 | — | 正常返回 | |
||||
|
| `page < 0` / `size < 1` / `size > 50` / 非法 `status` | 400 | 1001 | BAD_REQUEST | |
||||
|
|
||||
|
不新增错误码,复用既有 ErrorCode。 |
||||
|
|
||||
|
## 4. 响应结构 |
||||
|
|
||||
|
``` |
||||
|
ApiResponse<CareerProgressResponse> |
||||
|
|
||||
|
CareerProgressResponse { |
||||
|
ProgressTodoSummary todo; |
||||
|
PaginatedApplications applications; |
||||
|
Instant generatedAt; |
||||
|
} |
||||
|
|
||||
|
ProgressTodoSummary { |
||||
|
long pendingInterviewResponseCount; // 状态 SCHEDULED 的面试数量 |
||||
|
long upcomingConfirmedInterviewCount; // 状态 CONFIRMED 且 startAt > 当前时间 |
||||
|
long pendingOfferResponseCount; // 状态 SENT/NEGOTIATING 且 expireAt > 当前时间 |
||||
|
long unreadNotificationCount; // 未读通知总数 |
||||
|
long unreadReportDecisionCount; // 未读 REPORT_DECISION 通知数 |
||||
|
} |
||||
|
|
||||
|
PaginatedApplications { |
||||
|
List<CareerProgressItem> items; |
||||
|
long total; |
||||
|
int page; |
||||
|
int size; |
||||
|
int totalPages; |
||||
|
} |
||||
|
|
||||
|
CareerProgressItem { |
||||
|
Long applicationId; |
||||
|
String applicationStatus; |
||||
|
LocalDateTime appliedAt; |
||||
|
LocalDateTime updatedAt; |
||||
|
JobSummary job; |
||||
|
InterviewSummary nextInterview; // 可空 |
||||
|
ScoreSummary latestSubmittedInterviewScore; // 可空 |
||||
|
OfferSummary offer; // 可空 |
||||
|
CompetitionSummary competition; |
||||
|
} |
||||
|
|
||||
|
JobSummary { Long jobId; String title; String companyName; String city; String employmentType; String jobStatus; } |
||||
|
InterviewSummary { Long interviewId; Integer roundNo; String title; String interviewType; LocalDateTime startAt; LocalDateTime endAt; String status; } |
||||
|
ScoreSummary { Long interviewId; BigDecimal overallScore; String recommendation; LocalDateTime submittedAt; } |
||||
|
OfferSummary { Long offerId; String status; String title; LocalDateTime expireAt; LocalDate onboardingDate; } |
||||
|
CompetitionSummary { |
||||
|
boolean eligible; |
||||
|
Integer rank; // 有分才返回;WITHDRAWN/REJECTED 或本人无分 → null |
||||
|
Integer cohortSize; // 仅 eligible=true 时返回 |
||||
|
BigDecimal overallScore; // 本人无分 → null |
||||
|
BigDecimal percentile; // 有分者 < 2 → null |
||||
|
String status; // OK / PARTIAL / INSUFFICIENT_DATA / PENDING |
||||
|
String mySnapshotPeriod; // 本人实际使用快照月;无快照 → null |
||||
|
String rulesVersion; |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
### 明确不返回字段 |
||||
|
|
||||
|
- 不返回 `studentUserId`、`candidateId`、`companyUserId`、`evaluatorUserId`、`phone`、`email`、`resumeUrl`、`passwordHash`、JWT。 |
||||
|
- 不返回通知标题/正文(`title`/`content`)、举报详情、面试评分 `comment`、Offer 薪资/条款细节(`salaryMin/salaryMax/baseSalary/housingAllowance/annualBonusMonths/benefits/description/otherAllowances`)。 |
||||
|
- 不返回其他候选人的任何身份信息,也不返回 V32 的 `dimensions`、`gaps`、`topCandidates`。 |
||||
|
|
||||
|
## 5. 聚合口径 |
||||
|
|
||||
|
### 5.1 投递 |
||||
|
|
||||
|
- 读取当前学生全部投递,按 `createdAt DESC` 分页。 |
||||
|
- `status` 存在时按该状态筛选。 |
||||
|
- 不过滤 `WITHDRAWN/REJECTED/HIRED`,均属于进度历史。 |
||||
|
|
||||
|
### 5.2 Todo |
||||
|
|
||||
|
| 字段 | 口径 | |
||||
|
|---|---| |
||||
|
| `pendingInterviewResponseCount` | 当前学生 `SCHEDULED` 面试数量 | |
||||
|
| `upcomingConfirmedInterviewCount` | 当前学生 `CONFIRMED` 且 `startAt > now` 的面试数量 | |
||||
|
| `pendingOfferResponseCount` | 当前学生 `SENT/NEGOTIATING` 且 `expireAt > now` 的 Offer 数量 | |
||||
|
| `unreadNotificationCount` | 复用 `NotificationRepository` 未读总数 | |
||||
|
| `unreadReportDecisionCount` | 未读且 `type='REPORT_DECISION'` 的通知数量 | |
||||
|
|
||||
|
### 5.3 nextInterview |
||||
|
|
||||
|
- 只看该投递的 `SCHEDULED / CONFIRMED` 面试,取 `startAt` 最早一场。 |
||||
|
- `RESCHEDULE_REQUESTED` 不算已确定;已完成/取消/拒绝不作为 nextInterview。 |
||||
|
|
||||
|
### 5.4 latestSubmittedInterviewScore |
||||
|
|
||||
|
- 只读 `SUBMITTED` 状态的 `InterviewScore`,每个 `applicationId` 取 `submittedAt` 最新一条。 |
||||
|
- 不返回 DRAFT、不返回 `comment`。 |
||||
|
|
||||
|
### 5.5 Offer |
||||
|
|
||||
|
- 仅学生本人 `candidateId` 的 Offer;`DRAFT` 永不返回。 |
||||
|
- 优先展示 `SENT/NEGOTIATING` 且 `expireAt > now` 的有效 Offer,按 `expireAt ASC` 取最早到期一条。 |
||||
|
- 无有效 Offer 时,展示该投递 `createdAt` 最新的一条非 DRAFT Offer。 |
||||
|
- 不返回薪资/条款细节。 |
||||
|
|
||||
|
### 5.6 competition |
||||
|
|
||||
|
- 投递状态 `SUBMITTED/VIEWED/INTERVIEW/HIRED` → `eligible=true`;`WITHDRAWN/REJECTED` → `eligible=false`(不返回其它排名字段)。 |
||||
|
- 复用 V32 候选池口径、最新快照口径、`CandidateScoringCalculator#overallScoreFromSnapshots`、稳定排序与百分位公式。 |
||||
|
- `percentile = 严格低于本人分数的人数 / (有分候选人数 - 1) × 100`,`scale=2`、`HALF_UP`;有分者 `< 2` 时 `null`。 |
||||
|
- 不返回 V32 的 `dimensions`/`gaps`/`topCandidates`。 |
||||
|
|
||||
|
## 6. 综合分 / 排序 / 百分位 |
||||
|
|
||||
|
- 全部复用 `CompetitionRankingCalculator`(V32/V34 共享),禁止复制第二套公式、禁止 SQL 重算。 |
||||
|
- `status` 复用引擎语义:`OK / PARTIAL / INSUFFICIENT_DATA / PENDING`,不把 `PENDING/INSUFFICIENT_DATA` 折算为 0。 |
||||
|
|
||||
|
## 7. 只读与性能 |
||||
|
|
||||
|
- `CareerProgressService` 标注 `@Transactional(readOnly=true)`,不写业务表/审计表/通知表/缓存。 |
||||
|
- 所有附属数据批量读取(jobs/companies/interviews/scores/offers/cohort/快照/通知计数),查询次数与分页 `items` 数量无关,目标约 8–10 次,无 N+1。 |
||||
|
- `size` 上限固定 50。 |
||||
|
|
||||
|
## 8. 数据库与 Flyway |
||||
|
|
||||
|
- 不需要新表。 |
||||
|
- 不新增 Flyway V34。 |
||||
|
- 无 DDL、无数据变更。 |
||||
|
|
||||
|
## 9. 测试 |
||||
|
|
||||
|
- `CareerProgressServiceTest`:权限/参数、空数据、完整聚合、DRAFT 不泄露、WITHDRAWN/REJECTED、无快照、批量查询无 N+1。 |
||||
|
- `CareerProgressControllerTest`:STUDENT 正常、COMPANY/ADMIN 403、未登录 401、参数非法 400、脱敏断言。 |
||||
|
- `CareerProgressPersistenceIntegrationTest`:真实仓库批量读取 + 只读性(关键表计数前后不变)。 |
||||
|
|
||||
|
## 10. 禁止事项(实现阶段) |
||||
|
|
||||
|
- 不修改 Candidate Scoring Engine、Competition Ranking 对外接口/权限/状态口径。 |
||||
|
- 不修改 Offer、Interview、Notification、Report/Block 的既有接口语义。 |
||||
|
- 不新增数据库表、不新增 Flyway、不新增 Redis 缓存、不新增异步任务。 |
||||
|
- 不新增错误码。 |
||||
|
- 不做企业回复率、推荐算法、AI 分析、薪资建议、通知内容聚合。 |
||||
|
- 不启停 Docker(黑盒阶段除外)。 |
||||
@ -0,0 +1,73 @@ |
|||||
|
package com.mydreamjob.careerprogress; |
||||
|
|
||||
|
import com.mydreamjob.careerprogress.dto.CareerProgressResponse; |
||||
|
import com.mydreamjob.common.api.ApiResponse; |
||||
|
import com.mydreamjob.common.exception.BusinessException; |
||||
|
import com.mydreamjob.common.exception.ErrorCode; |
||||
|
import io.swagger.v3.oas.annotations.Operation; |
||||
|
import io.swagger.v3.oas.annotations.Parameter; |
||||
|
import io.swagger.v3.oas.annotations.enums.ParameterIn; |
||||
|
import io.swagger.v3.oas.annotations.responses.ApiResponses; |
||||
|
import io.swagger.v3.oas.annotations.security.SecurityRequirement; |
||||
|
import io.swagger.v3.oas.annotations.tags.Tag; |
||||
|
import jakarta.servlet.http.HttpServletRequest; |
||||
|
import lombok.RequiredArgsConstructor; |
||||
|
import org.springframework.http.ResponseEntity; |
||||
|
import org.springframework.security.core.Authentication; |
||||
|
import org.springframework.security.core.context.SecurityContextHolder; |
||||
|
import org.springframework.web.bind.annotation.GetMapping; |
||||
|
import org.springframework.web.bind.annotation.RequestParam; |
||||
|
import org.springframework.web.bind.annotation.RestController; |
||||
|
|
||||
|
/** |
||||
|
* 统一求职进度聚合接口(V34)。 |
||||
|
* |
||||
|
* <p>仅 STUDENT 可访问;身份只取 JWT,不接受 userId/studentUserId/candidateId 等参数。 |
||||
|
* 未登录 401;COMPANY/ADMIN 403/2002;参数非法 400/1001。</p> |
||||
|
*/ |
||||
|
@Tag(name = "求职进度", description = "学生端聚合投递、面试、评分、Offer、竞争力排名与通知计数") |
||||
|
@RestController |
||||
|
@RequiredArgsConstructor |
||||
|
public class CareerProgressController { |
||||
|
|
||||
|
private final CareerProgressService careerProgressService; |
||||
|
|
||||
|
@Operation( |
||||
|
summary = "统一求职进度聚合", |
||||
|
description = "只读学生端接口:汇总当前学生的投递分页、岗位、下一场面试、最近已提交结构化面试评分、" |
||||
|
+ "可展示 Offer、同岗位竞争力排名,以及待办/通知计数(仅计数,不含通知详情)。" |
||||
|
+ "身份仅从 JWT/SecurityContext 获取。", |
||||
|
security = @SecurityRequirement(name = "bearerAuth")) |
||||
|
@ApiResponses({ |
||||
|
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "OK"), |
||||
|
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "page/size/status 参数非法"), |
||||
|
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "未登录"), |
||||
|
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "非 STUDENT 角色") |
||||
|
}) |
||||
|
@GetMapping("/profile/me/career-progress") |
||||
|
public ResponseEntity<ApiResponse<CareerProgressResponse>> getCareerProgress( |
||||
|
@Parameter(name = "page", in = ParameterIn.QUERY, description = "页码,从 0 开始,默认 0") |
||||
|
@RequestParam(defaultValue = "0") int page, |
||||
|
@Parameter(name = "size", in = ParameterIn.QUERY, description = "每页条数,1–50,默认 20") |
||||
|
@RequestParam(defaultValue = "20") int size, |
||||
|
@Parameter(name = "status", in = ParameterIn.QUERY, |
||||
|
description = "投递状态过滤,可选 SUBMITTED/VIEWED/INTERVIEW/HIRED/REJECTED/WITHDRAWN") |
||||
|
@RequestParam(required = false) String status, |
||||
|
HttpServletRequest request) { |
||||
|
Long userId = resolveUserId(request); |
||||
|
CareerProgressResponse data = careerProgressService.getCareerProgress(userId, page, size, status); |
||||
|
return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request))); |
||||
|
} |
||||
|
|
||||
|
private Long resolveUserId(HttpServletRequest request) { |
||||
|
Authentication auth = SecurityContextHolder.getContext().getAuthentication(); |
||||
|
if (auth == null || !auth.isAuthenticated()) { |
||||
|
throw new BusinessException(ErrorCode.UNAUTHORIZED); |
||||
|
} |
||||
|
return Long.valueOf(auth.getName()); |
||||
|
} |
||||
|
|
||||
|
private String getRequestId(HttpServletRequest request) { |
||||
|
return request.getHeader("X-Request-Id"); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,333 @@ |
|||||
|
package com.mydreamjob.careerprogress; |
||||
|
|
||||
|
import com.mydreamjob.application.Application; |
||||
|
import com.mydreamjob.application.ApplicationRepository; |
||||
|
import com.mydreamjob.auth.User; |
||||
|
import com.mydreamjob.auth.UserRepository; |
||||
|
import com.mydreamjob.candidatescore.CandidateDimensionScore; |
||||
|
import com.mydreamjob.candidatescore.CandidateScoringCalculator; |
||||
|
import com.mydreamjob.careerprogress.dto.CareerProgressResponse; |
||||
|
import com.mydreamjob.careerprogress.dto.CareerProgressResponse.CareerProgressItem; |
||||
|
import com.mydreamjob.careerprogress.dto.CareerProgressResponse.CompetitionSummary; |
||||
|
import com.mydreamjob.careerprogress.dto.CareerProgressResponse.InterviewSummary; |
||||
|
import com.mydreamjob.careerprogress.dto.CareerProgressResponse.JobSummary; |
||||
|
import com.mydreamjob.careerprogress.dto.CareerProgressResponse.OfferSummary; |
||||
|
import com.mydreamjob.careerprogress.dto.CareerProgressResponse.PaginatedApplications; |
||||
|
import com.mydreamjob.careerprogress.dto.CareerProgressResponse.ProgressTodoSummary; |
||||
|
import com.mydreamjob.careerprogress.dto.CareerProgressResponse.ScoreSummary; |
||||
|
import com.mydreamjob.common.exception.BusinessException; |
||||
|
import com.mydreamjob.common.exception.ErrorCode; |
||||
|
import com.mydreamjob.company.Company; |
||||
|
import com.mydreamjob.company.CompanyRepository; |
||||
|
import com.mydreamjob.competition.CompetitionRankingCalculator; |
||||
|
import com.mydreamjob.interview.Interview; |
||||
|
import com.mydreamjob.interview.InterviewRepository; |
||||
|
import com.mydreamjob.interviewscore.InterviewScore; |
||||
|
import com.mydreamjob.interviewscore.InterviewScoreRepository; |
||||
|
import com.mydreamjob.job.Job; |
||||
|
import com.mydreamjob.job.JobRepository; |
||||
|
import com.mydreamjob.notification.NotificationRepository; |
||||
|
import com.mydreamjob.offer.Offer; |
||||
|
import com.mydreamjob.offer.OfferRepository; |
||||
|
import lombok.RequiredArgsConstructor; |
||||
|
import lombok.extern.slf4j.Slf4j; |
||||
|
import org.springframework.data.domain.Page; |
||||
|
import org.springframework.data.domain.PageRequest; |
||||
|
import org.springframework.stereotype.Service; |
||||
|
import org.springframework.transaction.annotation.Transactional; |
||||
|
|
||||
|
import java.time.Instant; |
||||
|
import java.time.LocalDate; |
||||
|
import java.time.LocalDateTime; |
||||
|
import java.util.Comparator; |
||||
|
import java.util.HashMap; |
||||
|
import java.util.LinkedHashMap; |
||||
|
import java.util.List; |
||||
|
import java.util.Map; |
||||
|
import java.util.Set; |
||||
|
import java.util.function.Function; |
||||
|
import java.util.stream.Collectors; |
||||
|
|
||||
|
/** |
||||
|
* 统一求职进度聚合服务(V34)。 |
||||
|
* |
||||
|
* <p>只读学生端接口,汇总投递分页 + 岗位 + 下一场面试 + 最近已提交评分 + 可展示 Offer + |
||||
|
* 同岗位竞争力排名 + 待办/通知计数。<b>纯只读:</b>@Transactional(readOnly=true), |
||||
|
* 不写业务表/审计表/通知表/缓存。所有附属数据批量读取,DB 查询次数固定(与分页 |
||||
|
* items 数量无关),避免 N+1。身份仅从 JWT/SecurityContext 获取。</p> |
||||
|
*/ |
||||
|
@Slf4j |
||||
|
@Service |
||||
|
@RequiredArgsConstructor |
||||
|
public class CareerProgressService { |
||||
|
|
||||
|
private static final Set<String> ALLOWED_STATUSES = Set.of( |
||||
|
"SUBMITTED", "VIEWED", "INTERVIEW", "HIRED", "REJECTED", "WITHDRAWN"); |
||||
|
private static final Set<String> NEXT_INTERVIEW_STATUSES = Set.of("SCHEDULED", "CONFIRMED"); |
||||
|
private static final String REPORT_DECISION_TYPE = "REPORT_DECISION"; |
||||
|
|
||||
|
private final ApplicationRepository applicationRepository; |
||||
|
private final InterviewRepository interviewRepository; |
||||
|
private final InterviewScoreRepository interviewScoreRepository; |
||||
|
private final OfferRepository offerRepository; |
||||
|
private final NotificationRepository notificationRepository; |
||||
|
private final JobRepository jobRepository; |
||||
|
private final CompanyRepository companyRepository; |
||||
|
private final UserRepository userRepository; |
||||
|
private final CompetitionRankingCalculator rankingCalculator; |
||||
|
|
||||
|
@Transactional(readOnly = true) |
||||
|
public CareerProgressResponse getCareerProgress(Long studentUserId, int page, int size, String status) { |
||||
|
assertStudentRole(studentUserId); |
||||
|
validateParams(page, size, status); |
||||
|
|
||||
|
LocalDateTime now = LocalDateTime.now(); |
||||
|
String asOf = yearMonth(LocalDate.now()); |
||||
|
|
||||
|
// 1. 投递分页(按 createdAt DESC;status 可选筛选)
|
||||
|
PageRequest pr = PageRequest.of(page, size); |
||||
|
Page<Application> appPage = (status == null || status.isBlank()) |
||||
|
? applicationRepository.findByStudentUserIdOrderByCreatedAtDesc(studentUserId, pr) |
||||
|
: applicationRepository.findByStudentUserIdAndStatusOrderByCreatedAtDesc(studentUserId, status, pr); |
||||
|
List<Application> pageApps = appPage.getContent(); |
||||
|
|
||||
|
// 2. 岗位与企业(批量,与 items 数量无关)
|
||||
|
List<Long> jobIds = pageApps.stream().map(Application::getJobId).distinct().toList(); |
||||
|
Map<Long, Job> jobs = jobIds.isEmpty() ? Map.of() |
||||
|
: jobRepository.findAllById(jobIds).stream() |
||||
|
.collect(Collectors.toMap(Job::getId, Function.identity())); |
||||
|
List<Long> companyIds = jobs.values().stream().map(Job::getCompanyId).distinct().toList(); |
||||
|
Map<Long, Company> companies = companyIds.isEmpty() ? Map.of() |
||||
|
: companyRepository.findAllById(companyIds).stream() |
||||
|
.collect(Collectors.toMap(Company::getId, Function.identity())); |
||||
|
|
||||
|
// 3. 面试:学生全部 SCHEDULED/CONFIRMED(todo 计数 + nextInterview 共用一个批次)
|
||||
|
List<Interview> pendingInterviews = interviewRepository |
||||
|
.findByStudentUserIdAndStatusInOrderByStartAtAsc(studentUserId, NEXT_INTERVIEW_STATUSES); |
||||
|
|
||||
|
// 4. 已提交评分(本页投递,批量)
|
||||
|
List<Long> appIds = pageApps.stream().map(Application::getId).toList(); |
||||
|
List<InterviewScore> scores = appIds.isEmpty() ? List.of() |
||||
|
: interviewScoreRepository.findByStudentUserIdAndApplicationIdInAndStatus( |
||||
|
studentUserId, appIds, InterviewScore.STATUS_SUBMITTED); |
||||
|
|
||||
|
// 5. 非 DRAFT Offer(todo 有效 Offer 计数 + 每条投递 Offer 展示共用一个批次)
|
||||
|
List<Offer> offers = offerRepository.findByCandidateIdAndStatusNotOrderByCreatedAtDesc(studentUserId, "DRAFT"); |
||||
|
|
||||
|
// 6. 竞争力:本页岗位的 cohort 投递与候选人快照各批量一次,再按岗位内存排序
|
||||
|
List<Application> cohortApps = jobIds.isEmpty() ? List.of() |
||||
|
: applicationRepository.findByJobIdInAndStatusNotInOrderByCreatedAtAsc( |
||||
|
jobIds, CompetitionRankingCalculator.EXCLUDED_STATUSES); |
||||
|
List<Long> cohortCandidateIds = cohortApps.stream() |
||||
|
.map(Application::getStudentUserId).distinct().toList(); |
||||
|
Map<Long, List<CandidateDimensionScore>> latestByStudent = |
||||
|
rankingCalculator.loadLatestSnapshots(cohortCandidateIds, asOf); |
||||
|
Map<Long, List<Application>> cohortByJob = cohortApps.stream() |
||||
|
.collect(Collectors.groupingBy(Application::getJobId)); |
||||
|
|
||||
|
// 7. 通知计数(只读 count)
|
||||
|
long unreadTotal = notificationRepository.countUnreadByUserId(studentUserId); |
||||
|
long unreadReportDecision = notificationRepository.countUnreadByUserIdAndType(studentUserId, REPORT_DECISION_TYPE); |
||||
|
|
||||
|
// 组装 todo
|
||||
|
ProgressTodoSummary todo = buildTodo(pendingInterviews, offers, unreadTotal, unreadReportDecision, now); |
||||
|
|
||||
|
// 组装 items
|
||||
|
Map<Long, Interview> nextInterviewByApp = buildNextInterviewMap(pendingInterviews); |
||||
|
Map<Long, InterviewScore> latestScoreByApp = buildLatestScoreMap(scores); |
||||
|
Map<Long, Offer> offerByApp = buildOfferMap(offers, now); |
||||
|
|
||||
|
List<CareerProgressItem> items = pageApps.stream().map(app -> { |
||||
|
Job job = jobs.get(app.getJobId()); |
||||
|
return CareerProgressItem.builder() |
||||
|
.applicationId(app.getId()) |
||||
|
.applicationStatus(app.getStatus()) |
||||
|
.appliedAt(app.getCreatedAt()) |
||||
|
.updatedAt(app.getUpdatedAt()) |
||||
|
.job(job == null ? null : buildJobSummary(job, companies)) |
||||
|
.nextInterview(buildInterviewSummary(nextInterviewByApp.get(app.getId()))) |
||||
|
.latestSubmittedInterviewScore(buildScoreSummary(latestScoreByApp.get(app.getId()))) |
||||
|
.offer(buildOfferSummary(offerByApp.get(app.getId()))) |
||||
|
.competition(buildCompetition(app, cohortByJob, latestByStudent, studentUserId)) |
||||
|
.build(); |
||||
|
}).collect(Collectors.toList()); |
||||
|
|
||||
|
PaginatedApplications applications = PaginatedApplications.builder() |
||||
|
.items(items) |
||||
|
.total(appPage.getTotalElements()) |
||||
|
.page(page) |
||||
|
.size(size) |
||||
|
.totalPages(appPage.getTotalPages()) |
||||
|
.build(); |
||||
|
|
||||
|
return CareerProgressResponse.builder() |
||||
|
.todo(todo) |
||||
|
.applications(applications) |
||||
|
.generatedAt(Instant.now()) |
||||
|
.build(); |
||||
|
} |
||||
|
|
||||
|
// ── todo ────────────────────────────────────────────────
|
||||
|
|
||||
|
private ProgressTodoSummary buildTodo(List<Interview> interviews, List<Offer> offers, |
||||
|
long unreadTotal, long unreadReportDecision, LocalDateTime now) { |
||||
|
long pendingInterview = interviews.stream() |
||||
|
.filter(i -> "SCHEDULED".equals(i.getStatus())).count(); |
||||
|
long upcomingConfirmed = interviews.stream() |
||||
|
.filter(i -> "CONFIRMED".equals(i.getStatus()) |
||||
|
&& i.getStartAt() != null && i.getStartAt().isAfter(now)).count(); |
||||
|
long pendingOffer = offers.stream() |
||||
|
.filter(o -> ("SENT".equals(o.getStatus()) || "NEGOTIATING".equals(o.getStatus())) |
||||
|
&& o.getExpireAt() != null && o.getExpireAt().isAfter(now)).count(); |
||||
|
return ProgressTodoSummary.builder() |
||||
|
.pendingInterviewResponseCount(pendingInterview) |
||||
|
.upcomingConfirmedInterviewCount(upcomingConfirmed) |
||||
|
.pendingOfferResponseCount(pendingOffer) |
||||
|
.unreadNotificationCount(unreadTotal) |
||||
|
.unreadReportDecisionCount(unreadReportDecision) |
||||
|
.build(); |
||||
|
} |
||||
|
|
||||
|
// ── 批量结果归组(内存,无额外查询)────────────────────────
|
||||
|
|
||||
|
private Map<Long, Interview> buildNextInterviewMap(List<Interview> interviews) { |
||||
|
Map<Long, Interview> map = new LinkedHashMap<>(); |
||||
|
// 已按 startAt ASC,putIfAbsent 保留每个 applicationId 最早一场
|
||||
|
for (Interview iv : interviews) { |
||||
|
map.putIfAbsent(iv.getApplicationId(), iv); |
||||
|
} |
||||
|
return map; |
||||
|
} |
||||
|
|
||||
|
private Map<Long, InterviewScore> buildLatestScoreMap(List<InterviewScore> scores) { |
||||
|
Map<Long, InterviewScore> map = new HashMap<>(); |
||||
|
for (InterviewScore s : scores) { |
||||
|
InterviewScore existing = map.get(s.getApplicationId()); |
||||
|
if (existing == null || isAfter(s.getSubmittedAt(), existing.getSubmittedAt())) { |
||||
|
map.put(s.getApplicationId(), s); |
||||
|
} |
||||
|
} |
||||
|
return map; |
||||
|
} |
||||
|
|
||||
|
private boolean isAfter(LocalDateTime a, LocalDateTime b) { |
||||
|
if (a == null) return false; |
||||
|
if (b == null) return true; |
||||
|
return a.isAfter(b); |
||||
|
} |
||||
|
|
||||
|
private Map<Long, Offer> buildOfferMap(List<Offer> offers, LocalDateTime now) { |
||||
|
Map<Long, List<Offer>> byApp = offers.stream().collect(Collectors.groupingBy(Offer::getApplicationId)); |
||||
|
Map<Long, Offer> result = new HashMap<>(); |
||||
|
for (Map.Entry<Long, List<Offer>> e : byApp.entrySet()) { |
||||
|
List<Offer> list = e.getValue(); |
||||
|
Offer effective = list.stream() |
||||
|
.filter(o -> ("SENT".equals(o.getStatus()) || "NEGOTIATING".equals(o.getStatus())) |
||||
|
&& o.getExpireAt() != null && o.getExpireAt().isAfter(now)) |
||||
|
.min(Comparator.comparing(Offer::getExpireAt)) |
||||
|
.orElse(null); |
||||
|
if (effective != null) { |
||||
|
result.put(e.getKey(), effective); |
||||
|
} else if (!list.isEmpty()) { |
||||
|
// offers 已按 createdAt DESC,第一个即该投递最新的非 DRAFT Offer
|
||||
|
result.put(e.getKey(), list.get(0)); |
||||
|
} |
||||
|
} |
||||
|
return result; |
||||
|
} |
||||
|
|
||||
|
// ── 子对象映射 ─────────────────────────────────────────
|
||||
|
|
||||
|
private JobSummary buildJobSummary(Job job, Map<Long, Company> companies) { |
||||
|
Company c = companies.get(job.getCompanyId()); |
||||
|
return JobSummary.builder() |
||||
|
.jobId(job.getId()) |
||||
|
.title(job.getTitle()) |
||||
|
.companyName(c != null ? c.getName() : null) |
||||
|
.city(job.getCity()) |
||||
|
.employmentType(job.getEmploymentType()) |
||||
|
.jobStatus(job.getStatus()) |
||||
|
.build(); |
||||
|
} |
||||
|
|
||||
|
private InterviewSummary buildInterviewSummary(Interview iv) { |
||||
|
if (iv == null) return null; |
||||
|
return InterviewSummary.builder() |
||||
|
.interviewId(iv.getId()) |
||||
|
.roundNo(iv.getRoundNo()) |
||||
|
.title(iv.getTitle()) |
||||
|
.interviewType(iv.getInterviewType()) |
||||
|
.startAt(iv.getStartAt()) |
||||
|
.endAt(iv.getEndAt()) |
||||
|
.status(iv.getStatus()) |
||||
|
.build(); |
||||
|
} |
||||
|
|
||||
|
private ScoreSummary buildScoreSummary(InterviewScore s) { |
||||
|
if (s == null) return null; |
||||
|
return ScoreSummary.builder() |
||||
|
.interviewId(s.getInterviewId()) |
||||
|
.overallScore(s.getOverallScore()) |
||||
|
.recommendation(s.getRecommendation()) |
||||
|
.submittedAt(s.getSubmittedAt()) |
||||
|
.build(); |
||||
|
} |
||||
|
|
||||
|
private OfferSummary buildOfferSummary(Offer o) { |
||||
|
if (o == null) return null; |
||||
|
return OfferSummary.builder() |
||||
|
.offerId(o.getId()) |
||||
|
.status(o.getStatus()) |
||||
|
.title(o.getTitle()) |
||||
|
.expireAt(o.getExpireAt()) |
||||
|
.onboardingDate(o.getOnboardingDate()) |
||||
|
.build(); |
||||
|
} |
||||
|
|
||||
|
private CompetitionSummary buildCompetition(Application app, |
||||
|
Map<Long, List<Application>> cohortByJob, |
||||
|
Map<Long, List<CandidateDimensionScore>> latestByStudent, |
||||
|
Long meId) { |
||||
|
String st = app.getStatus(); |
||||
|
if ("WITHDRAWN".equals(st) || "REJECTED".equals(st)) { |
||||
|
return CompetitionSummary.builder().eligible(false).build(); |
||||
|
} |
||||
|
List<Application> cohort = cohortByJob.getOrDefault(app.getJobId(), List.of()); |
||||
|
CompetitionRankingCalculator.CohortRanking cr = |
||||
|
rankingCalculator.rankCohort(cohort, latestByStudent, meId); |
||||
|
return CompetitionSummary.builder() |
||||
|
.eligible(true) |
||||
|
.rank(cr.rank) |
||||
|
.cohortSize(cr.cohortSize) |
||||
|
.overallScore(cr.overallScore) |
||||
|
.percentile(cr.percentile) |
||||
|
.status(cr.status) |
||||
|
.mySnapshotPeriod(cr.mySnapshotPeriod) |
||||
|
.rulesVersion(CandidateScoringCalculator.RULES_VERSION) |
||||
|
.build(); |
||||
|
} |
||||
|
|
||||
|
// ── 权限 / 参数 / 工具 ─────────────────────────────────
|
||||
|
|
||||
|
private void assertStudentRole(Long userId) { |
||||
|
User user = userRepository.findById(userId) |
||||
|
.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); |
||||
|
boolean isStudent = user.getRoles().stream().anyMatch(r -> r == User.Role.STUDENT); |
||||
|
if (!isStudent) { |
||||
|
throw new BusinessException(ErrorCode.FORBIDDEN); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private void validateParams(int page, int size, String status) { |
||||
|
if (page < 0 || size < 1 || size > 50) { |
||||
|
throw new BusinessException(ErrorCode.BAD_REQUEST); |
||||
|
} |
||||
|
if (status != null && !status.isBlank() && !ALLOWED_STATUSES.contains(status)) { |
||||
|
throw new BusinessException(ErrorCode.BAD_REQUEST); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private String yearMonth(LocalDate d) { |
||||
|
return String.format("%04d-%02d", d.getYear(), d.getMonthValue()); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,143 @@ |
|||||
|
package com.mydreamjob.careerprogress.dto; |
||||
|
|
||||
|
import com.fasterxml.jackson.annotation.JsonInclude; |
||||
|
import lombok.Builder; |
||||
|
import lombok.Data; |
||||
|
|
||||
|
import java.math.BigDecimal; |
||||
|
import java.time.Instant; |
||||
|
import java.time.LocalDate; |
||||
|
import java.time.LocalDateTime; |
||||
|
import java.util.List; |
||||
|
|
||||
|
/** |
||||
|
* 统一求职进度聚合响应(V34)。 |
||||
|
* |
||||
|
* <p>只读学生端接口。todo 仅返回通知/待办计数,不返回任何通知标题、正文或业务详情 |
||||
|
* (通知详情仍由既有 /notifications 提供)。applications 为当前学生投递分页, |
||||
|
* 每条投递附带岗位摘要、下一场面试、最近已提交评分、可展示 Offer 与同岗位竞争力摘要。 |
||||
|
* 对所有 null 子对象省略,避免泄露 DRAFT 或他人信息。</p> |
||||
|
*/ |
||||
|
@Data |
||||
|
@Builder |
||||
|
@JsonInclude(JsonInclude.Include.NON_NULL) |
||||
|
public class CareerProgressResponse { |
||||
|
|
||||
|
private ProgressTodoSummary todo; |
||||
|
|
||||
|
private PaginatedApplications applications; |
||||
|
|
||||
|
private Instant generatedAt; |
||||
|
|
||||
|
/** 待办与通知计数明细 */ |
||||
|
@Data |
||||
|
@Builder |
||||
|
@JsonInclude(JsonInclude.Include.NON_NULL) |
||||
|
public static class ProgressTodoSummary { |
||||
|
/** 待响应面试数:状态为 SCHEDULED 的面试数量 */ |
||||
|
private long pendingInterviewResponseCount; |
||||
|
/** 未来已确认面试数:状态 CONFIRMED 且 startAt > 当前时间 */ |
||||
|
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<CareerProgressItem> items; |
||||
|
private long total; |
||||
|
private int page; |
||||
|
private int size; |
||||
|
private int totalPages; |
||||
|
} |
||||
|
|
||||
|
/** 单条投递聚合项 */ |
||||
|
@Data |
||||
|
@Builder |
||||
|
@JsonInclude(JsonInclude.Include.NON_NULL) |
||||
|
public static class CareerProgressItem { |
||||
|
private Long applicationId; |
||||
|
private String applicationStatus; |
||||
|
private LocalDateTime appliedAt; |
||||
|
private LocalDateTime updatedAt; |
||||
|
|
||||
|
private JobSummary job; |
||||
|
private InterviewSummary nextInterview; |
||||
|
private ScoreSummary latestSubmittedInterviewScore; |
||||
|
private OfferSummary offer; |
||||
|
private CompetitionSummary competition; |
||||
|
} |
||||
|
|
||||
|
/** 岗位摘要(不含任何候选人/企业内部字段) */ |
||||
|
@Data |
||||
|
@Builder |
||||
|
@JsonInclude(JsonInclude.Include.NON_NULL) |
||||
|
public static class JobSummary { |
||||
|
private Long jobId; |
||||
|
private String title; |
||||
|
private String companyName; |
||||
|
private String city; |
||||
|
private String employmentType; |
||||
|
private String jobStatus; |
||||
|
} |
||||
|
|
||||
|
/** 下一场面试摘要(仅 SCHEDULED/CONFIRMED,取 startAt 最早) */ |
||||
|
@Data |
||||
|
@Builder |
||||
|
@JsonInclude(JsonInclude.Include.NON_NULL) |
||||
|
public static class InterviewSummary { |
||||
|
private Long interviewId; |
||||
|
private Integer roundNo; |
||||
|
private String title; |
||||
|
private String interviewType; |
||||
|
private LocalDateTime startAt; |
||||
|
private LocalDateTime endAt; |
||||
|
private String status; |
||||
|
} |
||||
|
|
||||
|
/** 最近已提交结构化面试评分摘要(不返回 DRAFT、不返回 comment) */ |
||||
|
@Data |
||||
|
@Builder |
||||
|
@JsonInclude(JsonInclude.Include.NON_NULL) |
||||
|
public static class ScoreSummary { |
||||
|
private Long interviewId; |
||||
|
private BigDecimal overallScore; |
||||
|
private String recommendation; |
||||
|
private LocalDateTime submittedAt; |
||||
|
} |
||||
|
|
||||
|
/** Offer 摘要(不返回薪资/条款细节) */ |
||||
|
@Data |
||||
|
@Builder |
||||
|
@JsonInclude(JsonInclude.Include.NON_NULL) |
||||
|
public static class OfferSummary { |
||||
|
private Long offerId; |
||||
|
private String status; |
||||
|
private String title; |
||||
|
private LocalDateTime expireAt; |
||||
|
private LocalDate onboardingDate; |
||||
|
} |
||||
|
|
||||
|
/** 同岗位竞争力摘要(复用 V32 候选池/排序/百分位口径;不含 dimensions/gaps/topCandidates) */ |
||||
|
@Data |
||||
|
@Builder |
||||
|
@JsonInclude(JsonInclude.Include.NON_NULL) |
||||
|
public static class CompetitionSummary { |
||||
|
/** 是否计入竞争力(仅 SUBMITTED/VIEWED/INTERVIEW/HIRED 为 true) */ |
||||
|
private boolean eligible; |
||||
|
private Integer rank; |
||||
|
private Integer cohortSize; |
||||
|
private BigDecimal overallScore; |
||||
|
private BigDecimal percentile; |
||||
|
private String status; |
||||
|
private String mySnapshotPeriod; |
||||
|
private String rulesVersion; |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,181 @@ |
|||||
|
package com.mydreamjob.competition; |
||||
|
|
||||
|
import com.mydreamjob.application.Application; |
||||
|
import com.mydreamjob.candidatescore.CandidateDimensionScore; |
||||
|
import com.mydreamjob.candidatescore.CandidateDimensionScoreRepository; |
||||
|
import com.mydreamjob.candidatescore.CandidateScoringCalculator; |
||||
|
import lombok.RequiredArgsConstructor; |
||||
|
import org.springframework.stereotype.Component; |
||||
|
|
||||
|
import java.math.BigDecimal; |
||||
|
import java.math.RoundingMode; |
||||
|
import java.time.LocalDateTime; |
||||
|
import java.util.ArrayList; |
||||
|
import java.util.Collections; |
||||
|
import java.util.Comparator; |
||||
|
import java.util.HashMap; |
||||
|
import java.util.List; |
||||
|
import java.util.Map; |
||||
|
import java.util.Objects; |
||||
|
import java.util.Set; |
||||
|
import java.util.stream.Collectors; |
||||
|
|
||||
|
/** |
||||
|
* 同岗位竞争力排名的共享计算组件(V32 / V34 复用)。 |
||||
|
* |
||||
|
* <p>承载候选人池(cohort)的稳定排序、综合分、百分位与状态判定,全部为纯确定 |
||||
|
* 规则、无数据库写入。综合分复用 {@link CandidateScoringCalculator#overallScoreFromSnapshots} |
||||
|
* 真实公式;排序在 Java 侧完成(有分优先 → overallScore DESC → createdAt ASC → |
||||
|
* studentUserId ASC → 无分最后);百分位复用企业看板公式 |
||||
|
* {@code 严格低于本人分数的人数 / (有分候选人数 - 1) × 100}(scale=2、HALF_UP)。 |
||||
|
* 禁止在 SQL 里重算 overallScore 或 percentile,禁止复制粘贴第二套公式。</p> |
||||
|
* |
||||
|
* <p>「读快照」与「排序计算」分离:{@link #loadLatestSnapshots} 一次批量读取, |
||||
|
* {@link #rankCohort} 纯计算不触碰数据库,保证 V34 按岗位批量排名时无 N+1。</p> |
||||
|
*/ |
||||
|
@Component |
||||
|
@RequiredArgsConstructor |
||||
|
public class CompetitionRankingCalculator { |
||||
|
|
||||
|
/** 已退出候选人池的投递状态(与 V32 cohort 口径一致) */ |
||||
|
public static final Set<String> EXCLUDED_STATUSES = Set.of("WITHDRAWN", "REJECTED"); |
||||
|
|
||||
|
private final CandidateDimensionScoreRepository scoreRepository; |
||||
|
private final CandidateScoringCalculator scoringCalculator; |
||||
|
|
||||
|
/** |
||||
|
* 批量读取一批学生的快照(period <= asOf),Java 侧按学生取最新周期,无 N+1。 |
||||
|
* period 为 'yyyy-MM',字典序即时间序。 |
||||
|
*/ |
||||
|
public Map<Long, List<CandidateDimensionScore>> loadLatestSnapshots(List<Long> studentUserIds, String asOf) { |
||||
|
if (studentUserIds == null || studentUserIds.isEmpty()) return Map.of(); |
||||
|
List<CandidateDimensionScore> all = scoreRepository.findUpToPeriod(studentUserIds, asOf); |
||||
|
Map<Long, Map<String, List<CandidateDimensionScore>>> byStudentPeriod = new HashMap<>(); |
||||
|
for (CandidateDimensionScore s : all) { |
||||
|
byStudentPeriod.computeIfAbsent(s.getStudentUserId(), k -> new HashMap<>()) |
||||
|
.computeIfAbsent(s.getPeriod(), k -> new ArrayList<>()).add(s); |
||||
|
} |
||||
|
Map<Long, List<CandidateDimensionScore>> result = new HashMap<>(); |
||||
|
for (Map.Entry<Long, Map<String, List<CandidateDimensionScore>>> e : byStudentPeriod.entrySet()) { |
||||
|
String latest = Collections.max(e.getValue().keySet()); |
||||
|
result.put(e.getKey(), e.getValue().get(latest)); |
||||
|
} |
||||
|
return result; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 对单个岗位的 cohort 投递做稳定排序并返回本人排名摘要。纯计算,不读库。 |
||||
|
* |
||||
|
* @param cohortApps 已排除 WITHDRAWN/REJECTED 的候选人投递(同岗位) |
||||
|
* @param latestByStudent 每个学生的最新快照(由 loadLatestSnapshots 批量产出) |
||||
|
* @param meId 当前学生 userId |
||||
|
*/ |
||||
|
public CohortRanking rankCohort(List<Application> cohortApps, |
||||
|
Map<Long, List<CandidateDimensionScore>> latestByStudent, |
||||
|
Long meId) { |
||||
|
List<Row> rows = new ArrayList<>(); |
||||
|
for (Application app : cohortApps) { |
||||
|
List<CandidateDimensionScore> snaps = latestByStudent.getOrDefault(app.getStudentUserId(), List.of()); |
||||
|
BigDecimal overall = scoringCalculator.overallScoreFromSnapshots(snaps); |
||||
|
rows.add(new Row(app.getStudentUserId(), app.getCreatedAt(), overall, |
||||
|
snaps.isEmpty() ? null : latestPeriod(snaps))); |
||||
|
} |
||||
|
|
||||
|
rows.sort(Comparator |
||||
|
.comparing((Row r) -> r.overallScore, Comparator.nullsLast(Comparator.reverseOrder())) |
||||
|
.thenComparing(Row::getCreatedAt) |
||||
|
.thenComparing(Row::getStudentUserId)); |
||||
|
|
||||
|
Row me = null; |
||||
|
Integer rank = null; |
||||
|
for (int i = 0; i < rows.size(); i++) { |
||||
|
Row r = rows.get(i); |
||||
|
if (r.studentUserId.equals(meId)) { |
||||
|
me = r; |
||||
|
if (r.overallScore != null) rank = i + 1; |
||||
|
break; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
BigDecimal percentile = computePercentile(rows, me); |
||||
|
List<CandidateDimensionScore> meSnaps = latestByStudent.getOrDefault(meId, List.of()); |
||||
|
String status = overviewStatus(meSnaps); |
||||
|
String myPeriod = me == null ? null : me.snapshotPeriod; |
||||
|
|
||||
|
return new CohortRanking(rows.size(), rows, me, rank, |
||||
|
me == null ? null : me.overallScore, percentile, status, myPeriod, meSnaps); |
||||
|
} |
||||
|
|
||||
|
private String latestPeriod(List<CandidateDimensionScore> snaps) { |
||||
|
return snaps.stream().map(CandidateDimensionScore::getPeriod) |
||||
|
.max(String::compareTo).orElse(null); |
||||
|
} |
||||
|
|
||||
|
private BigDecimal computePercentile(List<Row> rows, Row me) { |
||||
|
if (me == null || me.overallScore == null) return null; |
||||
|
List<BigDecimal> scored = rows.stream().map(r -> r.overallScore) |
||||
|
.filter(Objects::nonNull).collect(Collectors.toList()); |
||||
|
if (scored.size() < 2) return null; |
||||
|
long lower = scored.stream().filter(s -> s.compareTo(me.overallScore) < 0).count(); |
||||
|
return BigDecimal.valueOf(lower) |
||||
|
.divide(BigDecimal.valueOf(scored.size() - 1), 10, RoundingMode.HALF_UP) |
||||
|
.multiply(BigDecimal.valueOf(100)) |
||||
|
.setScale(2, RoundingMode.HALF_UP); |
||||
|
} |
||||
|
|
||||
|
/** 综合卡状态:OK / PARTIAL / INSUFFICIENT_DATA / PENDING(复用引擎语义) */ |
||||
|
public String overviewStatus(List<CandidateDimensionScore> snaps) { |
||||
|
if (snaps == null || snaps.isEmpty()) return "INSUFFICIENT_DATA"; |
||||
|
long computable = snaps.stream() |
||||
|
.filter(s -> "OK".equals(s.getStatus()) || "ESTIMATED".equals(s.getStatus())).count(); |
||||
|
if (computable == snaps.size()) return "OK"; |
||||
|
if (computable > 0) return "PARTIAL"; |
||||
|
boolean allPending = snaps.stream().allMatch(s -> "PENDING".equals(s.getStatus())); |
||||
|
return allPending ? "PENDING" : "INSUFFICIENT_DATA"; |
||||
|
} |
||||
|
|
||||
|
/** 排序载体(不参与响应序列化) */ |
||||
|
public static class Row { |
||||
|
public final Long studentUserId; |
||||
|
public final LocalDateTime createdAt; |
||||
|
public final BigDecimal overallScore; |
||||
|
public final String snapshotPeriod; |
||||
|
|
||||
|
Row(Long studentUserId, LocalDateTime createdAt, BigDecimal overallScore, String snapshotPeriod) { |
||||
|
this.studentUserId = studentUserId; |
||||
|
this.createdAt = createdAt; |
||||
|
this.overallScore = overallScore; |
||||
|
this.snapshotPeriod = snapshotPeriod; |
||||
|
} |
||||
|
|
||||
|
LocalDateTime getCreatedAt() { return createdAt; } |
||||
|
Long getStudentUserId() { return studentUserId; } |
||||
|
} |
||||
|
|
||||
|
/** cohort 排名结果(领域载体,V32/V34 各自映射为 DTO) */ |
||||
|
public static class CohortRanking { |
||||
|
public final int cohortSize; |
||||
|
public final List<Row> rows; |
||||
|
public final Row me; |
||||
|
public final Integer rank; |
||||
|
public final BigDecimal overallScore; |
||||
|
public final BigDecimal percentile; |
||||
|
public final String status; |
||||
|
public final String mySnapshotPeriod; |
||||
|
public final List<CandidateDimensionScore> meSnaps; |
||||
|
|
||||
|
CohortRanking(int cohortSize, List<Row> rows, Row me, Integer rank, |
||||
|
BigDecimal overallScore, BigDecimal percentile, String status, |
||||
|
String mySnapshotPeriod, List<CandidateDimensionScore> meSnaps) { |
||||
|
this.cohortSize = cohortSize; |
||||
|
this.rows = rows; |
||||
|
this.me = me; |
||||
|
this.rank = rank; |
||||
|
this.overallScore = overallScore; |
||||
|
this.percentile = percentile; |
||||
|
this.status = status; |
||||
|
this.mySnapshotPeriod = mySnapshotPeriod; |
||||
|
this.meSnaps = meSnaps; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,318 @@ |
|||||
|
package com.mydreamjob.careerprogress; |
||||
|
|
||||
|
import com.mydreamjob.application.Application; |
||||
|
import com.mydreamjob.application.ApplicationRepository; |
||||
|
import com.mydreamjob.auth.JwtTokenProvider; |
||||
|
import com.mydreamjob.auth.User; |
||||
|
import com.mydreamjob.auth.UserRepository; |
||||
|
import com.mydreamjob.candidatescore.CandidateDimensionScore; |
||||
|
import com.mydreamjob.candidatescore.CandidateDimensionScoreRepository; |
||||
|
import com.mydreamjob.company.Company; |
||||
|
import com.mydreamjob.company.CompanyRepository; |
||||
|
import com.mydreamjob.interview.Interview; |
||||
|
import com.mydreamjob.interview.InterviewRepository; |
||||
|
import com.mydreamjob.interviewscore.InterviewScore; |
||||
|
import com.mydreamjob.interviewscore.InterviewScoreRepository; |
||||
|
import com.mydreamjob.job.Job; |
||||
|
import com.mydreamjob.job.JobRepository; |
||||
|
import com.mydreamjob.notification.Notification; |
||||
|
import com.mydreamjob.notification.NotificationRepository; |
||||
|
import com.mydreamjob.offer.Offer; |
||||
|
import com.mydreamjob.offer.OfferRepository; |
||||
|
import org.junit.jupiter.api.AfterEach; |
||||
|
import org.junit.jupiter.api.BeforeEach; |
||||
|
import org.junit.jupiter.api.Test; |
||||
|
import org.springframework.beans.factory.annotation.Autowired; |
||||
|
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; |
||||
|
import org.springframework.boot.test.context.SpringBootTest; |
||||
|
import org.springframework.boot.test.mock.mockito.MockBean; |
||||
|
import org.springframework.data.redis.core.RedisTemplate; |
||||
|
import org.springframework.data.redis.core.ValueOperations; |
||||
|
import org.springframework.test.context.ActiveProfiles; |
||||
|
import org.springframework.test.context.TestPropertySource; |
||||
|
import org.springframework.test.web.servlet.MockMvc; |
||||
|
|
||||
|
import java.math.BigDecimal; |
||||
|
import java.time.LocalDate; |
||||
|
import java.time.LocalDateTime; |
||||
|
import java.util.Map; |
||||
|
import java.util.Set; |
||||
|
import java.util.concurrent.atomic.AtomicLong; |
||||
|
|
||||
|
import static org.mockito.ArgumentMatchers.anyString; |
||||
|
import static org.mockito.Mockito.mock; |
||||
|
import static org.mockito.Mockito.when; |
||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; |
||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; |
||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; |
||||
|
|
||||
|
/** |
||||
|
* 统一求职进度聚合接口控制器集成测试(V34,真实 Service + H2)。 |
||||
|
* |
||||
|
* <p>覆盖权限矩阵(未登录 401、COMPANY/ADMIN 403/2002)、参数校验 400/1001、空投递、 |
||||
|
* 正常聚合、脱敏、通知仅计数、competition 摘要(无 dimensions/gaps/topCandidates), |
||||
|
* 以及 DRAFT 评分/Offer 不泄露。使用独立 H2 库避免共享 integrationdb 跨类外键污染。</p> |
||||
|
*/ |
||||
|
@SpringBootTest |
||||
|
@AutoConfigureMockMvc |
||||
|
@ActiveProfiles("integration-test") |
||||
|
@TestPropertySource(properties = |
||||
|
"spring.datasource.url=jdbc:h2:mem:integrationdb_careerprogress;MODE=MySQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE") |
||||
|
class CareerProgressControllerTest { |
||||
|
|
||||
|
@Autowired private MockMvc mockMvc; |
||||
|
@Autowired private JwtTokenProvider jwtTokenProvider; |
||||
|
@Autowired private UserRepository userRepository; |
||||
|
@Autowired private CompanyRepository companyRepository; |
||||
|
@Autowired private JobRepository jobRepository; |
||||
|
@Autowired private ApplicationRepository applicationRepository; |
||||
|
@Autowired private InterviewRepository interviewRepository; |
||||
|
@Autowired private InterviewScoreRepository interviewScoreRepository; |
||||
|
@Autowired private OfferRepository offerRepository; |
||||
|
@Autowired private NotificationRepository notificationRepository; |
||||
|
@Autowired private CandidateDimensionScoreRepository dimensionScoreRepository; |
||||
|
|
||||
|
@MockBean private RedisTemplate<String, Object> redisTemplate; |
||||
|
|
||||
|
private static final AtomicLong PHONE = new AtomicLong(800); |
||||
|
|
||||
|
private Long me; |
||||
|
private Long rival; |
||||
|
private Long emptyStudent; |
||||
|
private Long companyUserId; |
||||
|
private Long adminId; |
||||
|
private Long jobId; |
||||
|
private Long appId; |
||||
|
private String period; |
||||
|
|
||||
|
@BeforeEach |
||||
|
@SuppressWarnings("unchecked") |
||||
|
void setUp() { |
||||
|
ValueOperations<String, Object> ops = mock(ValueOperations.class); |
||||
|
when(redisTemplate.opsForValue()).thenReturn(ops); |
||||
|
when(redisTemplate.hasKey(anyString())).thenReturn(false); |
||||
|
|
||||
|
period = String.format("%04d-%02d", LocalDate.now().getYear(), LocalDate.now().getMonthValue()); |
||||
|
|
||||
|
me = createUser(User.Role.STUDENT, "me"); |
||||
|
rival = createUser(User.Role.STUDENT, "rival"); |
||||
|
emptyStudent = createUser(User.Role.STUDENT, "empty"); |
||||
|
companyUserId = createUser(User.Role.COMPANY, "company"); |
||||
|
adminId = createUser(User.Role.ADMIN, "admin"); |
||||
|
|
||||
|
Long companyId = companyRepository.save( |
||||
|
Company.builder().ownerUserId(companyUserId).name("示例科技").build()).getId(); |
||||
|
Job job = jobRepository.save(Job.builder().companyId(companyId).title("Java 后端工程师") |
||||
|
.description("d").city("杭州").employmentType("FULL_TIME").status("PUBLISHED").build()); |
||||
|
jobId = job.getId(); |
||||
|
|
||||
|
Application app = applicationRepository.save(Application.builder() |
||||
|
.jobId(jobId).studentUserId(me).status("INTERVIEW") |
||||
|
.createdAt(LocalDateTime.now().minusDays(1)).updatedAt(LocalDateTime.now()).build()); |
||||
|
appId = app.getId(); |
||||
|
applicationRepository.save(Application.builder() |
||||
|
.jobId(jobId).studentUserId(rival).status("SUBMITTED") |
||||
|
.createdAt(LocalDateTime.now().minusDays(2)).updatedAt(LocalDateTime.now()).build()); |
||||
|
|
||||
|
// 竞争力快照:本人 77.50,rival 90.00
|
||||
|
dimensionScoreRepository.save(dim(me, "77.50")); |
||||
|
dimensionScoreRepository.save(dim(rival, "90.00")); |
||||
|
|
||||
|
// 面试:SCHEDULED(nextInterview 最早) + CONFIRMED 未来
|
||||
|
Interview sched = interviewRepository.save(interview("SCHEDULED", LocalDateTime.now().plusDays(1), 2, "技术二面")); |
||||
|
interviewRepository.save(interview("CONFIRMED", LocalDateTime.now().plusDays(2), 3, "技术三面")); |
||||
|
|
||||
|
// 已提交评分(仅摘要,不泄 comment)
|
||||
|
interviewScoreRepository.save(InterviewScore.builder() |
||||
|
.interviewId(sched.getId()).applicationId(appId).companyUserId(companyUserId) |
||||
|
.studentUserId(me).status(InterviewScore.STATUS_SUBMITTED) |
||||
|
.technicalScore(4).communicationScore(3).problemSolvingScore(4) |
||||
|
.roleFitScore(4).professionalismScore(3) |
||||
|
.overallScore(new BigDecimal("3.60")).recommendation("HIRE") |
||||
|
.comment("含敏感评语,不应返回给学生").submittedAt(LocalDateTime.now()) |
||||
|
.version(0L).build()); |
||||
|
|
||||
|
// Offer:SENT 有效(不返回薪资/条款)
|
||||
|
offerRepository.save(Offer.builder() |
||||
|
.applicationId(appId).jobId(jobId).companyId(companyId).candidateId(me) |
||||
|
.title("Java 后端工程师").description("d").expireAt(LocalDateTime.now().plusDays(9)) |
||||
|
.salaryMin(20000).salaryMax(30000).baseSalary(25000).housingAllowance(3000) |
||||
|
.annualBonusMonths(3).otherAllowances("补贴").benefits("五险一金") |
||||
|
.status("SENT").version(0L).createdBy(companyUserId).build()); |
||||
|
|
||||
|
// 通知:未读 2 条(其中 1 条 REPORT_DECISION)
|
||||
|
notificationRepository.save(Notification.builder().userId(me).type("INTERVIEW_SCHEDULED") |
||||
|
.title("面试通知").content("你有一场面试").build()); |
||||
|
notificationRepository.save(Notification.builder().userId(me).type("REPORT_DECISION") |
||||
|
.title("举报处理结果").content("举报已处理").build()); |
||||
|
} |
||||
|
|
||||
|
@AfterEach |
||||
|
void tearDown() { |
||||
|
dimensionScoreRepository.deleteAll(); |
||||
|
interviewScoreRepository.deleteAll(); |
||||
|
offerRepository.deleteAll(); |
||||
|
interviewRepository.deleteAll(); |
||||
|
notificationRepository.deleteAll(); |
||||
|
applicationRepository.deleteAll(); |
||||
|
jobRepository.deleteAll(); |
||||
|
companyRepository.deleteAll(); |
||||
|
userRepository.deleteAll(); |
||||
|
} |
||||
|
|
||||
|
// ── 辅助 ────────────────────────────────────────────────
|
||||
|
|
||||
|
private Long createUser(User.Role role, String tag) { |
||||
|
User u = userRepository.save(User.builder() |
||||
|
.phone("177" + String.format("%08d", PHONE.incrementAndGet())) |
||||
|
.nickname(tag).role(role).roles(Set.of(role)).active(true).verified(true).build()); |
||||
|
return u.getId(); |
||||
|
} |
||||
|
|
||||
|
private Interview interview(String status, LocalDateTime startAt, int roundNo, String title) { |
||||
|
return Interview.builder().applicationId(appId).companyUserId(companyUserId).studentUserId(me) |
||||
|
.roundNo(roundNo).title(title).interviewType("ONLINE").startAt(startAt) |
||||
|
.endAt(startAt.plusHours(1)).status(status).build(); |
||||
|
} |
||||
|
|
||||
|
private CandidateDimensionScore dim(Long sid, String score) { |
||||
|
return CandidateDimensionScore.builder().studentUserId(sid).dimension("EDUCATION").period(period) |
||||
|
.score(new BigDecimal(score)).status("OK").weight(new BigDecimal("12.50")) |
||||
|
.rulesVersion("candidate-scoring-v0.1").calculatedAt(LocalDateTime.now()).build(); |
||||
|
} |
||||
|
|
||||
|
private String token(Long userId) { |
||||
|
return "Bearer " + jwtTokenProvider.generateAccessToken(userId, |
||||
|
String.valueOf(userId), Map.of("roles", "STUDENT")); |
||||
|
} |
||||
|
|
||||
|
// ── 权限矩阵 ──────────────────────────────────────────────
|
||||
|
|
||||
|
@Test |
||||
|
void unauthenticated_returns401() throws Exception { |
||||
|
mockMvc.perform(get("/profile/me/career-progress")) |
||||
|
.andExpect(status().isUnauthorized()); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void company_returns403_2002() throws Exception { |
||||
|
mockMvc.perform(get("/profile/me/career-progress") |
||||
|
.header("Authorization", token(companyUserId))) |
||||
|
.andExpect(status().isForbidden()) |
||||
|
.andExpect(jsonPath("$.code").value(2002)); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void admin_returns403_2002() throws Exception { |
||||
|
mockMvc.perform(get("/profile/me/career-progress") |
||||
|
.header("Authorization", token(adminId))) |
||||
|
.andExpect(status().isForbidden()) |
||||
|
.andExpect(jsonPath("$.code").value(2002)); |
||||
|
} |
||||
|
|
||||
|
// ── 参数校验 ──────────────────────────────────────────────
|
||||
|
|
||||
|
@Test |
||||
|
void negativePage_returns400_1001() throws Exception { |
||||
|
mockMvc.perform(get("/profile/me/career-progress").param("page", "-1") |
||||
|
.header("Authorization", token(me))) |
||||
|
.andExpect(status().isBadRequest()) |
||||
|
.andExpect(jsonPath("$.code").value(1001)); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void sizeOutOfRange_returns400_1001() throws Exception { |
||||
|
mockMvc.perform(get("/profile/me/career-progress").param("size", "51") |
||||
|
.header("Authorization", token(me))) |
||||
|
.andExpect(status().isBadRequest()) |
||||
|
.andExpect(jsonPath("$.code").value(1001)); |
||||
|
mockMvc.perform(get("/profile/me/career-progress").param("size", "0") |
||||
|
.header("Authorization", token(me))) |
||||
|
.andExpect(status().isBadRequest()) |
||||
|
.andExpect(jsonPath("$.code").value(1001)); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void invalidStatus_returns400_1001() throws Exception { |
||||
|
mockMvc.perform(get("/profile/me/career-progress").param("status", "NONEXISTENT") |
||||
|
.header("Authorization", token(me))) |
||||
|
.andExpect(status().isBadRequest()) |
||||
|
.andExpect(jsonPath("$.code").value(1001)); |
||||
|
} |
||||
|
|
||||
|
// ── 空投递 ──────────────────────────────────────────────
|
||||
|
|
||||
|
@Test |
||||
|
void emptyApplications_returns200_empty_andZeroTodo() throws Exception { |
||||
|
mockMvc.perform(get("/profile/me/career-progress") |
||||
|
.header("Authorization", token(emptyStudent))) |
||||
|
.andExpect(status().isOk()) |
||||
|
.andExpect(jsonPath("$.data.applications.items").isArray()) |
||||
|
.andExpect(jsonPath("$.data.applications.items.length()").value(0)) |
||||
|
.andExpect(jsonPath("$.data.applications.total").value(0)) |
||||
|
.andExpect(jsonPath("$.data.todo.pendingInterviewResponseCount").value(0)) |
||||
|
.andExpect(jsonPath("$.data.todo.upcomingConfirmedInterviewCount").value(0)) |
||||
|
.andExpect(jsonPath("$.data.todo.pendingOfferResponseCount").value(0)) |
||||
|
.andExpect(jsonPath("$.data.todo.unreadNotificationCount").value(0)) |
||||
|
.andExpect(jsonPath("$.data.todo.unreadReportDecisionCount").value(0)); |
||||
|
} |
||||
|
|
||||
|
// ── 正常聚合 + 脱敏 + 通知仅计数 ────────────────────────────
|
||||
|
|
||||
|
@Test |
||||
|
void studentReturnsAggregatedResult_andDesensitized() throws Exception { |
||||
|
mockMvc.perform(get("/profile/me/career-progress") |
||||
|
.header("Authorization", token(me))) |
||||
|
.andExpect(status().isOk()) |
||||
|
// todo 计数
|
||||
|
.andExpect(jsonPath("$.data.todo.pendingInterviewResponseCount").value(1)) |
||||
|
.andExpect(jsonPath("$.data.todo.upcomingConfirmedInterviewCount").value(1)) |
||||
|
.andExpect(jsonPath("$.data.todo.pendingOfferResponseCount").value(1)) |
||||
|
.andExpect(jsonPath("$.data.todo.unreadNotificationCount").value(2)) |
||||
|
.andExpect(jsonPath("$.data.todo.unreadReportDecisionCount").value(1)) |
||||
|
// 投递分页
|
||||
|
.andExpect(jsonPath("$.data.applications.total").value(1)) |
||||
|
.andExpect(jsonPath("$.data.applications.items.length()").value(1)) |
||||
|
.andExpect(jsonPath("$.data.applications.items[0].applicationStatus").value("INTERVIEW")) |
||||
|
// job 摘要
|
||||
|
.andExpect(jsonPath("$.data.applications.items[0].job.title").value("Java 后端工程师")) |
||||
|
.andExpect(jsonPath("$.data.applications.items[0].job.companyName").value("示例科技")) |
||||
|
.andExpect(jsonPath("$.data.applications.items[0].job.city").value("杭州")) |
||||
|
.andExpect(jsonPath("$.data.applications.items[0].job.employmentType").value("FULL_TIME")) |
||||
|
.andExpect(jsonPath("$.data.applications.items[0].job.jobStatus").value("PUBLISHED")) |
||||
|
// nextInterview:最早 SCHEDULED
|
||||
|
.andExpect(jsonPath("$.data.applications.items[0].nextInterview.roundNo").value(2)) |
||||
|
.andExpect(jsonPath("$.data.applications.items[0].nextInterview.status").value("SCHEDULED")) |
||||
|
// 已提交评分摘要(无 comment)
|
||||
|
.andExpect(jsonPath("$.data.applications.items[0].latestSubmittedInterviewScore.overallScore").value(3.60)) |
||||
|
.andExpect(jsonPath("$.data.applications.items[0].latestSubmittedInterviewScore.recommendation").value("HIRE")) |
||||
|
.andExpect(jsonPath("$.data.applications.items[0].latestSubmittedInterviewScore.comment").doesNotExist()) |
||||
|
// offer 摘要(无薪资/条款)
|
||||
|
.andExpect(jsonPath("$.data.applications.items[0].offer.status").value("SENT")) |
||||
|
.andExpect(jsonPath("$.data.applications.items[0].offer.salaryMin").doesNotExist()) |
||||
|
.andExpect(jsonPath("$.data.applications.items[0].offer.salaryMax").doesNotExist()) |
||||
|
.andExpect(jsonPath("$.data.applications.items[0].offer.baseSalary").doesNotExist()) |
||||
|
.andExpect(jsonPath("$.data.applications.items[0].offer.benefits").doesNotExist()) |
||||
|
// competition 摘要(无 dimensions/gaps/topCandidates,脱敏)
|
||||
|
.andExpect(jsonPath("$.data.applications.items[0].competition.eligible").value(true)) |
||||
|
.andExpect(jsonPath("$.data.applications.items[0].competition.cohortSize").value(2)) |
||||
|
.andExpect(jsonPath("$.data.applications.items[0].competition.rank").value(2)) |
||||
|
.andExpect(jsonPath("$.data.applications.items[0].competition.overallScore").value(77.50)) |
||||
|
.andExpect(jsonPath("$.data.applications.items[0].competition.percentile").value(0.00)) |
||||
|
.andExpect(jsonPath("$.data.applications.items[0].competition.dimensions").doesNotExist()) |
||||
|
.andExpect(jsonPath("$.data.applications.items[0].competition.gaps").doesNotExist()) |
||||
|
.andExpect(jsonPath("$.data.applications.items[0].competition.topCandidates").doesNotExist()) |
||||
|
// 不泄露身份/联系/简历等字段
|
||||
|
.andExpect(jsonPath("$.data.applications.items[0].studentUserId").doesNotExist()) |
||||
|
.andExpect(jsonPath("$.data.applications.items[0].candidateId").doesNotExist()) |
||||
|
.andExpect(jsonPath("$.data.applications.items[0].companyUserId").doesNotExist()) |
||||
|
.andExpect(jsonPath("$.data.applications.items[0].evaluatorUserId").doesNotExist()) |
||||
|
.andExpect(jsonPath("$.data.applications.items[0].phone").doesNotExist()) |
||||
|
.andExpect(jsonPath("$.data.applications.items[0].email").doesNotExist()) |
||||
|
.andExpect(jsonPath("$.data.applications.items[0].resumeUrl").doesNotExist()) |
||||
|
.andExpect(jsonPath("$.data.applications.items[0].passwordHash").doesNotExist()) |
||||
|
// 通知只返回计数,无 title/content 详情
|
||||
|
.andExpect(jsonPath("$.data.notifications").doesNotExist()) |
||||
|
.andExpect(jsonPath("$.data.notificationTitle").doesNotExist()); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,230 @@ |
|||||
|
package com.mydreamjob.careerprogress; |
||||
|
|
||||
|
import com.mydreamjob.application.Application; |
||||
|
import com.mydreamjob.application.ApplicationRepository; |
||||
|
import com.mydreamjob.auth.User; |
||||
|
import com.mydreamjob.auth.UserRepository; |
||||
|
import com.mydreamjob.candidatescore.CandidateDimensionScore; |
||||
|
import com.mydreamjob.candidatescore.CandidateDimensionScoreRepository; |
||||
|
import com.mydreamjob.careerprogress.dto.CareerProgressResponse; |
||||
|
import com.mydreamjob.company.Company; |
||||
|
import com.mydreamjob.company.CompanyRepository; |
||||
|
import com.mydreamjob.interview.Interview; |
||||
|
import com.mydreamjob.interview.InterviewRepository; |
||||
|
import com.mydreamjob.interviewscore.InterviewScore; |
||||
|
import com.mydreamjob.interviewscore.InterviewScoreRepository; |
||||
|
import com.mydreamjob.job.Job; |
||||
|
import com.mydreamjob.job.JobRepository; |
||||
|
import com.mydreamjob.notification.Notification; |
||||
|
import com.mydreamjob.notification.NotificationRepository; |
||||
|
import com.mydreamjob.offer.Offer; |
||||
|
import com.mydreamjob.offer.OfferRepository; |
||||
|
import org.junit.jupiter.api.AfterEach; |
||||
|
import org.junit.jupiter.api.BeforeEach; |
||||
|
import org.junit.jupiter.api.Test; |
||||
|
import org.springframework.beans.factory.annotation.Autowired; |
||||
|
import org.springframework.boot.test.context.SpringBootTest; |
||||
|
import org.springframework.boot.test.mock.mockito.MockBean; |
||||
|
import org.springframework.data.redis.core.RedisTemplate; |
||||
|
import org.springframework.data.redis.core.ValueOperations; |
||||
|
import org.springframework.test.context.ActiveProfiles; |
||||
|
import org.springframework.test.context.TestPropertySource; |
||||
|
|
||||
|
import java.math.BigDecimal; |
||||
|
import java.time.LocalDate; |
||||
|
import java.time.LocalDateTime; |
||||
|
import java.util.Set; |
||||
|
import java.util.concurrent.atomic.AtomicLong; |
||||
|
|
||||
|
import static org.junit.jupiter.api.Assertions.assertEquals; |
||||
|
import static org.junit.jupiter.api.Assertions.assertNotNull; |
||||
|
import static org.junit.jupiter.api.Assertions.assertTrue; |
||||
|
import static org.mockito.ArgumentMatchers.anyString; |
||||
|
import static org.mockito.Mockito.mock; |
||||
|
import static org.mockito.Mockito.when; |
||||
|
|
||||
|
/** |
||||
|
* 统一求职进度聚合持久化/只读集成测试(V34,独立 H2 + Flyway 真实表结构)。 |
||||
|
* |
||||
|
* <p>证明:真实批量查询在真实 Repository 上正确返回聚合结果;CareerProgressService |
||||
|
* 标注 @Transactional(readOnly=true) 且不写业务表 —— 调用前后关键业务表计数不变; |
||||
|
* 不新增 Flyway、不删除业务数据。</p> |
||||
|
*/ |
||||
|
@SpringBootTest |
||||
|
@TestPropertySource(properties = |
||||
|
"spring.datasource.url=jdbc:h2:mem:integrationdb_careerprogress_persist;MODE=MySQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE") |
||||
|
@ActiveProfiles("integration-test") |
||||
|
class CareerProgressPersistenceIntegrationTest { |
||||
|
|
||||
|
@Autowired private CareerProgressService service; |
||||
|
@Autowired private UserRepository userRepository; |
||||
|
@Autowired private CompanyRepository companyRepository; |
||||
|
@Autowired private JobRepository jobRepository; |
||||
|
@Autowired private ApplicationRepository applicationRepository; |
||||
|
@Autowired private InterviewRepository interviewRepository; |
||||
|
@Autowired private InterviewScoreRepository interviewScoreRepository; |
||||
|
@Autowired private OfferRepository offerRepository; |
||||
|
@Autowired private NotificationRepository notificationRepository; |
||||
|
@Autowired private CandidateDimensionScoreRepository dimensionScoreRepository; |
||||
|
|
||||
|
@MockBean private RedisTemplate<String, Object> redisTemplate; |
||||
|
|
||||
|
private static final AtomicLong PHONE_SEQ = new AtomicLong(600); |
||||
|
private String period; |
||||
|
|
||||
|
private Long studentUserId; |
||||
|
private Long companyUserId; |
||||
|
private Long companyId; |
||||
|
private Long jobId; |
||||
|
private Long appId; |
||||
|
|
||||
|
@BeforeEach |
||||
|
@SuppressWarnings("unchecked") |
||||
|
void setUp() { |
||||
|
ValueOperations<String, Object> ops = mock(ValueOperations.class); |
||||
|
when(redisTemplate.opsForValue()).thenReturn(ops); |
||||
|
when(redisTemplate.hasKey(anyString())).thenReturn(false); |
||||
|
|
||||
|
period = String.format("%04d-%02d", LocalDate.now().getYear(), LocalDate.now().getMonthValue()); |
||||
|
|
||||
|
studentUserId = createUser(User.Role.STUDENT); |
||||
|
companyUserId = createUser(User.Role.COMPANY); |
||||
|
Long rivalId = createUser(User.Role.STUDENT); |
||||
|
|
||||
|
companyId = companyRepository.save( |
||||
|
Company.builder().ownerUserId(companyUserId).name("示例科技").build()).getId(); |
||||
|
jobId = jobRepository.save(Job.builder().companyId(companyId).title("Java 后端工程师") |
||||
|
.description("d").city("杭州").employmentType("FULL_TIME").status("PUBLISHED").build()).getId(); |
||||
|
|
||||
|
Application app = applicationRepository.save(Application.builder() |
||||
|
.jobId(jobId).studentUserId(studentUserId).status("INTERVIEW") |
||||
|
.createdAt(LocalDateTime.now().minusDays(1)).updatedAt(LocalDateTime.now()).build()); |
||||
|
appId = app.getId(); |
||||
|
applicationRepository.save(Application.builder() |
||||
|
.jobId(jobId).studentUserId(rivalId).status("SUBMITTED") |
||||
|
.createdAt(LocalDateTime.now().minusDays(2)).updatedAt(LocalDateTime.now()).build()); |
||||
|
|
||||
|
// 竞争力快照
|
||||
|
dimensionScoreRepository.save(dim(studentUserId, "77.50")); |
||||
|
dimensionScoreRepository.save(dim(rivalId, "90.00")); |
||||
|
|
||||
|
// 面试:SCHEDULED(待响应)+ CONFIRMED(未来)
|
||||
|
Interview sched = interviewRepository.save(interview("SCHEDULED", LocalDateTime.now().plusDays(1), 2)); |
||||
|
interviewRepository.save(interview("CONFIRMED", LocalDateTime.now().plusDays(2), 3)); |
||||
|
|
||||
|
// 已提交评分(摘要口径,不返回 comment)
|
||||
|
interviewScoreRepository.save(InterviewScore.builder() |
||||
|
.interviewId(sched.getId()).applicationId(appId).companyUserId(companyUserId) |
||||
|
.studentUserId(studentUserId).status(InterviewScore.STATUS_SUBMITTED) |
||||
|
.technicalScore(4).communicationScore(3).problemSolvingScore(4) |
||||
|
.roleFitScore(4).professionalismScore(3) |
||||
|
.overallScore(new BigDecimal("3.60")).recommendation("HIRE") |
||||
|
.comment("不应返回给学生").submittedAt(LocalDateTime.now()).version(0L).build()); |
||||
|
|
||||
|
// 有效 Offer
|
||||
|
offerRepository.save(Offer.builder() |
||||
|
.applicationId(appId).jobId(jobId).companyId(companyId).candidateId(studentUserId) |
||||
|
.title("Java 后端工程师").description("d").expireAt(LocalDateTime.now().plusDays(9)) |
||||
|
.salaryMin(20000).salaryMax(30000).baseSalary(25000) |
||||
|
.status("SENT").version(0L).createdBy(companyUserId).build()); |
||||
|
|
||||
|
// 通知:未读 2 条(其中 1 条 REPORT_DECISION)
|
||||
|
notificationRepository.save(Notification.builder().userId(studentUserId).type("INTERVIEW_SCHEDULED") |
||||
|
.title("面试通知").content("你有一场面试").build()); |
||||
|
notificationRepository.save(Notification.builder().userId(studentUserId).type("REPORT_DECISION") |
||||
|
.title("举报处理结果").content("举报已处理").build()); |
||||
|
} |
||||
|
|
||||
|
@AfterEach |
||||
|
void tearDown() { |
||||
|
dimensionScoreRepository.deleteAll(); |
||||
|
interviewScoreRepository.deleteAll(); |
||||
|
offerRepository.deleteAll(); |
||||
|
interviewRepository.deleteAll(); |
||||
|
notificationRepository.deleteAll(); |
||||
|
applicationRepository.deleteAll(); |
||||
|
jobRepository.deleteAll(); |
||||
|
companyRepository.deleteAll(); |
||||
|
userRepository.deleteAll(); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void aggregation_returnsCorrectSummaries_andIsReadOnly() { |
||||
|
long appsBefore = applicationRepository.count(); |
||||
|
long interviewsBefore = interviewRepository.count(); |
||||
|
long scoresBefore = interviewScoreRepository.count(); |
||||
|
long offersBefore = offerRepository.count(); |
||||
|
long notificationsBefore = notificationRepository.count(); |
||||
|
long dimsBefore = dimensionScoreRepository.count(); |
||||
|
|
||||
|
CareerProgressResponse resp = service.getCareerProgress(studentUserId, 0, 20, null); |
||||
|
|
||||
|
// todo 计数(真实批量查询)
|
||||
|
assertEquals(1L, resp.getTodo().getPendingInterviewResponseCount()); |
||||
|
assertEquals(1L, resp.getTodo().getUpcomingConfirmedInterviewCount()); |
||||
|
assertEquals(1L, resp.getTodo().getPendingOfferResponseCount()); |
||||
|
assertEquals(2L, resp.getTodo().getUnreadNotificationCount()); |
||||
|
assertEquals(1L, resp.getTodo().getUnreadReportDecisionCount()); |
||||
|
|
||||
|
// 投递分页(本人仅 1 条,不包含 rival 投递)
|
||||
|
assertEquals(1L, resp.getApplications().getTotal()); |
||||
|
assertEquals(1, resp.getApplications().getItems().size()); |
||||
|
CareerProgressResponse.CompetitionSummary comp = |
||||
|
resp.getApplications().getItems().get(0).getCompetition(); |
||||
|
assertTrue(comp.isEligible()); |
||||
|
assertEquals(2, comp.getCohortSize()); |
||||
|
assertEquals(2, comp.getRank().intValue()); |
||||
|
assertEquals(new BigDecimal("77.50"), comp.getOverallScore()); |
||||
|
assertEquals(new BigDecimal("0.00"), comp.getPercentile()); |
||||
|
assertEquals(period, comp.getMySnapshotPeriod()); |
||||
|
assertNotNull(resp.getGeneratedAt()); |
||||
|
|
||||
|
// 只读:调用前后关键业务表计数完全不变(不写、不删、不新增)
|
||||
|
assertEquals(appsBefore, applicationRepository.count()); |
||||
|
assertEquals(interviewsBefore, interviewRepository.count()); |
||||
|
assertEquals(scoresBefore, interviewScoreRepository.count()); |
||||
|
assertEquals(offersBefore, offerRepository.count()); |
||||
|
assertEquals(notificationsBefore, notificationRepository.count()); |
||||
|
assertEquals(dimsBefore, dimensionScoreRepository.count()); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void withdrawnApplication_keptInHistory_competitionNotEligible() { |
||||
|
// 独立岗位避免 (job_id, student_user_id) 唯一约束冲突
|
||||
|
Long withdrawnJobId = jobRepository.save(Job.builder().companyId(companyId).title("已撤回岗位") |
||||
|
.description("d").city("上海").employmentType("FULL_TIME").status("PUBLISHED").build()).getId(); |
||||
|
applicationRepository.save(Application.builder() |
||||
|
.jobId(withdrawnJobId).studentUserId(studentUserId).status("WITHDRAWN") |
||||
|
.createdAt(LocalDateTime.now().minusDays(3)).updatedAt(LocalDateTime.now()).build()); |
||||
|
|
||||
|
CareerProgressResponse resp = service.getCareerProgress(studentUserId, 0, 20, null); |
||||
|
// 现在本人有两条投递(INTERVIEW + WITHDRAWN)
|
||||
|
assertEquals(2L, resp.getApplications().getTotal()); |
||||
|
CareerProgressResponse.CareerProgressItem withdrawn = resp.getApplications().getItems().stream() |
||||
|
.filter(i -> "WITHDRAWN".equals(i.getApplicationStatus())).findFirst().orElseThrow(); |
||||
|
assertEquals("WITHDRAWN", withdrawn.getApplicationStatus()); |
||||
|
assertEquals(false, withdrawn.getCompetition().isEligible()); |
||||
|
assertEquals(null, withdrawn.getCompetition().getRank()); |
||||
|
assertEquals(null, withdrawn.getCompetition().getCohortSize()); |
||||
|
assertEquals(null, withdrawn.getCompetition().getOverallScore()); |
||||
|
assertEquals(null, withdrawn.getCompetition().getPercentile()); |
||||
|
} |
||||
|
|
||||
|
private Long createUser(User.Role role) { |
||||
|
return userRepository.save(User.builder() |
||||
|
.phone("168" + String.format("%08d", PHONE_SEQ.incrementAndGet())) |
||||
|
.nickname("u").role(role).roles(Set.of(role)).active(true).verified(true).build()).getId(); |
||||
|
} |
||||
|
|
||||
|
private Interview interview(String status, LocalDateTime startAt, int roundNo) { |
||||
|
return Interview.builder().applicationId(appId).companyUserId(companyUserId).studentUserId(studentUserId) |
||||
|
.roundNo(roundNo).title("技术面试").interviewType("ONLINE").startAt(startAt) |
||||
|
.endAt(startAt.plusHours(1)).status(status).build(); |
||||
|
} |
||||
|
|
||||
|
private CandidateDimensionScore dim(Long sid, String score) { |
||||
|
return CandidateDimensionScore.builder().studentUserId(sid).dimension("EDUCATION").period(period) |
||||
|
.score(new BigDecimal(score)).status("OK").weight(new BigDecimal("12.50")) |
||||
|
.rulesVersion("candidate-scoring-v0.1").calculatedAt(LocalDateTime.now()).build(); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,442 @@ |
|||||
|
package com.mydreamjob.careerprogress; |
||||
|
|
||||
|
import com.mydreamjob.application.Application; |
||||
|
import com.mydreamjob.application.ApplicationRepository; |
||||
|
import com.mydreamjob.auth.User; |
||||
|
import com.mydreamjob.auth.UserRepository; |
||||
|
import com.mydreamjob.candidatescore.CandidateDimensionScore; |
||||
|
import com.mydreamjob.candidatescore.CandidateDimensionScoreRepository; |
||||
|
import com.mydreamjob.candidatescore.CandidateScoringCalculator; |
||||
|
import com.mydreamjob.careerprogress.dto.CareerProgressResponse; |
||||
|
import com.mydreamjob.common.exception.BusinessException; |
||||
|
import com.mydreamjob.common.exception.ErrorCode; |
||||
|
import com.mydreamjob.company.Company; |
||||
|
import com.mydreamjob.company.CompanyRepository; |
||||
|
import com.mydreamjob.competition.CompetitionRankingCalculator; |
||||
|
import com.mydreamjob.interview.Interview; |
||||
|
import com.mydreamjob.interview.InterviewRepository; |
||||
|
import com.mydreamjob.interviewscore.InterviewScore; |
||||
|
import com.mydreamjob.interviewscore.InterviewScoreRepository; |
||||
|
import com.mydreamjob.job.Job; |
||||
|
import com.mydreamjob.job.JobRepository; |
||||
|
import com.mydreamjob.notification.NotificationRepository; |
||||
|
import com.mydreamjob.offer.Offer; |
||||
|
import com.mydreamjob.offer.OfferRepository; |
||||
|
import org.junit.jupiter.api.BeforeEach; |
||||
|
import org.junit.jupiter.api.Test; |
||||
|
import org.junit.jupiter.api.extension.ExtendWith; |
||||
|
import org.mockito.Mock; |
||||
|
import org.mockito.junit.jupiter.MockitoExtension; |
||||
|
import org.mockito.junit.jupiter.MockitoSettings; |
||||
|
import org.mockito.quality.Strictness; |
||||
|
import org.springframework.data.domain.Page; |
||||
|
import org.springframework.data.domain.PageImpl; |
||||
|
import org.springframework.data.domain.PageRequest; |
||||
|
|
||||
|
import java.math.BigDecimal; |
||||
|
import java.time.LocalDate; |
||||
|
import java.time.LocalDateTime; |
||||
|
import java.util.HashSet; |
||||
|
import java.util.List; |
||||
|
import java.util.Map; |
||||
|
import java.util.Optional; |
||||
|
import java.util.Set; |
||||
|
|
||||
|
import static org.junit.jupiter.api.Assertions.*; |
||||
|
import static org.mockito.ArgumentMatchers.*; |
||||
|
import static org.mockito.Mockito.*; |
||||
|
|
||||
|
/** |
||||
|
* 统一求职进度聚合服务单测(V34,编排 + 脱敏 + 聚合口径 + 无 N+1)。 |
||||
|
* |
||||
|
* <p>排名/排序/百分位由真实 {@link CompetitionRankingCalculator}(配合 mock 的 |
||||
|
* 快照仓库与综合分计算器)承载,与 V32 共用同一组件,保证口径一致。本类聚焦断言 |
||||
|
* 权限、参数、投递分页、todo 计数、nextInterview、评分摘要、Offer 优先级、 |
||||
|
* competition 摘要、通知仅计数与批量查询(无 N+1)。</p> |
||||
|
*/ |
||||
|
@ExtendWith(MockitoExtension.class) |
||||
|
@MockitoSettings(strictness = Strictness.LENIENT) |
||||
|
class CareerProgressServiceTest { |
||||
|
|
||||
|
private static final Long ME = 100L; |
||||
|
private static final Long JOB = 9L; |
||||
|
private static final Long COMPANY = 1L; |
||||
|
private static final String PERIOD = yearMonth(LocalDate.now()); |
||||
|
|
||||
|
@Mock private ApplicationRepository applicationRepository; |
||||
|
@Mock private InterviewRepository interviewRepository; |
||||
|
@Mock private InterviewScoreRepository interviewScoreRepository; |
||||
|
@Mock private OfferRepository offerRepository; |
||||
|
@Mock private NotificationRepository notificationRepository; |
||||
|
@Mock private JobRepository jobRepository; |
||||
|
@Mock private CompanyRepository companyRepository; |
||||
|
@Mock private UserRepository userRepository; |
||||
|
@Mock private CandidateDimensionScoreRepository dimensionScoreRepository; |
||||
|
@Mock private CandidateScoringCalculator scoringCalculator; |
||||
|
|
||||
|
private CompetitionRankingCalculator calculator; |
||||
|
private CareerProgressService service; |
||||
|
|
||||
|
private Map<Long, BigDecimal> overallByStudent = Map.of(); |
||||
|
|
||||
|
@BeforeEach |
||||
|
void setUp() { |
||||
|
calculator = spy(new CompetitionRankingCalculator(dimensionScoreRepository, scoringCalculator)); |
||||
|
service = new CareerProgressService(applicationRepository, interviewRepository, |
||||
|
interviewScoreRepository, offerRepository, notificationRepository, |
||||
|
jobRepository, companyRepository, userRepository, calculator); |
||||
|
|
||||
|
when(scoringCalculator.overallScoreFromSnapshots(anyList())).thenAnswer(inv -> { |
||||
|
List<?> snaps = inv.getArgument(0); |
||||
|
if (snaps == null || snaps.isEmpty()) return null; |
||||
|
Long sid = ((CandidateDimensionScore) snaps.get(0)).getStudentUserId(); |
||||
|
return overallByStudent.get(sid); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
// ── 权限与参数 ────────────────────────────────────────────
|
||||
|
|
||||
|
@Test |
||||
|
void companyRole_throwsForbidden() { |
||||
|
mockUser(ME, User.Role.COMPANY); |
||||
|
BusinessException ex = assertThrows(BusinessException.class, |
||||
|
() -> service.getCareerProgress(ME, 0, 20, null)); |
||||
|
assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCode()); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void adminRole_throwsForbidden() { |
||||
|
mockUser(ME, User.Role.ADMIN); |
||||
|
BusinessException ex = assertThrows(BusinessException.class, |
||||
|
() -> service.getCareerProgress(ME, 0, 20, null)); |
||||
|
assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCode()); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void negativePage_throwsBadRequest() { |
||||
|
mockUser(ME, User.Role.STUDENT); |
||||
|
BusinessException ex = assertThrows(BusinessException.class, |
||||
|
() -> service.getCareerProgress(ME, -1, 20, null)); |
||||
|
assertEquals(ErrorCode.BAD_REQUEST.getCode(), ex.getErrorCode()); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void sizeOutOfRange_throwsBadRequest() { |
||||
|
mockUser(ME, User.Role.STUDENT); |
||||
|
assertThrows(BusinessException.class, |
||||
|
() -> service.getCareerProgress(ME, 0, 0, null)); |
||||
|
assertThrows(BusinessException.class, |
||||
|
() -> service.getCareerProgress(ME, 0, 51, null)); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void invalidStatus_throwsBadRequest() { |
||||
|
mockUser(ME, User.Role.STUDENT); |
||||
|
BusinessException ex = assertThrows(BusinessException.class, |
||||
|
() -> service.getCareerProgress(ME, 0, 20, "NONEXISTENT")); |
||||
|
assertEquals(ErrorCode.BAD_REQUEST.getCode(), ex.getErrorCode()); |
||||
|
} |
||||
|
|
||||
|
// ── 空数据 ──────────────────────────────────────────────
|
||||
|
|
||||
|
@Test |
||||
|
void noApplications_returnsEmptyPage_andZeroTodo() { |
||||
|
mockUser(ME, User.Role.STUDENT); |
||||
|
when(applicationRepository.findByStudentUserIdOrderByCreatedAtDesc(eq(ME), any(PageRequest.class))) |
||||
|
.thenReturn(new PageImpl<>(List.of(), PageRequest.of(0, 20), 0)); |
||||
|
when(interviewRepository.findByStudentUserIdAndStatusInOrderByStartAtAsc(anyLong(), anyCollection())) |
||||
|
.thenReturn(List.of()); |
||||
|
when(offerRepository.findByCandidateIdAndStatusNotOrderByCreatedAtDesc(anyLong(), anyString())) |
||||
|
.thenReturn(List.of()); |
||||
|
when(notificationRepository.countUnreadByUserId(ME)).thenReturn(0L); |
||||
|
when(notificationRepository.countUnreadByUserIdAndType(ME, "REPORT_DECISION")).thenReturn(0L); |
||||
|
|
||||
|
CareerProgressResponse resp = service.getCareerProgress(ME, 0, 20, null); |
||||
|
|
||||
|
assertEquals(0L, resp.getApplications().getItems().size()); |
||||
|
assertEquals(0L, resp.getApplications().getTotal()); |
||||
|
assertEquals(0L, resp.getTodo().getPendingInterviewResponseCount()); |
||||
|
assertEquals(0L, resp.getTodo().getUpcomingConfirmedInterviewCount()); |
||||
|
assertEquals(0L, resp.getTodo().getPendingOfferResponseCount()); |
||||
|
assertEquals(0L, resp.getTodo().getUnreadNotificationCount()); |
||||
|
assertEquals(0L, resp.getTodo().getUnreadReportDecisionCount()); |
||||
|
} |
||||
|
|
||||
|
// ── 聚合口径(完整 happy path)────────────────────────────
|
||||
|
|
||||
|
@Test |
||||
|
void fullAggregation_returnsCorrectSummaries() { |
||||
|
mockUser(ME, User.Role.STUDENT); |
||||
|
Application app = app(1L, ME, JOB, "SUBMITTED", "2026-08-20T10:00:00"); |
||||
|
when(applicationRepository.findByStudentUserIdOrderByCreatedAtDesc(eq(ME), any(PageRequest.class))) |
||||
|
.thenReturn(new PageImpl<>(List.of(app), PageRequest.of(0, 20), 1)); |
||||
|
when(jobRepository.findAllById(anyCollection())) |
||||
|
.thenReturn(List.of(Job.builder().id(JOB).companyId(COMPANY).title("Java 后端工程师") |
||||
|
.description("d").city("杭州").employmentType("FULL_TIME").status("PUBLISHED").build())); |
||||
|
when(companyRepository.findAllById(anyCollection())) |
||||
|
.thenReturn(List.of(Company.builder().id(COMPANY).ownerUserId(999L).name("示例科技").build())); |
||||
|
|
||||
|
// 面试:两场 SCHEDULED(取最早为 nextInterview)+ 一场 CONFIRMED(未来)
|
||||
|
Interview later = interview(2L, app.getId(), "SCHEDULED", "2026-08-30T14:00:00"); |
||||
|
Interview earlier = interview(1L, app.getId(), "SCHEDULED", "2026-08-28T10:00:00"); |
||||
|
Interview confirmed = interview(3L, app.getId(), "CONFIRMED", LocalDateTime.now().plusDays(3).toString()); |
||||
|
when(interviewRepository.findByStudentUserIdAndStatusInOrderByStartAtAsc(anyLong(), anyCollection())) |
||||
|
.thenReturn(List.of(earlier, later, confirmed)); |
||||
|
|
||||
|
// 已提交评分:两条,取 submittedAt 最新
|
||||
|
InterviewScore s1 = score(22L, app.getId(), "2026-08-25T16:00:00", new BigDecimal("3.60")); |
||||
|
InterviewScore s2 = score(23L, app.getId(), "2026-08-24T16:00:00", new BigDecimal("3.00")); |
||||
|
when(interviewScoreRepository.findByStudentUserIdAndApplicationIdInAndStatus( |
||||
|
anyLong(), anyCollection(), anyString())).thenReturn(List.of(s1, s2)); |
||||
|
|
||||
|
// Offer:一条 SENT 有效 + 一条 DRAFT(不应返回 DRAFT)
|
||||
|
Offer sent = offer(14L, app.getId(), "SENT", "2026-09-05T23:59:59"); |
||||
|
when(offerRepository.findByCandidateIdAndStatusNotOrderByCreatedAtDesc(anyLong(), anyString())) |
||||
|
.thenReturn(List.of(sent)); |
||||
|
|
||||
|
// 竞争力:同岗位 cohort 含本人 + 另一有分者
|
||||
|
Application other = app(2L, 200L, JOB, "SUBMITTED", "2026-08-19T10:00:00"); |
||||
|
when(applicationRepository.findByJobIdInAndStatusNotInOrderByCreatedAtAsc(anyCollection(), anyCollection())) |
||||
|
.thenReturn(List.of(other, app)); |
||||
|
overallByStudent = Map.of(ME, new BigDecimal("77.50"), 200L, new BigDecimal("90.00")); |
||||
|
when(dimensionScoreRepository.findUpToPeriod(anyCollection(), anyString())) |
||||
|
.thenReturn(List.of(dim(ME, "77.50"), dim(200L, "90.00"))); |
||||
|
|
||||
|
when(notificationRepository.countUnreadByUserId(ME)).thenReturn(5L); |
||||
|
when(notificationRepository.countUnreadByUserIdAndType(ME, "REPORT_DECISION")).thenReturn(2L); |
||||
|
|
||||
|
CareerProgressResponse resp = service.getCareerProgress(ME, 0, 20, null); |
||||
|
|
||||
|
// todo
|
||||
|
assertEquals(2L, resp.getTodo().getPendingInterviewResponseCount()); |
||||
|
assertEquals(1L, resp.getTodo().getUpcomingConfirmedInterviewCount()); |
||||
|
assertEquals(1L, resp.getTodo().getPendingOfferResponseCount()); |
||||
|
assertEquals(5L, resp.getTodo().getUnreadNotificationCount()); |
||||
|
assertEquals(2L, resp.getTodo().getUnreadReportDecisionCount()); |
||||
|
|
||||
|
// item
|
||||
|
assertEquals(1L, resp.getApplications().getItems().size()); |
||||
|
CareerProgressResponse.CareerProgressItem item = resp.getApplications().getItems().get(0); |
||||
|
assertEquals(1L, item.getApplicationId()); |
||||
|
assertEquals("SUBMITTED", item.getApplicationStatus()); |
||||
|
assertEquals("Java 后端工程师", item.getJob().getTitle()); |
||||
|
assertEquals("示例科技", item.getJob().getCompanyName()); |
||||
|
assertEquals("杭州", item.getJob().getCity()); |
||||
|
assertEquals("FULL_TIME", item.getJob().getEmploymentType()); |
||||
|
assertEquals("PUBLISHED", item.getJob().getJobStatus()); |
||||
|
|
||||
|
// nextInterview:取 startAt 最早 1L
|
||||
|
assertEquals(1L, item.getNextInterview().getInterviewId()); |
||||
|
// latest score:22(submittedAt 最新)
|
||||
|
assertEquals(22L, item.getLatestSubmittedInterviewScore().getInterviewId()); |
||||
|
assertEquals(new BigDecimal("3.60"), item.getLatestSubmittedInterviewScore().getOverallScore()); |
||||
|
assertEquals("HIRE", item.getLatestSubmittedInterviewScore().getRecommendation()); |
||||
|
// offer
|
||||
|
assertEquals(14L, item.getOffer().getOfferId()); |
||||
|
assertEquals("SENT", item.getOffer().getStatus()); |
||||
|
|
||||
|
// competition:cohort=2(other 90.00、me 77.50),me 排名 2;
|
||||
|
// percentile = 严格低于 me 的人数(0) / (有分人数-1=1) * 100 = 0.00
|
||||
|
assertTrue(item.getCompetition().isEligible()); |
||||
|
assertEquals(2, item.getCompetition().getCohortSize()); |
||||
|
assertEquals(2, item.getCompetition().getRank()); |
||||
|
assertEquals(new BigDecimal("77.50"), item.getCompetition().getOverallScore()); |
||||
|
assertEquals(new BigDecimal("0.00"), item.getCompetition().getPercentile()); |
||||
|
assertEquals(PERIOD, item.getCompetition().getMySnapshotPeriod()); |
||||
|
} |
||||
|
|
||||
|
// ── DRAFT 不泄露 ────────────────────────────────────────
|
||||
|
|
||||
|
@Test |
||||
|
void draftScore_notReturned_andDraftOffer_notReturned() { |
||||
|
mockUser(ME, User.Role.STUDENT); |
||||
|
Application app = app(1L, ME, JOB, "SUBMITTED", "2026-08-20T10:00:00"); |
||||
|
when(applicationRepository.findByStudentUserIdOrderByCreatedAtDesc(eq(ME), any(PageRequest.class))) |
||||
|
.thenReturn(new PageImpl<>(List.of(app), PageRequest.of(0, 20), 1)); |
||||
|
when(jobRepository.findAllById(anyCollection())) |
||||
|
.thenReturn(List.of(Job.builder().id(JOB).companyId(COMPANY).title("t").description("d") |
||||
|
.city("c").employmentType("FULL_TIME").status("PUBLISHED").build())); |
||||
|
when(companyRepository.findAllById(anyCollection())).thenReturn(List.of()); |
||||
|
when(interviewRepository.findByStudentUserIdAndStatusInOrderByStartAtAsc(anyLong(), anyCollection())) |
||||
|
.thenReturn(List.of()); |
||||
|
// 只有 DRAFT 评分 → 不应出现在 latestSubmittedInterviewScore
|
||||
|
InterviewScore draft = score(1L, app.getId(), null, null); |
||||
|
draft.setStatus(InterviewScore.STATUS_DRAFT); |
||||
|
when(interviewScoreRepository.findByStudentUserIdAndApplicationIdInAndStatus( |
||||
|
anyLong(), anyCollection(), anyString())).thenReturn(List.of()); |
||||
|
// 只有 DRAFT offer → offer 为 null
|
||||
|
when(offerRepository.findByCandidateIdAndStatusNotOrderByCreatedAtDesc(anyLong(), anyString())) |
||||
|
.thenReturn(List.of()); |
||||
|
when(applicationRepository.findByJobIdInAndStatusNotInOrderByCreatedAtAsc(anyCollection(), anyCollection())) |
||||
|
.thenReturn(List.of(app)); |
||||
|
when(dimensionScoreRepository.findUpToPeriod(anyCollection(), anyString())).thenReturn(List.of()); |
||||
|
when(notificationRepository.countUnreadByUserId(ME)).thenReturn(0L); |
||||
|
when(notificationRepository.countUnreadByUserIdAndType(ME, "REPORT_DECISION")).thenReturn(0L); |
||||
|
|
||||
|
CareerProgressResponse resp = service.getCareerProgress(ME, 0, 20, null); |
||||
|
CareerProgressResponse.CareerProgressItem item = resp.getApplications().getItems().get(0); |
||||
|
assertNull(item.getLatestSubmittedInterviewScore()); |
||||
|
assertNull(item.getOffer()); |
||||
|
} |
||||
|
|
||||
|
// ── WITHDRAWN/REJECTED 保留历史但 eligible=false ─────────
|
||||
|
|
||||
|
@Test |
||||
|
void withdrawnApplication_eligibleFalse_andNoCompetitionFields() { |
||||
|
mockUser(ME, User.Role.STUDENT); |
||||
|
Application withdrawn = app(1L, ME, JOB, "WITHDRAWN", "2026-08-20T10:00:00"); |
||||
|
when(applicationRepository.findByStudentUserIdOrderByCreatedAtDesc(eq(ME), any(PageRequest.class))) |
||||
|
.thenReturn(new PageImpl<>(List.of(withdrawn), PageRequest.of(0, 20), 1)); |
||||
|
when(jobRepository.findAllById(anyCollection())) |
||||
|
.thenReturn(List.of(Job.builder().id(JOB).companyId(COMPANY).title("t").description("d") |
||||
|
.city("c").employmentType("FULL_TIME").status("PUBLISHED").build())); |
||||
|
when(companyRepository.findAllById(anyCollection())).thenReturn(List.of()); |
||||
|
when(interviewRepository.findByStudentUserIdAndStatusInOrderByStartAtAsc(anyLong(), anyCollection())) |
||||
|
.thenReturn(List.of()); |
||||
|
when(interviewScoreRepository.findByStudentUserIdAndApplicationIdInAndStatus( |
||||
|
anyLong(), anyCollection(), anyString())).thenReturn(List.of()); |
||||
|
when(offerRepository.findByCandidateIdAndStatusNotOrderByCreatedAtDesc(anyLong(), anyString())) |
||||
|
.thenReturn(List.of()); |
||||
|
when(notificationRepository.countUnreadByUserId(ME)).thenReturn(0L); |
||||
|
when(notificationRepository.countUnreadByUserIdAndType(ME, "REPORT_DECISION")).thenReturn(0L); |
||||
|
|
||||
|
CareerProgressResponse resp = service.getCareerProgress(ME, 0, 20, null); |
||||
|
CareerProgressResponse.CareerProgressItem item = resp.getApplications().getItems().get(0); |
||||
|
// 保留历史
|
||||
|
assertEquals(1L, item.getApplicationId()); |
||||
|
assertEquals("WITHDRAWN", item.getApplicationStatus()); |
||||
|
// competition 只返回 eligible=false,不返回其它排名字段
|
||||
|
assertFalse(item.getCompetition().isEligible()); |
||||
|
assertNull(item.getCompetition().getRank()); |
||||
|
assertNull(item.getCompetition().getCohortSize()); |
||||
|
assertNull(item.getCompetition().getOverallScore()); |
||||
|
assertNull(item.getCompetition().getPercentile()); |
||||
|
assertNull(item.getCompetition().getStatus()); |
||||
|
assertNull(item.getCompetition().getMySnapshotPeriod()); |
||||
|
assertNull(item.getCompetition().getRulesVersion()); |
||||
|
} |
||||
|
|
||||
|
// ── 有有效投递但无快照 → competition 分数/排名/百分位为 null ─
|
||||
|
|
||||
|
@Test |
||||
|
void validApplication_noSnapshot_competitionScoresNull_cohortSizeReturned() { |
||||
|
mockUser(ME, User.Role.STUDENT); |
||||
|
Application app = app(1L, ME, JOB, "SUBMITTED", "2026-08-20T10:00:00"); |
||||
|
when(applicationRepository.findByStudentUserIdOrderByCreatedAtDesc(eq(ME), any(PageRequest.class))) |
||||
|
.thenReturn(new PageImpl<>(List.of(app), PageRequest.of(0, 20), 1)); |
||||
|
when(jobRepository.findAllById(anyCollection())) |
||||
|
.thenReturn(List.of(Job.builder().id(JOB).companyId(COMPANY).title("t").description("d") |
||||
|
.city("c").employmentType("FULL_TIME").status("PUBLISHED").build())); |
||||
|
when(companyRepository.findAllById(anyCollection())).thenReturn(List.of()); |
||||
|
when(interviewRepository.findByStudentUserIdAndStatusInOrderByStartAtAsc(anyLong(), anyCollection())) |
||||
|
.thenReturn(List.of()); |
||||
|
when(interviewScoreRepository.findByStudentUserIdAndApplicationIdInAndStatus( |
||||
|
anyLong(), anyCollection(), anyString())).thenReturn(List.of()); |
||||
|
when(offerRepository.findByCandidateIdAndStatusNotOrderByCreatedAtDesc(anyLong(), anyString())) |
||||
|
.thenReturn(List.of()); |
||||
|
when(applicationRepository.findByJobIdInAndStatusNotInOrderByCreatedAtAsc(anyCollection(), anyCollection())) |
||||
|
.thenReturn(List.of(app)); |
||||
|
when(dimensionScoreRepository.findUpToPeriod(anyCollection(), anyString())).thenReturn(List.of()); |
||||
|
when(notificationRepository.countUnreadByUserId(ME)).thenReturn(0L); |
||||
|
when(notificationRepository.countUnreadByUserIdAndType(ME, "REPORT_DECISION")).thenReturn(0L); |
||||
|
|
||||
|
CareerProgressResponse resp = service.getCareerProgress(ME, 0, 20, null); |
||||
|
CareerProgressResponse.CareerProgressItem item = resp.getApplications().getItems().get(0); |
||||
|
assertTrue(item.getCompetition().isEligible()); |
||||
|
assertNull(item.getCompetition().getRank()); |
||||
|
assertNull(item.getCompetition().getOverallScore()); |
||||
|
assertNull(item.getCompetition().getPercentile()); |
||||
|
assertNull(item.getCompetition().getMySnapshotPeriod()); |
||||
|
assertEquals(1, item.getCompetition().getCohortSize()); |
||||
|
} |
||||
|
|
||||
|
// ── 无 N+1:所有批量查询各执行一次 ────────────────────────
|
||||
|
|
||||
|
@Test |
||||
|
void multipleApplications_noNPlusOne_batchQueriesSingleCallEach() { |
||||
|
mockUser(ME, User.Role.STUDENT); |
||||
|
Application a1 = app(1L, ME, 9L, "SUBMITTED", "2026-08-20T10:00:00"); |
||||
|
Application a2 = app(2L, ME, 10L, "INTERVIEW", "2026-08-19T10:00:00"); |
||||
|
Application a3 = app(3L, ME, 11L, "VIEWED", "2026-08-18T10:00:00"); |
||||
|
when(applicationRepository.findByStudentUserIdOrderByCreatedAtDesc(eq(ME), any(PageRequest.class))) |
||||
|
.thenReturn(new PageImpl<>(List.of(a1, a2, a3), PageRequest.of(0, 20), 3)); |
||||
|
when(jobRepository.findAllById(anyCollection())).thenReturn(List.of( |
||||
|
Job.builder().id(9L).companyId(COMPANY).title("t1").description("d").city("c") |
||||
|
.employmentType("FULL_TIME").status("PUBLISHED").build(), |
||||
|
Job.builder().id(10L).companyId(COMPANY).title("t2").description("d").city("c") |
||||
|
.employmentType("FULL_TIME").status("PUBLISHED").build(), |
||||
|
Job.builder().id(11L).companyId(COMPANY).title("t3").description("d").city("c") |
||||
|
.employmentType("FULL_TIME").status("PUBLISHED").build())); |
||||
|
when(companyRepository.findAllById(anyCollection())) |
||||
|
.thenReturn(List.of(Company.builder().id(COMPANY).ownerUserId(9L).name("c").build())); |
||||
|
when(interviewRepository.findByStudentUserIdAndStatusInOrderByStartAtAsc(anyLong(), anyCollection())) |
||||
|
.thenReturn(List.of()); |
||||
|
when(interviewScoreRepository.findByStudentUserIdAndApplicationIdInAndStatus( |
||||
|
anyLong(), anyCollection(), anyString())).thenReturn(List.of()); |
||||
|
when(offerRepository.findByCandidateIdAndStatusNotOrderByCreatedAtDesc(anyLong(), anyString())) |
||||
|
.thenReturn(List.of()); |
||||
|
when(applicationRepository.findByJobIdInAndStatusNotInOrderByCreatedAtAsc(anyCollection(), anyCollection())) |
||||
|
.thenReturn(List.of(a1, a2, a3)); |
||||
|
when(dimensionScoreRepository.findUpToPeriod(anyCollection(), anyString())).thenReturn(List.of()); |
||||
|
when(notificationRepository.countUnreadByUserId(ME)).thenReturn(0L); |
||||
|
when(notificationRepository.countUnreadByUserIdAndType(ME, "REPORT_DECISION")).thenReturn(0L); |
||||
|
|
||||
|
service.getCareerProgress(ME, 0, 20, null); |
||||
|
|
||||
|
verify(jobRepository, times(1)).findAllById(anyCollection()); |
||||
|
verify(companyRepository, times(1)).findAllById(anyCollection()); |
||||
|
verify(interviewRepository, times(1)).findByStudentUserIdAndStatusInOrderByStartAtAsc(anyLong(), anyCollection()); |
||||
|
verify(interviewScoreRepository, times(1)).findByStudentUserIdAndApplicationIdInAndStatus(anyLong(), anyCollection(), anyString()); |
||||
|
verify(offerRepository, times(1)).findByCandidateIdAndStatusNotOrderByCreatedAtDesc(anyLong(), anyString()); |
||||
|
verify(applicationRepository, times(1)).findByJobIdInAndStatusNotInOrderByCreatedAtAsc(anyCollection(), anyCollection()); |
||||
|
verify(dimensionScoreRepository, times(1)).findUpToPeriod(anyCollection(), anyString()); |
||||
|
verify(notificationRepository, times(1)).countUnreadByUserId(ME); |
||||
|
verify(notificationRepository, times(1)).countUnreadByUserIdAndType(ME, "REPORT_DECISION"); |
||||
|
// 未按投递/面试/Offer 循环查询
|
||||
|
verify(interviewRepository, never()).findByApplicationIdOrderByCreatedAtDesc(anyLong()); |
||||
|
} |
||||
|
|
||||
|
// ── 辅助 ────────────────────────────────────────────────
|
||||
|
|
||||
|
private void mockUser(Long uid, User.Role role) { |
||||
|
User u = User.builder().id(uid).phone("1").role(role).build(); |
||||
|
u.setRoles(new HashSet<>(Set.of(role))); |
||||
|
when(userRepository.findById(uid)).thenReturn(Optional.of(u)); |
||||
|
} |
||||
|
|
||||
|
private Application app(Long id, Long sid, Long jobId, String status, String created) { |
||||
|
return Application.builder().id(id).jobId(jobId).studentUserId(sid).status(status) |
||||
|
.createdAt(LocalDateTime.parse(created)).updatedAt(LocalDateTime.parse(created)).build(); |
||||
|
} |
||||
|
|
||||
|
private Interview interview(Long id, Long appId, String status, String startAt) { |
||||
|
return Interview.builder().id(id).applicationId(appId).companyUserId(999L).studentUserId(ME) |
||||
|
.roundNo(1).title("技术面试").interviewType("ONLINE") |
||||
|
.startAt(LocalDateTime.parse(startAt)).endAt(LocalDateTime.parse(startAt).plusHours(1)) |
||||
|
.status(status).build(); |
||||
|
} |
||||
|
|
||||
|
private InterviewScore score(Long interviewId, Long appId, String submittedAt, BigDecimal overall) { |
||||
|
LocalDateTime submitted = submittedAt == null ? null : LocalDateTime.parse(submittedAt); |
||||
|
return InterviewScore.builder().id(interviewId).interviewId(interviewId).applicationId(appId) |
||||
|
.companyUserId(999L).studentUserId(ME).status(InterviewScore.STATUS_SUBMITTED) |
||||
|
.overallScore(overall).recommendation("HIRE").submittedAt(submitted).version(0L).build(); |
||||
|
} |
||||
|
|
||||
|
private Offer offer(Long id, Long appId, String status, String expireAt) { |
||||
|
return Offer.builder().id(id).applicationId(appId).jobId(JOB).companyId(COMPANY).candidateId(ME) |
||||
|
.title("Java 后端工程师").description("d").expireAt(LocalDateTime.parse(expireAt)) |
||||
|
.createdBy(999L).status(status).version(0L) |
||||
|
.createdAt(LocalDateTime.of(2026, 8, 25, 12, 0)).build(); |
||||
|
} |
||||
|
|
||||
|
private CandidateDimensionScore dim(Long sid, String score) { |
||||
|
return CandidateDimensionScore.builder().studentUserId(sid).dimension("EDUCATION").period(PERIOD) |
||||
|
.score(new BigDecimal(score)).status("OK").weight(new BigDecimal("12.50")) |
||||
|
.rulesVersion("candidate-scoring-v0.1").build(); |
||||
|
} |
||||
|
|
||||
|
private static String yearMonth(LocalDate d) { |
||||
|
return String.format("%04d-%02d", d.getYear(), d.getMonthValue()); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,168 @@ |
|||||
|
package com.mydreamjob.competition; |
||||
|
|
||||
|
import com.mydreamjob.application.Application; |
||||
|
import com.mydreamjob.candidatescore.CandidateDimensionScore; |
||||
|
import com.mydreamjob.candidatescore.CandidateDimensionScoreRepository; |
||||
|
import com.mydreamjob.candidatescore.CandidateScoringCalculator; |
||||
|
import org.junit.jupiter.api.BeforeEach; |
||||
|
import org.junit.jupiter.api.Test; |
||||
|
import org.junit.jupiter.api.extension.ExtendWith; |
||||
|
import org.mockito.Mock; |
||||
|
import org.mockito.junit.jupiter.MockitoExtension; |
||||
|
import org.mockito.junit.jupiter.MockitoSettings; |
||||
|
import org.mockito.quality.Strictness; |
||||
|
|
||||
|
import java.math.BigDecimal; |
||||
|
import java.time.LocalDateTime; |
||||
|
import java.util.List; |
||||
|
import java.util.Map; |
||||
|
|
||||
|
import static org.junit.jupiter.api.Assertions.*; |
||||
|
import static org.mockito.ArgumentMatchers.*; |
||||
|
import static org.mockito.Mockito.*; |
||||
|
|
||||
|
/** |
||||
|
* 共享排名计算器单测(V32/V34 复用核心)。 |
||||
|
* |
||||
|
* <p>覆盖稳定排序(有分优先 → overallScore DESC → createdAt ASC → studentUserId ASC → |
||||
|
* 无分最后)、cohort、百分位、最新快照周期选择与 overviewStatus。综合分走真实 |
||||
|
* CandidateScoringCalculator 语义(此处以 mock 模拟按 studentUserId 返回预设分)。</p> |
||||
|
*/ |
||||
|
@ExtendWith(MockitoExtension.class) |
||||
|
@MockitoSettings(strictness = Strictness.LENIENT) |
||||
|
class CompetitionRankingCalculatorTest { |
||||
|
|
||||
|
private static final Long ME = 100L; |
||||
|
|
||||
|
@Mock private CandidateDimensionScoreRepository scoreRepository; |
||||
|
@Mock private CandidateScoringCalculator scoringCalculator; |
||||
|
|
||||
|
private CompetitionRankingCalculator calculator; |
||||
|
|
||||
|
private Map<Long, BigDecimal> overallByStudent; |
||||
|
|
||||
|
@BeforeEach |
||||
|
void setUp() { |
||||
|
calculator = new CompetitionRankingCalculator(scoreRepository, scoringCalculator); |
||||
|
overallByStudent = Map.of(); |
||||
|
when(scoringCalculator.overallScoreFromSnapshots(anyList())).thenAnswer(inv -> { |
||||
|
List<?> snaps = inv.getArgument(0); |
||||
|
if (snaps == null || snaps.isEmpty()) return null; |
||||
|
Number sid = ((CandidateDimensionScore) snaps.get(0)).getStudentUserId(); |
||||
|
return overallByStudent.get(sid.longValue()); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void rankCohort_sortsScoredFirstThenScoreDescThenCreatedAtThenUserId() { |
||||
|
Application a = app(501L, "2026-01-01T10:00:00"); |
||||
|
Application b = app(502L, "2026-01-02T10:00:00"); |
||||
|
Application c = app(503L, "2026-01-03T10:00:00"); // 无分
|
||||
|
Application me = app(ME, "2026-01-05T10:00:00"); |
||||
|
Map<Long, List<CandidateDimensionScore>> latest = Map.of( |
||||
|
a.getStudentUserId(), List.of(dim(a.getStudentUserId(), "2026-07")), |
||||
|
b.getStudentUserId(), List.of(dim(b.getStudentUserId(), "2026-07")), |
||||
|
ME, List.of(dim(ME, "2026-07"))); |
||||
|
overallByStudent = Map.of(a.getStudentUserId(), new BigDecimal("90.00"), |
||||
|
b.getStudentUserId(), new BigDecimal("80.00"), |
||||
|
ME, new BigDecimal("85.00")); |
||||
|
|
||||
|
CompetitionRankingCalculator.CohortRanking cr = |
||||
|
calculator.rankCohort(List.of(me, a, b, c), latest, ME); |
||||
|
|
||||
|
assertEquals(4, cr.cohortSize); |
||||
|
assertEquals(2, cr.rank.intValue()); |
||||
|
assertEquals(new BigDecimal("85.00"), cr.overallScore); |
||||
|
assertEquals(new BigDecimal("50.00"), cr.percentile); // lower=1, denom=2
|
||||
|
// 排序:A(90) -> me(85) -> B(80) -> C(null)
|
||||
|
assertEquals(501L, cr.rows.get(0).studentUserId.longValue()); |
||||
|
assertEquals(ME.longValue(), cr.rows.get(1).studentUserId.longValue()); |
||||
|
assertEquals(502L, cr.rows.get(2).studentUserId.longValue()); |
||||
|
assertEquals(503L, cr.rows.get(3).studentUserId.longValue()); |
||||
|
assertNull(cr.rows.get(3).overallScore); |
||||
|
assertEquals("OK", cr.status); |
||||
|
assertEquals("2026-07", cr.mySnapshotPeriod); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void rankCohort_tieBrokenByCreatedAtThenStudentUserId() { |
||||
|
Application x = app(900L, "2026-04-01T10:00:00"); |
||||
|
Application me = app(ME, "2026-04-02T10:00:00"); |
||||
|
Map<Long, List<CandidateDimensionScore>> latest = Map.of( |
||||
|
x.getStudentUserId(), List.of(dim(x.getStudentUserId(), "2026-07")), |
||||
|
ME, List.of(dim(ME, "2026-07"))); |
||||
|
overallByStudent = Map.of(x.getStudentUserId(), new BigDecimal("60.00"), |
||||
|
ME, new BigDecimal("60.00")); |
||||
|
|
||||
|
CompetitionRankingCalculator.CohortRanking cr = |
||||
|
calculator.rankCohort(List.of(x, me), latest, ME); |
||||
|
|
||||
|
assertEquals(2, cr.rank.intValue()); // x first, me second
|
||||
|
assertEquals(900L, cr.rows.get(0).studentUserId.longValue()); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void rankCohort_currentNoScore_rankScorePercentileNull() { |
||||
|
Application a = app(501L, "2026-01-01T10:00:00"); |
||||
|
Application me = app(ME, "2026-01-02T10:00:00"); |
||||
|
Map<Long, List<CandidateDimensionScore>> latest = Map.of( |
||||
|
a.getStudentUserId(), List.of(dim(a.getStudentUserId(), "2026-07"))); |
||||
|
overallByStudent = Map.of(a.getStudentUserId(), new BigDecimal("90.00")); |
||||
|
|
||||
|
CompetitionRankingCalculator.CohortRanking cr = |
||||
|
calculator.rankCohort(List.of(a, me), latest, ME); |
||||
|
|
||||
|
assertEquals(2, cr.cohortSize); |
||||
|
assertNull(cr.rank); |
||||
|
assertNull(cr.overallScore); |
||||
|
assertNull(cr.percentile); |
||||
|
assertNull(cr.mySnapshotPeriod); |
||||
|
assertEquals("INSUFFICIENT_DATA", cr.status); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void rankCohort_scoredLessThan2_percentileNull() { |
||||
|
Application a = app(501L, "2026-01-01T10:00:00"); |
||||
|
Application me = app(ME, "2026-01-02T10:00:00"); |
||||
|
Map<Long, List<CandidateDimensionScore>> latest = Map.of(ME, List.of(dim(ME, "2026-07"))); |
||||
|
overallByStudent = Map.of(ME, new BigDecimal("85.00")); |
||||
|
|
||||
|
CompetitionRankingCalculator.CohortRanking cr = |
||||
|
calculator.rankCohort(List.of(a, me), latest, ME); |
||||
|
|
||||
|
assertNull(cr.percentile); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void loadLatestSnapshots_picksLatestPeriodPerStudent() { |
||||
|
when(scoreRepository.findUpToPeriod(anyCollection(), anyString())) |
||||
|
.thenReturn(List.of( |
||||
|
dim(ME, "2026-06"), |
||||
|
dim(ME, "2026-07"), |
||||
|
dim(501L, "2026-05"))); |
||||
|
|
||||
|
Map<Long, List<CandidateDimensionScore>> latest = |
||||
|
calculator.loadLatestSnapshots(List.of(ME, 501L), "2026-07"); |
||||
|
|
||||
|
assertEquals(1, latest.get(ME).size()); |
||||
|
assertEquals("2026-07", latest.get(ME).get(0).getPeriod()); |
||||
|
assertEquals("2026-05", latest.get(501L).get(0).getPeriod()); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void overviewStatus_emptyIsInsufficientData() { |
||||
|
assertEquals("INSUFFICIENT_DATA", calculator.overviewStatus(List.of())); |
||||
|
} |
||||
|
|
||||
|
private Application app(Long sid, String created) { |
||||
|
return Application.builder().studentUserId(sid) |
||||
|
.status("SUBMITTED") |
||||
|
.createdAt(LocalDateTime.parse(created)).build(); |
||||
|
} |
||||
|
|
||||
|
private CandidateDimensionScore dim(Long sid, String period) { |
||||
|
return CandidateDimensionScore.builder().studentUserId(sid).dimension("EDUCATION").period(period) |
||||
|
.score(new BigDecimal("80")).status("OK") |
||||
|
.weight(new BigDecimal("12.50")).rulesVersion("candidate-scoring-v0.1").build(); |
||||
|
} |
||||
|
} |
||||
Loading…
Reference in new issue