From 8c40fd433c227bc7fd38c36783421d528a433e47 Mon Sep 17 00:00:00 2001 From: Zhang Shun Date: Wed, 16 Sep 2026 09:37:10 +0800 Subject: [PATCH] perf: batch personal data export queries --- docs/backend-development-roadmap.md | 2 +- docs/r01-d2-personal-data-query-report.md | 29 +++ .../ApplicationStatusHistoryRepository.java | 3 + ...ndidateScoringAppealHistoryRepository.java | 2 + .../dataexport/PersonalDataCollector.java | 109 ++++++---- .../feedback/CandidateFeedbackRepository.java | 3 + .../InterviewStatusHistoryRepository.java | 5 +- .../messaging/MessageRepository.java | 3 + .../offer/OfferStatusHistoryRepository.java | 2 + .../PersonalDataCollectorQueryBudgetTest.java | 186 ++++++++++++++++++ 10 files changed, 305 insertions(+), 39 deletions(-) create mode 100644 docs/r01-d2-personal-data-query-report.md create mode 100644 mydreamjob-server/src/test/java/com/mydreamjob/dataexport/PersonalDataCollectorQueryBudgetTest.java diff --git a/docs/backend-development-roadmap.md b/docs/backend-development-roadmap.md index 7c09df5..5d570f9 100644 --- a/docs/backend-development-roadmap.md +++ b/docs/backend-development-roadmap.md @@ -15,7 +15,7 @@ | S02 Access/Refresh Token 隔离 | 已实现并完成自动化验证与干净构建,待独立代码复验及真实 MySQL/Docker 黑盒验收 | 类型隔离、独立密钥、轮换、重放处置、会话吊销已锁定;OpenAPI 175/213 | | S03 生产配置与简历隐私 | 已实现并完成自动化验证与干净构建,待独立代码复验及真实 MySQL/Docker 黑盒验收 | V43 禁用原始指纹种子账号;管理员初始化默认关闭且幂等;企业简历下载按隐私决策表授权;Actuator 隔离到管理端口;OpenAPI 176/214 | | Q01 岗位搜索真实查询覆盖(C02 证据补充) | 已完成实现、跨库一致性整改、独立复验及 Git 检查点(`77dc624`)。新增 54 条真实库测试:H2 Repository 21、H2 Service 9、H2 副作用 4、MySQL 8.4 Specification 15、MySQL 副作用 4、MySQL EXPLAIN 1;全量 1890/1890 | OpenAPI 保持 176/214;前导通配符、filesort 与真实规模执行计划移交 R01 | -| R01 结构与性能收束 | R01-A `0b5b849`、R01-B `15bd98b`、R01-C `b68519e` 已建本地检查点;R01-D1 已完成百分位 O(n log n) 收束,待独立复验 | overview 与推荐偏好口径已集中;后续继续处理数据导出 N+1、会话未读数和其余性能项 | +| R01 结构与性能收束 | R01-A `0b5b849`、R01-B `15bd98b`、R01-C `b68519e`、R01-D1 `cf0c71e` 已建本地检查点;R01-D2 已完成个人数据导出 N+1 收束,待独立复验 | overview 与偏好口径已集中;百分位 O(n log n);数据导出子集合改为固定批量查询;后续处理会话未读数和其余性能项 | | 其余五个功能组 | 技能词云、入职清单/公司知识、薪资计算器/参考、诚信积分/激励、简历查重 | N03–N06、N08仍属一期,细节设计后编码 | | 上线保障 | 现有资产待逐项核验 | G01–G04,一期交付门槛 | | 后续候选池 | AI简历、AI模拟面试/预测、SHAP高级解释等 | 以清单第6节为准,移出一期 | diff --git a/docs/r01-d2-personal-data-query-report.md b/docs/r01-d2-personal-data-query-report.md new file mode 100644 index 0000000..9989a3b --- /dev/null +++ b/docs/r01-d2-personal-data-query-report.md @@ -0,0 +1,29 @@ +# R01-D2 个人数据导出查询收束报告 + +> 状态:R01-D2 已完成批量查询整改、自动化验证与干净构建,待独立复验。 + +## 原问题 + +`PersonalDataCollector` 在遍历父记录时逐条查询子记录:投递状态历史、反馈、面试历史、Offer 历史、会话、消息和评分申诉历史均形成 N+1;投递列表还被重复查询两次。 + +## 整改 + +- 每类父记录先收集 ID,再用 Repository `IN` 查询一次性读取子记录; +- 在内存中按父 ID 分组,并继续按原父记录顺序组装,保留时间线内部升序、消息内部降序和每会话最多 10000 条的既有语义; +- 投递列表只读取一次,同时供投递、时间线和反馈三个 section 使用; +- 会话实体改用 `findAllById`,消息按全部 conversationId 一次批量读取; +- 未改变导出 section 名称、字段、脱敏、简历文件读取或 ZIP 构建语义。 + +## 测试证据 + +- `PersonalDataCollectorQueryBudgetTest`:3 条; +- 投递规模 1 与 50 时,投递主查询、状态历史查询、反馈查询均固定各 1 次; +- 面试历史、Offer 历史、会话实体、消息、申诉历史均固定为每类 1 次批量查询;旧逐条 Repository 方法明确断言为 0 次; +- 父记录顺序与每个父记录下的时间线顺序有独立断言; +- 定向及数据导出关联回归:19/19,Failures=0、Errors=0、Skipped=0; +- `mvn clean verify`:2102/2102,Failures=0、Errors=0、Skipped=20,BUILD SUCCESS;20 项为既有环境条件跳过项,R01-D2 定向与关联测试 Skipped=0; +- OpenAPI:176 paths / 214 operations,All checks passed。 + +## 边界 + +无 Flyway、表、索引、ErrorCode、公网接口或依赖变化;未暂存、未提交、未推送。 diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationStatusHistoryRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationStatusHistoryRepository.java index 57341dc..667cab0 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationStatusHistoryRepository.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationStatusHistoryRepository.java @@ -9,4 +9,7 @@ import java.util.List; public interface ApplicationStatusHistoryRepository extends JpaRepository { List findByApplicationIdOrderByCreatedAtAsc(Long applicationId); + + List findByApplicationIdInOrderByApplicationIdAscCreatedAtAsc( + List applicationIds); } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/governance/CandidateScoringAppealHistoryRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/governance/CandidateScoringAppealHistoryRepository.java index 570362f..d3c2f17 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/governance/CandidateScoringAppealHistoryRepository.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/governance/CandidateScoringAppealHistoryRepository.java @@ -9,4 +9,6 @@ import java.util.List; public interface CandidateScoringAppealHistoryRepository extends JpaRepository { List findByAppealIdOrderByCreatedAtAsc(Long appealId); + + List findByAppealIdInOrderByAppealIdAscCreatedAtAsc(List appealIds); } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/dataexport/PersonalDataCollector.java b/mydreamjob-server/src/main/java/com/mydreamjob/dataexport/PersonalDataCollector.java index 29e03a9..bd17491 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/dataexport/PersonalDataCollector.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/dataexport/PersonalDataCollector.java @@ -52,6 +52,8 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.function.Function; +import java.util.stream.Collectors; /** * 个人数据导出收集器:汇总当前用户自己的业务数据,用于打包 ZIP。 @@ -97,13 +99,14 @@ public class PersonalDataCollector { public CollectResult collect(Long userId) { CollectResult r = new CollectResult(); + List applications = applicationRepository.findByStudentUserIdOrderByCreatedAtDesc(userId); r.getSections().put("profile", buildProfile(userId)); r.getSections().put("preferences", buildPreferences(userId)); buildResumes(r, userId); - buildApplications(r, userId); + buildApplications(r, applications); buildInterviews(r, userId); buildOffers(r, userId); - buildFeedback(r, userId); + buildFeedback(r, applications); buildFavorites(r, userId); buildNotifications(r, userId); buildConversationsAndMessages(r, userId); @@ -187,16 +190,17 @@ public class PersonalDataCollector { } // ── applications + timelines ──────────────────────────── - private void buildApplications(CollectResult r, Long userId) { + private void buildApplications(CollectResult r, List applications) { List> apps = new ArrayList<>(); List> timelines = new ArrayList<>(); - applicationRepository.findByStudentUserIdOrderByCreatedAtDesc(userId) - .forEach(a -> { - apps.add(applicationView(a)); - applicationStatusHistoryRepository - .findByApplicationIdOrderByCreatedAtAsc(a.getId()) - .forEach(h -> timelines.add(applicationTimelineView(h))); - }); + Map> histories = groupBy( + applicationStatusHistoryRepository.findByApplicationIdInOrderByApplicationIdAscCreatedAtAsc( + ids(applications, Application::getId)), ApplicationStatusHistory::getApplicationId); + applications.forEach(a -> { + apps.add(applicationView(a)); + histories.getOrDefault(a.getId(), List.of()) + .forEach(h -> timelines.add(applicationTimelineView(h))); + }); r.getSections().put("applications", apps); r.getSections().put("application-timelines", timelines); } @@ -226,16 +230,22 @@ public class PersonalDataCollector { private void buildInterviews(CollectResult r, Long userId) { List> ivs = new ArrayList<>(); List> timelines = new ArrayList<>(); - interviewRepository.findByStudentUserIdOrderByStartAtDesc(userId, PageRequest.of(0, MAX_ROWS)) - .forEach(iv -> addInterview(r, iv, ivs, timelines)); - interviewRepository.findByCompanyUserIdOrderByStartAtDesc(userId, PageRequest.of(0, MAX_ROWS)) - .forEach(iv -> addInterview(r, iv, ivs, timelines)); + List interviews = new ArrayList<>(); + interviews.addAll(interviewRepository.findByStudentUserIdOrderByStartAtDesc( + userId, PageRequest.of(0, MAX_ROWS)).getContent()); + interviews.addAll(interviewRepository.findByCompanyUserIdOrderByStartAtDesc( + userId, PageRequest.of(0, MAX_ROWS)).getContent()); + Map> histories = groupBy( + interviewStatusHistoryRepository.findByInterviewIdInOrderByInterviewIdAscCreatedAtAsc( + ids(interviews, Interview::getId)), InterviewStatusHistory::getInterviewId); + interviews.forEach(iv -> addInterview(iv, ivs, timelines, histories)); r.getSections().put("interviews", ivs); r.getSections().put("interview-timelines", timelines); } - private void addInterview(CollectResult r, Interview iv, - List> ivs, List> timelines) { + private void addInterview(Interview iv, List> ivs, + List> timelines, + Map> histories) { Map m = new LinkedHashMap<>(); m.put("interviewId", iv.getId()); m.put("applicationId", iv.getApplicationId()); @@ -250,7 +260,7 @@ public class PersonalDataCollector { m.put("notes", iv.getNotes()); m.put("status", iv.getStatus()); ivs.add(m); - interviewStatusHistoryRepository.findByInterviewIdOrderByCreatedAtAsc(iv.getId()) + histories.getOrDefault(iv.getId(), List.of()) .forEach(h -> timelines.add(interviewTimelineView(h))); } @@ -268,21 +278,27 @@ public class PersonalDataCollector { private void buildOffers(CollectResult r, Long userId) { List> offers = new ArrayList<>(); List> timelines = new ArrayList<>(); - offerRepository.findByCandidateIdOrderByCreatedAtDesc(userId, PageRequest.of(0, MAX_ROWS)) - .forEach(o -> addOffer(r, o, offers, timelines)); + List source = new ArrayList<>(); + source.addAll(offerRepository.findByCandidateIdOrderByCreatedAtDesc( + userId, PageRequest.of(0, MAX_ROWS)).getContent()); companyRepository.findByOwnerUserId(userId).ifPresent(c -> - offerRepository.findByCompanyIdOrderByCreatedAtDesc(c.getId(), PageRequest.of(0, MAX_ROWS)) - .forEach(o -> addOffer(r, o, offers, timelines))); + source.addAll(offerRepository.findByCompanyIdOrderByCreatedAtDesc( + c.getId(), PageRequest.of(0, MAX_ROWS)).getContent())); + Map> histories = groupBy( + offerStatusHistoryRepository.findByOfferIdInOrderByOfferIdAscCreatedAtAsc( + ids(source, Offer::getId)), OfferStatusHistory::getOfferId); + source.forEach(o -> addOffer(o, offers, timelines, histories)); r.getSections().put("offers", offers); r.getSections().put("offer-timelines", timelines); } - private void addOffer(CollectResult r, Offer o, - List> offers, List> timelines) { + private void addOffer(Offer o, List> offers, + List> timelines, + Map> histories) { if (!offersContains(offers, o.getId())) { offers.add(offerView(o)); } - offerStatusHistoryRepository.findByOfferIdOrderByCreatedAtAsc(o.getId()) + histories.getOrDefault(o.getId(), List.of()) .forEach(h -> timelines.add(offerTimelineView(h))); } @@ -325,11 +341,13 @@ public class PersonalDataCollector { } // ── feedback ──────────────────────────────────────────── - private void buildFeedback(CollectResult r, Long userId) { + private void buildFeedback(CollectResult r, List applications) { List> list = new ArrayList<>(); - applicationRepository.findByStudentUserIdOrderByCreatedAtDesc(userId) - .forEach(a -> candidateFeedbackRepository.findByApplicationId(a.getId()) - .ifPresent(f -> list.add(feedbackView(f)))); + Map feedbackByApplication = candidateFeedbackRepository + .findByApplicationIdIn(ids(applications, Application::getId)).stream() + .collect(Collectors.toMap(CandidateFeedback::getApplicationId, Function.identity())); + applications.forEach(a -> Optional.ofNullable(feedbackByApplication.get(a.getId())) + .ifPresent(f -> list.add(feedbackView(f)))); r.getSections().put("feedback", list); } @@ -381,8 +399,15 @@ public class PersonalDataCollector { private void buildConversationsAndMessages(CollectResult r, Long userId) { List> convs = new ArrayList<>(); List> msgs = new ArrayList<>(); - for (ConversationMember member : conversationMemberRepository.findByUserId(userId)) { - conversationRepository.findById(member.getConversationId()).ifPresent(c -> { + List members = conversationMemberRepository.findByUserId(userId); + List conversationIds = ids(members, ConversationMember::getConversationId); + Map conversations = conversationRepository.findAllById(conversationIds).stream() + .collect(Collectors.toMap(Conversation::getId, Function.identity())); + Map> messages = groupBy( + messageRepository.findByConversationIdInOrderByConversationIdAscCreatedAtDesc(conversationIds), + Message::getConversationId); + for (ConversationMember member : members) { + Optional.ofNullable(conversations.get(member.getConversationId())).ifPresent(c -> { Map mc = new LinkedHashMap<>(); mc.put("conversationId", c.getId()); mc.put("title", c.getTitle()); @@ -391,8 +416,7 @@ public class PersonalDataCollector { mc.put("updatedAt", c.getUpdatedAt()); convs.add(mc); }); - messageRepository.findByConversationIdOrderByCreatedAtDesc( - member.getConversationId(), PageRequest.of(0, MAX_ROWS)) + messages.getOrDefault(member.getConversationId(), List.of()).stream().limit(MAX_ROWS) .forEach(msg -> msgs.add(messageView(msg))); } r.getSections().put("conversations", convs); @@ -428,9 +452,12 @@ public class PersonalDataCollector { r.getSections().put("candidate-scoring", scores); List> appeals = new ArrayList<>(); - candidateScoringAppealRepository.findByCandidateUserIdOrderByCreatedAtDesc( - userId, PageRequest.of(0, MAX_ROWS)) - .forEach(a -> { + List source = candidateScoringAppealRepository + .findByCandidateUserIdOrderByCreatedAtDesc(userId, PageRequest.of(0, MAX_ROWS)).getContent(); + Map> histories = groupBy( + candidateScoringAppealHistoryRepository.findByAppealIdInOrderByAppealIdAscCreatedAtAsc( + ids(source, CandidateScoringAppeal::getId)), CandidateScoringAppealHistory::getAppealId); + source.forEach(a -> { Map m = new LinkedHashMap<>(); m.put("appealId", a.getId()); m.put("dimension", a.getDimension()); @@ -441,7 +468,7 @@ public class PersonalDataCollector { m.put("createdAt", a.getCreatedAt()); m.put("updatedAt", a.getUpdatedAt()); List> hist = new ArrayList<>(); - candidateScoringAppealHistoryRepository.findByAppealIdOrderByCreatedAtAsc(a.getId()) + histories.getOrDefault(a.getId(), List.of()) .forEach(h -> { Map hm = new LinkedHashMap<>(); hm.put("fromStatus", h.getFromStatus()); @@ -479,4 +506,12 @@ public class PersonalDataCollector { String n = name.replaceAll("[\\\\/:*?\"<>|\\s]+", "_"); return n.isEmpty() ? "resume" : n; } -} \ No newline at end of file + + private List ids(List values, Function idExtractor) { + return values.stream().map(idExtractor).toList(); + } + + private Map> groupBy(List values, Function idExtractor) { + return values.stream().collect(Collectors.groupingBy(idExtractor)); + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/feedback/CandidateFeedbackRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/feedback/CandidateFeedbackRepository.java index b0d0c95..00f485d 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/feedback/CandidateFeedbackRepository.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/feedback/CandidateFeedbackRepository.java @@ -4,11 +4,14 @@ import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; import java.util.Optional; +import java.util.List; @Repository public interface CandidateFeedbackRepository extends JpaRepository { Optional findByApplicationId(Long applicationId); + List findByApplicationIdIn(List applicationIds); + boolean existsByApplicationId(Long applicationId); } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/interview/InterviewStatusHistoryRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/interview/InterviewStatusHistoryRepository.java index aa9de0c..79f8a02 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/interview/InterviewStatusHistoryRepository.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/interview/InterviewStatusHistoryRepository.java @@ -9,4 +9,7 @@ import java.util.List; public interface InterviewStatusHistoryRepository extends JpaRepository { List findByInterviewIdOrderByCreatedAtAsc(Long interviewId); -} \ No newline at end of file + + List findByInterviewIdInOrderByInterviewIdAscCreatedAtAsc( + List interviewIds); +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/messaging/MessageRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/messaging/MessageRepository.java index 37a1f54..d67e3af 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/messaging/MessageRepository.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/messaging/MessageRepository.java @@ -11,12 +11,15 @@ import org.springframework.stereotype.Repository; import jakarta.persistence.LockModeType; import java.time.LocalDateTime; +import java.util.List; @Repository public interface MessageRepository extends JpaRepository { Page findByConversationIdOrderByCreatedAtDesc(Long conversationId, Pageable pageable); + List findByConversationIdInOrderByConversationIdAscCreatedAtDesc(List conversationIds); + /** * 条件更新未命中后的当前读,用于区分并发请求已完成撤回与真实冲突。 * PESSIMISTIC_WRITE 强制读取提交后的最新行,避免 MySQL REPEATABLE READ 使用旧快照。 diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/offer/OfferStatusHistoryRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/offer/OfferStatusHistoryRepository.java index 97a1772..eb090c8 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/offer/OfferStatusHistoryRepository.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/offer/OfferStatusHistoryRepository.java @@ -13,6 +13,8 @@ public interface OfferStatusHistoryRepository extends JpaRepository findByOfferIdOrderByCreatedAtAsc(Long offerId); + List findByOfferIdInOrderByOfferIdAscCreatedAtAsc(List offerIds); + /** 查询某企业指定时间范围内的状态历史(用于统计平均响应时间) */ @Query("SELECT h FROM OfferStatusHistory h, Offer o " + "WHERE h.offerId = o.id AND o.companyId = :companyId AND h.createdAt >= :since " + diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/dataexport/PersonalDataCollectorQueryBudgetTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/dataexport/PersonalDataCollectorQueryBudgetTest.java new file mode 100644 index 0000000..4369cc4 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/dataexport/PersonalDataCollectorQueryBudgetTest.java @@ -0,0 +1,186 @@ +package com.mydreamjob.dataexport; + +import com.mydreamjob.application.*; +import com.mydreamjob.auth.UserRepository; +import com.mydreamjob.candidatescore.CandidateDimensionScoreRepository; +import com.mydreamjob.candidatescore.governance.*; +import com.mydreamjob.company.CompanyRepository; +import com.mydreamjob.feedback.*; +import com.mydreamjob.interview.*; +import com.mydreamjob.job.JobFavoriteRepository; +import com.mydreamjob.messaging.*; +import com.mydreamjob.notification.NotificationRepository; +import com.mydreamjob.offer.*; +import com.mydreamjob.preference.StudentPreferenceRepository; +import com.mydreamjob.profile.StudentProfileRepository; +import com.mydreamjob.profile.storage.ResumeStorageService; +import com.mydreamjob.resume.ResumeVersionRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; + +import java.util.List; +import java.util.Optional; +import java.util.stream.LongStream; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class PersonalDataCollectorQueryBudgetTest { + + @Mock UserRepository userRepository; + @Mock StudentProfileRepository studentProfileRepository; + @Mock StudentPreferenceRepository studentPreferenceRepository; + @Mock ResumeVersionRepository resumeVersionRepository; + @Mock ApplicationRepository applicationRepository; + @Mock ApplicationStatusHistoryRepository applicationStatusHistoryRepository; + @Mock InterviewRepository interviewRepository; + @Mock InterviewStatusHistoryRepository interviewStatusHistoryRepository; + @Mock OfferRepository offerRepository; + @Mock OfferStatusHistoryRepository offerStatusHistoryRepository; + @Mock CandidateFeedbackRepository candidateFeedbackRepository; + @Mock JobFavoriteRepository jobFavoriteRepository; + @Mock NotificationRepository notificationRepository; + @Mock ConversationMemberRepository conversationMemberRepository; + @Mock ConversationRepository conversationRepository; + @Mock MessageRepository messageRepository; + @Mock CandidateDimensionScoreRepository candidateDimensionScoreRepository; + @Mock CandidateScoringAppealRepository candidateScoringAppealRepository; + @Mock CandidateScoringAppealHistoryRepository candidateScoringAppealHistoryRepository; + @Mock CompanyRepository companyRepository; + @Mock ResumeStorageService resumeStorageService; + + @InjectMocks PersonalDataCollector collector; + + @BeforeEach + void stubUnrelatedSections() { + when(userRepository.findById(anyLong())).thenReturn(Optional.empty()); + when(studentProfileRepository.findByUserId(anyLong())).thenReturn(Optional.empty()); + when(studentPreferenceRepository.findByStudentUserId(anyLong())).thenReturn(Optional.empty()); + when(resumeVersionRepository.findByUserIdAndStatusOrderByVersionNoDesc(anyLong(), anyString())) + .thenReturn(List.of()); + lenient().when(interviewRepository.findByStudentUserIdOrderByStartAtDesc(anyLong(), any(Pageable.class))) + .thenReturn(Page.empty()); + when(interviewRepository.findByCompanyUserIdOrderByStartAtDesc(anyLong(), any(Pageable.class))) + .thenReturn(Page.empty()); + when(interviewStatusHistoryRepository.findByInterviewIdInOrderByInterviewIdAscCreatedAtAsc(anyList())) + .thenReturn(List.of()); + lenient().when(offerRepository.findByCandidateIdOrderByCreatedAtDesc(anyLong(), any(Pageable.class))) + .thenReturn(Page.empty()); + when(companyRepository.findByOwnerUserId(anyLong())).thenReturn(Optional.empty()); + when(offerStatusHistoryRepository.findByOfferIdInOrderByOfferIdAscCreatedAtAsc(anyList())) + .thenReturn(List.of()); + when(jobFavoriteRepository.findByStudentUserIdOrderByCreatedAtDesc(anyLong(), any(Pageable.class))) + .thenReturn(Page.empty()); + when(notificationRepository.findByUserIdOrderByCreatedAtDesc(anyLong(), any(Pageable.class))) + .thenReturn(Page.empty()); + when(conversationMemberRepository.findByUserId(anyLong())).thenReturn(List.of()); + when(conversationRepository.findAllById(anyList())).thenReturn(List.of()); + when(messageRepository.findByConversationIdInOrderByConversationIdAscCreatedAtDesc(anyList())) + .thenReturn(List.of()); + when(candidateDimensionScoreRepository.findByStudentUserId(anyLong())).thenReturn(List.of()); + lenient().when(candidateScoringAppealRepository.findByCandidateUserIdOrderByCreatedAtDesc( + anyLong(), any(Pageable.class))).thenReturn(Page.empty()); + when(candidateScoringAppealHistoryRepository.findByAppealIdInOrderByAppealIdAscCreatedAtAsc(anyList())) + .thenReturn(List.of()); + } + + @Test + void applicationQueriesStayFixedFromOneToFiftyRows() { + assertFixedApplicationQueries(1); + clearInvocations(applicationRepository, applicationStatusHistoryRepository, candidateFeedbackRepository); + assertFixedApplicationQueries(50); + } + + @Test + void batchResultsPreserveParentOrderAndTimelineOrder() { + Application newer = application(20L); + Application older = application(10L); + when(applicationRepository.findByStudentUserIdOrderByCreatedAtDesc(7L)) + .thenReturn(List.of(newer, older)); + when(applicationStatusHistoryRepository.findByApplicationIdInOrderByApplicationIdAscCreatedAtAsc(anyList())) + .thenReturn(List.of(history(10L, "A"), history(10L, "B"), history(20L, "C"))); + when(candidateFeedbackRepository.findByApplicationIdIn(anyList())).thenReturn(List.of()); + + PersonalDataCollector.CollectResult result = collector.collect(7L); + + List applications = (List) result.getSections().get("applications"); + List timelines = (List) result.getSections().get("application-timelines"); + assertEquals(20L, ((java.util.Map) applications.get(0)).get("applicationId")); + assertEquals(10L, ((java.util.Map) applications.get(1)).get("applicationId")); + assertEquals("C", ((java.util.Map) timelines.get(0)).get("toStatus")); + assertEquals("A", ((java.util.Map) timelines.get(1)).get("toStatus")); + assertEquals("B", ((java.util.Map) timelines.get(2)).get("toStatus")); + } + + @Test + void everyChildCollectionUsesOneBatchQueryInsteadOfPerParentQueries() { + when(applicationRepository.findByStudentUserIdOrderByCreatedAtDesc(7L)).thenReturn(List.of()); + when(applicationStatusHistoryRepository.findByApplicationIdInOrderByApplicationIdAscCreatedAtAsc(anyList())) + .thenReturn(List.of()); + when(candidateFeedbackRepository.findByApplicationIdIn(anyList())).thenReturn(List.of()); + when(interviewRepository.findByStudentUserIdOrderByStartAtDesc(eq(7L), any(Pageable.class))) + .thenReturn(new PageImpl<>(List.of( + Interview.builder().id(1L).build(), Interview.builder().id(2L).build()))); + when(offerRepository.findByCandidateIdOrderByCreatedAtDesc(eq(7L), any(Pageable.class))) + .thenReturn(new PageImpl<>(List.of( + Offer.builder().id(3L).build(), Offer.builder().id(4L).build()))); + when(conversationMemberRepository.findByUserId(7L)).thenReturn(List.of( + ConversationMember.builder().conversationId(5L).userId(7L).build(), + ConversationMember.builder().conversationId(6L).userId(7L).build())); + when(candidateScoringAppealRepository.findByCandidateUserIdOrderByCreatedAtDesc( + eq(7L), any(Pageable.class))).thenReturn(new PageImpl<>(List.of( + CandidateScoringAppeal.builder().id(7L).build(), + CandidateScoringAppeal.builder().id(8L).build()))); + + collector.collect(7L); + + verify(interviewStatusHistoryRepository, times(1)) + .findByInterviewIdInOrderByInterviewIdAscCreatedAtAsc(anyList()); + verify(offerStatusHistoryRepository, times(1)) + .findByOfferIdInOrderByOfferIdAscCreatedAtAsc(anyList()); + verify(conversationRepository, times(1)).findAllById(anyList()); + verify(messageRepository, times(1)) + .findByConversationIdInOrderByConversationIdAscCreatedAtDesc(anyList()); + verify(candidateScoringAppealHistoryRepository, times(1)) + .findByAppealIdInOrderByAppealIdAscCreatedAtAsc(anyList()); + verify(interviewStatusHistoryRepository, never()).findByInterviewIdOrderByCreatedAtAsc(anyLong()); + verify(offerStatusHistoryRepository, never()).findByOfferIdOrderByCreatedAtAsc(anyLong()); + verify(conversationRepository, never()).findById(anyLong()); + verify(messageRepository, never()).findByConversationIdOrderByCreatedAtDesc(anyLong(), any()); + verify(candidateScoringAppealHistoryRepository, never()).findByAppealIdOrderByCreatedAtAsc(anyLong()); + } + + private void assertFixedApplicationQueries(int count) { + List applications = LongStream.rangeClosed(1, count).mapToObj(this::application).toList(); + when(applicationRepository.findByStudentUserIdOrderByCreatedAtDesc(7L)).thenReturn(applications); + when(applicationStatusHistoryRepository.findByApplicationIdInOrderByApplicationIdAscCreatedAtAsc(anyList())) + .thenReturn(List.of()); + when(candidateFeedbackRepository.findByApplicationIdIn(anyList())).thenReturn(List.of()); + + collector.collect(7L); + + verify(applicationRepository, times(1)).findByStudentUserIdOrderByCreatedAtDesc(7L); + verify(applicationStatusHistoryRepository, times(1)) + .findByApplicationIdInOrderByApplicationIdAscCreatedAtAsc(anyList()); + verify(candidateFeedbackRepository, times(1)).findByApplicationIdIn(anyList()); + verify(applicationStatusHistoryRepository, never()).findByApplicationIdOrderByCreatedAtAsc(anyLong()); + verify(candidateFeedbackRepository, never()).findByApplicationId(anyLong()); + } + + private Application application(long id) { + return Application.builder().id(id).studentUserId(7L).jobId(100L + id).status("SUBMITTED").build(); + } + + private ApplicationStatusHistory history(long applicationId, String toStatus) { + return ApplicationStatusHistory.builder().applicationId(applicationId).toStatus(toStatus).build(); + } +}