10 changed files with 305 additions and 39 deletions
@ -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、公网接口或依赖变化;未暂存、未提交、未推送。 |
|||
@ -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…
Reference in new issue