diff --git a/ops/db/migrations/20260730_analysis_async_tasks_home_lookup_index.sql b/ops/db/migrations/20260730_analysis_async_tasks_home_lookup_index.sql new file mode 100644 index 0000000..c13621f --- /dev/null +++ b/ops/db/migrations/20260730_analysis_async_tasks_home_lookup_index.sql @@ -0,0 +1,2 @@ +CREATE INDEX IF NOT EXISTS idx_analysis_async_tasks_user_mock_apply_status + ON analysis_async_tasks (user_id, mock_apply_id, status); diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/repository/AnalysisAsyncTaskRepository.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/repository/AnalysisAsyncTaskRepository.java index 4426619..4a849e6 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/repository/AnalysisAsyncTaskRepository.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/repository/AnalysisAsyncTaskRepository.java @@ -17,5 +17,11 @@ Optional findFirstByUserIdAndMockApplyIdAndStatusInOrderByCre Collection statuses ); + List findByUserIdAndMockApplyIdInAndStatusIn( + Long userId, + Collection mockApplyIds, + Collection statuses + ); + List findByStatusIn(Collection statuses); } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/mockapply/dto/response/MockApplyHomeItemResponse.java b/src/main/java/com/jobdri/jobdri_api/domain/mockapply/dto/response/MockApplyHomeItemResponse.java index bf46381..23a9c23 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/mockapply/dto/response/MockApplyHomeItemResponse.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/mockapply/dto/response/MockApplyHomeItemResponse.java @@ -22,9 +22,10 @@ public record MockApplyHomeItemResponse( JobPostingProfileColor profileColor, LocalDateTime createdAt, ApplyType applyType, - Integer score + Integer score, + boolean analysisInProgress ) { - public static MockApplyHomeItemResponse from(MockApply mockApply) { + public static MockApplyHomeItemResponse from(MockApply mockApply, boolean analysisInProgress) { JobPosting jobPosting = mockApply.getJobPosting(); String detailClassificationName = jobPosting.getDetailClassification().getDetailName(); Analysis analysis = mockApply.getAnalysis(); @@ -42,7 +43,8 @@ public static MockApplyHomeItemResponse from(MockApply mockApply) { jobPosting.getProfileColor(), mockApply.getCreatedAt(), mockApply.getApplyType(), - analysis == null ? null : analysis.getScore() + analysis == null ? null : analysis.getScore(), + analysisInProgress ); } diff --git a/src/main/java/com/jobdri/jobdri_api/domain/mockapply/service/MockApplyService.java b/src/main/java/com/jobdri/jobdri_api/domain/mockapply/service/MockApplyService.java index 18f0ac0..d2251b5 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/mockapply/service/MockApplyService.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/mockapply/service/MockApplyService.java @@ -1,6 +1,8 @@ package com.jobdri.jobdri_api.domain.mockapply.service; import com.jobdri.jobdri_api.domain.analysis.entity.Question; +import com.jobdri.jobdri_api.domain.analysis.entity.AnalysisAsyncTask.TaskStatus; +import com.jobdri.jobdri_api.domain.analysis.repository.AnalysisAsyncTaskRepository; import com.jobdri.jobdri_api.domain.analysis.repository.AnalysisRepository; import com.jobdri.jobdri_api.domain.analysis.repository.QuestionAnalysisRepository; import com.jobdri.jobdri_api.domain.analysis.repository.QuestionRepository; @@ -36,6 +38,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; @@ -46,6 +49,8 @@ import java.sql.SQLException; import java.util.List; import java.util.Locale; +import java.util.Set; +import java.util.stream.Collectors; @Service @RequiredArgsConstructor @@ -72,6 +77,7 @@ public class MockApplyService { private final UserService userService; private final MockApplyPersistenceService mockApplyPersistenceService; private final MockApplySequenceService mockApplySequenceService; + private final AnalysisAsyncTaskRepository analysisAsyncTaskRepository; @Transactional(propagation = Propagation.NOT_SUPPORTED) @AuditLogEvent(action = "MOCK_APPLY_CREATE", targetType = "MOCK_APPLY", targetId = "#result.mockApplyId()") @@ -222,11 +228,9 @@ public MockApplySequenceResponse getMockApplySequence(User user, Long mockApplyI public MockApplyHomeResponse getMyMockApplies(User user, int page, int size) { User validatedUser = userService.validateUser(user); - List inProgressItems = mockApplyRepository - .findAllByUserIdAndStatusNotOrderByCreatedAtDescIdDesc(validatedUser.getId(), MockApplyStatus.COMPLETED) - .stream() - .map(MockApplyHomeItemResponse::from) - .toList(); + List inProgressMockApplies = mockApplyRepository + .findAllByUserIdAndStatusNotOrderByCreatedAtDescIdDesc(validatedUser.getId(), MockApplyStatus.COMPLETED); + List inProgressItems = toHomeItems(validatedUser.getId(), inProgressMockApplies); Pageable pageable = PageRequest.of( Math.max(page, 0), Math.min(Math.max(size, 1), MAX_PAGE_SIZE), @@ -237,7 +241,7 @@ public MockApplyHomeResponse getMyMockApplies(User user, int page, int size) { ); Page completedItems = mockApplyRepository .findAllByUserIdAndStatus(validatedUser.getId(), MockApplyStatus.COMPLETED, pageable) - .map(MockApplyHomeItemResponse::from); + .map(mockApply -> MockApplyHomeItemResponse.from(mockApply, false)); return new MockApplyHomeResponse( inProgressItems, @@ -256,9 +260,8 @@ public List getRecentMockApplies(User user, int limit ) ); - return mockApplyRepository.findAllByUserId(validatedUser.getId(), pageable) - .map(MockApplyHomeItemResponse::from) - .getContent(); + List mockApplies = mockApplyRepository.findAllByUserId(validatedUser.getId(), pageable).getContent(); + return toHomeItems(validatedUser.getId(), mockApplies); } public Page getCompletedMockApplies( @@ -285,7 +288,7 @@ public Page getCompletedMockApplies( maxScoreExclusive(resolvedFilter), pageable ) - .map(MockApplyHomeItemResponse::from); + .map(mockApply -> MockApplyHomeItemResponse.from(mockApply, false)); } public Page searchMyMockApplies(User user, String query, int page, int size) { @@ -300,8 +303,40 @@ public Page searchMyMockApplies(User user, String que ) ); - return mockApplyRepository.searchByUserId(validatedUser.getId(), normalizedQuery, pageable) - .map(MockApplyHomeItemResponse::from); + Page mockApplyPage = mockApplyRepository.searchByUserId(validatedUser.getId(), normalizedQuery, pageable); + return new PageImpl<>( + toHomeItems(validatedUser.getId(), mockApplyPage.getContent()), + pageable, + mockApplyPage.getTotalElements() + ); + } + + private List toHomeItems(Long userId, List mockApplies) { + Set activeAnalysisMockApplyIds = findActiveAnalysisMockApplyIds(userId, mockApplies); + return mockApplies.stream() + .map(mockApply -> MockApplyHomeItemResponse.from( + mockApply, + activeAnalysisMockApplyIds.contains(mockApply.getId()) + )) + .toList(); + } + + private Set findActiveAnalysisMockApplyIds(Long userId, List mockApplies) { + List mockApplyIds = mockApplies.stream() + .filter(mockApply -> mockApply.getStatus() != MockApplyStatus.COMPLETED) + .map(MockApply::getId) + .toList(); + if (mockApplyIds.isEmpty()) { + return Set.of(); + } + return analysisAsyncTaskRepository.findByUserIdAndMockApplyIdInAndStatusIn( + userId, + mockApplyIds, + List.of(TaskStatus.PENDING, TaskStatus.RUNNING) + ) + .stream() + .map(task -> task.getMockApplyId()) + .collect(Collectors.toSet()); } @Transactional diff --git a/src/main/resources/schema.sql b/src/main/resources/schema.sql index 61aef4b..8dadec3 100644 --- a/src/main/resources/schema.sql +++ b/src/main/resources/schema.sql @@ -55,6 +55,9 @@ CREATE INDEX IF NOT EXISTS idx_mock_question_embeddings_hnsw CREATE INDEX IF NOT EXISTS idx_job_posting_embeddings_hnsw ON job_posting_embeddings USING hnsw (embedding vector_cosine_ops); +CREATE INDEX IF NOT EXISTS idx_analysis_async_tasks_user_mock_apply_status + ON analysis_async_tasks (user_id, mock_apply_id, status); + ALTER TABLE IF EXISTS analyses ADD COLUMN IF NOT EXISTS missing_keywords TEXT NOT NULL DEFAULT '[]'; diff --git a/src/test/java/com/jobdri/jobdri_api/domain/mockapply/service/MockApplyServiceTest.java b/src/test/java/com/jobdri/jobdri_api/domain/mockapply/service/MockApplyServiceTest.java index 4419422..1f184bd 100644 --- a/src/test/java/com/jobdri/jobdri_api/domain/mockapply/service/MockApplyServiceTest.java +++ b/src/test/java/com/jobdri/jobdri_api/domain/mockapply/service/MockApplyServiceTest.java @@ -1,9 +1,11 @@ package com.jobdri.jobdri_api.domain.mockapply.service; import com.jobdri.jobdri_api.domain.analysis.entity.Analysis; +import com.jobdri.jobdri_api.domain.analysis.entity.AnalysisAsyncTask; import com.jobdri.jobdri_api.domain.analysis.entity.Question; import com.jobdri.jobdri_api.domain.analysis.entity.QuestionAnalysis; import com.jobdri.jobdri_api.domain.analysis.entity.QuestionAnalysisStatus; +import com.jobdri.jobdri_api.domain.analysis.repository.AnalysisAsyncTaskRepository; import com.jobdri.jobdri_api.domain.analysis.repository.AnalysisRepository; import com.jobdri.jobdri_api.domain.analysis.repository.QuestionAnalysisRepository; import com.jobdri.jobdri_api.domain.analysis.repository.QuestionRepository; @@ -49,6 +51,7 @@ import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.support.TransactionTemplate; +import java.time.Instant; import java.time.LocalDateTime; import java.util.List; import java.util.UUID; @@ -73,6 +76,9 @@ class MockApplyServiceTest { @Autowired private AnalysisRepository analysisRepository; + @Autowired + private AnalysisAsyncTaskRepository analysisAsyncTaskRepository; + @Autowired private QuestionAnalysisRepository questionAnalysisRepository; @@ -338,6 +344,12 @@ void getMyMockApplies() { MockApply inProgress = mockApplyRepository.save(MockApply.create(user, backendPosting, ApplyType.ACTUAL)); inProgress.updateStatus(MockApplyStatus.ANSWER_WRITE); inProgress.updateDisplayName("카카오 백엔드 지원 연습"); + analysisAsyncTaskRepository.save(AnalysisAsyncTask.pending(user.getId(), inProgress.getId(), 3)); + MockApply runningInProgress = mockApplyRepository.save(MockApply.create(user, backendPosting, ApplyType.MOCK)); + runningInProgress.updateStatus(MockApplyStatus.ANSWER_WRITE); + AnalysisAsyncTask runningTask = AnalysisAsyncTask.pending(user.getId(), runningInProgress.getId(), 3); + runningTask.markRunning("worker-1", 0, Instant.now()); + analysisAsyncTaskRepository.save(runningTask); MockApply completedFirst = mockApplyRepository.save(MockApply.create(user, dataPosting, ApplyType.MOCK)); completedFirst.updateStatus(MockApplyStatus.COMPLETED); MockApply completedSecond = mockApplyRepository.save(MockApply.create(user, dataPosting, ApplyType.ACTUAL)); @@ -350,29 +362,36 @@ void getMyMockApplies() { LocalDateTime baseTime = LocalDateTime.of(2026, 1, 1, 12, 0); ReflectionTestUtils.setField(inProgress, "createdAt", baseTime); + ReflectionTestUtils.setField(runningInProgress, "createdAt", baseTime.plusSeconds(30)); ReflectionTestUtils.setField(completedFirst, "createdAt", baseTime.plusMinutes(1)); ReflectionTestUtils.setField(completedSecond, "createdAt", baseTime.plusMinutes(2)); mockApplyRepository.saveAndFlush(inProgress); + mockApplyRepository.saveAndFlush(runningInProgress); mockApplyRepository.saveAndFlush(completedFirst); mockApplyRepository.saveAndFlush(completedSecond); MockApplyHomeResponse response = mockApplyService.getMyMockApplies(user, 0, 9); - assertThat(response.inProgress()).hasSize(1); + assertThat(response.inProgress()).hasSize(2); assertThat(response.completed().getContent()).hasSize(2); - assertThat(response.inProgress().get(0).mockApplyId()).isEqualTo(inProgress.getId()); - assertThat(response.inProgress().get(0).jobPostingId()).isEqualTo(backendPosting.getId()); - assertThat(response.inProgress().get(0).displayName()).isEqualTo("카카오 백엔드 지원 연습"); - assertThat(response.inProgress().get(0).sequence()).isEqualTo(1); - assertThat(response.inProgress().get(0).status()).isEqualTo(MockApplyStatus.ANSWER_WRITE); - assertThat(response.inProgress().get(0).companyName()).isEqualTo("테스트 기업"); - assertThat(response.inProgress().get(0).detailClassificationName()).isEqualTo("백엔드 개발"); - assertThat(response.inProgress().get(0).jobTitle()).isEqualTo("백엔드 개발"); - assertThat(response.inProgress().get(0).profileColor()).isEqualTo(JobPostingProfileColor.BLUE); - assertThat(response.inProgress().get(0).createdAt()).isEqualTo(baseTime); - assertThat(response.inProgress().get(0).applyType()).isEqualTo(ApplyType.ACTUAL); - assertThat(response.inProgress().get(0).score()).isNull(); - assertThat(response.inProgress().get(0).resumePath()).isEqualTo("/mock-applies/" + inProgress.getId() + "/answers"); + MockApplyHomeItemResponse runningItem = response.inProgress().get(0); + MockApplyHomeItemResponse pendingItem = response.inProgress().get(1); + assertThat(runningItem.mockApplyId()).isEqualTo(runningInProgress.getId()); + assertThat(runningItem.analysisInProgress()).isTrue(); + assertThat(pendingItem.mockApplyId()).isEqualTo(inProgress.getId()); + assertThat(pendingItem.jobPostingId()).isEqualTo(backendPosting.getId()); + assertThat(pendingItem.displayName()).isEqualTo("카카오 백엔드 지원 연습"); + assertThat(pendingItem.sequence()).isEqualTo(1); + assertThat(pendingItem.status()).isEqualTo(MockApplyStatus.ANSWER_WRITE); + assertThat(pendingItem.companyName()).isEqualTo("테스트 기업"); + assertThat(pendingItem.detailClassificationName()).isEqualTo("백엔드 개발"); + assertThat(pendingItem.jobTitle()).isEqualTo("백엔드 개발"); + assertThat(pendingItem.profileColor()).isEqualTo(JobPostingProfileColor.BLUE); + assertThat(pendingItem.createdAt()).isEqualTo(baseTime); + assertThat(pendingItem.applyType()).isEqualTo(ApplyType.ACTUAL); + assertThat(pendingItem.score()).isNull(); + assertThat(pendingItem.analysisInProgress()).isTrue(); + assertThat(pendingItem.resumePath()).isEqualTo("/mock-applies/" + inProgress.getId() + "/answers"); assertThat(response.completed().getContent()).extracting(MockApplyHomeItemResponse::mockApplyId) .containsExactly(completedSecond.getId(), completedFirst.getId()); assertThat(response.completed().getTotalElements()).isEqualTo(2); @@ -383,6 +402,7 @@ void getMyMockApplies() { assertThat(response.completed().getContent().get(0).profileColor()).isEqualTo(JobPostingProfileColor.GREEN); assertThat(response.completed().getContent().get(0).displayName()).isNull(); assertThat(response.completed().getContent().get(0).score()).isEqualTo(81); + assertThat(response.completed().getContent().get(0).analysisInProgress()).isFalse(); assertThat(response.completed().getContent().get(0).applyType()).isEqualTo(ApplyType.ACTUAL); assertThat(response.completed().getContent().get(0).resumePath()).isEqualTo("/mock-applies/" + completedSecond.getId() + "/analysis"); }