diff --git a/src/main/java/repit/repit_api_server/domain/userdata/question/controller/QuestionTailorController.java b/src/main/java/repit/repit_api_server/domain/userdata/question/controller/QuestionTailorController.java index 3afc01ea..4770475e 100644 --- a/src/main/java/repit/repit_api_server/domain/userdata/question/controller/QuestionTailorController.java +++ b/src/main/java/repit/repit_api_server/domain/userdata/question/controller/QuestionTailorController.java @@ -19,20 +19,23 @@ public class QuestionTailorController { private final QuestionTailorService questionTailorService; // 분석 서버 전용 콜백. 2xx가 늦거나 실패하면 결과가 폐기되므로 저장과 전달만 하고 바로 응답한다. + // tailorId는 우리가 콜백 주소에 실어 보낸 준비 건 id다. 배포 전에 나간 요청의 콜백에는 없다. @PostMapping("/callback") public ResponseEntity callback( + @RequestParam(name = QuestionTailorService.TAILOR_ID_PARAM, required = false) Long tailorId, @RequestBody QuestionTailorCallbackRequest request ) { - questionTailorService.handleCallback(request); + questionTailorService.handleCallback(request, tailorId); return ResponseEntity.ok().build(); } // N:1 전용 콜백. 1:1과 본문 형태가 달라 경로를 나눈다. @PostMapping("/multi/callback") public ResponseEntity multiCallback( + @RequestParam(name = QuestionTailorService.TAILOR_ID_PARAM, required = false) Long tailorId, @RequestBody QuestionTailorMultiCallbackRequest request ) { - questionTailorService.handleMultiCallback(request); + questionTailorService.handleMultiCallback(request, tailorId); return ResponseEntity.ok().build(); } diff --git a/src/main/java/repit/repit_api_server/domain/userdata/question/entity/QuestionTailorEntity.java b/src/main/java/repit/repit_api_server/domain/userdata/question/entity/QuestionTailorEntity.java index d292021b..90e979b2 100644 --- a/src/main/java/repit/repit_api_server/domain/userdata/question/entity/QuestionTailorEntity.java +++ b/src/main/java/repit/repit_api_server/domain/userdata/question/entity/QuestionTailorEntity.java @@ -23,7 +23,7 @@ @Table(name = "question_tailor") @Getter @Setter -@Builder +@Builder(toBuilder = true) @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor public class QuestionTailorEntity { diff --git a/src/main/java/repit/repit_api_server/domain/userdata/question/repository/QuestionTailorRepository.java b/src/main/java/repit/repit_api_server/domain/userdata/question/repository/QuestionTailorRepository.java index 71a72d8f..bd312a8f 100644 --- a/src/main/java/repit/repit_api_server/domain/userdata/question/repository/QuestionTailorRepository.java +++ b/src/main/java/repit/repit_api_server/domain/userdata/question/repository/QuestionTailorRepository.java @@ -89,4 +89,59 @@ and t.status in (repit.repit_api_server.domain.userdata.question.entity.enums.Ta and t.status = repit.repit_api_server.domain.userdata.question.entity.enums.TailorStatus.WAITING """) int claimResume(@Param("tailorId") Long tailorId, @Param("now") LocalDateTime now); + + /** + * 분석 서버가 돌려준 jobId를 남긴다. jobId가 아직 없을 때만 채운다. + * + *

콜백이 접수 응답보다 먼저 올 수 있다. 접수 응답을 받고 엔티티를 통째로 저장하면 이미 들어온 결과를 PENDING으로 + * 되돌린다. 그래서 접수 응답은 이 칸 하나만 조건을 담아 고친다. + */ + @Transactional + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(""" + update QuestionTailorEntity t + set t.jobId = :jobId + where t.tailorId = :tailorId + and t.jobId is null + """) + int recordJobId(@Param("tailorId") Long tailorId, @Param("jobId") String jobId); + + /** + * 분석 서버에 접수하지 못한 새 건을 지운다. 그사이 콜백이 와서 결과가 들어갔거나 jobId를 받았으면 남긴다. + * + *

분석 서버를 부르기 전에 저장해 두므로, 요청이 실패하면 콜백이 올 리 없는 PENDING이 남는다. 그대로 두면 면접을 + * 다시 시작해도 이 건이 최신이라 제한 시간까지 기다려야 한다. + */ + @Transactional + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(""" + delete from QuestionTailorEntity t + where t.tailorId = :tailorId + and t.jobId is null + and t.status = repit.repit_api_server.domain.userdata.question.entity.enums.TailorStatus.PENDING + """) + int discardUnaccepted(@Param("tailorId") Long tailorId); + + /** + * 콜백 결과를 확정한다. 아직 PENDING일 때만 상태와 결과를 한 문장으로 함께 고치고 1을 돌려받는다. + * + *

상태를 읽은 뒤 따로 저장하면, 그사이 실패 처리(시간 초과, 사이클 대기에서 이어가기 실패)가 claimExpiration으로 + * 먼저 확정한 건을 콜백이 덮거나, 반대로 실패 처리가 들고 있던 낡은 엔티티로 콜백 결과를 덮는다. 둘 다 조건부 갱신이라 + * 한쪽만 이긴다. jobId는 비어 있을 때만 채운다 — 접수 응답이 먼저 남긴 값을 지우지 않는다. + */ + @Transactional + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(""" + update QuestionTailorEntity t + set t.status = :#{#tailor.status}, + t.tailored = :#{#tailor.tailored}, + t.questions = :#{#tailor.questions}, + t.errorStatusCode = :#{#tailor.errorStatusCode}, + t.errorMessage = :#{#tailor.errorMessage}, + t.jobId = coalesce(t.jobId, :#{#tailor.jobId}), + t.updatedAt = local datetime + where t.tailorId = :#{#tailor.tailorId} + and t.status = repit.repit_api_server.domain.userdata.question.entity.enums.TailorStatus.PENDING + """) + int settleCallback(@Param("tailor") QuestionTailorEntity tailor); } 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 acae6e2b..f4ecbbb9 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 @@ -6,6 +6,7 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; +import org.springframework.web.util.UriComponentsBuilder; import org.springframework.transaction.annotation.Transactional; import repit.repit_api_server.domain.metadata.dto.response.ProjectSummaryResponse; import repit.repit_api_server.domain.userdata.interview.MultiInterviewPanel; @@ -68,6 +69,8 @@ public class QuestionTailorService { private static final String CALLBACK_PATH = "/api/questions/tailor/callback"; private static final String MULTI_CALLBACK_PATH = "/api/questions/tailor/multi/callback"; private static final String STATUS_SUCCEEDED = "succeeded"; + // 콜백 주소에 싣는 준비 건 id. 컨트롤러가 이 이름으로 읽는다. + public static final String TAILOR_ID_PARAM = "tailorId"; // 기술 외 면접관 한 명이 맡을 문항 수. 분석 서버 기본값과 같다. 면접관이 늘면 이만큼씩 늘어난다. private static final int OTHER_QUESTION_COUNT = 2; @@ -176,21 +179,50 @@ private QuestionTailorEntity startTailor(InterviewEntity interview, String userM return waitForCycle(interview, tailor); } - String major = userMajor != null ? userMajor : drawn.major(); - QuestionTailorAcceptedResponse accepted = interview.getMode() == InterviewMode.MULTI - ? requestMultiTailor(interview, major, drawn) - : requestSoloTailor(interview, major, drawn); - - tailor.setJobId(accepted == null ? null : accepted.getJobId()); + // 콜백이 접수 응답보다 먼저 올 수 있다. 분석 서버를 부르기 전에 이 건을 저장하고, 콜백 주소에 그 id를 실어 + // 보낸다 — 콜백은 접수 응답의 jobId 없이도 이 건을 찾는다. 이 메서드는 트랜잭션 밖에서 불려 저장이 곧바로 + // 커밋되므로, 분석 서버가 부르기 전에 다른 트랜잭션의 콜백 처리에서도 보인다. + tailor.setJobId(null); tailor.setStatus(TailorStatus.PENDING); // N:1이면 기술 면접관에게 넘긴 원질문만 남는다. 나머지 면접관 몫은 아직 존재하지 않는다. // 재작성이 실패해 원질문으로 되돌아가도 문항 수는 세트 그대로다. tailor.setSourceQuestions(drawn.questions()); - return questionTailorRepository.save(tailor); + tailor = questionTailorRepository.save(tailor); + + String major = userMajor != null ? userMajor : drawn.major(); + QuestionTailorAcceptedResponse accepted; + try { + accepted = interview.getMode() == InterviewMode.MULTI + ? requestMultiTailor(interview, major, drawn, tailor.getTailorId()) + : requestSoloTailor(interview, major, drawn, tailor.getTailorId()); + } catch (RuntimeException e) { + // 접수되지 않은 새 건은 남기지 않는다. 남기면 면접을 다시 시작해도 이 건에 막혀 제한 시간까지 기다린다. + // 그사이 콜백이 와서 결과가 들어갔으면 지우지 않는다. 사이클을 기다리던 건은 부르는 쪽이 실패로 닫는다. + if (target == null) { + questionTailorRepository.discardUnaccepted(tailor.getTailorId()); + } + throw e; + } + + // 접수 응답은 jobId만 채운다. 엔티티를 통째로 저장하면 먼저 도착한 콜백의 결과를 PENDING으로 되돌린다. + String jobId = accepted == null ? null : accepted.getJobId(); + if (jobId != null) { + questionTailorRepository.recordJobId(tailor.getTailorId(), jobId); + tailor.setJobId(jobId); + } + return tailor; + } + + /** 콜백이 접수 응답보다 먼저 와도 이 건을 찾도록 id를 주소에 싣는다. 주소에 이미 붙은 쿼리는 그대로 둔다. */ + static String withTailorId(String callbackUrl, Long tailorId) { + return UriComponentsBuilder.fromUriString(callbackUrl) + .queryParam(TAILOR_ID_PARAM, tailorId) + .build() + .toUriString(); } private QuestionTailorAcceptedResponse requestSoloTailor(InterviewEntity interview, String major, - QuestionPoolService.DrawnSet drawn) { + QuestionPoolService.DrawnSet drawn, Long tailorId) { QuestionTailorRequest.Profile profile = resolveProfile(major, interview.getPersonaId()); return aiServerClient.tailorQuestions(QuestionTailorRequest.builder() @@ -198,7 +230,7 @@ private QuestionTailorAcceptedResponse requestSoloTailor(InterviewEntity intervi .userId(String.valueOf(interview.getUserId())) .profile(profile) .questions(drawn.questions().stream().map(this::toRequestQuestion).toList()) - .callbackUrl(callbackBaseUrl + CALLBACK_PATH) + .callbackUrl(withTailorId(callbackBaseUrl + CALLBACK_PATH, tailorId)) .build()); } @@ -233,7 +265,7 @@ private QuestionTailorEntity waitForCycle(InterviewEntity interview, QuestionTai * {@link MultiInterviewPanel}에 있다. */ private QuestionTailorAcceptedResponse requestMultiTailor(InterviewEntity interview, String major, - QuestionPoolService.DrawnSet drawn) { + QuestionPoolService.DrawnSet drawn, Long tailorId) { List members = orderedPersonas(interview.getInterviewId()); PersonaEntity tech = members.getFirst(); List others = members.subList(1, members.size()); @@ -253,7 +285,7 @@ private QuestionTailorAcceptedResponse requestMultiTailor(InterviewEntity interv .toList()) .questions(techQuestions.stream().map(this::toMultiRequestQuestion).toList()) .projectSummary(toRequestProjectSummary(drawn)) - .callbackUrl(callbackBaseUrl + MULTI_CALLBACK_PATH) + .callbackUrl(withTailorId(callbackBaseUrl + MULTI_CALLBACK_PATH, tailorId)) .build()); } @@ -624,10 +656,13 @@ private QuestionTailorEntity resumeWaiting(QuestionTailorEntity tailor) { } catch (RuntimeException e) { log.error("질문 사이클을 받았지만 면접 준비를 이어가지 못했습니다. tailorId={}, interviewId={}", tailor.getTailorId(), tailor.getInterviewId(), e); - failWithoutFallback(tailor, "질문을 준비하지 못했습니다. 잠시 후 다시 시도해주세요."); - QuestionTailorEntity saved = questionTailorRepository.save(tailor); - notifyPreparationFailed(saved, FailureStage.QUESTION_GENERATION); - return saved; + // 접수 응답만 늦게 실패했고 콜백은 먼저 와서 결과가 확정됐을 수 있다. 아직 기다리는 건만 실패로 닫는다 — + // 엔티티를 통째로 실패로 저장하면 이미 채팅 서버로 넘긴 질문까지 지운다. + if (!closeWaiting(tailor, "질문을 준비하지 못했습니다. 잠시 후 다시 시도해주세요.")) { + return questionTailorRepository.findById(tailor.getTailorId()).orElse(tailor); + } + notifyPreparationFailed(tailor, FailureStage.QUESTION_GENERATION); + return tailor; } } @@ -649,13 +684,19 @@ private boolean closeWaiting(QuestionTailorEntity tailor, String errorMessage) { *

저장과 채팅 서버 호출을 한 트랜잭션으로 묶지 않는다. 외부 호출이 늦어져도 재작성 결과는 * 이미 DB에 남아 있어야 조회와 재전달이 가능하다. */ - public void handleCallback(QuestionTailorCallbackRequest request) { - QuestionTailorEntity tailor = findTarget(request); + public void handleCallback(QuestionTailorCallbackRequest request, Long tailorId) { + QuestionTailorEntity tailor = findTarget(tailorId, request.getJobId(), request.getInterviewId(), InterviewMode.SOLO); if (tailor == null) { - log.warn("알 수 없는 질문 재작성 콜백을 받았습니다. jobId={}, interviewId={}", - request.getJobId(), request.getInterviewId()); + log.warn("알 수 없는 질문 재작성 콜백을 받았습니다. tailorId={}, jobId={}, interviewId={}", + tailorId, request.getJobId(), request.getInterviewId()); return; } + if (alreadySettled(tailor)) { + return; + } + // 결과는 복사본에 계산한다. 조회한 엔티티는 open-in-view로 요청 내내 관리 상태라, 그것을 고치면 조건부 확정이 + // 쿼리 전에 변경을 먼저 flush해 PENDING 조건이 어긋나고 확정이 0건으로 끝난다 — 그 flush는 조건 없이 쓰기도 한다. + tailor = tailor.toBuilder().build(); if (tailor.getJobId() == null) { tailor.setJobId(request.getJobId()); @@ -667,10 +708,24 @@ public void handleCallback(QuestionTailorCallbackRequest request) { applyFailure(tailor, request.getError()); } - questionTailorRepository.save(tailor); + if (!settle(tailor)) { + return; + } completePreparation(tailor); } + /** + * 콜백 결과를 확정한다. 아직 PENDING일 때만 상태와 결과를 한 문장으로 고친다 — 차지하지 못했으면 그사이 실패 처리가 + * 먼저 확정한 것이라 아무것도 쓰지 않는다. 채팅 서버 전달은 확정이 커밋된 뒤에 부르는 쪽이 한다. + */ + private boolean settle(QuestionTailorEntity tailor) { + if (questionTailorRepository.settleCallback(tailor) == 1) { + return true; + } + log.info("그사이 다른 처리가 질문 준비를 먼저 확정해 콜백 결과를 반영하지 않습니다. tailorId={}", tailor.getTailorId()); + return false; + } + /** * 재작성이 확정된 면접 데이터를 채팅 서버로 넘긴다. * @@ -824,13 +879,19 @@ private InterviewReadyResponse toReady(QuestionTailorEntity tailor) { * 실패하면 면접에 쓸 질문이 남지 않는다. 그 경우 채팅 서버로 넘기지 않고 실패로 남긴다 — * 반쪽짜리로 넘기면 기술 질문 {@link QuestionPoolService#setSize}개짜리 면접이 N:1인 척 열린다. */ - public void handleMultiCallback(QuestionTailorMultiCallbackRequest request) { - QuestionTailorEntity tailor = findMultiTarget(request); + public void handleMultiCallback(QuestionTailorMultiCallbackRequest request, Long tailorId) { + QuestionTailorEntity tailor = findTarget(tailorId, request.getJobId(), request.getInterviewId(), InterviewMode.MULTI); if (tailor == null) { - log.warn("알 수 없는 N:1 질문 구성 콜백을 받았습니다. jobId={}, interviewId={}", - request.getJobId(), request.getInterviewId()); + log.warn("알 수 없는 N:1 질문 구성 콜백을 받았습니다. tailorId={}, jobId={}, interviewId={}", + tailorId, request.getJobId(), request.getInterviewId()); return; } + if (alreadySettled(tailor)) { + return; + } + // 결과는 복사본에 계산한다. 조회한 엔티티는 open-in-view로 요청 내내 관리 상태라, 그것을 고치면 조건부 확정이 + // 쿼리 전에 변경을 먼저 flush해 PENDING 조건이 어긋나고 확정이 0건으로 끝난다 — 그 flush는 조건 없이 쓰기도 한다. + tailor = tailor.toBuilder().build(); if (tailor.getJobId() == null) { tailor.setJobId(request.getJobId()); @@ -846,7 +907,9 @@ public void handleMultiCallback(QuestionTailorMultiCallbackRequest request) { : error.getMessage()); } - questionTailorRepository.save(tailor); + if (!settle(tailor)) { + return; + } completePreparation(tailor); } @@ -893,33 +956,52 @@ private void applyMultiSuccess(QuestionTailorEntity tailor, QuestionTailorMultiC tailor.setQuestions(prepared); } - private QuestionTailorEntity findMultiTarget(QuestionTailorMultiCallbackRequest request) { - if (request.getJobId() != null) { - QuestionTailorEntity byJobId = questionTailorRepository.findByJobId(request.getJobId()).orElse(null); - if (byJobId != null) { - return byJobId; - } + /** + * 콜백이 가리키는 준비 건. 콜백 주소에 실어 보낸 tailorId로 찾는다 — 접수 응답이 늦어 jobId가 아직 없어도 찾는다. + * + *

tailorId가 없으면 콜백 주소에 그 값이 없던 시절(배포 전)에 나간 요청이다. 그 건을 확실히 짚는 것은 jobId뿐이라 + * jobId로만 찾는다. 면접으로 찾으면 같은 면접의 새 시도(아직 jobId가 없는 건)에 이전 요청의 결과가 들어간다. + * + *

어느 쪽으로 찾았든 함께 온 면접·jobId·방식이 그 건과 어긋나면 다른 요청의 결과다. 잇지 않는다. tailorId가 + * 있는데 맞지 않으면 다른 건으로 우회하지도 않는다 — 우회하면 다른 시도의 결과가 이 면접에 들어간다. + */ + private QuestionTailorEntity findTarget(Long tailorId, String jobId, String interviewIdValue, InterviewMode mode) { + QuestionTailorEntity tailor; + if (tailorId != null) { + tailor = questionTailorRepository.findById(tailorId).orElse(null); + } else { + tailor = jobId == null ? null : questionTailorRepository.findByJobId(jobId).orElse(null); } - Long interviewId = parseInterviewId(request.getInterviewId()); - if (interviewId == null) { + if (tailor == null) { return null; } - return questionTailorRepository.findTopByInterviewIdOrderByCreatedAtDesc(interviewId).orElse(null); + // 세션이 아직 없는 시점이라 면접은 interviewId로 가리킨다. + Long interviewId = parseInterviewId(interviewIdValue); + boolean otherInterview = interviewId != null && !interviewId.equals(tailor.getInterviewId()); + boolean otherJob = jobId != null && tailor.getJobId() != null && !jobId.equals(tailor.getJobId()); + if (otherInterview || otherJob || tailor.getMode() != mode) { + log.warn("콜백의 식별자가 준비 건과 맞지 않아 반영하지 않습니다. tailorId={}, jobId={}(저장 {}), " + + "interviewId={}(저장 {}), 방식={}(저장 {})", + tailorId, jobId, tailor.getJobId(), interviewIdValue, tailor.getInterviewId(), mode, tailor.getMode()); + return null; + } + return tailor; } - private QuestionTailorEntity findTarget(QuestionTailorCallbackRequest request) { - if (request.getJobId() != null) { - QuestionTailorEntity byJobId = questionTailorRepository.findByJobId(request.getJobId()).orElse(null); - if (byJobId != null) { - return byJobId; - } - } - // 세션이 아직 없는 시점이라 매칭 키는 interviewId다. - Long interviewId = parseInterviewId(request.getInterviewId()); - if (interviewId == null) { - return null; + /** + * 이미 확정된 건이면 콜백 결과를 반영하지 않는다. 재전송이거나, 시간 초과로 원질문 폴백·실패가 먼저 확정된 경우다. + * + *

확정된 질문은 채팅 서버가 이미 그것으로 면접을 열었을 수 있어 바꾸지 않는다. 넘기지 못한 채 남은 건이면 + * 전달만 다시 해본다. + */ + private boolean alreadySettled(QuestionTailorEntity tailor) { + if (tailor.getStatus() == TailorStatus.PENDING) { + return false; } - return questionTailorRepository.findTopByInterviewIdOrderByCreatedAtDesc(interviewId).orElse(null); + log.info("이미 확정된 질문 준비라 콜백 결과를 반영하지 않습니다. tailorId={}, status={}", + tailor.getTailorId(), tailor.getStatus()); + completePreparation(tailor); + return true; } private Long parseInterviewId(String interviewId) { diff --git a/src/test/java/repit/repit_api_server/domain/userdata/question/service/QuestionTailorEarlyCallbackTest.java b/src/test/java/repit/repit_api_server/domain/userdata/question/service/QuestionTailorEarlyCallbackTest.java new file mode 100644 index 00000000..838b9a05 --- /dev/null +++ b/src/test/java/repit/repit_api_server/domain/userdata/question/service/QuestionTailorEarlyCallbackTest.java @@ -0,0 +1,366 @@ +package repit.repit_api_server.domain.userdata.question.service; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.web.util.UriComponentsBuilder; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.WebApplicationContext; +import tools.jackson.databind.ObjectMapper; +import repit.repit_api_server.domain.userdata.interview.entity.InterviewEntity; +import repit.repit_api_server.domain.userdata.interview.entity.InterviewPersonaEntity; +import repit.repit_api_server.domain.userdata.interview.entity.enums.InterviewMode; +import repit.repit_api_server.domain.userdata.interview.entity.enums.Status; +import repit.repit_api_server.domain.userdata.interview.repository.InterviewPersonaRepository; +import repit.repit_api_server.domain.userdata.interview.repository.InterviewRepository; +import repit.repit_api_server.domain.userdata.interview.service.ChatInterviewHandoffService; +import repit.repit_api_server.domain.userdata.persona.entity.PersonaEntity; +import repit.repit_api_server.domain.userdata.persona.entity.enums.InterviewTone; +import repit.repit_api_server.domain.userdata.persona.entity.enums.Role; +import repit.repit_api_server.domain.userdata.persona.entity.enums.Type; +import repit.repit_api_server.domain.userdata.persona.repository.PersonaRepository; +import repit.repit_api_server.domain.userdata.question.dto.request.QuestionCycleCallbackRequest; +import repit.repit_api_server.domain.userdata.question.dto.request.QuestionTailorCallbackRequest; +import repit.repit_api_server.domain.userdata.question.dto.request.QuestionTailorMultiCallbackRequest; +import repit.repit_api_server.domain.userdata.question.dto.request.QuestionTailorMultiRequest; +import repit.repit_api_server.domain.userdata.question.dto.request.QuestionTailorRequest; +import repit.repit_api_server.domain.userdata.question.dto.response.QuestionTailorAcceptedResponse; +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.client.AiServerClient; +import repit.repit_api_server.global.response.UserResponse; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ThreadLocalRandom; +import java.util.stream.IntStream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * 분석 서버가 접수 응답보다 결과 콜백을 먼저 보내는 경우(P-2). 실제 DB에 대고 본다. + * + *

콜백은 다른 스레드에서 처리한다. 다른 커넥션·트랜잭션이라, 분석 서버를 부르기 전에 준비 건이 커밋돼 있어야만 + * 그 건을 찾는다. 테스트 트랜잭션으로 감싸지 않고, 끝나면 이 면접의 준비 건을 지운다. + */ +@SpringBootTest +class QuestionTailorEarlyCallbackTest { + + @Autowired + private QuestionTailorService service; + @Autowired + private QuestionTailorRepository questionTailorRepository; + + @MockitoBean + private AiServerClient aiServerClient; + @MockitoBean + private QuestionPoolService questionPoolService; + @MockitoBean + private ChatInterviewHandoffService chatInterviewHandoffService; + @MockitoBean + private PersonaRepository personaRepository; + @MockitoBean + private InterviewPersonaRepository interviewPersonaRepository; + @MockitoBean + private InterviewRepository interviewRepository; + + // 다른 테스트나 로컬 데이터와 겹치지 않는 면접·사용자 id. question_tailor는 둘 다 외래 키로 묶지 않는다. + // 사용자도 따로 둔다 — 사이클 콜백은 사용자의 기다리는 준비를 모두 이어간다. + private final long interviewId = 900_000_000L + ThreadLocalRandom.current().nextInt(1_000_000); + private final long userId = 900_000_000L + ThreadLocalRandom.current().nextInt(1_000_000); + + @AfterEach + void cleanUp() { + questionTailorRepository.deleteAll(questionTailorRepository.findAll().stream() + .filter(tailor -> tailor.getInterviewId().equals(interviewId)) + .toList()); + } + + private InterviewEntity interview(InterviewMode mode) { + return InterviewEntity.builder() + .interviewId(interviewId) + .userId(userId) + .mode(mode) + .sessionId(UUID.randomUUID().toString()) + .status(Status.IN_PROGRESS) + .build(); + } + + private UserResponse user() { + UserResponse user = mock(UserResponse.class); + when(user.getId()).thenReturn(userId); + when(user.getMajor()).thenReturn("BACKEND"); + return user; + } + + private void givenSet(InterviewMode mode) { + List questions = IntStream.rangeClosed(1, QuestionPoolService.setSize(mode)) + .mapToObj(id -> TailoredQuestionResponse.builder() + .id(id) + .category("tech_choice") + .question("원질문 " + id) + .intention("확인할 것 " + id) + .expectedAnswer("기대 답변 " + id) + .basedOn(List.of()) + .build()) + .toList(); + Map projectSummary = Map.of("overview", "주문 처리를 맡는 백엔드", + "repositories", List.of(), "coreFeatures", List.of(), "techStack", List.of("Spring")); + when(questionPoolService.takeSet(eq(userId), eq(mode), eq(interviewId))) + // 이어가기 경로에는 사용자 정보가 없어 종합 데이터에 실었던 전공을 쓴다. + .thenReturn(new QuestionPoolService.DrawnSet("analysis-1", questions, projectSummary, "BACKEND")); + } + + private static Long tailorIdOf(String callbackUrl) { + return Long.valueOf(UriComponentsBuilder.fromUriString(callbackUrl).build() + .getQueryParams().getFirst(QuestionTailorService.TAILOR_ID_PARAM)); + } + + private QuestionTailorAcceptedResponse accepted(String jobId) { + return new QuestionTailorAcceptedResponse(jobId, String.valueOf(interviewId), "accepted", null); + } + + @Test + void 일대일_콜백이_접수_응답보다_먼저_와도_결과가_남는다() { + givenSet(InterviewMode.SOLO); + when(aiServerClient.tailorQuestions(any())).thenAnswer(call -> { + QuestionTailorRequest request = call.getArgument(0); + QuestionTailorCallbackRequest callback = new QuestionTailorCallbackRequest("job-early", + String.valueOf(interviewId), "succeeded", + new QuestionTailorCallbackRequest.Result(true, IntStream.rangeClosed(1, 5) + .mapToObj(id -> new QuestionTailorCallbackRequest.Question(id, "다시 쓴 질문 " + id, null)) + .toList()), + null); + CompletableFuture.runAsync(() -> service.handleCallback(callback, tailorIdOf(request.getCallbackUrl()))) + .join(); + return accepted("job-early"); + }); + + QuestionTailorEntity returned = service.requestTailor(interview(InterviewMode.SOLO), user()); + + // 늦게 온 접수 응답이 결과를 PENDING으로 되돌리지 않는다. + QuestionTailorEntity saved = questionTailorRepository.findById(returned.getTailorId()).orElseThrow(); + assertThat(saved.getStatus()).isEqualTo(TailorStatus.SUCCEEDED); + assertThat(saved.getTailored()).isTrue(); + assertThat(saved.getJobId()).isEqualTo("job-early"); + assertThat(saved.getQuestions()).extracting(TailoredQuestionResponse::getQuestion) + .containsExactly("다시 쓴 질문 1", "다시 쓴 질문 2", "다시 쓴 질문 3", "다시 쓴 질문 4", "다시 쓴 질문 5"); + assertThat(saved.getChatDelivered()).isTrue(); + verify(chatInterviewHandoffService).deliver(any()); + } + + @Test + void N대1_콜백이_접수_응답보다_먼저_와도_결과가_남는다() { + givenSet(InterviewMode.MULTI); + when(interviewPersonaRepository.findAllByInterviewIdOrderByPersonaOrderAsc(interviewId)).thenReturn(List.of( + InterviewPersonaEntity.builder().interviewId(interviewId).personaId(11L).personaOrder(0).build(), + InterviewPersonaEntity.builder().interviewId(interviewId).personaId(12L).personaOrder(1).build())); + when(personaRepository.findAllById(List.of(11L, 12L))).thenReturn(List.of( + PersonaEntity.builder().personaId(11L).role(Role.TECH).type(Type.REALISTIC).tone(InterviewTone.DIRECT).build(), + PersonaEntity.builder().personaId(12L).role(Role.HR).type(Type.FRIENDLY).tone(InterviewTone.GENTLE).build())); + when(aiServerClient.tailorQuestionsMulti(any())).thenAnswer(call -> { + QuestionTailorMultiRequest request = call.getArgument(0); + QuestionTailorMultiCallbackRequest callback = new QuestionTailorMultiCallbackRequest("job-early-multi", + String.valueOf(interviewId), "succeeded", + new QuestionTailorMultiCallbackRequest.Result(List.of( + new QuestionTailorMultiCallbackRequest.Question(1, 11L, "tech_choice", "다시 쓴 기술 질문", + "확인할 것 1", null, "기대 답변 1", List.of()), + new QuestionTailorMultiCallbackRequest.Question(3, 12L, "motivation", "새 인사 질문", + "지원 동기", null, "동기와 근거", List.of()))), + null); + CompletableFuture.runAsync(() -> service.handleMultiCallback(callback, tailorIdOf(request.getCallbackUrl()))) + .join(); + return accepted("job-early-multi"); + }); + + QuestionTailorEntity returned = service.requestTailor(interview(InterviewMode.MULTI), user()); + + QuestionTailorEntity saved = questionTailorRepository.findById(returned.getTailorId()).orElseThrow(); + assertThat(saved.getStatus()).isEqualTo(TailorStatus.SUCCEEDED); + assertThat(saved.getJobId()).isEqualTo("job-early-multi"); + assertThat(saved.getQuestions()).extracting(TailoredQuestionResponse::getQuestion) + .containsExactly("다시 쓴 기술 질문", "새 인사 질문"); + verify(chatInterviewHandoffService).deliver(any()); + } + + /** 접수 응답이 먼저 오는 평소 흐름. 접수 응답은 jobId만 채우고 콜백을 기다린다. */ + @Test + void 접수_응답이_먼저_오면_jobId를_남기고_콜백을_기다린다() { + givenSet(InterviewMode.SOLO); + when(aiServerClient.tailorQuestions(any())).thenReturn(accepted("job-normal")); + + QuestionTailorEntity returned = service.requestTailor(interview(InterviewMode.SOLO), user()); + + QuestionTailorEntity saved = questionTailorRepository.findById(returned.getTailorId()).orElseThrow(); + assertThat(saved.getStatus()).isEqualTo(TailorStatus.PENDING); + assertThat(saved.getJobId()).isEqualTo("job-normal"); + assertThat(saved.getSourceQuestions()).hasSize(5); + } + + /** 접수되지 않은 건을 남기면 면접을 다시 시작해도 그 건에 막혀 제한 시간까지 기다린다. */ + @Test + void 분석_서버_요청이_실패하면_준비_건을_남기지_않는다() { + givenSet(InterviewMode.SOLO); + when(aiServerClient.tailorQuestions(any())).thenThrow(new IllegalStateException("분석 서버 연결 실패")); + + assertThatThrownBy(() -> service.requestTailor(interview(InterviewMode.SOLO), user())) + .isInstanceOf(IllegalStateException.class); + + assertThat(questionTailorRepository.findTopByInterviewIdOrderByCreatedAtDesc(interviewId)).isEmpty(); + } + + // ---- 콜백과 실패 처리가 같은 건을 두고 겹칠 때 ---- + + private QuestionTailorEntity pendingRow() { + return questionTailorRepository.save(QuestionTailorEntity.builder() + .interviewId(interviewId) + .userId(userId) + .mode(InterviewMode.SOLO) + .status(TailorStatus.PENDING) + .chatDelivered(false) + .sourceQuestions(List.of(TailoredQuestionResponse.builder().id(1).question("원질문 1").build())) + .build()); + } + + /** 콜백이 읽어 둔 결과. 이 건이 PENDING일 때 계산했다. */ + private static QuestionTailorEntity succeeded(QuestionTailorEntity read) { + read.setStatus(TailorStatus.SUCCEEDED); + read.setTailored(true); + read.setJobId("job-cross"); + read.setQuestions(List.of(TailoredQuestionResponse.builder().id(1).question("다시 쓴 질문 1").build())); + return read; + } + + /** 콜백이 PENDING을 읽은 뒤 실패 처리가 먼저 차지했다. 콜백은 아무것도 쓰지 못하고 실패 처리가 확정한다. */ + @Test + void 실패_처리가_먼저_차지하면_콜백은_결과를_쓰지_못한다() { + QuestionTailorEntity read = pendingRow(); + + assertThat(questionTailorRepository.claimExpiration(read.getTailorId())).isEqualTo(1); + assertThat(questionTailorRepository.settleCallback(succeeded(read))).isZero(); + + QuestionTailorEntity saved = questionTailorRepository.findById(read.getTailorId()).orElseThrow(); + assertThat(saved.getStatus()).isEqualTo(TailorStatus.FAILED); + assertThat(saved.getQuestions()).isNull(); + } + + /** 콜백이 먼저 확정했다. 실패 처리는 차지하지 못해 낡은 엔티티로 결과를 덮지 않는다. */ + @Test + void 콜백이_먼저_확정하면_실패_처리는_차지하지_못한다() { + QuestionTailorEntity read = pendingRow(); + + assertThat(questionTailorRepository.settleCallback(succeeded(read))).isEqualTo(1); + assertThat(questionTailorRepository.claimExpiration(read.getTailorId())).isZero(); + + QuestionTailorEntity saved = questionTailorRepository.findById(read.getTailorId()).orElseThrow(); + assertThat(saved.getStatus()).isEqualTo(TailorStatus.SUCCEEDED); + assertThat(saved.getJobId()).isEqualTo("job-cross"); + assertThat(saved.getQuestions()).extracting(TailoredQuestionResponse::getQuestion).containsExactly("다시 쓴 질문 1"); + } + + /** + * 사이클 대기에서 이어가다 콜백이 먼저 와서 결과를 확정·전달하고, 접수 요청은 뒤늦게 실패한 경우. + * 실패 처리가 결과를 FAILED·질문 없음으로 덮으면 이미 채팅 서버로 넘긴 질문이 DB에서 사라진다. + */ + @Test + void 이어가다_콜백이_먼저_확정한_뒤_요청이_실패해도_결과가_남는다() { + QuestionTailorEntity waiting = questionTailorRepository.save(QuestionTailorEntity.builder() + .interviewId(interviewId) + .userId(userId) + .mode(InterviewMode.SOLO) + .analysisJobId("analysis-1") + .status(TailorStatus.WAITING) + .chatDelivered(false) + .build()); + + givenSet(InterviewMode.SOLO); + when(questionPoolService.applyCycleResult(any(), eq(5L), eq(1))) + .thenReturn(new QuestionPoolService.CycleOutcome(5L, userId, InterviewMode.SOLO, true, false)); + when(interviewRepository.findById(interviewId)).thenReturn(Optional.of(interview(InterviewMode.SOLO))); + when(aiServerClient.tailorQuestions(any())).thenAnswer(call -> { + QuestionTailorRequest request = call.getArgument(0); + QuestionTailorCallbackRequest callback = new QuestionTailorCallbackRequest("job-resume", + String.valueOf(interviewId), "succeeded", + new QuestionTailorCallbackRequest.Result(true, IntStream.rangeClosed(1, 5) + .mapToObj(id -> new QuestionTailorCallbackRequest.Question(id, "다시 쓴 질문 " + id, null)) + .toList()), + null); + CompletableFuture.runAsync(() -> service.handleCallback(callback, tailorIdOf(request.getCallbackUrl()))) + .join(); + throw new IllegalStateException("접수 응답 읽기 시간 초과"); + }); + + service.handleCycleCallback( + QuestionCycleCallbackRequest.builder().jobId("cycle-job").status("succeeded").build(), 5L, 1); + + QuestionTailorEntity saved = questionTailorRepository.findById(waiting.getTailorId()).orElseThrow(); + assertThat(saved.getStatus()).isEqualTo(TailorStatus.SUCCEEDED); + assertThat(saved.getQuestions()).hasSize(5); + assertThat(saved.getChatDelivered()).isTrue(); + verify(chatInterviewHandoffService).deliver(any()); + } + + // ---- HTTP 콜백(open-in-view) ---- + + @Autowired + private WebApplicationContext webApplicationContext; + + /** + * 실제 콜백은 HTTP로 온다. open-in-view가 켜져 있어 요청 동안 조회한 준비 건이 관리 상태로 남는다. 그 엔티티를 고친 뒤 + * 조건부 확정을 부르면 쿼리 전에 변경이 먼저 flush돼, 확정이 0건으로 끝나고 채팅 서버 전달이 빠진다. + */ + private void postCallback(String query, String jobId) throws Exception { + QuestionTailorCallbackRequest callback = new QuestionTailorCallbackRequest(jobId, String.valueOf(interviewId), + "succeeded", new QuestionTailorCallbackRequest.Result(true, + List.of(new QuestionTailorCallbackRequest.Question(1, "다시 쓴 질문 1", null))), null); + MockMvcBuilders.webAppContextSetup(webApplicationContext).build() + .perform(post("/api/questions/tailor/callback" + query) + .contentType(MediaType.APPLICATION_JSON) + .content(new ObjectMapper().writeValueAsString(callback))) + .andExpect(status().isOk()); + } + + @Test + void HTTP_콜백이_tailorId로_결과를_확정하고_넘긴다() throws Exception { + QuestionTailorEntity row = pendingRow(); + + postCallback("?tailorId=" + row.getTailorId(), "job-http"); + + QuestionTailorEntity saved = questionTailorRepository.findById(row.getTailorId()).orElseThrow(); + assertThat(saved.getStatus()).isEqualTo(TailorStatus.SUCCEEDED); + assertThat(saved.getQuestions()).extracting(TailoredQuestionResponse::getQuestion).containsExactly("다시 쓴 질문 1"); + assertThat(saved.getChatDelivered()).isTrue(); + verify(chatInterviewHandoffService).deliver(any()); + } + + /** 배포 전에 나간 요청의 콜백. jobId로 찾은 엔티티도 요청 동안 관리 상태로 남는다. */ + @Test + void 배포_전_HTTP_콜백도_jobId로_결과를_확정하고_넘긴다() throws Exception { + QuestionTailorEntity row = pendingRow(); + row.setJobId("job-http-legacy-" + interviewId); + questionTailorRepository.save(row); + + postCallback("", "job-http-legacy-" + interviewId); + + QuestionTailorEntity saved = questionTailorRepository.findById(row.getTailorId()).orElseThrow(); + assertThat(saved.getStatus()).isEqualTo(TailorStatus.SUCCEEDED); + assertThat(saved.getQuestions()).extracting(TailoredQuestionResponse::getQuestion).containsExactly("다시 쓴 질문 1"); + verify(chatInterviewHandoffService).deliver(any()); + } +} diff --git a/src/test/java/repit/repit_api_server/domain/userdata/question/service/QuestionTailorServiceCallbackTest.java b/src/test/java/repit/repit_api_server/domain/userdata/question/service/QuestionTailorServiceCallbackTest.java index 5a9b2201..84b06a12 100644 --- a/src/test/java/repit/repit_api_server/domain/userdata/question/service/QuestionTailorServiceCallbackTest.java +++ b/src/test/java/repit/repit_api_server/domain/userdata/question/service/QuestionTailorServiceCallbackTest.java @@ -10,6 +10,7 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoSettings; import org.mockito.quality.Strictness; +import repit.repit_api_server.domain.userdata.interview.entity.enums.InterviewMode; import repit.repit_api_server.domain.userdata.interview.repository.InterviewPersonaRepository; import repit.repit_api_server.domain.userdata.interview.repository.InterviewRepository; import repit.repit_api_server.domain.userdata.interview.service.ChatInterviewHandoffService; @@ -73,6 +74,8 @@ void setUp() { // 넘길 권리를 차지한 상태를 기본으로 둔다. 차지하지 못하는 경우는 따로 검증한다. when(questionTailorRepository.claimChatDelivery(anyLong())).thenReturn(1); + // 콜백 결과는 아직 PENDING일 때만 확정된다. 다른 처리가 먼저 확정한 경우는 따로 검증한다. + when(questionTailorRepository.settleCallback(any())).thenReturn(1); } private QuestionTailorEntity pendingTailor() { @@ -101,8 +104,9 @@ private TailoredQuestionResponse question(int id, String content) { } /** 콜백 처리는 결과 저장과 채팅 서버 전달 기록으로 두 번 저장한다. 마지막 상태가 최종본이다. */ + /** 콜백 결과는 settleCallback으로 확정한다. 넘긴 엔티티가 최종본이고, 채팅 서버 전달 기록도 같은 객체에 남는다. */ private QuestionTailorEntity lastSaved() { - verify(questionTailorRepository, atLeastOnce()).save(savedTailor.capture()); + verify(questionTailorRepository, atLeastOnce()).settleCallback(savedTailor.capture()); return savedTailor.getValue(); } @@ -115,7 +119,7 @@ private QuestionTailorEntity lastSaved() { new QuestionTailorCallbackRequest.Result(true, List.of( new QuestionTailorCallbackRequest.Question(1, "다시 쓴 Redis 질문", null), new QuestionTailorCallbackRequest.Question(2, "다시 쓴 WebFlux 질문", null))), - null)); + null), null); QuestionTailorEntity saved = lastSaved(); assertThat(saved.getStatus()).isEqualTo(TailorStatus.SUCCEEDED); @@ -144,10 +148,11 @@ private QuestionTailorEntity lastSaved() { new QuestionTailorCallbackRequest.Result(true, List.of( new QuestionTailorCallbackRequest.Question(1, "다시 쓴 Redis 질문", null), new QuestionTailorCallbackRequest.Question(2, "다시 쓴 WebFlux 질문", null))), - null)); + null), null); - verify(chatInterviewHandoffService).deliver(tailor); - assertThat(lastSaved().getChatDelivered()).isTrue(); + QuestionTailorEntity settled = lastSaved(); + verify(chatInterviewHandoffService).deliver(settled); + assertThat(settled.getChatDelivered()).isTrue(); } @Test @@ -161,7 +166,7 @@ private QuestionTailorEntity lastSaved() { new QuestionTailorCallbackRequest.Result(true, List.of( new QuestionTailorCallbackRequest.Question(1, "다시 쓴 Redis 질문", null), new QuestionTailorCallbackRequest.Question(2, "다시 쓴 WebFlux 질문", null))), - null)); + null), null); QuestionTailorEntity saved = lastSaved(); assertThat(saved.getStatus()).isEqualTo(TailorStatus.SUCCEEDED); @@ -180,7 +185,7 @@ private QuestionTailorEntity lastSaved() { new QuestionTailorCallbackRequest.Result(true, List.of( new QuestionTailorCallbackRequest.Question(1, "다시 쓴 Redis 질문", null), new QuestionTailorCallbackRequest.Question(2, "다시 쓴 WebFlux 질문", null))), - null)); + null), null); verify(chatInterviewHandoffService, never()).deliver(any()); } @@ -193,7 +198,7 @@ private QuestionTailorEntity lastSaved() { service.handleCallback(new QuestionTailorCallbackRequest("job-1", "3", "succeeded", new QuestionTailorCallbackRequest.Result(true, List.of( new QuestionTailorCallbackRequest.Question(1, "다시 쓴 Redis 질문", "다시 쓴 레디스 질문"))), - null)); + null), null); QuestionTailorEntity saved = lastSaved(); assertThat(saved.getStatus()).isEqualTo(TailorStatus.SUCCEEDED); @@ -215,7 +220,7 @@ private QuestionTailorEntity lastSaved() { new QuestionTailorCallbackRequest.Result(true, List.of( new QuestionTailorCallbackRequest.Question(1, "다시 쓴 Redis 질문", "다시 쓴 레디스 질문"), new QuestionTailorCallbackRequest.Question(2, "다시 쓴 WebFlux 질문", " "))), - null)); + null), null); QuestionTailorEntity saved = lastSaved(); assertThat(saved.getTailored()).isTrue(); @@ -235,7 +240,7 @@ private QuestionTailorEntity lastSaved() { new QuestionTailorCallbackRequest.Result(false, List.of( new QuestionTailorCallbackRequest.Question(1, "왜 Redis 를 썼나요?", "왜 레디스를 썼나요?"), new QuestionTailorCallbackRequest.Question(2, "왜 WebFlux 를 썼나요?", "왜 웹플럭스를 썼나요?"))), - null)); + null), null); QuestionTailorEntity saved = lastSaved(); assertThat(saved.getTailored()).isFalse(); @@ -252,7 +257,7 @@ private QuestionTailorEntity lastSaved() { new QuestionTailorCallbackRequest.Result(false, List.of( new QuestionTailorCallbackRequest.Question(1, "왜 Redis 를 썼나요?", null), new QuestionTailorCallbackRequest.Question(2, "왜 WebFlux 를 썼나요?", null))), - null)); + null), null); QuestionTailorEntity saved = lastSaved(); assertThat(saved.getStatus()).isEqualTo(TailorStatus.SUCCEEDED); @@ -266,7 +271,7 @@ private QuestionTailorEntity lastSaved() { when(questionTailorRepository.findByJobId("job-1")).thenReturn(Optional.of(tailor)); service.handleCallback(new QuestionTailorCallbackRequest("job-1", "3", "failed", null, - new QuestionTailorCallbackRequest.Error(422, "사전 정보가 없습니다."))); + new QuestionTailorCallbackRequest.Error(422, "사전 정보가 없습니다.")), null); QuestionTailorEntity saved = lastSaved(); assertThat(saved.getStatus()).isEqualTo(TailorStatus.FAILED); @@ -274,19 +279,33 @@ private QuestionTailorEntity lastSaved() { assertThat(saved.getErrorStatusCode()).isEqualTo(422); assertThat(saved.getQuestions()).hasSize(2); // 재작성이 실패해도 원질문으로 면접은 열려야 한다. - verify(chatInterviewHandoffService).deliver(tailor); + verify(chatInterviewHandoffService).deliver(saved); } + /** + * 배포 전에 나간 요청의 콜백(tailorId 없음)은 jobId로만 잇는다. 면접으로 찾으면 같은 면접의 새 시도 — 아직 접수 응답을 + * 받지 못해 jobId가 빈 건 — 에 이전 요청의 결과가 들어간다. + */ @Test - void jobId로_못_찾으면_interviewId로_매칭한다() { - QuestionTailorEntity tailor = pendingTailor(); - when(questionTailorRepository.findByJobId("job-1")).thenReturn(Optional.empty()); - when(questionTailorRepository.findTopByInterviewIdOrderByCreatedAtDesc(3L)).thenReturn(Optional.of(tailor)); + void jobId로_못_찾으면_면접으로_잇지_않는다() { + QuestionTailorEntity newAttempt = pendingTailor(); + newAttempt.setJobId(null); + when(questionTailorRepository.findByJobId("job-old")).thenReturn(Optional.empty()); + when(questionTailorRepository.findTopByInterviewIdOrderByCreatedAtDesc(3L)).thenReturn(Optional.of(newAttempt)); - service.handleCallback(new QuestionTailorCallbackRequest("job-1", "3", "succeeded", - new QuestionTailorCallbackRequest.Result(false, List.of()), null)); + service.handleCallback(rewritten("job-old", "3"), null); + + verify(questionTailorRepository, never()).settleCallback(any()); + verify(questionTailorRepository, never()).findTopByInterviewIdOrderByCreatedAtDesc(any()); + } + + @Test + void 배포_전_콜백도_jobId로는_잇는다() { + when(questionTailorRepository.findByJobId("job-1")).thenReturn(Optional.of(pendingTailor())); - verify(questionTailorRepository, atLeastOnce()).save(any(QuestionTailorEntity.class)); + service.handleCallback(rewritten("job-1", "3"), null); + + assertThat(lastSaved().getStatus()).isEqualTo(TailorStatus.SUCCEEDED); } @Test @@ -295,9 +314,9 @@ private QuestionTailorEntity lastSaved() { when(questionTailorRepository.findTopByInterviewIdOrderByCreatedAtDesc(99L)).thenReturn(Optional.empty()); service.handleCallback(new QuestionTailorCallbackRequest("job-9", "99", "succeeded", - new QuestionTailorCallbackRequest.Result(true, List.of()), null)); + new QuestionTailorCallbackRequest.Result(true, List.of()), null), null); - verify(questionTailorRepository, never()).save(any()); + verify(questionTailorRepository, never()).settleCallback(any()); verify(chatInterviewHandoffService, never()).deliver(any()); } @@ -316,7 +335,7 @@ private QuestionTailorEntity lastSaved() { new QuestionTailorCallbackRequest.Result(true, List.of( new QuestionTailorCallbackRequest.Question(1, "다시 쓴 Redis 질문", null), new QuestionTailorCallbackRequest.Question(2, "다시 쓴 WebFlux 질문", null))), - null)); + null), null); verify(chatInterviewHandoffService, never()).deliver(any()); // 넘기지 못했을 뿐 재작성 결과는 남아야 한다. @@ -334,11 +353,12 @@ private QuestionTailorEntity lastSaved() { new QuestionTailorCallbackRequest.Result(true, List.of( new QuestionTailorCallbackRequest.Question(1, "다시 쓴 Redis 질문", null), new QuestionTailorCallbackRequest.Question(2, "다시 쓴 WebFlux 질문", null))), - null)); + null), null); + QuestionTailorEntity settled = lastSaved(); InOrder order = inOrder(questionTailorRepository, chatInterviewHandoffService); order.verify(questionTailorRepository).claimChatDelivery(1L); - order.verify(chatInterviewHandoffService).deliver(tailor); + order.verify(chatInterviewHandoffService).deliver(settled); } /** 이미 넘긴 건은 DB까지 가지 않고 걸러낸다. 콜백 재전송마다 갱신이 나갈 이유가 없다. */ @@ -352,8 +372,113 @@ private QuestionTailorEntity lastSaved() { new QuestionTailorCallbackRequest.Result(true, List.of( new QuestionTailorCallbackRequest.Question(1, "다시 쓴 Redis 질문", null), new QuestionTailorCallbackRequest.Question(2, "다시 쓴 WebFlux 질문", null))), - null)); + null), null); verify(questionTailorRepository, never()).claimChatDelivery(anyLong()); } + + // ---- 콜백 주소의 tailorId로 찾기 (P-2) ---- + + private QuestionTailorCallbackRequest rewritten(String jobId, String interviewId) { + return new QuestionTailorCallbackRequest(jobId, interviewId, "succeeded", + new QuestionTailorCallbackRequest.Result(true, List.of( + new QuestionTailorCallbackRequest.Question(1, "다시 쓴 Redis 질문", null), + new QuestionTailorCallbackRequest.Question(2, "다시 쓴 WebFlux 질문", null))), + null); + } + + /** 접수 응답보다 콜백이 먼저 오면 이 건에는 아직 jobId가 없다. tailorId로 찾고 jobId를 채운다. */ + @Test + void 접수_응답_전이라_jobId가_없어도_tailorId로_찾는다() { + QuestionTailorEntity tailor = pendingTailor(); + tailor.setJobId(null); + when(questionTailorRepository.findById(1L)).thenReturn(Optional.of(tailor)); + + service.handleCallback(rewritten("job-1", "3"), 1L); + + QuestionTailorEntity saved = lastSaved(); + assertThat(saved.getStatus()).isEqualTo(TailorStatus.SUCCEEDED); + assertThat(saved.getJobId()).isEqualTo("job-1"); + verify(questionTailorRepository, never()).findByJobId(any()); + } + + /** tailorId가 틀렸으면 jobId나 면접으로 다른 건을 찾아 잇지 않는다. 그 건은 다른 시도일 수 있다. */ + @Test + void 없는_tailorId면_다른_건으로_우회하지_않는다() { + when(questionTailorRepository.findById(9L)).thenReturn(Optional.empty()); + when(questionTailorRepository.findByJobId("job-1")).thenReturn(Optional.of(pendingTailor())); + when(questionTailorRepository.findTopByInterviewIdOrderByCreatedAtDesc(3L)).thenReturn(Optional.of(pendingTailor())); + + service.handleCallback(rewritten("job-1", "3"), 9L); + + verify(questionTailorRepository, never()).settleCallback(any()); + verify(chatInterviewHandoffService, never()).deliver(any()); + } + + @Test + void 면접이나_jobId나_방식이_어긋나면_반영하지_않는다() { + when(questionTailorRepository.findById(1L)).thenReturn(Optional.of(pendingTailor())); + + service.handleCallback(rewritten("job-1", "4"), 1L); + service.handleCallback(rewritten("job-2", "3"), 1L); + + QuestionTailorEntity multi = pendingTailor(); + multi.setMode(InterviewMode.MULTI); + when(questionTailorRepository.findById(2L)).thenReturn(Optional.of(multi)); + service.handleCallback(rewritten("job-1", "3"), 2L); + + verify(questionTailorRepository, never()).settleCallback(any()); + } + + /** 재전송이거나 시간 초과로 먼저 확정된 건이다. 채팅 서버가 이미 그 질문으로 열었을 수 있어 바꾸지 않는다. */ + @Test + void 이미_확정된_건은_콜백이_다시_와도_결과를_바꾸지_않는다() { + QuestionTailorEntity tailor = pendingTailor(); + tailor.setStatus(TailorStatus.FAILED); + tailor.setTailored(false); + tailor.setQuestions(tailor.getSourceQuestions()); + tailor.setChatDelivered(true); + when(questionTailorRepository.findById(1L)).thenReturn(Optional.of(tailor)); + + service.handleCallback(rewritten("job-1", "3"), 1L); + + verify(questionTailorRepository, never()).settleCallback(any()); + assertThat(tailor.getQuestions()).extracting(TailoredQuestionResponse::getQuestion) + .containsExactly("왜 Redis 를 썼나요?", "왜 WebFlux 를 썼나요?"); + verify(chatInterviewHandoffService, never()).deliver(any()); + } + + /** jobId로 찾은 배포 전 콜백도 tailorId로 찾은 콜백과 같은 검사를 거친다. */ + @Test + void 배포_전_콜백도_면접이나_방식이_어긋나면_반영하지_않는다() { + when(questionTailorRepository.findByJobId("job-1")).thenReturn(Optional.of(pendingTailor())); + service.handleCallback(rewritten("job-1", "4"), null); + + QuestionTailorEntity multi = pendingTailor(); + multi.setMode(InterviewMode.MULTI); + when(questionTailorRepository.findByJobId("job-2")).thenReturn(Optional.of(multi)); + service.handleCallback(rewritten("job-2", "3"), null); + + verify(questionTailorRepository, never()).settleCallback(any()); + } + + /** 그사이 시간 초과나 이어가기 실패가 먼저 확정했다. 콜백은 아무것도 쓰지 않고 넘기지도 않는다. */ + @Test + void 다른_처리가_먼저_확정했으면_콜백은_결과를_쓰지_않는다() { + when(questionTailorRepository.findById(1L)).thenReturn(Optional.of(pendingTailor())); + when(questionTailorRepository.settleCallback(any())).thenReturn(0); + + service.handleCallback(rewritten("job-1", "3"), 1L); + + verify(questionTailorRepository, never()).save(any()); + verify(chatInterviewHandoffService, never()).deliver(any()); + } + + @Test + void 콜백_주소에_tailorId를_붙이고_원래_쿼리는_남긴다() { + assertThat(QuestionTailorService.withTailorId("https://api.repit.test/api/questions/tailor/callback", 5L)) + .isEqualTo("https://api.repit.test/api/questions/tailor/callback?tailorId=5"); + assertThat(QuestionTailorService.withTailorId("https://api.repit.test/cb?env=dev", 5L)) + .isEqualTo("https://api.repit.test/cb?env=dev&tailorId=5"); + } } diff --git a/src/test/java/repit/repit_api_server/domain/userdata/question/service/QuestionTailorServiceMultiCallbackTest.java b/src/test/java/repit/repit_api_server/domain/userdata/question/service/QuestionTailorServiceMultiCallbackTest.java index c1c4ee07..f2f4d4c2 100644 --- a/src/test/java/repit/repit_api_server/domain/userdata/question/service/QuestionTailorServiceMultiCallbackTest.java +++ b/src/test/java/repit/repit_api_server/domain/userdata/question/service/QuestionTailorServiceMultiCallbackTest.java @@ -78,6 +78,8 @@ void setUp() { new ObjectMapper()); when(questionTailorRepository.claimChatDelivery(anyLong())).thenReturn(1); + // 콜백 결과는 아직 PENDING일 때만 확정된다. 다른 처리가 먼저 확정한 경우는 따로 검증한다. + when(questionTailorRepository.settleCallback(any())).thenReturn(1); when(questionTailorRepository.findByJobId("job-1")).thenReturn(Optional.of(pendingMultiTailor())); } @@ -102,8 +104,9 @@ private QuestionTailorEntity pendingMultiTailor() { .build(); } + /** 콜백 결과는 settleCallback으로 확정한다. 넘긴 엔티티가 최종본이고, 채팅 서버 전달 기록도 같은 객체에 남는다. */ private QuestionTailorEntity lastSaved() { - verify(questionTailorRepository, atLeastOnce()).save(savedTailor.capture()); + verify(questionTailorRepository, atLeastOnce()).settleCallback(savedTailor.capture()); return savedTailor.getValue(); } @@ -122,7 +125,7 @@ private QuestionTailorMultiCallbackRequest.Question question(int id, long person new QuestionTailorMultiCallbackRequest.Question(6, 12L, "hr", "갈등이 있었다면?", "확인할 것 6", " ", "기대 6", List.of()), question(7, 13L, "왜 지원했나요?"))), - null)); + null), null); assertThat(lastSaved().getQuestions()).extracting(TailoredQuestionResponse::getSpokenText) .containsExactly("왜 레디스를 썼나요?", null, null); @@ -135,7 +138,7 @@ private QuestionTailorMultiCallbackRequest.Question question(int id, long person new QuestionTailorMultiCallbackRequest.Result(List.of( new QuestionTailorMultiCallbackRequest.Question(2, 11L, "tech_choice", "다시 쓴 Redis 질문", null, "다시 쓴 레디스 질문", "기대 2", List.of()))), - null)); + null), null); TailoredQuestionResponse saved = lastSaved().getQuestions().getFirst(); assertThat(saved.getIntention()).isEqualTo("캐시 계층을 고른 근거"); @@ -148,7 +151,7 @@ private QuestionTailorMultiCallbackRequest.Question question(int id, long person new QuestionTailorMultiCallbackRequest.Result(List.of( question(2, 11L, "다시 쓴 Redis 질문"), question(6, 12L, "팀에서 갈등이 있었다면?"))), - null)); + null), null); QuestionTailorEntity saved = lastSaved(); assertThat(saved.getStatus()).isEqualTo(TailorStatus.SUCCEEDED); @@ -168,7 +171,7 @@ private QuestionTailorMultiCallbackRequest.Question question(int id, long person @Test void 질문을_만들지_못하면_실패를_구독에_알린다() { service.handleMultiCallback(new QuestionTailorMultiCallbackRequest("job-1", "3", "failed", null, - new QuestionTailorMultiCallbackRequest.Error(502, "질문 생성에 실패했습니다."))); + new QuestionTailorMultiCallbackRequest.Error(502, "질문 생성에 실패했습니다.")), null); ArgumentCaptor sent = ArgumentCaptor.forClass(InterviewReadyResponse.class); verify(sseNotifier).sendFinal(eq("analysis-1"), @@ -185,7 +188,7 @@ private QuestionTailorMultiCallbackRequest.Question question(int id, long person @Test void 실패하면_원질문으로_폴백하지_않고_면접을_열지_않는다() { service.handleMultiCallback(new QuestionTailorMultiCallbackRequest("job-1", "3", "failed", null, - new QuestionTailorMultiCallbackRequest.Error(502, "질문 생성에 실패했습니다."))); + new QuestionTailorMultiCallbackRequest.Error(502, "질문 생성에 실패했습니다.")), null); QuestionTailorEntity saved = lastSaved(); assertThat(saved.getStatus()).isEqualTo(TailorStatus.FAILED); @@ -200,7 +203,7 @@ private QuestionTailorMultiCallbackRequest.Question question(int id, long person service.handleMultiCallback(new QuestionTailorMultiCallbackRequest("job-1", "3", "succeeded", new QuestionTailorMultiCallbackRequest.Result(List.of( new QuestionTailorMultiCallbackRequest.Question(6, 12L, "hr", " ", "의도", null, "의도", List.of()))), - null)); + null), null); assertThat(lastSaved().getStatus()).isEqualTo(TailorStatus.FAILED); verify(chatInterviewHandoffService, never()).deliver(any()); @@ -210,7 +213,7 @@ private QuestionTailorMultiCallbackRequest.Question question(int id, long person void 성공하면_채팅_서버로_넘긴다() { service.handleMultiCallback(new QuestionTailorMultiCallbackRequest("job-1", "3", "succeeded", new QuestionTailorMultiCallbackRequest.Result(List.of(question(2, 11L, "다시 쓴 Redis 질문"))), - null)); + null), null); verify(chatInterviewHandoffService).deliver(any()); assertThat(lastSaved().getChatDelivered()).isTrue(); diff --git a/src/test/java/repit/repit_api_server/domain/userdata/question/service/QuestionTailorServiceReadyNotifyTest.java b/src/test/java/repit/repit_api_server/domain/userdata/question/service/QuestionTailorServiceReadyNotifyTest.java index d42e1e76..ee21091c 100644 --- a/src/test/java/repit/repit_api_server/domain/userdata/question/service/QuestionTailorServiceReadyNotifyTest.java +++ b/src/test/java/repit/repit_api_server/domain/userdata/question/service/QuestionTailorServiceReadyNotifyTest.java @@ -76,6 +76,8 @@ void setUp() { new ObjectMapper()); when(questionTailorRepository.claimChatDelivery(anyLong())).thenReturn(1); + // 콜백 결과는 아직 PENDING일 때만 확정된다. 다른 처리가 먼저 확정한 경우는 따로 검증한다. + when(questionTailorRepository.settleCallback(any())).thenReturn(1); when(questionTailorRepository.save(any(QuestionTailorEntity.class))) .thenAnswer(call -> call.getArgument(0)); when(interviewRepository.findById(3L)).thenReturn(Optional.of(InterviewEntity.builder() @@ -114,7 +116,7 @@ private QuestionTailorCallbackRequest succeeded() { QuestionTailorEntity tailor = pendingTailor(); when(questionTailorRepository.findByJobId("job-1")).thenReturn(Optional.of(tailor)); - service.handleCallback(succeeded()); + service.handleCallback(succeeded(), null); ArgumentCaptor sent = ArgumentCaptor.forClass(InterviewReadyResponse.class); // 웹이 구독한 열쇠는 면접 id가 아니라 분석 jobId다. @@ -133,7 +135,7 @@ private QuestionTailorCallbackRequest succeeded() { when(questionTailorRepository.findByJobId("job-1")).thenReturn(Optional.of(tailor)); service.handleCallback(new QuestionTailorCallbackRequest("job-1", "3", "failed", null, - new QuestionTailorCallbackRequest.Error(502, "질문 재작성에 실패했습니다."))); + new QuestionTailorCallbackRequest.Error(502, "질문 재작성에 실패했습니다.")), null); ArgumentCaptor sent = ArgumentCaptor.forClass(InterviewReadyResponse.class); verify(sseNotifier).sendFinal(eq("analysis-1"), eq(SseNotifier.INTERVIEW_READY), sent.capture()); @@ -150,7 +152,7 @@ private QuestionTailorCallbackRequest succeeded() { doThrow(new ExternalApiException("채팅 서버에 오류가 발생했습니다.", null, null)) .when(chatInterviewHandoffService).deliver(any()); - service.handleCallback(succeeded()); + service.handleCallback(succeeded(), null); verify(sseNotifier).sendFinal(eq("analysis-1"), eq(SseNotifier.INTERVIEW_PREPARATION_FAILED), any(InterviewReadyResponse.class)); diff --git a/src/test/java/repit/repit_api_server/domain/userdata/question/service/QuestionTailorServiceWaitingTest.java b/src/test/java/repit/repit_api_server/domain/userdata/question/service/QuestionTailorServiceWaitingTest.java index 29a8635c..86e52806 100644 --- a/src/test/java/repit/repit_api_server/domain/userdata/question/service/QuestionTailorServiceWaitingTest.java +++ b/src/test/java/repit/repit_api_server/domain/userdata/question/service/QuestionTailorServiceWaitingTest.java @@ -305,4 +305,41 @@ private UserResponse user() { assertThat(waiting.getStatus()).isEqualTo(TailorStatus.FAILED); assertThat(waiting.getErrorMessage()).contains("public으로 변경해주세요"); } + + // ---- 이어가다 분석 서버 요청이 실패한 경우 ---- + + private QuestionTailorEntity givenResumeFails(int claimExpiration) { + QuestionTailorEntity waiting = waitingTailor(); + when(questionPoolService.applyCycleResult(any(), eq(5L), eq(1))) + .thenReturn(new QuestionPoolService.CycleOutcome(5L, 7L, InterviewMode.SOLO, true, false)); + when(questionTailorRepository.findAllByUserIdAndModeAndStatus(7L, InterviewMode.SOLO, TailorStatus.WAITING)) + .thenReturn(List.of(waiting)); + when(questionTailorRepository.claimResume(eq(1L), any())).thenReturn(1); + when(questionPoolService.takeSet(7L, InterviewMode.SOLO, 3L)).thenReturn(SET); + when(aiServerClient.tailorQuestions(any())).thenThrow(new IllegalStateException("접수 응답 읽기 시간 초과")); + when(questionTailorRepository.claimExpiration(1L)).thenReturn(claimExpiration); + return waiting; + } + + /** 접수 응답만 늦게 실패하고 콜백은 먼저 와서 결과를 확정했다. 그 결과를 실패로 덮지 않는다. */ + @Test + void 이어가다_요청이_실패해도_먼저_확정된_결과는_실패로_덮지_않는다() { + QuestionTailorEntity waiting = givenResumeFails(0); + + service.handleCycleCallback(QuestionCycleCallbackRequest.builder().jobId("cycle-job").status("succeeded").build(), 5L, 1); + + assertThat(waiting.getStatus()).isNotEqualTo(TailorStatus.FAILED); + verify(sseNotifier, never()).sendFinal(any(), eq(SseNotifier.INTERVIEW_PREPARATION_FAILED), any()); + } + + @Test + void 이어가다_요청이_실패하고_아직_기다리는_건이면_실패로_닫고_알린다() { + QuestionTailorEntity waiting = givenResumeFails(1); + + service.handleCycleCallback(QuestionCycleCallbackRequest.builder().jobId("cycle-job").status("succeeded").build(), 5L, 1); + + assertThat(waiting.getStatus()).isEqualTo(TailorStatus.FAILED); + assertThat(waiting.getQuestions()).isNull(); + verify(sseNotifier).sendFinal(eq("profile-1"), eq(SseNotifier.INTERVIEW_PREPARATION_FAILED), any()); + } }