Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -31,15 +35,30 @@ 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를 다시 읽는다.
entityManager.refresh(interview, jakarta.persistence.LockModeType.PESSIMISTIC_WRITE);
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)))) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Require an existing failed tailor before snapshotting retries

When a general-job user invokes the retry endpoint for an interview that has never been prepared, latest is null, so this condition falls through and creates a persistent persona snapshot before QuestionTailorService.retryPreparation rejects the request as “not started.” A rejected retry can therefore freeze the user's current major/job title; if the profile changes before the actual first preparation, that preparation keeps using the stale snapshot. In retry mode, return without snapshotting unless a non-null latest tailor is specifically in the question-generation failure state.

Useful? React with 👍 / 👎.

return interview;
}
InterviewPersonaEntity link = interview.getMode() == InterviewMode.MULTI
Expand Down Expand Up @@ -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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}
// 쓸 수 있는 종합 데이터가 없으면(예전 분석만 있거나, 분석 서버가 지금 것을 거부했으면) 종합 데이터부터 요청한다.
Expand All @@ -257,13 +257,25 @@ 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);
QuestionTailorEntity tailor = questionTailorService.retryPreparation(interview, authUser.user());
return InterviewPrepareResponse.of(tailor, interview);
}

/** 직무명을 고정해야 하는 직무(일반 직군)인지. 고정은 면접 전용 면접관 복제로 한다. */
private static boolean requiresJobSnapshot(AuthUser authUser) {
return JobRole.find(UserJobMajors.normalize(authUser.user().getMajor()))
.map(JobRole::requiresJobTitle).orElse(false);
}

/**
* 사용자의 면접 목록. 면접마다 대표 면접관을 붙인다.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
Loading