From 4717fb85682c7bd6f1f89751026a1fc92edd41e8 Mon Sep 17 00:00:00 2001 From: kimwuju Date: Thu, 15 Oct 2026 14:46:09 +0900 Subject: [PATCH 1/6] =?UTF-8?q?fix:=20=ED=85=8C=EC=9D=BC=EB=9F=AC=20?= =?UTF-8?q?=EC=BD=9C=EB=B0=B1=EC=9D=B4=20=EC=A0=91=EC=88=98=20=EC=9D=91?= =?UTF-8?q?=EB=8B=B5=EB=B3=B4=EB=8B=A4=20=EB=A8=BC=EC=A0=80=20=EC=99=80?= =?UTF-8?q?=EB=8F=84=20=EA=B2=B0=EA=B3=BC=EB=A5=BC=20=EC=9E=83=EC=A7=80=20?= =?UTF-8?q?=EC=95=8A=EA=B2=8C=20=ED=95=A8(P-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 준비 건을 분석 서버의 접수 응답을 받은 뒤에야 저장해, 콜백이 먼저 오면 대상을 찾지 못하거나 같은 면접의 이전 시도에 결과가 붙었다. 늦게 온 접수 응답이 엔티티를 통째로 저장해 결과를 PENDING으로 되돌리기도 했다. - 분석 서버를 부르기 전에 준비 건을 저장(커밋)하고, callbackUrl에 ?tailorId={id}를 붙인다(UriComponentsBuilder) - 콜백 컨트롤러가 tailorId 쿼리를 읽어 넘기고, 콜백은 tailorId로 대상을 찾는다(1:1·N:1) - 함께 온 interviewId·jobId·방식이 그 건과 어긋나면 반영하지 않는다. tailorId가 틀리면 다른 건으로 우회하지 않는다 - tailorId 없는 콜백(배포 전 요청)은 jobId로 찾고, 없으면 면접의 마지막 건이 그 jobId를 기다릴 때만 잇는다 - 접수 응답은 jobId가 비어 있을 때만 채우는 조건부 갱신으로 바꾼다. 상태를 건드리지 않는다 - 이미 확정된 건(재전송, 시간 초과 폴백)은 콜백 결과를 바꾸지 않는다. 넘기지 못한 건이면 전달만 다시 한다 - 분석 서버 요청이 실패하면 접수되지 않은 새 건은 지운다. 그사이 콜백이 와서 결과가 들어갔으면 남긴다 스키마 변경 없음. Analysis 변경 없음(callbackUrl을 불투명한 주소로 쓴다). Co-Authored-By: Claude Opus 5.5 --- .../controller/QuestionTailorController.java | 7 +- .../repository/QuestionTailorRepository.java | 32 +++ .../service/QuestionTailorService.java | 145 +++++++++--- .../QuestionTailorEarlyCallbackTest.java | 213 ++++++++++++++++++ .../QuestionTailorServiceCallbackTest.java | 126 +++++++++-- ...uestionTailorServiceMultiCallbackTest.java | 14 +- .../QuestionTailorServiceReadyNotifyTest.java | 6 +- 7 files changed, 483 insertions(+), 60 deletions(-) create mode 100644 src/test/java/repit/repit_api_server/domain/userdata/question/service/QuestionTailorEarlyCallbackTest.java 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/repository/QuestionTailorRepository.java b/src/main/java/repit/repit_api_server/domain/userdata/question/repository/QuestionTailorRepository.java index 71a72d8f..d5b159dd 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,36 @@ 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); } 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..e0dd883b 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()); } @@ -649,11 +681,14 @@ 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; } @@ -824,11 +859,14 @@ 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; } @@ -893,33 +931,72 @@ 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가 아직 없어도 찾는다. + * + *

함께 온 면접·jobId·방식이 그 건과 어긋나면 다른 요청의 결과다. 잇지 않는다. tailorId가 있는데 맞지 않으면 + * 다른 건으로 우회하지도 않는다 — 우회하면 다른 시도의 결과가 이 면접에 들어간다. + */ + private QuestionTailorEntity findTarget(Long tailorId, String jobId, String interviewIdValue, InterviewMode mode) { + // 세션이 아직 없는 시점이라 면접은 interviewId로 가리킨다. + Long interviewId = parseInterviewId(interviewIdValue); + if (tailorId == null) { + return findLegacyTarget(jobId, interviewId); } - Long interviewId = parseInterviewId(request.getInterviewId()); - if (interviewId == null) { + QuestionTailorEntity tailor = questionTailorRepository.findById(tailorId).orElse(null); + if (tailor == null) { return null; } - return questionTailorRepository.findTopByInterviewIdOrderByCreatedAtDesc(interviewId).orElse(null); + 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); + /** + * 콜백 주소에 tailorId가 없던 시절(배포 전)에 나간 요청의 콜백. 그 건을 하나로 짚을 수 있을 때만 잇는다. + * + *

jobId로 찾고, 없으면 이 면접의 마지막 건이 아직 콜백을 기다리고 그 jobId와 어긋나지 않을 때만 그 건으로 본다. + * 이미 닫힌 건이나 다른 jobId를 받은 건에 이으면 다른 시도의 결과가 섞인다. + */ + private QuestionTailorEntity findLegacyTarget(String jobId, Long interviewId) { + if (jobId != null) { + QuestionTailorEntity byJobId = questionTailorRepository.findByJobId(jobId).orElse(null); if (byJobId != null) { return byJobId; } } - // 세션이 아직 없는 시점이라 매칭 키는 interviewId다. - Long interviewId = parseInterviewId(request.getInterviewId()); if (interviewId == null) { return null; } - return questionTailorRepository.findTopByInterviewIdOrderByCreatedAtDesc(interviewId).orElse(null); + QuestionTailorEntity latest = questionTailorRepository.findTopByInterviewIdOrderByCreatedAtDesc(interviewId) + .orElse(null); + if (latest == null || latest.getStatus() != TailorStatus.PENDING + || (latest.getJobId() != null && !latest.getJobId().equals(jobId))) { + return null; + } + return latest; + } + + /** + * 이미 확정된 건이면 콜백 결과를 반영하지 않는다. 재전송이거나, 시간 초과로 원질문 폴백·실패가 먼저 확정된 경우다. + * + *

확정된 질문은 채팅 서버가 이미 그것으로 면접을 열었을 수 있어 바꾸지 않는다. 넘기지 못한 채 남은 건이면 + * 전달만 다시 해본다. + */ + private boolean alreadySettled(QuestionTailorEntity tailor) { + if (tailor.getStatus() == TailorStatus.PENDING) { + return false; + } + 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..899df7b2 --- /dev/null +++ b/src/test/java/repit/repit_api_server/domain/userdata/question/service/QuestionTailorEarlyCallbackTest.java @@ -0,0 +1,213 @@ +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 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.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.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.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; + +/** + * 분석 서버가 접수 응답보다 결과 콜백을 먼저 보내는 경우(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; + + // 다른 테스트나 로컬 데이터와 겹치지 않는 면접 id. question_tailor는 면접을 외래 키로 묶지 않는다. + private final long interviewId = 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(7L) + .mode(mode) + .sessionId(UUID.randomUUID().toString()) + .status(Status.IN_PROGRESS) + .build(); + } + + private UserResponse user() { + UserResponse user = mock(UserResponse.class); + when(user.getId()).thenReturn(7L); + 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(7L), eq(mode), eq(interviewId))) + .thenReturn(new QuestionPoolService.DrawnSet("analysis-1", questions, projectSummary, null)); + } + + 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(); + } +} 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..e0fbdc2d 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; @@ -115,7 +116,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,7 +145,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).deliver(tailor); assertThat(lastSaved().getChatDelivered()).isTrue(); @@ -161,7 +162,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 +181,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 +194,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 +216,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 +236,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 +253,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 +267,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); @@ -284,7 +285,7 @@ private QuestionTailorEntity lastSaved() { when(questionTailorRepository.findTopByInterviewIdOrderByCreatedAtDesc(3L)).thenReturn(Optional.of(tailor)); service.handleCallback(new QuestionTailorCallbackRequest("job-1", "3", "succeeded", - new QuestionTailorCallbackRequest.Result(false, List.of()), null)); + new QuestionTailorCallbackRequest.Result(false, List.of()), null), null); verify(questionTailorRepository, atLeastOnce()).save(any(QuestionTailorEntity.class)); } @@ -295,7 +296,7 @@ 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(chatInterviewHandoffService, never()).deliver(any()); @@ -316,7 +317,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,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); InOrder order = inOrder(questionTailorRepository, chatInterviewHandoffService); order.verify(questionTailorRepository).claimChatDelivery(1L); @@ -352,8 +353,105 @@ 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()).save(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()).save(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()).save(any()); + assertThat(tailor.getQuestions()).extracting(TailoredQuestionResponse::getQuestion) + .containsExactly("왜 Redis 를 썼나요?", "왜 WebFlux 를 썼나요?"); + verify(chatInterviewHandoffService, never()).deliver(any()); + } + + /** 배포 전에 나간 요청의 콜백(tailorId 없음). 면접의 마지막 건이 그 jobId를 기다릴 때만 잇는다. */ + @Test + void 배포_전_콜백은_기다리는_건_하나로_짚힐_때만_면접으로_잇는다() { + when(questionTailorRepository.findByJobId("job-1")).thenReturn(Optional.empty()); + + QuestionTailorEntity settled = pendingTailor(); + settled.setStatus(TailorStatus.SUCCEEDED); + when(questionTailorRepository.findTopByInterviewIdOrderByCreatedAtDesc(3L)).thenReturn(Optional.of(settled)); + service.handleCallback(rewritten("job-1", "3"), null); + + QuestionTailorEntity otherJob = pendingTailor(); + otherJob.setJobId("job-other"); + when(questionTailorRepository.findTopByInterviewIdOrderByCreatedAtDesc(3L)).thenReturn(Optional.of(otherJob)); + service.handleCallback(rewritten("job-1", "3"), null); + + verify(questionTailorRepository, never()).save(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..dde3cd99 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 @@ -122,7 +122,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 +135,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 +148,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 +168,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 +185,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 +200,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 +210,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..76228845 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 @@ -114,7 +114,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 +133,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 +150,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)); From 164177c15b10cbd12da92d236a1058b490f8c25b Mon Sep 17 00:00:00 2001 From: kimwuju Date: Thu, 15 Oct 2026 15:02:09 +0900 Subject: [PATCH 2/6] =?UTF-8?q?fix:=20tailorId=20=EC=97=86=EB=8A=94=20?= =?UTF-8?q?=EC=BD=9C=EB=B0=B1=EC=9D=80=20jobId=EB=A1=9C=EB=A7=8C=20?= =?UTF-8?q?=EC=9D=B4=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 배포 전에 나간 요청의 콜백(tailorId 없음)을 jobId로 못 찾으면 면접의 마지막 PENDING 건에 이었다. 그 건이 배포 뒤 새 시도라 아직 jobId가 비어 있으면, 이전 요청의 질문이 새 시도에 저장될 수 있었다(PR #98 리뷰). 면접 id는 시도를 가리지 못하므로 그 경로를 없앤다. Co-Authored-By: Claude Opus 5.5 --- .../service/QuestionTailorService.java | 33 ++----------- .../QuestionTailorServiceCallbackTest.java | 46 +++++++++---------- 2 files changed, 26 insertions(+), 53 deletions(-) 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 e0dd883b..1d2fd67c 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 @@ -938,11 +938,13 @@ private void applyMultiSuccess(QuestionTailorEntity tailor, QuestionTailorMultiC * 다른 건으로 우회하지도 않는다 — 우회하면 다른 시도의 결과가 이 면접에 들어간다. */ private QuestionTailorEntity findTarget(Long tailorId, String jobId, String interviewIdValue, InterviewMode mode) { - // 세션이 아직 없는 시점이라 면접은 interviewId로 가리킨다. - Long interviewId = parseInterviewId(interviewIdValue); if (tailorId == null) { - return findLegacyTarget(jobId, interviewId); + // 콜백 주소에 tailorId가 없던 시절(배포 전)에 나간 요청의 콜백이다. 그 건을 확실히 짚는 것은 jobId뿐이라 + // jobId로만 잇는다. 면접으로 찾으면 같은 면접의 새 시도(아직 jobId가 없는 건)에 이전 요청의 결과가 들어간다. + return jobId == null ? null : questionTailorRepository.findByJobId(jobId).orElse(null); } + // 세션이 아직 없는 시점이라 면접은 interviewId로 가리킨다. + Long interviewId = parseInterviewId(interviewIdValue); QuestionTailorEntity tailor = questionTailorRepository.findById(tailorId).orElse(null); if (tailor == null) { return null; @@ -958,31 +960,6 @@ private QuestionTailorEntity findTarget(Long tailorId, String jobId, String inte return tailor; } - /** - * 콜백 주소에 tailorId가 없던 시절(배포 전)에 나간 요청의 콜백. 그 건을 하나로 짚을 수 있을 때만 잇는다. - * - *

jobId로 찾고, 없으면 이 면접의 마지막 건이 아직 콜백을 기다리고 그 jobId와 어긋나지 않을 때만 그 건으로 본다. - * 이미 닫힌 건이나 다른 jobId를 받은 건에 이으면 다른 시도의 결과가 섞인다. - */ - private QuestionTailorEntity findLegacyTarget(String jobId, Long interviewId) { - if (jobId != null) { - QuestionTailorEntity byJobId = questionTailorRepository.findByJobId(jobId).orElse(null); - if (byJobId != null) { - return byJobId; - } - } - if (interviewId == null) { - return null; - } - QuestionTailorEntity latest = questionTailorRepository.findTopByInterviewIdOrderByCreatedAtDesc(interviewId) - .orElse(null); - if (latest == null || latest.getStatus() != TailorStatus.PENDING - || (latest.getJobId() != null && !latest.getJobId().equals(jobId))) { - return null; - } - return latest; - } - /** * 이미 확정된 건이면 콜백 결과를 반영하지 않는다. 재전송이거나, 시간 초과로 원질문 폴백·실패가 먼저 확정된 경우다. * 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 e0fbdc2d..ec1c2f73 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 @@ -278,16 +278,30 @@ private QuestionTailorEntity lastSaved() { verify(chatInterviewHandoffService).deliver(tailor); } + /** + * 배포 전에 나간 요청의 콜백(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), null); + service.handleCallback(rewritten("job-old", "3"), null); + + verify(questionTailorRepository, never()).save(any()); + verify(questionTailorRepository, never()).findTopByInterviewIdOrderByCreatedAtDesc(any()); + } + + @Test + void 배포_전_콜백도_jobId로는_잇는다() { + when(questionTailorRepository.findByJobId("job-1")).thenReturn(Optional.of(pendingTailor())); + + service.handleCallback(rewritten("job-1", "3"), null); - verify(questionTailorRepository, atLeastOnce()).save(any(QuestionTailorEntity.class)); + assertThat(lastSaved().getStatus()).isEqualTo(TailorStatus.SUCCEEDED); } @Test @@ -429,24 +443,6 @@ private QuestionTailorCallbackRequest rewritten(String jobId, String interviewId verify(chatInterviewHandoffService, never()).deliver(any()); } - /** 배포 전에 나간 요청의 콜백(tailorId 없음). 면접의 마지막 건이 그 jobId를 기다릴 때만 잇는다. */ - @Test - void 배포_전_콜백은_기다리는_건_하나로_짚힐_때만_면접으로_잇는다() { - when(questionTailorRepository.findByJobId("job-1")).thenReturn(Optional.empty()); - - QuestionTailorEntity settled = pendingTailor(); - settled.setStatus(TailorStatus.SUCCEEDED); - when(questionTailorRepository.findTopByInterviewIdOrderByCreatedAtDesc(3L)).thenReturn(Optional.of(settled)); - service.handleCallback(rewritten("job-1", "3"), null); - - QuestionTailorEntity otherJob = pendingTailor(); - otherJob.setJobId("job-other"); - when(questionTailorRepository.findTopByInterviewIdOrderByCreatedAtDesc(3L)).thenReturn(Optional.of(otherJob)); - service.handleCallback(rewritten("job-1", "3"), null); - - verify(questionTailorRepository, never()).save(any()); - } - @Test void 콜백_주소에_tailorId를_붙이고_원래_쿼리는_남긴다() { assertThat(QuestionTailorService.withTailorId("https://api.repit.test/api/questions/tailor/callback", 5L)) From cecfb4e6e9f5d095ff412126363268f6823e2b3d Mon Sep 17 00:00:00 2001 From: kimwuju Date: Thu, 15 Oct 2026 15:07:21 +0900 Subject: [PATCH 3/6] =?UTF-8?q?fix:=20jobId=EB=A1=9C=20=EC=B0=BE=EC=9D=80?= =?UTF-8?q?=20=EB=B0=B0=ED=8F=AC=20=EC=A0=84=20=EC=BD=9C=EB=B0=B1=EB=8F=84?= =?UTF-8?q?=20=EB=A9=B4=EC=A0=91=C2=B7=EB=B0=A9=EC=8B=9D=20=EA=B2=80?= =?UTF-8?q?=EC=82=AC=EB=A5=BC=20=EA=B1=B0=EC=B9=98=EA=B2=8C=20=ED=95=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tailorId 없는 콜백은 jobId로 찾은 건을 곧바로 돌려줘, tailorId로 찾은 콜백이 거치는 면접·방식 검사를 건너뛰었다 (PR #98 리뷰). 두 경로가 같은 검사를 거치도록 찾는 부분만 나눈다. Co-Authored-By: Claude Opus 5.5 --- .../service/QuestionTailorService.java | 21 +++++++++++-------- .../QuestionTailorServiceCallbackTest.java | 14 +++++++++++++ 2 files changed, 26 insertions(+), 9 deletions(-) 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 1d2fd67c..fc3558cd 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 @@ -934,21 +934,24 @@ private void applyMultiSuccess(QuestionTailorEntity tailor, QuestionTailorMultiC /** * 콜백이 가리키는 준비 건. 콜백 주소에 실어 보낸 tailorId로 찾는다 — 접수 응답이 늦어 jobId가 아직 없어도 찾는다. * - *

함께 온 면접·jobId·방식이 그 건과 어긋나면 다른 요청의 결과다. 잇지 않는다. tailorId가 있는데 맞지 않으면 - * 다른 건으로 우회하지도 않는다 — 우회하면 다른 시도의 결과가 이 면접에 들어간다. + *

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

어느 쪽으로 찾았든 함께 온 면접·jobId·방식이 그 건과 어긋나면 다른 요청의 결과다. 잇지 않는다. tailorId가 + * 있는데 맞지 않으면 다른 건으로 우회하지도 않는다 — 우회하면 다른 시도의 결과가 이 면접에 들어간다. */ private QuestionTailorEntity findTarget(Long tailorId, String jobId, String interviewIdValue, InterviewMode mode) { - if (tailorId == null) { - // 콜백 주소에 tailorId가 없던 시절(배포 전)에 나간 요청의 콜백이다. 그 건을 확실히 짚는 것은 jobId뿐이라 - // jobId로만 잇는다. 면접으로 찾으면 같은 면접의 새 시도(아직 jobId가 없는 건)에 이전 요청의 결과가 들어간다. - return jobId == null ? null : questionTailorRepository.findByJobId(jobId).orElse(null); + QuestionTailorEntity tailor; + if (tailorId != null) { + tailor = questionTailorRepository.findById(tailorId).orElse(null); + } else { + tailor = jobId == null ? null : questionTailorRepository.findByJobId(jobId).orElse(null); } - // 세션이 아직 없는 시점이라 면접은 interviewId로 가리킨다. - Long interviewId = parseInterviewId(interviewIdValue); - QuestionTailorEntity tailor = questionTailorRepository.findById(tailorId).orElse(null); if (tailor == null) { return 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) { 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 ec1c2f73..64994c21 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 @@ -443,6 +443,20 @@ private QuestionTailorCallbackRequest rewritten(String jobId, String interviewId 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()).save(any()); + } + @Test void 콜백_주소에_tailorId를_붙이고_원래_쿼리는_남긴다() { assertThat(QuestionTailorService.withTailorId("https://api.repit.test/api/questions/tailor/callback", 5L)) From f0ef89162f5c187c861784579c12984a853a5113 Mon Sep 17 00:00:00 2001 From: kimwuju Date: Thu, 15 Oct 2026 15:19:23 +0900 Subject: [PATCH 4/6] =?UTF-8?q?fix:=20=EC=82=AC=EC=9D=B4=ED=81=B4=20?= =?UTF-8?q?=EB=8C=80=EA=B8=B0=EC=97=90=EC=84=9C=20=EC=9D=B4=EC=96=B4?= =?UTF-8?q?=EA=B0=80=EB=8B=A4=20=EC=8B=A4=ED=8C=A8=ED=95=B4=EB=8F=84=20?= =?UTF-8?q?=EB=A8=BC=EC=A0=80=20=ED=99=95=EC=A0=95=EB=90=9C=20=EA=B2=B0?= =?UTF-8?q?=EA=B3=BC=EB=A5=BC=20=EB=8D=AE=EC=A7=80=20=EC=95=8A=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사이클을 기다리던 준비를 이어가다 접수 요청이 실패하면 resumeWaiting이 읽어 둔 엔티티를 FAILED·questions=null로 통째로 저장했다. 그사이 콜백이 먼저 와서 결과를 확정·전달했으면 그 결과가 DB에서 사라졌다(PR #98 리뷰). 시간 초과 처리와 같은 조건부 차지(claimExpiration: PENDING·WAITING일 때만)를 얻은 경우에만 실패로 닫고 알린다. 차지하지 못하면 확정된 결과를 그대로 둔다. Co-Authored-By: Claude Opus 5.5 --- .../service/QuestionTailorService.java | 11 ++++-- .../QuestionTailorServiceWaitingTest.java | 37 +++++++++++++++++++ 2 files changed, 44 insertions(+), 4 deletions(-) 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 fc3558cd..15998f15 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 @@ -656,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; } } 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()); + } } From 7230bc028e691951270481cb228b854e1233ed0b Mon Sep 17 00:00:00 2001 From: kimwuju Date: Thu, 15 Oct 2026 15:46:08 +0900 Subject: [PATCH 5/6] =?UTF-8?q?fix:=20=EC=BD=9C=EB=B0=B1=20=EA=B2=B0?= =?UTF-8?q?=EA=B3=BC=EB=A5=BC=20PENDING=EC=9D=BC=20=EB=95=8C=EB=A7=8C=20?= =?UTF-8?q?=ED=95=9C=20=EB=AC=B8=EC=9E=A5=EC=9C=BC=EB=A1=9C=20=ED=99=95?= =?UTF-8?q?=EC=A0=95=ED=95=B4=20=EC=8B=A4=ED=8C=A8=20=EC=B2=98=EB=A6=AC?= =?UTF-8?q?=EC=99=80=20=EC=84=9C=EB=A1=9C=20=EB=8D=AE=EC=A7=80=20=EC=95=8A?= =?UTF-8?q?=EA=B2=8C=20=ED=95=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 콜백은 PENDING을 읽고 확인한 뒤 엔티티를 따로 저장했다. 그사이 실패 처리(시간 초과, 사이클 대기에서 이어가기 실패)가 claimExpiration으로 먼저 차지하면 콜백이 그 위에 결과를 덮거나, 실패 처리가 들고 있던 낡은 엔티티로 콜백 결과를 다시 지울 수 있었다(PR #98 리뷰). - settleCallback: 아직 PENDING일 때만 상태·tailored·questions·오류·jobId(빈 경우만)를 한 문장으로 고친다. 실패 처리의 claimExpiration과 둘 다 조건부 갱신이라 한쪽만 이긴다. 진 콜백은 아무것도 쓰지 않는다 - 콜백 경로의 엔티티 통째 저장을 없앤다. 채팅 서버 전달은 확정이 커밋된 뒤에 한다 - 실제 DB에서 두 순서를 교차시키는 테스트와, 이어가다 콜백이 먼저 확정한 뒤 요청이 실패하는 시나리오 테스트를 추가한다 Co-Authored-By: Claude Opus 5.5 --- .../repository/QuestionTailorRepository.java | 23 ++++ .../service/QuestionTailorService.java | 20 +++- .../QuestionTailorEarlyCallbackTest.java | 109 +++++++++++++++++- .../QuestionTailorServiceCallbackTest.java | 27 ++++- ...uestionTailorServiceMultiCallbackTest.java | 5 +- .../QuestionTailorServiceReadyNotifyTest.java | 2 + 6 files changed, 172 insertions(+), 14 deletions(-) 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 d5b159dd..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 @@ -121,4 +121,27 @@ 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.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 15998f15..e13ca22e 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 @@ -705,10 +705,24 @@ public void handleCallback(QuestionTailorCallbackRequest request, Long tailorId) 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; + } + /** * 재작성이 확정된 면접 데이터를 채팅 서버로 넘긴다. * @@ -887,7 +901,9 @@ public void handleMultiCallback(QuestionTailorMultiCallbackRequest request, Long : error.getMessage()); } - questionTailorRepository.save(tailor); + if (!settle(tailor)) { + return; + } completePreparation(tailor); } 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 index 899df7b2..c492b4df 100644 --- 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 @@ -11,12 +11,14 @@ 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; @@ -31,6 +33,7 @@ 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; @@ -68,9 +71,13 @@ class QuestionTailorEarlyCallbackTest { private PersonaRepository personaRepository; @MockitoBean private InterviewPersonaRepository interviewPersonaRepository; + @MockitoBean + private InterviewRepository interviewRepository; - // 다른 테스트나 로컬 데이터와 겹치지 않는 면접 id. question_tailor는 면접을 외래 키로 묶지 않는다. + // 다른 테스트나 로컬 데이터와 겹치지 않는 면접·사용자 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() { @@ -82,7 +89,7 @@ void cleanUp() { private InterviewEntity interview(InterviewMode mode) { return InterviewEntity.builder() .interviewId(interviewId) - .userId(7L) + .userId(userId) .mode(mode) .sessionId(UUID.randomUUID().toString()) .status(Status.IN_PROGRESS) @@ -91,7 +98,7 @@ private InterviewEntity interview(InterviewMode mode) { private UserResponse user() { UserResponse user = mock(UserResponse.class); - when(user.getId()).thenReturn(7L); + when(user.getId()).thenReturn(userId); when(user.getMajor()).thenReturn("BACKEND"); return user; } @@ -109,8 +116,9 @@ private void givenSet(InterviewMode mode) { .toList(); Map projectSummary = Map.of("overview", "주문 처리를 맡는 백엔드", "repositories", List.of(), "coreFeatures", List.of(), "techStack", List.of("Spring")); - when(questionPoolService.takeSet(eq(7L), eq(mode), eq(interviewId))) - .thenReturn(new QuestionPoolService.DrawnSet("analysis-1", questions, projectSummary, null)); + when(questionPoolService.takeSet(eq(userId), eq(mode), eq(interviewId))) + // 이어가기 경로에는 사용자 정보가 없어 종합 데이터에 실었던 전공을 쓴다. + .thenReturn(new QuestionPoolService.DrawnSet("analysis-1", questions, projectSummary, "BACKEND")); } private static Long tailorIdOf(String callbackUrl) { @@ -210,4 +218,95 @@ private QuestionTailorAcceptedResponse accepted(String jobId) { 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()); + } } 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 64994c21..bbfc2fab 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 @@ -74,6 +74,8 @@ void setUp() { // 넘길 권리를 차지한 상태를 기본으로 둔다. 차지하지 못하는 경우는 따로 검증한다. when(questionTailorRepository.claimChatDelivery(anyLong())).thenReturn(1); + // 콜백 결과는 아직 PENDING일 때만 확정된다. 다른 처리가 먼저 확정한 경우는 따로 검증한다. + when(questionTailorRepository.settleCallback(any())).thenReturn(1); } private QuestionTailorEntity pendingTailor() { @@ -102,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(); } @@ -291,7 +294,7 @@ private QuestionTailorEntity lastSaved() { service.handleCallback(rewritten("job-old", "3"), null); - verify(questionTailorRepository, never()).save(any()); + verify(questionTailorRepository, never()).settleCallback(any()); verify(questionTailorRepository, never()).findTopByInterviewIdOrderByCreatedAtDesc(any()); } @@ -312,7 +315,7 @@ private QuestionTailorEntity lastSaved() { service.handleCallback(new QuestionTailorCallbackRequest("job-9", "99", "succeeded", new QuestionTailorCallbackRequest.Result(true, List.of()), null), null); - verify(questionTailorRepository, never()).save(any()); + verify(questionTailorRepository, never()).settleCallback(any()); verify(chatInterviewHandoffService, never()).deliver(any()); } @@ -406,7 +409,7 @@ private QuestionTailorCallbackRequest rewritten(String jobId, String interviewId service.handleCallback(rewritten("job-1", "3"), 9L); - verify(questionTailorRepository, never()).save(any()); + verify(questionTailorRepository, never()).settleCallback(any()); verify(chatInterviewHandoffService, never()).deliver(any()); } @@ -422,7 +425,7 @@ private QuestionTailorCallbackRequest rewritten(String jobId, String interviewId when(questionTailorRepository.findById(2L)).thenReturn(Optional.of(multi)); service.handleCallback(rewritten("job-1", "3"), 2L); - verify(questionTailorRepository, never()).save(any()); + verify(questionTailorRepository, never()).settleCallback(any()); } /** 재전송이거나 시간 초과로 먼저 확정된 건이다. 채팅 서버가 이미 그 질문으로 열었을 수 있어 바꾸지 않는다. */ @@ -437,7 +440,7 @@ private QuestionTailorCallbackRequest rewritten(String jobId, String interviewId service.handleCallback(rewritten("job-1", "3"), 1L); - verify(questionTailorRepository, never()).save(any()); + verify(questionTailorRepository, never()).settleCallback(any()); assertThat(tailor.getQuestions()).extracting(TailoredQuestionResponse::getQuestion) .containsExactly("왜 Redis 를 썼나요?", "왜 WebFlux 를 썼나요?"); verify(chatInterviewHandoffService, never()).deliver(any()); @@ -454,7 +457,19 @@ private QuestionTailorCallbackRequest rewritten(String jobId, String interviewId 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 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 dde3cd99..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(); } 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 76228845..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() From 7a6f928014947cccd1789dbba998bfe649b8ab33 Mon Sep 17 00:00:00 2001 From: kimwuju Date: Thu, 15 Oct 2026 15:52:44 +0900 Subject: [PATCH 6/6] =?UTF-8?q?fix:=20=EC=BD=9C=EB=B0=B1=20=EA=B2=B0?= =?UTF-8?q?=EA=B3=BC=EB=A5=BC=20=EC=A1=B0=ED=9A=8C=ED=95=9C=20=EC=97=94?= =?UTF-8?q?=ED=8B=B0=ED=8B=B0=EA=B0=80=20=EC=95=84=EB=8B=8C=20=EB=B3=B5?= =?UTF-8?q?=EC=82=AC=EB=B3=B8=EC=97=90=20=EA=B3=84=EC=82=B0=ED=95=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit open-in-view가 켜져 있어 HTTP 콜백 동안 조회한 준비 건이 관리 상태로 남는다. 그 엔티티에 결과를 적은 뒤 settleCallback을 부르면 flushAutomatically가 쿼리 전에 변경을 먼저 flush해, SUCCEEDED가 조건 없이 저장되고 WHERE status = PENDING이 어긋나 확정이 0건으로 끝났다. 채팅 서버 전달과 준비 완료 알림이 빠졌다(PR #98 리뷰). - 콜백(1:1·N:1)은 조회한 엔티티를 고치지 않고 toBuilder 복사본에 결과를 계산해 조건부 확정에 넘긴다 - MockMvc로 실제 HTTP 콜백(open-in-view 적용)을 보내는 테스트를 추가한다. tailorId 경로와 배포 전 jobId 경로 모두 수정 전 코드에서 실패하고 수정 후 통과한다 Co-Authored-By: Claude Opus 5.5 --- .../question/entity/QuestionTailorEntity.java | 2 +- .../service/QuestionTailorService.java | 6 +++ .../QuestionTailorEarlyCallbackTest.java | 54 +++++++++++++++++++ .../QuestionTailorServiceCallbackTest.java | 10 ++-- 4 files changed, 67 insertions(+), 5 deletions(-) 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/service/QuestionTailorService.java b/src/main/java/repit/repit_api_server/domain/userdata/question/service/QuestionTailorService.java index e13ca22e..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 @@ -694,6 +694,9 @@ public void handleCallback(QuestionTailorCallbackRequest request, Long tailorId) if (alreadySettled(tailor)) { return; } + // 결과는 복사본에 계산한다. 조회한 엔티티는 open-in-view로 요청 내내 관리 상태라, 그것을 고치면 조건부 확정이 + // 쿼리 전에 변경을 먼저 flush해 PENDING 조건이 어긋나고 확정이 0건으로 끝난다 — 그 flush는 조건 없이 쓰기도 한다. + tailor = tailor.toBuilder().build(); if (tailor.getJobId() == null) { tailor.setJobId(request.getJobId()); @@ -886,6 +889,9 @@ public void handleMultiCallback(QuestionTailorMultiCallbackRequest request, Long if (alreadySettled(tailor)) { return; } + // 결과는 복사본에 계산한다. 조회한 엔티티는 open-in-view로 요청 내내 관리 상태라, 그것을 고치면 조건부 확정이 + // 쿼리 전에 변경을 먼저 flush해 PENDING 조건이 어긋나고 확정이 0건으로 끝난다 — 그 flush는 조건 없이 쓰기도 한다. + tailor = tailor.toBuilder().build(); if (tailor.getJobId() == null) { tailor.setJobId(request.getJobId()); 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 index c492b4df..838b9a05 100644 --- 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 @@ -6,6 +6,10 @@ 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; @@ -46,6 +50,8 @@ 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에 대고 본다. @@ -309,4 +315,52 @@ private static QuestionTailorEntity succeeded(QuestionTailorEntity read) { 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 bbfc2fab..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 @@ -150,8 +150,9 @@ private QuestionTailorEntity lastSaved() { new QuestionTailorCallbackRequest.Question(2, "다시 쓴 WebFlux 질문", null))), null), null); - verify(chatInterviewHandoffService).deliver(tailor); - assertThat(lastSaved().getChatDelivered()).isTrue(); + QuestionTailorEntity settled = lastSaved(); + verify(chatInterviewHandoffService).deliver(settled); + assertThat(settled.getChatDelivered()).isTrue(); } @Test @@ -278,7 +279,7 @@ private QuestionTailorEntity lastSaved() { assertThat(saved.getErrorStatusCode()).isEqualTo(422); assertThat(saved.getQuestions()).hasSize(2); // 재작성이 실패해도 원질문으로 면접은 열려야 한다. - verify(chatInterviewHandoffService).deliver(tailor); + verify(chatInterviewHandoffService).deliver(saved); } /** @@ -354,9 +355,10 @@ private QuestionTailorEntity lastSaved() { new QuestionTailorCallbackRequest.Question(2, "다시 쓴 WebFlux 질문", 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까지 가지 않고 걸러낸다. 콜백 재전송마다 갱신이 나갈 이유가 없다. */