forked from baskbuddy/zhangshun
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
325 lines
16 KiB
325 lines
16 KiB
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 com.mydreamjob.security.UserRoleGuard userRoleGuard;
|
|
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;
|
|
private final CandidateScoringCalculator candidateScoringCalculator;
|
|
|
|
@Transactional(readOnly = true)
|
|
public CareerProgressResponse getCareerProgress(Long studentUserId, int page, int size, String status) {
|
|
userRoleGuard.requireStudent(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.currentRulesVersion())
|
|
.build();
|
|
}
|
|
|
|
// ── 权限 / 参数 / 工具 ─────────────────────────────────
|
|
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());
|
|
}
|
|
}
|