diff --git a/src/main/java/repit/repit_api_server/domain/userdata/interview/service/InterviewJobSnapshotService.java b/src/main/java/repit/repit_api_server/domain/userdata/interview/service/InterviewJobSnapshotService.java index d450859..001c2c3 100644 --- a/src/main/java/repit/repit_api_server/domain/userdata/interview/service/InterviewJobSnapshotService.java +++ b/src/main/java/repit/repit_api_server/domain/userdata/interview/service/InterviewJobSnapshotService.java @@ -15,6 +15,10 @@ import repit.repit_api_server.domain.userdata.persona.entity.enums.Role; import repit.repit_api_server.domain.userdata.persona.repository.PersonaRepository; import repit.repit_api_server.domain.userdata.persona.support.UserJobMajors; +import repit.repit_api_server.domain.userdata.question.entity.QuestionTailorEntity; +import repit.repit_api_server.domain.userdata.question.preparation.FailureStage; +import repit.repit_api_server.domain.userdata.question.preparation.PreparationState; +import repit.repit_api_server.domain.userdata.question.preparation.PreparationStatus; import repit.repit_api_server.domain.userdata.question.repository.QuestionTailorRepository; import repit.repit_api_server.global.auth.AuthUser; import repit.repit_api_server.global.exception.BusinessException; @@ -31,6 +35,21 @@ public class InterviewJobSnapshotService { @Transactional public InterviewEntity capture(AuthUser user, Long interviewId) { + return snapshot(user, interviewId, false); + } + + /** + * 준비 재시도 때의 스냅샷. 질문을 처음부터 다시 만드는 재시도이고 아직 면접 전용 면접관이 없으면 지금 만든다. + * 첫 준비 때 스냅샷 없이 질문 생성이 실패했다면(직무를 일반 직군으로 바꾸기 전에 준비한 면접 등) 재시도가 + * 사용자의 지금 직무로 질문을 다시 만드므로, 면접관 직무·직무명도 그 값으로 고정해야 서로 어긋나지 않는다. + * 채팅 서버 전달만 실패한 경우는 질문이 이미 정해져 있어 건드리지 않는다. + */ + @Transactional + public InterviewEntity captureForRetry(AuthUser user, Long interviewId) { + return snapshot(user, interviewId, true); + } + + private InterviewEntity snapshot(AuthUser user, Long interviewId, boolean retry) { InterviewEntity interview = interviews.findForJobSnapshot(interviewId) .orElseThrow(() -> BusinessException.notFound("면접을 찾을 수 없습니다")); // OSIV가 앞선 조회의 managed 엔티티를 보관해도 잠금 획득 뒤 최신 FK를 다시 읽는다. @@ -38,8 +57,8 @@ public InterviewEntity capture(AuthUser user, Long interviewId) { if (!user.id().equals(interview.getUserId())) { throw BusinessException.forbidden("본인의 면접만 시작할 수 있습니다."); } - if (interview.getStatus() != Status.IN_PROGRESS - || tailors.findTopByInterviewIdOrderByCreatedAtDesc(interviewId).isPresent()) { + QuestionTailorEntity latest = tailors.findTopByInterviewIdOrderByCreatedAtDesc(interviewId).orElse(null); + if (interview.getStatus() != Status.IN_PROGRESS || (latest != null && !(retry && regeneratesQuestions(latest)))) { return interview; } InterviewPersonaEntity link = interview.getMode() == InterviewMode.MULTI @@ -73,4 +92,9 @@ public InterviewEntity capture(AuthUser user, Long interviewId) { } return interview; } + + private static boolean regeneratesQuestions(QuestionTailorEntity tailor) { + PreparationState state = PreparationState.of(tailor); + return state.status() == PreparationStatus.FAILED && state.failureStage() == FailureStage.QUESTION_GENERATION; + } } diff --git a/src/main/java/repit/repit_api_server/domain/userdata/interview/service/InterviewService.java b/src/main/java/repit/repit_api_server/domain/userdata/interview/service/InterviewService.java index 890467f..bf4c524 100644 --- a/src/main/java/repit/repit_api_server/domain/userdata/interview/service/InterviewService.java +++ b/src/main/java/repit/repit_api_server/domain/userdata/interview/service/InterviewService.java @@ -8,6 +8,8 @@ import repit.repit_api_server.domain.company.dto.CompanySnapshot; import repit.repit_api_server.domain.company.service.CompanySnapshotFactory; import repit.repit_api_server.domain.metadata.service.AnalysisLaunchService; +import repit.repit_api_server.domain.metadata.support.JobRole; +import repit.repit_api_server.domain.userdata.persona.support.UserJobMajors; import repit.repit_api_server.domain.userdata.interview.dto.request.CreateInterviewRequest; import repit.repit_api_server.domain.userdata.interview.dto.request.SaveInterviewRequest; import repit.repit_api_server.domain.userdata.interview.dto.response.ChatAnswerResponse; @@ -232,9 +234,7 @@ public InterviewPrepareResponse prepareInterview(AuthUser authUser, Long intervi if (!authUser.id().equals(interview.getUserId())) { throw BusinessException.forbidden("본인의 면접만 시작할 수 있습니다."); } - if (repit.repit_api_server.domain.metadata.support.JobRole.find( - repit.repit_api_server.domain.userdata.persona.support.UserJobMajors.normalize(authUser.user().getMajor())) - .map(repit.repit_api_server.domain.metadata.support.JobRole::requiresJobTitle).orElse(false)) { + if (requiresJobSnapshot(authUser)) { interview = interviewJobSnapshotService.capture(authUser, interviewId); } // 쓸 수 있는 종합 데이터가 없으면(예전 분석만 있거나, 분석 서버가 지금 것을 거부했으면) 종합 데이터부터 요청한다. @@ -257,6 +257,12 @@ public InterviewPrepareResponse retryPreparation(AuthUser authUser, Long intervi if (!authUser.id().equals(interview.getUserId())) { throw BusinessException.forbidden("본인의 면접만 다시 준비할 수 있습니다."); } + // 질문을 다시 만드는 재시도는 처음 시작과 같은 직무 고정을 거친다. 이미 고정됐으면 그대로 둔다. + if (requiresJobSnapshot(authUser)) { + // 콜백을 기다리다 시간만 지난 건이 실패로 닫혀야 질문을 다시 만드는 재시도인지 알 수 있다. + questionTailorService.expireTimedOutPreparation(interviewId); + interview = interviewJobSnapshotService.captureForRetry(authUser, interviewId); + } // 처음 시작할 때 종합 데이터를 요청하지 못했거나, 그 사이 분석 서버가 지금 것을 거부했으면 여기서 요청한다. analysisLaunchService.launchIfMissing(authUser); @@ -264,6 +270,12 @@ public InterviewPrepareResponse retryPreparation(AuthUser authUser, Long intervi return InterviewPrepareResponse.of(tailor, interview); } + /** 직무명을 고정해야 하는 직무(일반 직군)인지. 고정은 면접 전용 면접관 복제로 한다. */ + private static boolean requiresJobSnapshot(AuthUser authUser) { + return JobRole.find(UserJobMajors.normalize(authUser.user().getMajor())) + .map(JobRole::requiresJobTitle).orElse(false); + } + /** * 사용자의 면접 목록. 면접마다 대표 면접관을 붙인다. * diff --git a/src/main/java/repit/repit_api_server/domain/userdata/question/service/QuestionTailorService.java b/src/main/java/repit/repit_api_server/domain/userdata/question/service/QuestionTailorService.java index d3ff043..e5501db 100644 --- a/src/main/java/repit/repit_api_server/domain/userdata/question/service/QuestionTailorService.java +++ b/src/main/java/repit/repit_api_server/domain/userdata/question/service/QuestionTailorService.java @@ -154,6 +154,17 @@ public QuestionTailorEntity retryPreparation(InterviewEntity interview, UserResp return startTailor(interview, user.getMajor(), user.getJobTitle(), null); } + /** + * 준비 재시도 전에 시간이 지난 준비 건을 먼저 닫는다. 재시도가 질문을 다시 만들지는 이 결과로 갈린다. + * 면접 전용 면접관 고정처럼 그 판단에 기대는 일이 재시도보다 먼저 돌 때 쓴다. + */ + public void expireTimedOutPreparation(Long interviewId) { + QuestionTailorEntity latest = latestTailor(interviewId); + if (latest != null) { + expireIfTimedOut(latest); + } + } + private QuestionTailorEntity latestTailor(Long interviewId) { return questionTailorRepository.findTopByInterviewIdOrderByCreatedAtDesc(interviewId).orElse(null); } diff --git a/src/test/java/repit/repit_api_server/domain/userdata/interview/service/InterviewJobSnapshotServiceTest.java b/src/test/java/repit/repit_api_server/domain/userdata/interview/service/InterviewJobSnapshotServiceTest.java index e8d7550..89de6ae 100644 --- a/src/test/java/repit/repit_api_server/domain/userdata/interview/service/InterviewJobSnapshotServiceTest.java +++ b/src/test/java/repit/repit_api_server/domain/userdata/interview/service/InterviewJobSnapshotServiceTest.java @@ -11,6 +11,9 @@ import repit.repit_api_server.domain.userdata.persona.entity.PersonaEntity; import repit.repit_api_server.domain.userdata.persona.entity.enums.*; import repit.repit_api_server.domain.userdata.persona.repository.PersonaRepository; +import repit.repit_api_server.domain.userdata.question.dto.response.TailoredQuestionResponse; +import repit.repit_api_server.domain.userdata.question.entity.QuestionTailorEntity; +import repit.repit_api_server.domain.userdata.question.entity.enums.TailorStatus; import repit.repit_api_server.domain.userdata.question.repository.QuestionTailorRepository; import repit.repit_api_server.global.auth.AuthUser; import repit.repit_api_server.global.response.UserResponse; @@ -106,4 +109,60 @@ InterviewEntity interview(long id) { assertThatThrownBy(() -> service.capture(user("마케터"), 1L)).hasMessageContaining("본인의"); verifyNoInteractions(personas, members); } + + QuestionTailorEntity tailor(TailorStatus status, boolean withQuestions) { + return QuestionTailorEntity.builder().interviewId(1L).userId(7L).status(status).chatDelivered(false) + .questions(withQuestions ? List.of(TailoredQuestionResponse.builder().id(1).question("질문").build()) : List.of()) + .build(); + } + + void givenSharedPersona(InterviewEntity interview) { + when(interviews.findForJobSnapshot(1L)).thenReturn(Optional.of(interview)); + when(personas.findById(5L)).thenReturn(Optional.of(shared())); + lenient().when(personas.save(any())).thenAnswer(inv -> ((PersonaEntity) inv.getArgument(0)).toBuilder().personaId(11L).build()); + } + + @Test void 질문_생성이_실패한_재시도는_스냅샷이_없으면_지금_직무로_면접_전용_면접관을_만든다() { + InterviewEntity interview = interview(1L); + givenSharedPersona(interview); + when(tailors.findTopByInterviewIdOrderByCreatedAtDesc(1L)).thenReturn(Optional.of(tailor(TailorStatus.FAILED, false))); + service.captureForRetry(user("퍼포먼스 마케터"), 1L); + var saved = org.mockito.ArgumentCaptor.forClass(PersonaEntity.class); + verify(personas).save(saved.capture()); + assertThat(saved.getValue().getJobTitle()).isEqualTo("퍼포먼스 마케터"); + assertThat(interview.getPersonaId()).isEqualTo(11L); + } + + @Test void 처음_시작은_실패한_준비가_있으면_스냅샷을_만들지_않는다() { + InterviewEntity interview = interview(1L); + when(interviews.findForJobSnapshot(1L)).thenReturn(Optional.of(interview)); + when(tailors.findTopByInterviewIdOrderByCreatedAtDesc(1L)).thenReturn(Optional.of(tailor(TailorStatus.FAILED, false))); + service.capture(user("퍼포먼스 마케터"), 1L); + verify(personas, never()).save(any()); + assertThat(interview.getPersonaId()).isEqualTo(5L); + } + + @Test void 채팅_서버_전달만_실패한_재시도는_정해진_질문을_건드리지_않는다() { + InterviewEntity interview = interview(1L); + when(interviews.findForJobSnapshot(1L)).thenReturn(Optional.of(interview)); + when(tailors.findTopByInterviewIdOrderByCreatedAtDesc(1L)).thenReturn(Optional.of(tailor(TailorStatus.SUCCEEDED, true))); + service.captureForRetry(user("퍼포먼스 마케터"), 1L); + verify(personas, never()).save(any()); + } + + @Test void 준비_중인_면접의_재시도는_스냅샷을_만들지_않는다() { + InterviewEntity interview = interview(1L); + when(interviews.findForJobSnapshot(1L)).thenReturn(Optional.of(interview)); + when(tailors.findTopByInterviewIdOrderByCreatedAtDesc(1L)).thenReturn(Optional.of(tailor(TailorStatus.PENDING, false))); + service.captureForRetry(user("퍼포먼스 마케터"), 1L); + verify(personas, never()).save(any()); + } + + @Test void 재시도도_직무명이_없으면_먼저_저장하라고_알린다() { + InterviewEntity interview = interview(1L); + givenSharedPersona(interview); + when(tailors.findTopByInterviewIdOrderByCreatedAtDesc(1L)).thenReturn(Optional.of(tailor(TailorStatus.FAILED, false))); + assertThatThrownBy(() -> service.captureForRetry(user(null), 1L)).hasMessageContaining("직무명을 먼저 저장"); + verify(personas, never()).save(any()); + } } diff --git a/src/test/java/repit/repit_api_server/domain/userdata/interview/service/InterviewServicePrepareTest.java b/src/test/java/repit/repit_api_server/domain/userdata/interview/service/InterviewServicePrepareTest.java index d38cb47..b3aa9b6 100644 --- a/src/test/java/repit/repit_api_server/domain/userdata/interview/service/InterviewServicePrepareTest.java +++ b/src/test/java/repit/repit_api_server/domain/userdata/interview/service/InterviewServicePrepareTest.java @@ -125,4 +125,32 @@ private static AuthUser userWithMajor(String major) { .interviewId(INTERVIEW_ID).userId(USER_ID).sessionId("session-1").build())); assertThat(service.prepareInterview(user, INTERVIEW_ID).getCompany()).isNull(); } + + @Test + void 일반_직군의_준비_재시도는_직무_고정을_거친_면접으로_다시_준비한다() { + InterviewEntity snapshotted = InterviewEntity.builder().interviewId(INTERVIEW_ID).userId(USER_ID) + .sessionId("session-1").personaId(11L).build(); + AuthUser user = userWithMajor("MAJOR_GEN_MARKETING"); + when(snapshotService.captureForRetry(user, INTERVIEW_ID)).thenReturn(snapshotted); + when(questionTailorService.retryPreparation(any(), any())).thenReturn(QuestionTailorEntity.builder() + .interviewId(INTERVIEW_ID).userId(USER_ID).status(TailorStatus.PENDING).build()); + + service.retryPreparation(user, INTERVIEW_ID); + + var order = org.mockito.Mockito.inOrder(questionTailorService, snapshotService); + order.verify(questionTailorService).expireTimedOutPreparation(INTERVIEW_ID); + order.verify(snapshotService).captureForRetry(user, INTERVIEW_ID); + order.verify(questionTailorService).retryPreparation(snapshotted, user.user()); + } + + @Test + void 개발과_IT_비개발_직무의_준비_재시도는_직무_고정을_하지_않는다() { + when(questionTailorService.retryPreparation(any(), any())).thenReturn(QuestionTailorEntity.builder() + .interviewId(INTERVIEW_ID).userId(USER_ID).status(TailorStatus.PENDING).build()); + for (String major : new String[]{"MAJOR_BACKEND", "MAJOR_QA_ENGINEER", null}) { + service.retryPreparation(userWithMajor(major), INTERVIEW_ID); + } + verify(snapshotService, never()).captureForRetry(any(), any()); + verify(questionTailorService, never()).expireTimedOutPreparation(any()); + } } diff --git a/src/test/java/repit/repit_api_server/domain/userdata/question/service/QuestionTailorServicePreparationTest.java b/src/test/java/repit/repit_api_server/domain/userdata/question/service/QuestionTailorServicePreparationTest.java index 3beffdd..8e8b21d 100644 --- a/src/test/java/repit/repit_api_server/domain/userdata/question/service/QuestionTailorServicePreparationTest.java +++ b/src/test/java/repit/repit_api_server/domain/userdata/question/service/QuestionTailorServicePreparationTest.java @@ -31,6 +31,7 @@ import repit.repit_api_server.domain.userdata.question.entity.QuestionTailorEntity; import repit.repit_api_server.domain.userdata.question.entity.enums.TailorStatus; import repit.repit_api_server.domain.userdata.question.preparation.FailureStage; +import repit.repit_api_server.domain.userdata.question.preparation.PreparationState; import repit.repit_api_server.domain.userdata.question.repository.QuestionTailorRepository; import repit.repit_api_server.global.client.AiServerClient; import repit.repit_api_server.global.exception.BusinessException; @@ -296,6 +297,30 @@ private void givenLatest(QuestionTailorEntity tailor) { verify(chatInterviewHandoffService).deliver(stale); } + /** 일반 직군 재시도는 면접관 고정 여부를 정하기 전에 시간이 지난 건부터 닫는다. 다시 만들 질문인지가 여기서 갈린다. */ + @Test + void 재시도_전_정리는_시간이_지난_N대1을_질문_생성_실패로_닫는다() { + QuestionTailorEntity stale = tailor(InterviewMode.MULTI, TailorStatus.PENDING); + givenLatest(stale); + + service.expireTimedOutPreparation(3L); + + assertThat(stale.getStatus()).isEqualTo(TailorStatus.FAILED); + assertThat(PreparationState.of(stale).failureStage()).isEqualTo(FailureStage.QUESTION_GENERATION); + } + + @Test + void 재시도_전_정리는_아직_시간이_남은_건을_건드리지_않는다() { + QuestionTailorEntity fresh = tailor(InterviewMode.MULTI, TailorStatus.PENDING); + fresh.setCreatedAt(LocalDateTime.now()); + givenLatest(fresh); + + service.expireTimedOutPreparation(3L); + + assertThat(fresh.getStatus()).isEqualTo(TailorStatus.PENDING); + verify(questionTailorRepository, never()).claimExpiration(anyLong()); + } + @Test void 시작한_적_없는_면접은_재시도할_수_없다() { givenLatest(null);