From 12a41b1644e217ec05786b2adc50ffeefc854b58 Mon Sep 17 00:00:00 2001 From: gituser Date: Tue, 20 Oct 2026 20:34:36 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20=EC=9D=BC=EB=B0=98=20=EC=A7=81?= =?UTF-8?q?=EA=B5=B0=20=EC=A4=80=EB=B9=84=20=EC=9E=AC=EC=8B=9C=EB=8F=84?= =?UTF-8?q?=EC=97=90=EC=84=9C=EB=8F=84=20=EB=A9=B4=EC=A0=91=20=EC=A0=84?= =?UTF-8?q?=EC=9A=A9=20=EB=A9=B4=EC=A0=91=EA=B4=80=EC=9D=84=20=EA=B3=A0?= =?UTF-8?q?=EC=A0=95=20(#117)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 질문 생성이 실패해 질문을 다시 만드는 재시도이고 아직 면접 전용 면접관이 없으면, 처음 시작과 같은 직무 스냅샷을 만든다. 채팅 서버 전달만 실패한 재시도와 준비 중인 면접은 건드리지 않는다. 일반 직군 판정은 requiresJobSnapshot으로 모아 시작과 재시도가 함께 쓴다. Co-Authored-By: Claude Opus 5.5 --- .../service/InterviewJobSnapshotService.java | 28 ++++++++- .../interview/service/InterviewService.java | 16 ++++- .../InterviewJobSnapshotServiceTest.java | 59 +++++++++++++++++++ .../service/InterviewServicePrepareTest.java | 25 ++++++++ 4 files changed, 123 insertions(+), 5 deletions(-) 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..853297a 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 @@ -232,9 +232,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 +255,10 @@ public InterviewPrepareResponse retryPreparation(AuthUser authUser, Long intervi if (!authUser.id().equals(interview.getUserId())) { throw BusinessException.forbidden("본인의 면접만 다시 준비할 수 있습니다."); } + // 질문을 다시 만드는 재시도는 처음 시작과 같은 직무 고정을 거친다. 이미 고정됐으면 그대로 둔다. + if (requiresJobSnapshot(authUser)) { + interview = interviewJobSnapshotService.captureForRetry(authUser, interviewId); + } // 처음 시작할 때 종합 데이터를 요청하지 못했거나, 그 사이 분석 서버가 지금 것을 거부했으면 여기서 요청한다. analysisLaunchService.launchIfMissing(authUser); @@ -264,6 +266,14 @@ public InterviewPrepareResponse retryPreparation(AuthUser authUser, Long intervi return InterviewPrepareResponse.of(tailor, interview); } + /** 직무명을 고정해야 하는 직무(일반 직군)인지. 고정은 면접 전용 면접관 복제로 한다. */ + private static boolean requiresJobSnapshot(AuthUser authUser) { + return 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); + } + /** * 사용자의 면접 목록. 면접마다 대표 면접관을 붙인다. * 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..28258c2 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,29 @@ 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); + + verify(snapshotService).captureForRetry(user, INTERVIEW_ID); + 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()); + } } From d38c16be306c7e86a671163bba1b6201c1065bbf Mon Sep 17 00:00:00 2001 From: gituser Date: Tue, 20 Oct 2026 20:36:33 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20=EC=9D=BC=EB=B0=98=20=EC=A7=81?= =?UTF-8?q?=EA=B5=B0=20=EC=9E=AC=EC=8B=9C=EB=8F=84=EB=8A=94=20=EC=8B=9C?= =?UTF-8?q?=EA=B0=84=EC=9D=B4=20=EC=A7=80=EB=82=9C=20=EC=A4=80=EB=B9=84=20?= =?UTF-8?q?=EA=B1=B4=EC=9D=84=20=EB=A8=BC=EC=A0=80=20=EB=8B=AB=EA=B3=A0=20?= =?UTF-8?q?=EB=A9=B4=EC=A0=91=EA=B4=80=20=EA=B3=A0=EC=A0=95=EC=9D=84=20?= =?UTF-8?q?=ED=8C=90=EB=8B=A8=20(#117)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 콜백을 기다리다 시간만 지난 N:1 준비 건은 재시도 안에서 실패로 닫히고 곧바로 질문을 다시 만든다. 면접관 고정 판단이 그보다 먼저 돌면 그 건을 아직 준비 중으로 보고 건너뛴다. 재시도 전에 시간이 지난 건부터 닫아 다시 만드는 재시도인지 확정한 뒤 고정한다. Co-Authored-By: Claude Opus 5.5 --- .../interview/service/InterviewService.java | 10 +++++--- .../service/QuestionTailorService.java | 11 ++++++++ .../service/InterviewServicePrepareTest.java | 7 ++++-- .../QuestionTailorServicePreparationTest.java | 25 +++++++++++++++++++ 4 files changed, 47 insertions(+), 6 deletions(-) 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 853297a..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; @@ -257,6 +259,8 @@ public InterviewPrepareResponse retryPreparation(AuthUser authUser, Long intervi } // 질문을 다시 만드는 재시도는 처음 시작과 같은 직무 고정을 거친다. 이미 고정됐으면 그대로 둔다. if (requiresJobSnapshot(authUser)) { + // 콜백을 기다리다 시간만 지난 건이 실패로 닫혀야 질문을 다시 만드는 재시도인지 알 수 있다. + questionTailorService.expireTimedOutPreparation(interviewId); interview = interviewJobSnapshotService.captureForRetry(authUser, interviewId); } @@ -268,10 +272,8 @@ public InterviewPrepareResponse retryPreparation(AuthUser authUser, Long intervi /** 직무명을 고정해야 하는 직무(일반 직군)인지. 고정은 면접 전용 면접관 복제로 한다. */ private static boolean requiresJobSnapshot(AuthUser authUser) { - return 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); + 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/InterviewServicePrepareTest.java b/src/test/java/repit/repit_api_server/domain/userdata/interview/service/InterviewServicePrepareTest.java index 28258c2..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 @@ -137,8 +137,10 @@ private static AuthUser userWithMajor(String major) { service.retryPreparation(user, INTERVIEW_ID); - verify(snapshotService).captureForRetry(user, INTERVIEW_ID); - verify(questionTailorService).retryPreparation(snapshotted, user.user()); + 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 @@ -149,5 +151,6 @@ private static AuthUser userWithMajor(String major) { 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);