package com.mydreamjob.careerprogress; import com.mydreamjob.application.Application; import com.mydreamjob.application.ApplicationRepository; import com.mydreamjob.auth.User; import com.mydreamjob.auth.UserRepository; import com.mydreamjob.candidatescore.CandidateDimensionScore; import com.mydreamjob.candidatescore.CandidateScoringCalculator; import com.mydreamjob.careerprogress.dto.CareerProgressResponse; import com.mydreamjob.careerprogress.dto.CareerProgressResponse.CareerProgressItem; import com.mydreamjob.careerprogress.dto.CareerProgressResponse.CompetitionSummary; import com.mydreamjob.careerprogress.dto.CareerProgressResponse.InterviewSummary; import com.mydreamjob.careerprogress.dto.CareerProgressResponse.JobSummary; import com.mydreamjob.careerprogress.dto.CareerProgressResponse.OfferSummary; import com.mydreamjob.careerprogress.dto.CareerProgressResponse.PaginatedApplications; import com.mydreamjob.careerprogress.dto.CareerProgressResponse.ProgressTodoSummary; import com.mydreamjob.careerprogress.dto.CareerProgressResponse.ScoreSummary; import com.mydreamjob.common.exception.BusinessException; import com.mydreamjob.common.exception.ErrorCode; import com.mydreamjob.company.Company; import com.mydreamjob.company.CompanyRepository; import com.mydreamjob.competition.CompetitionRankingCalculator; import com.mydreamjob.interview.Interview; import com.mydreamjob.interview.InterviewRepository; import com.mydreamjob.interviewscore.InterviewScore; import com.mydreamjob.interviewscore.InterviewScoreRepository; import com.mydreamjob.job.Job; import com.mydreamjob.job.JobRepository; import com.mydreamjob.notification.NotificationRepository; import com.mydreamjob.offer.Offer; import com.mydreamjob.offer.OfferRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.time.Instant; import java.time.LocalDate; import java.time.LocalDateTime; import java.util.Comparator; import java.util.HashMap; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Set; import java.util.function.Function; import java.util.stream.Collectors; /** * 统一求职进度聚合服务(V34)。 * *

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

*/ @Slf4j @Service @RequiredArgsConstructor public class CareerProgressService { private static final Set ALLOWED_STATUSES = Set.of( "SUBMITTED", "VIEWED", "INTERVIEW", "HIRED", "REJECTED", "WITHDRAWN"); private static final Set NEXT_INTERVIEW_STATUSES = Set.of("SCHEDULED", "CONFIRMED"); private static final String REPORT_DECISION_TYPE = "REPORT_DECISION"; private final 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 appPage = (status == null || status.isBlank()) ? applicationRepository.findByStudentUserIdOrderByCreatedAtDesc(studentUserId, pr) : applicationRepository.findByStudentUserIdAndStatusOrderByCreatedAtDesc(studentUserId, status, pr); List pageApps = appPage.getContent(); // 2. 岗位与企业(批量,与 items 数量无关) List jobIds = pageApps.stream().map(Application::getJobId).distinct().toList(); Map jobs = jobIds.isEmpty() ? Map.of() : jobRepository.findAllById(jobIds).stream() .collect(Collectors.toMap(Job::getId, Function.identity())); List companyIds = jobs.values().stream().map(Job::getCompanyId).distinct().toList(); Map companies = companyIds.isEmpty() ? Map.of() : companyRepository.findAllById(companyIds).stream() .collect(Collectors.toMap(Company::getId, Function.identity())); // 3. 面试:学生全部 SCHEDULED/CONFIRMED(todo 计数 + nextInterview 共用一个批次) List pendingInterviews = interviewRepository .findByStudentUserIdAndStatusInOrderByStartAtAsc(studentUserId, NEXT_INTERVIEW_STATUSES); // 4. 已提交评分(本页投递,批量) List appIds = pageApps.stream().map(Application::getId).toList(); List scores = appIds.isEmpty() ? List.of() : interviewScoreRepository.findByStudentUserIdAndApplicationIdInAndStatus( studentUserId, appIds, InterviewScore.STATUS_SUBMITTED); // 5. 非 DRAFT Offer(todo 有效 Offer 计数 + 每条投递 Offer 展示共用一个批次) List offers = offerRepository.findByCandidateIdAndStatusNotOrderByCreatedAtDesc(studentUserId, "DRAFT"); // 6. 竞争力:本页岗位的 cohort 投递与候选人快照各批量一次,再按岗位内存排序 List cohortApps = jobIds.isEmpty() ? List.of() : applicationRepository.findByJobIdInAndStatusNotInOrderByCreatedAtAsc( jobIds, CompetitionRankingCalculator.EXCLUDED_STATUSES); List cohortCandidateIds = cohortApps.stream() .map(Application::getStudentUserId).distinct().toList(); Map> latestByStudent = rankingCalculator.loadLatestSnapshots(cohortCandidateIds, asOf); Map> cohortByJob = cohortApps.stream() .collect(Collectors.groupingBy(Application::getJobId)); // 7. 通知计数(只读 count) long unreadTotal = notificationRepository.countUnreadByUserId(studentUserId); long unreadReportDecision = notificationRepository.countUnreadByUserIdAndType(studentUserId, REPORT_DECISION_TYPE); // 组装 todo ProgressTodoSummary todo = buildTodo(pendingInterviews, offers, unreadTotal, unreadReportDecision, now); // 组装 items Map nextInterviewByApp = buildNextInterviewMap(pendingInterviews); Map latestScoreByApp = buildLatestScoreMap(scores); Map offerByApp = buildOfferMap(offers, now); List items = pageApps.stream().map(app -> { Job job = jobs.get(app.getJobId()); return CareerProgressItem.builder() .applicationId(app.getId()) .applicationStatus(app.getStatus()) .appliedAt(app.getCreatedAt()) .updatedAt(app.getUpdatedAt()) .job(job == null ? null : buildJobSummary(job, companies)) .nextInterview(buildInterviewSummary(nextInterviewByApp.get(app.getId()))) .latestSubmittedInterviewScore(buildScoreSummary(latestScoreByApp.get(app.getId()))) .offer(buildOfferSummary(offerByApp.get(app.getId()))) .competition(buildCompetition(app, cohortByJob, latestByStudent, studentUserId)) .build(); }).collect(Collectors.toList()); PaginatedApplications applications = PaginatedApplications.builder() .items(items) .total(appPage.getTotalElements()) .page(page) .size(size) .totalPages(appPage.getTotalPages()) .build(); return CareerProgressResponse.builder() .todo(todo) .applications(applications) .generatedAt(Instant.now()) .build(); } // ── todo ──────────────────────────────────────────────── private ProgressTodoSummary buildTodo(List interviews, List offers, long unreadTotal, long unreadReportDecision, LocalDateTime now) { long pendingInterview = interviews.stream() .filter(i -> "SCHEDULED".equals(i.getStatus())).count(); long upcomingConfirmed = interviews.stream() .filter(i -> "CONFIRMED".equals(i.getStatus()) && i.getStartAt() != null && i.getStartAt().isAfter(now)).count(); long pendingOffer = offers.stream() .filter(o -> ("SENT".equals(o.getStatus()) || "NEGOTIATING".equals(o.getStatus())) && o.getExpireAt() != null && o.getExpireAt().isAfter(now)).count(); return ProgressTodoSummary.builder() .pendingInterviewResponseCount(pendingInterview) .upcomingConfirmedInterviewCount(upcomingConfirmed) .pendingOfferResponseCount(pendingOffer) .unreadNotificationCount(unreadTotal) .unreadReportDecisionCount(unreadReportDecision) .build(); } // ── 批量结果归组(内存,无额外查询)──────────────────────── private Map buildNextInterviewMap(List interviews) { Map map = new LinkedHashMap<>(); // 已按 startAt ASC,putIfAbsent 保留每个 applicationId 最早一场 for (Interview iv : interviews) { map.putIfAbsent(iv.getApplicationId(), iv); } return map; } private Map buildLatestScoreMap(List scores) { Map map = new HashMap<>(); for (InterviewScore s : scores) { InterviewScore existing = map.get(s.getApplicationId()); if (existing == null || isAfter(s.getSubmittedAt(), existing.getSubmittedAt())) { map.put(s.getApplicationId(), s); } } return map; } private boolean isAfter(LocalDateTime a, LocalDateTime b) { if (a == null) return false; if (b == null) return true; return a.isAfter(b); } private Map buildOfferMap(List offers, LocalDateTime now) { Map> byApp = offers.stream().collect(Collectors.groupingBy(Offer::getApplicationId)); Map result = new HashMap<>(); for (Map.Entry> e : byApp.entrySet()) { List list = e.getValue(); Offer effective = list.stream() .filter(o -> ("SENT".equals(o.getStatus()) || "NEGOTIATING".equals(o.getStatus())) && o.getExpireAt() != null && o.getExpireAt().isAfter(now)) .min(Comparator.comparing(Offer::getExpireAt)) .orElse(null); if (effective != null) { result.put(e.getKey(), effective); } else if (!list.isEmpty()) { // offers 已按 createdAt DESC,第一个即该投递最新的非 DRAFT Offer result.put(e.getKey(), list.get(0)); } } return result; } // ── 子对象映射 ───────────────────────────────────────── private JobSummary buildJobSummary(Job job, Map companies) { Company c = companies.get(job.getCompanyId()); return JobSummary.builder() .jobId(job.getId()) .title(job.getTitle()) .companyName(c != null ? c.getName() : null) .city(job.getCity()) .employmentType(job.getEmploymentType()) .jobStatus(job.getStatus()) .build(); } private InterviewSummary buildInterviewSummary(Interview iv) { if (iv == null) return null; return InterviewSummary.builder() .interviewId(iv.getId()) .roundNo(iv.getRoundNo()) .title(iv.getTitle()) .interviewType(iv.getInterviewType()) .startAt(iv.getStartAt()) .endAt(iv.getEndAt()) .status(iv.getStatus()) .build(); } private ScoreSummary buildScoreSummary(InterviewScore s) { if (s == null) return null; return ScoreSummary.builder() .interviewId(s.getInterviewId()) .overallScore(s.getOverallScore()) .recommendation(s.getRecommendation()) .submittedAt(s.getSubmittedAt()) .build(); } private OfferSummary buildOfferSummary(Offer o) { if (o == null) return null; return OfferSummary.builder() .offerId(o.getId()) .status(o.getStatus()) .title(o.getTitle()) .expireAt(o.getExpireAt()) .onboardingDate(o.getOnboardingDate()) .build(); } private CompetitionSummary buildCompetition(Application app, Map> cohortByJob, Map> latestByStudent, Long meId) { String st = app.getStatus(); if ("WITHDRAWN".equals(st) || "REJECTED".equals(st)) { return CompetitionSummary.builder().eligible(false).build(); } List cohort = cohortByJob.getOrDefault(app.getJobId(), List.of()); CompetitionRankingCalculator.CohortRanking cr = rankingCalculator.rankCohort(cohort, latestByStudent, meId); return CompetitionSummary.builder() .eligible(true) .rank(cr.rank) .cohortSize(cr.cohortSize) .overallScore(cr.overallScore) .percentile(cr.percentile) .status(cr.status) .mySnapshotPeriod(cr.mySnapshotPeriod) .rulesVersion(candidateScoringCalculator.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()); } }