forked from baskbuddy/zhangshun
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