Browse Source

perf: batch personal data export queries

master
Zhang Shun 1 month ago
parent
commit
8c40fd433c
  1. 2
      docs/backend-development-roadmap.md
  2. 29
      docs/r01-d2-personal-data-query-report.md
  3. 3
      mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationStatusHistoryRepository.java
  4. 2
      mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/governance/CandidateScoringAppealHistoryRepository.java
  5. 107
      mydreamjob-server/src/main/java/com/mydreamjob/dataexport/PersonalDataCollector.java
  6. 3
      mydreamjob-server/src/main/java/com/mydreamjob/feedback/CandidateFeedbackRepository.java
  7. 3
      mydreamjob-server/src/main/java/com/mydreamjob/interview/InterviewStatusHistoryRepository.java
  8. 3
      mydreamjob-server/src/main/java/com/mydreamjob/messaging/MessageRepository.java
  9. 2
      mydreamjob-server/src/main/java/com/mydreamjob/offer/OfferStatusHistoryRepository.java
  10. 186
      mydreamjob-server/src/test/java/com/mydreamjob/dataexport/PersonalDataCollectorQueryBudgetTest.java

2
docs/backend-development-roadmap.md

@ -15,7 +15,7 @@
| S02 Access/Refresh Token 隔离 | 已实现并完成自动化验证与干净构建,待独立代码复验及真实 MySQL/Docker 黑盒验收 | 类型隔离、独立密钥、轮换、重放处置、会话吊销已锁定;OpenAPI 175/213 | | S02 Access/Refresh Token 隔离 | 已实现并完成自动化验证与干净构建,待独立代码复验及真实 MySQL/Docker 黑盒验收 | 类型隔离、独立密钥、轮换、重放处置、会话吊销已锁定;OpenAPI 175/213 |
| S03 生产配置与简历隐私 | 已实现并完成自动化验证与干净构建,待独立代码复验及真实 MySQL/Docker 黑盒验收 | V43 禁用原始指纹种子账号;管理员初始化默认关闭且幂等;企业简历下载按隐私决策表授权;Actuator 隔离到管理端口;OpenAPI 176/214 | | 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 | | 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仍属一期,细节设计后编码 | | 其余五个功能组 | 技能词云、入职清单/公司知识、薪资计算器/参考、诚信积分/激励、简历查重 | N03–N06、N08仍属一期,细节设计后编码 |
| 上线保障 | 现有资产待逐项核验 | G01–G04,一期交付门槛 | | 上线保障 | 现有资产待逐项核验 | G01–G04,一期交付门槛 |
| 后续候选池 | AI简历、AI模拟面试/预测、SHAP高级解释等 | 以清单第6节为准,移出一期 | | 后续候选池 | AI简历、AI模拟面试/预测、SHAP高级解释等 | 以清单第6节为准,移出一期 |

29
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、公网接口或依赖变化;未暂存、未提交、未推送。

3
mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationStatusHistoryRepository.java

@ -9,4 +9,7 @@ import java.util.List;
public interface ApplicationStatusHistoryRepository extends JpaRepository<ApplicationStatusHistory, Long> { public interface ApplicationStatusHistoryRepository extends JpaRepository<ApplicationStatusHistory, Long> {
List<ApplicationStatusHistory> findByApplicationIdOrderByCreatedAtAsc(Long applicationId); List<ApplicationStatusHistory> findByApplicationIdOrderByCreatedAtAsc(Long applicationId);
List<ApplicationStatusHistory> findByApplicationIdInOrderByApplicationIdAscCreatedAtAsc(
List<Long> applicationIds);
} }

2
mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/governance/CandidateScoringAppealHistoryRepository.java

@ -9,4 +9,6 @@ import java.util.List;
public interface CandidateScoringAppealHistoryRepository extends JpaRepository<CandidateScoringAppealHistory, Long> { public interface CandidateScoringAppealHistoryRepository extends JpaRepository<CandidateScoringAppealHistory, Long> {
List<CandidateScoringAppealHistory> findByAppealIdOrderByCreatedAtAsc(Long appealId); List<CandidateScoringAppealHistory> findByAppealIdOrderByCreatedAtAsc(Long appealId);
List<CandidateScoringAppealHistory> findByAppealIdInOrderByAppealIdAscCreatedAtAsc(List<Long> appealIds);
} }

107
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.List;
import java.util.Map; import java.util.Map;
import java.util.Optional; import java.util.Optional;
import java.util.function.Function;
import java.util.stream.Collectors;
/** /**
* 个人数据导出收集器:汇总当前用户自己的业务数据,用于打包 ZIP。 * 个人数据导出收集器:汇总当前用户自己的业务数据,用于打包 ZIP。
@ -97,13 +99,14 @@ public class PersonalDataCollector {
public CollectResult collect(Long userId) { public CollectResult collect(Long userId) {
CollectResult r = new CollectResult(); CollectResult r = new CollectResult();
List<Application> applications = applicationRepository.findByStudentUserIdOrderByCreatedAtDesc(userId);
r.getSections().put("profile", buildProfile(userId)); r.getSections().put("profile", buildProfile(userId));
r.getSections().put("preferences", buildPreferences(userId)); r.getSections().put("preferences", buildPreferences(userId));
buildResumes(r, userId); buildResumes(r, userId);
buildApplications(r, userId); buildApplications(r, applications);
buildInterviews(r, userId); buildInterviews(r, userId);
buildOffers(r, userId); buildOffers(r, userId);
buildFeedback(r, userId); buildFeedback(r, applications);
buildFavorites(r, userId); buildFavorites(r, userId);
buildNotifications(r, userId); buildNotifications(r, userId);
buildConversationsAndMessages(r, userId); buildConversationsAndMessages(r, userId);
@ -187,16 +190,17 @@ public class PersonalDataCollector {
} }
// ── applications + timelines ──────────────────────────── // ── applications + timelines ────────────────────────────
private void buildApplications(CollectResult r, Long userId) { private void buildApplications(CollectResult r, List<Application> applications) {
List<Map<String, Object>> apps = new ArrayList<>(); List<Map<String, Object>> apps = new ArrayList<>();
List<Map<String, Object>> timelines = new ArrayList<>(); List<Map<String, Object>> timelines = new ArrayList<>();
applicationRepository.findByStudentUserIdOrderByCreatedAtDesc(userId) Map<Long, List<ApplicationStatusHistory>> histories = groupBy(
.forEach(a -> { applicationStatusHistoryRepository.findByApplicationIdInOrderByApplicationIdAscCreatedAtAsc(
apps.add(applicationView(a)); ids(applications, Application::getId)), ApplicationStatusHistory::getApplicationId);
applicationStatusHistoryRepository applications.forEach(a -> {
.findByApplicationIdOrderByCreatedAtAsc(a.getId()) apps.add(applicationView(a));
.forEach(h -> timelines.add(applicationTimelineView(h))); histories.getOrDefault(a.getId(), List.of())
}); .forEach(h -> timelines.add(applicationTimelineView(h)));
});
r.getSections().put("applications", apps); r.getSections().put("applications", apps);
r.getSections().put("application-timelines", timelines); r.getSections().put("application-timelines", timelines);
} }
@ -226,16 +230,22 @@ public class PersonalDataCollector {
private void buildInterviews(CollectResult r, Long userId) { private void buildInterviews(CollectResult r, Long userId) {
List<Map<String, Object>> ivs = new ArrayList<>(); List<Map<String, Object>> ivs = new ArrayList<>();
List<Map<String, Object>> timelines = new ArrayList<>(); List<Map<String, Object>> timelines = new ArrayList<>();
interviewRepository.findByStudentUserIdOrderByStartAtDesc(userId, PageRequest.of(0, MAX_ROWS)) List<Interview> interviews = new ArrayList<>();
.forEach(iv -> addInterview(r, iv, ivs, timelines)); interviews.addAll(interviewRepository.findByStudentUserIdOrderByStartAtDesc(
interviewRepository.findByCompanyUserIdOrderByStartAtDesc(userId, PageRequest.of(0, MAX_ROWS)) userId, PageRequest.of(0, MAX_ROWS)).getContent());
.forEach(iv -> addInterview(r, iv, ivs, timelines)); interviews.addAll(interviewRepository.findByCompanyUserIdOrderByStartAtDesc(
userId, PageRequest.of(0, MAX_ROWS)).getContent());
Map<Long, List<InterviewStatusHistory>> 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("interviews", ivs);
r.getSections().put("interview-timelines", timelines); r.getSections().put("interview-timelines", timelines);
} }
private void addInterview(CollectResult r, Interview iv, private void addInterview(Interview iv, List<Map<String, Object>> ivs,
List<Map<String, Object>> ivs, List<Map<String, Object>> timelines) { List<Map<String, Object>> timelines,
Map<Long, List<InterviewStatusHistory>> histories) {
Map<String, Object> m = new LinkedHashMap<>(); Map<String, Object> m = new LinkedHashMap<>();
m.put("interviewId", iv.getId()); m.put("interviewId", iv.getId());
m.put("applicationId", iv.getApplicationId()); m.put("applicationId", iv.getApplicationId());
@ -250,7 +260,7 @@ public class PersonalDataCollector {
m.put("notes", iv.getNotes()); m.put("notes", iv.getNotes());
m.put("status", iv.getStatus()); m.put("status", iv.getStatus());
ivs.add(m); ivs.add(m);
interviewStatusHistoryRepository.findByInterviewIdOrderByCreatedAtAsc(iv.getId()) histories.getOrDefault(iv.getId(), List.of())
.forEach(h -> timelines.add(interviewTimelineView(h))); .forEach(h -> timelines.add(interviewTimelineView(h)));
} }
@ -268,21 +278,27 @@ public class PersonalDataCollector {
private void buildOffers(CollectResult r, Long userId) { private void buildOffers(CollectResult r, Long userId) {
List<Map<String, Object>> offers = new ArrayList<>(); List<Map<String, Object>> offers = new ArrayList<>();
List<Map<String, Object>> timelines = new ArrayList<>(); List<Map<String, Object>> timelines = new ArrayList<>();
offerRepository.findByCandidateIdOrderByCreatedAtDesc(userId, PageRequest.of(0, MAX_ROWS)) List<Offer> source = new ArrayList<>();
.forEach(o -> addOffer(r, o, offers, timelines)); source.addAll(offerRepository.findByCandidateIdOrderByCreatedAtDesc(
userId, PageRequest.of(0, MAX_ROWS)).getContent());
companyRepository.findByOwnerUserId(userId).ifPresent(c -> companyRepository.findByOwnerUserId(userId).ifPresent(c ->
offerRepository.findByCompanyIdOrderByCreatedAtDesc(c.getId(), PageRequest.of(0, MAX_ROWS)) source.addAll(offerRepository.findByCompanyIdOrderByCreatedAtDesc(
.forEach(o -> addOffer(r, o, offers, timelines))); c.getId(), PageRequest.of(0, MAX_ROWS)).getContent()));
Map<Long, List<OfferStatusHistory>> 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("offers", offers);
r.getSections().put("offer-timelines", timelines); r.getSections().put("offer-timelines", timelines);
} }
private void addOffer(CollectResult r, Offer o, private void addOffer(Offer o, List<Map<String, Object>> offers,
List<Map<String, Object>> offers, List<Map<String, Object>> timelines) { List<Map<String, Object>> timelines,
Map<Long, List<OfferStatusHistory>> histories) {
if (!offersContains(offers, o.getId())) { if (!offersContains(offers, o.getId())) {
offers.add(offerView(o)); offers.add(offerView(o));
} }
offerStatusHistoryRepository.findByOfferIdOrderByCreatedAtAsc(o.getId()) histories.getOrDefault(o.getId(), List.of())
.forEach(h -> timelines.add(offerTimelineView(h))); .forEach(h -> timelines.add(offerTimelineView(h)));
} }
@ -325,11 +341,13 @@ public class PersonalDataCollector {
} }
// ── feedback ──────────────────────────────────────────── // ── feedback ────────────────────────────────────────────
private void buildFeedback(CollectResult r, Long userId) { private void buildFeedback(CollectResult r, List<Application> applications) {
List<Map<String, Object>> list = new ArrayList<>(); List<Map<String, Object>> list = new ArrayList<>();
applicationRepository.findByStudentUserIdOrderByCreatedAtDesc(userId) Map<Long, CandidateFeedback> feedbackByApplication = candidateFeedbackRepository
.forEach(a -> candidateFeedbackRepository.findByApplicationId(a.getId()) .findByApplicationIdIn(ids(applications, Application::getId)).stream()
.ifPresent(f -> list.add(feedbackView(f)))); .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); r.getSections().put("feedback", list);
} }
@ -381,8 +399,15 @@ public class PersonalDataCollector {
private void buildConversationsAndMessages(CollectResult r, Long userId) { private void buildConversationsAndMessages(CollectResult r, Long userId) {
List<Map<String, Object>> convs = new ArrayList<>(); List<Map<String, Object>> convs = new ArrayList<>();
List<Map<String, Object>> msgs = new ArrayList<>(); List<Map<String, Object>> msgs = new ArrayList<>();
for (ConversationMember member : conversationMemberRepository.findByUserId(userId)) { List<ConversationMember> members = conversationMemberRepository.findByUserId(userId);
conversationRepository.findById(member.getConversationId()).ifPresent(c -> { List<Long> conversationIds = ids(members, ConversationMember::getConversationId);
Map<Long, Conversation> conversations = conversationRepository.findAllById(conversationIds).stream()
.collect(Collectors.toMap(Conversation::getId, Function.identity()));
Map<Long, List<Message>> messages = groupBy(
messageRepository.findByConversationIdInOrderByConversationIdAscCreatedAtDesc(conversationIds),
Message::getConversationId);
for (ConversationMember member : members) {
Optional.ofNullable(conversations.get(member.getConversationId())).ifPresent(c -> {
Map<String, Object> mc = new LinkedHashMap<>(); Map<String, Object> mc = new LinkedHashMap<>();
mc.put("conversationId", c.getId()); mc.put("conversationId", c.getId());
mc.put("title", c.getTitle()); mc.put("title", c.getTitle());
@ -391,8 +416,7 @@ public class PersonalDataCollector {
mc.put("updatedAt", c.getUpdatedAt()); mc.put("updatedAt", c.getUpdatedAt());
convs.add(mc); convs.add(mc);
}); });
messageRepository.findByConversationIdOrderByCreatedAtDesc( messages.getOrDefault(member.getConversationId(), List.of()).stream().limit(MAX_ROWS)
member.getConversationId(), PageRequest.of(0, MAX_ROWS))
.forEach(msg -> msgs.add(messageView(msg))); .forEach(msg -> msgs.add(messageView(msg)));
} }
r.getSections().put("conversations", convs); r.getSections().put("conversations", convs);
@ -428,9 +452,12 @@ public class PersonalDataCollector {
r.getSections().put("candidate-scoring", scores); r.getSections().put("candidate-scoring", scores);
List<Map<String, Object>> appeals = new ArrayList<>(); List<Map<String, Object>> appeals = new ArrayList<>();
candidateScoringAppealRepository.findByCandidateUserIdOrderByCreatedAtDesc( List<CandidateScoringAppeal> source = candidateScoringAppealRepository
userId, PageRequest.of(0, MAX_ROWS)) .findByCandidateUserIdOrderByCreatedAtDesc(userId, PageRequest.of(0, MAX_ROWS)).getContent();
.forEach(a -> { Map<Long, List<CandidateScoringAppealHistory>> histories = groupBy(
candidateScoringAppealHistoryRepository.findByAppealIdInOrderByAppealIdAscCreatedAtAsc(
ids(source, CandidateScoringAppeal::getId)), CandidateScoringAppealHistory::getAppealId);
source.forEach(a -> {
Map<String, Object> m = new LinkedHashMap<>(); Map<String, Object> m = new LinkedHashMap<>();
m.put("appealId", a.getId()); m.put("appealId", a.getId());
m.put("dimension", a.getDimension()); m.put("dimension", a.getDimension());
@ -441,7 +468,7 @@ public class PersonalDataCollector {
m.put("createdAt", a.getCreatedAt()); m.put("createdAt", a.getCreatedAt());
m.put("updatedAt", a.getUpdatedAt()); m.put("updatedAt", a.getUpdatedAt());
List<Map<String, Object>> hist = new ArrayList<>(); List<Map<String, Object>> hist = new ArrayList<>();
candidateScoringAppealHistoryRepository.findByAppealIdOrderByCreatedAtAsc(a.getId()) histories.getOrDefault(a.getId(), List.of())
.forEach(h -> { .forEach(h -> {
Map<String, Object> hm = new LinkedHashMap<>(); Map<String, Object> hm = new LinkedHashMap<>();
hm.put("fromStatus", h.getFromStatus()); hm.put("fromStatus", h.getFromStatus());
@ -479,4 +506,12 @@ public class PersonalDataCollector {
String n = name.replaceAll("[\\\\/:*?\"<>|\\s]+", "_"); String n = name.replaceAll("[\\\\/:*?\"<>|\\s]+", "_");
return n.isEmpty() ? "resume" : n; return n.isEmpty() ? "resume" : n;
} }
private <T> List<Long> ids(List<T> values, Function<T, Long> idExtractor) {
return values.stream().map(idExtractor).toList();
}
private <T> Map<Long, List<T>> groupBy(List<T> values, Function<T, Long> idExtractor) {
return values.stream().collect(Collectors.groupingBy(idExtractor));
}
} }

3
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 org.springframework.stereotype.Repository;
import java.util.Optional; import java.util.Optional;
import java.util.List;
@Repository @Repository
public interface CandidateFeedbackRepository extends JpaRepository<CandidateFeedback, Long> { public interface CandidateFeedbackRepository extends JpaRepository<CandidateFeedback, Long> {
Optional<CandidateFeedback> findByApplicationId(Long applicationId); Optional<CandidateFeedback> findByApplicationId(Long applicationId);
List<CandidateFeedback> findByApplicationIdIn(List<Long> applicationIds);
boolean existsByApplicationId(Long applicationId); boolean existsByApplicationId(Long applicationId);
} }

3
mydreamjob-server/src/main/java/com/mydreamjob/interview/InterviewStatusHistoryRepository.java

@ -9,4 +9,7 @@ import java.util.List;
public interface InterviewStatusHistoryRepository extends JpaRepository<InterviewStatusHistory, Long> { public interface InterviewStatusHistoryRepository extends JpaRepository<InterviewStatusHistory, Long> {
List<InterviewStatusHistory> findByInterviewIdOrderByCreatedAtAsc(Long interviewId); List<InterviewStatusHistory> findByInterviewIdOrderByCreatedAtAsc(Long interviewId);
List<InterviewStatusHistory> findByInterviewIdInOrderByInterviewIdAscCreatedAtAsc(
List<Long> interviewIds);
} }

3
mydreamjob-server/src/main/java/com/mydreamjob/messaging/MessageRepository.java

@ -11,12 +11,15 @@ import org.springframework.stereotype.Repository;
import jakarta.persistence.LockModeType; import jakarta.persistence.LockModeType;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.List;
@Repository @Repository
public interface MessageRepository extends JpaRepository<Message, Long> { public interface MessageRepository extends JpaRepository<Message, Long> {
Page<Message> findByConversationIdOrderByCreatedAtDesc(Long conversationId, Pageable pageable); Page<Message> findByConversationIdOrderByCreatedAtDesc(Long conversationId, Pageable pageable);
List<Message> findByConversationIdInOrderByConversationIdAscCreatedAtDesc(List<Long> conversationIds);
/** /**
* 条件更新未命中后的当前读,用于区分并发请求已完成撤回与真实冲突。 * 条件更新未命中后的当前读,用于区分并发请求已完成撤回与真实冲突。
* PESSIMISTIC_WRITE 强制读取提交后的最新行,避免 MySQL REPEATABLE READ 使用旧快照。 * PESSIMISTIC_WRITE 强制读取提交后的最新行,避免 MySQL REPEATABLE READ 使用旧快照。

2
mydreamjob-server/src/main/java/com/mydreamjob/offer/OfferStatusHistoryRepository.java

@ -13,6 +13,8 @@ public interface OfferStatusHistoryRepository extends JpaRepository<OfferStatusH
List<OfferStatusHistory> findByOfferIdOrderByCreatedAtAsc(Long offerId); List<OfferStatusHistory> findByOfferIdOrderByCreatedAtAsc(Long offerId);
List<OfferStatusHistory> findByOfferIdInOrderByOfferIdAscCreatedAtAsc(List<Long> offerIds);
/** 查询某企业指定时间范围内的状态历史(用于统计平均响应时间) */ /** 查询某企业指定时间范围内的状态历史(用于统计平均响应时间) */
@Query("SELECT h FROM OfferStatusHistory h, Offer o " + @Query("SELECT h FROM OfferStatusHistory h, Offer o " +
"WHERE h.offerId = o.id AND o.companyId = :companyId AND h.createdAt >= :since " + "WHERE h.offerId = o.id AND o.companyId = :companyId AND h.createdAt >= :since " +

186
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<Application> 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();
}
}
Loading…
Cancel
Save