diff --git a/src/main/java/repit/repit_api_server/domain/userdata/feedback/dto/request/FeedbackMultiRequest.java b/src/main/java/repit/repit_api_server/domain/userdata/feedback/dto/request/FeedbackMultiRequest.java index 34eb0988..669619de 100644 --- a/src/main/java/repit/repit_api_server/domain/userdata/feedback/dto/request/FeedbackMultiRequest.java +++ b/src/main/java/repit/repit_api_server/domain/userdata/feedback/dto/request/FeedbackMultiRequest.java @@ -22,6 +22,9 @@ @NoArgsConstructor @AllArgsConstructor public class FeedbackMultiRequest { + // API가 저장한 면접 상태. 분석 서버는 "COMPLETED"만 받고 나머지는 422로 거부한다. + // 웹 입력을 옮기지 않고, 채점 직전에 검사한 DB 값을 그대로 싣는다. + private String interviewStatus; private String sessionId; private String interviewId; private String userId; diff --git a/src/main/java/repit/repit_api_server/domain/userdata/feedback/dto/request/FeedbackSoloRequest.java b/src/main/java/repit/repit_api_server/domain/userdata/feedback/dto/request/FeedbackSoloRequest.java index 53d6189c..f7bd50bd 100644 --- a/src/main/java/repit/repit_api_server/domain/userdata/feedback/dto/request/FeedbackSoloRequest.java +++ b/src/main/java/repit/repit_api_server/domain/userdata/feedback/dto/request/FeedbackSoloRequest.java @@ -16,6 +16,9 @@ @NoArgsConstructor @AllArgsConstructor public class FeedbackSoloRequest { + // API가 저장한 면접 상태. 분석 서버는 "COMPLETED"만 받고 나머지는 422로 거부한다. + // 웹 입력을 옮기지 않고, 채점 직전에 검사한 DB 값을 그대로 싣는다. + private String interviewStatus; private String sessionId; private String interviewId; private String userId; diff --git a/src/main/java/repit/repit_api_server/domain/userdata/feedback/service/FeedbackService.java b/src/main/java/repit/repit_api_server/domain/userdata/feedback/service/FeedbackService.java index 33bfd784..0a271b6f 100644 --- a/src/main/java/repit/repit_api_server/domain/userdata/feedback/service/FeedbackService.java +++ b/src/main/java/repit/repit_api_server/domain/userdata/feedback/service/FeedbackService.java @@ -92,6 +92,10 @@ public FeedbackAcceptedResponse requestFeedback(Long userId, Long interviewId) { InterviewEntity interview = findInterview(interviewId); verifyOwner(interview.getUserId(), userId); + if (!isFeedbackEligible(interview)) { + throw notEligible(interview); + } + FeedbackStatus running = runningStatusOf(interviewId); if (running == FeedbackStatus.PENDING) { throw BusinessException.conflict("이미 피드백을 생성하고 있습니다. 잠시 후 다시 확인해주세요."); @@ -109,8 +113,8 @@ public FeedbackAcceptedResponse requestFeedback(Long userId, Long interviewId) { *

채점 대상이 누구 것인지는 토큰이 아니라 면접에 적힌 소유자로 정한다. 면접을 시작할 때 * 이미 본인 확인을 거쳤고, 여기까지 오는 요청은 채팅 서버가 보내는 서버 간 호출뿐이다. * - *

중단된 면접도 그대로 채점한다. 답한 데까지는 평가할 것이 남아 있고, 웹이 직접 요청하던 - * 길도 상태를 가리지 않았다. 답변이 하나도 없는 면접은 {@code loadTranscript}가 막는다. + *

서버가 정상 완료(COMPLETED)로 확정한 면접만 채점한다. 중단·진행 중 면접은 외부 호출 없이 + * 건너뛰고, 자동 채점 작업은 실패가 아니라 완료로 닫힌다. * *

이미 채점 중이거나 끝난 면접은 건너뛴다. 웹이 먼저 요청했거나 같은 기록이 두 번 넘어온 * 경우라, 여기서 거절로 취급하면 정상적인 흐름이 실패로 남는다. @@ -118,6 +122,13 @@ public FeedbackAcceptedResponse requestFeedback(Long userId, Long interviewId) { public void requestFeedbackForFinishedInterview(Long interviewId) { InterviewEntity interview = findInterview(interviewId); + if (!isFeedbackEligible(interview)) { + // 정책상 채점하지 않는 면접이다. 실패가 아니므로 예외를 던지지 않는다 — 던지면 자동 채점 작업이 재시도된다. + log.info("정상 완료된 면접이 아니라 자동 채점을 하지 않습니다. interviewId={}, status={}", + interviewId, interview.getStatus()); + return; + } + FeedbackStatus running = runningStatusOf(interviewId); if (running != null) { log.info("이미 채점이 접수된 면접이라 자동 채점을 건너뜁니다. interviewId={}, status={}", @@ -130,6 +141,22 @@ public void requestFeedbackForFinishedInterview(Long interviewId) { interviewId, accepted == null ? null : accepted.getJobId()); } + /** + * 서버가 정상 완료로 확정한 면접만 채점한다. 중단(ABANDONED)과 진행 중(IN_PROGRESS)은 채점하지 않는다. + * 소켓이 끊긴 것만으로는 완료나 중단으로 보지 않는다 — 판단은 저장된 상태로만 한다. + */ + private static boolean isFeedbackEligible(InterviewEntity interview) { + return interview.getStatus() == Status.COMPLETED; + } + + /** 수동 요청이 채점 대상이 아닐 때 돌려줄 오류. 기존 오류 envelope 그대로 409다. */ + private static BusinessException notEligible(InterviewEntity interview) { + if (interview.getStatus() == Status.ABANDONED) { + return BusinessException.conflict("중단된 면접은 피드백을 생성하지 않습니다."); + } + return BusinessException.conflict("면접이 아직 끝나지 않았습니다. 완료된 면접만 피드백을 요청할 수 있습니다."); + } + private InterviewEntity findInterview(Long interviewId) { return interviewRepository.findById(interviewId) .orElseThrow(() -> BusinessException.notFound("면접을 찾을 수 없습니다")); @@ -156,6 +183,11 @@ private FeedbackStatus runningStatusOf(Long interviewId) { /** 분석 서버에 채점을 맡기고 접수 사실을 남긴다. 웹이 부르는 길과 채팅 서버가 부르는 길이 함께 쓴다. */ private FeedbackAcceptedResponse startFeedback(InterviewEntity interview) { + // 공통 마지막 검사. 이 메서드를 직접 부르는 새 경로가 생겨도 상태 검사를 건너뛰지 못하게 한다. + if (!isFeedbackEligible(interview)) { + throw notEligible(interview); + } + Transcript transcript = loadTranscript(interview); // N:1은 면접관별 평가까지 받아야 해서 엔드포인트가 다르다. 1:1 채점에 태우면 // 질문이 누구 것인지 잃고 personas 블록도 오지 않는다. @@ -291,6 +323,7 @@ private FeedbackSoloRequest toSoloRequest(InterviewEntity interview, Transcript PersonaEntity persona = soloPersonaOf(interview); return FeedbackSoloRequest.builder() + .interviewStatus(interview.getStatus().name()) .sessionId(interview.getSessionId()) .interviewId(String.valueOf(interview.getInterviewId())) .userId(String.valueOf(interview.getUserId())) @@ -324,6 +357,7 @@ private FeedbackMultiRequest toMultiRequest(InterviewEntity interview, Transcrip Map personaByQuestion = resolveQuestionPersonas(transcript, members.getFirst().getPersonaId()); return FeedbackMultiRequest.builder() + .interviewStatus(interview.getStatus().name()) .sessionId(interview.getSessionId()) .interviewId(String.valueOf(interview.getInterviewId())) .userId(String.valueOf(interview.getUserId())) diff --git a/src/test/java/repit/repit_api_server/domain/userdata/feedback/dto/request/FeedbackMultiRequestSerializationTest.java b/src/test/java/repit/repit_api_server/domain/userdata/feedback/dto/request/FeedbackMultiRequestSerializationTest.java index 4690aaca..b57e932a 100644 --- a/src/test/java/repit/repit_api_server/domain/userdata/feedback/dto/request/FeedbackMultiRequestSerializationTest.java +++ b/src/test/java/repit/repit_api_server/domain/userdata/feedback/dto/request/FeedbackMultiRequestSerializationTest.java @@ -21,6 +21,7 @@ class FeedbackMultiRequestSerializationTest { private FeedbackMultiRequest request() { return FeedbackMultiRequest.builder() + .interviewStatus("COMPLETED") .sessionId("sess-1") .interviewId("3") .userId("7") @@ -73,4 +74,15 @@ private FeedbackMultiRequest request() { assertThat(json).doesNotContain("created_at"); }); } + + @Test + void 면접_상태는_interviewStatus_키로_직렬화된다() { + contextRunner.run(context -> { + ObjectMapper objectMapper = context.getBean(ObjectMapper.class); + + JsonNode json = objectMapper.readTree(objectMapper.writeValueAsString(request())); + + assertThat(json.get("interviewStatus").stringValue()).isEqualTo("COMPLETED"); + }); + } } diff --git a/src/test/java/repit/repit_api_server/domain/userdata/feedback/dto/request/FeedbackSoloRequestSerializationTest.java b/src/test/java/repit/repit_api_server/domain/userdata/feedback/dto/request/FeedbackSoloRequestSerializationTest.java index d34b3d09..eeeff805 100644 --- a/src/test/java/repit/repit_api_server/domain/userdata/feedback/dto/request/FeedbackSoloRequestSerializationTest.java +++ b/src/test/java/repit/repit_api_server/domain/userdata/feedback/dto/request/FeedbackSoloRequestSerializationTest.java @@ -1,5 +1,6 @@ package repit.repit_api_server.domain.userdata.feedback.dto.request; +import tools.jackson.databind.JsonNode; import tools.jackson.databind.ObjectMapper; import org.junit.jupiter.api.Test; import org.springframework.boot.jackson.autoconfigure.JacksonAutoConfiguration; @@ -25,6 +26,7 @@ class FeedbackSoloRequestSerializationTest { LocalDateTime createdAt = LocalDateTime.of(2026, 8, 18, 10, 0, 0); FeedbackSoloRequest request = FeedbackSoloRequest.builder() + .interviewStatus("COMPLETED") .sessionId("sess-1") .interviewId("1") .userId("7") @@ -51,4 +53,24 @@ class FeedbackSoloRequestSerializationTest { assertThat(json).contains("\"parentId\":null"); }); } + + @Test + void 면접_상태는_interviewStatus_키로_직렬화된다() { + contextRunner.run(context -> { + ObjectMapper objectMapper = context.getBean(ObjectMapper.class); + + FeedbackSoloRequest request = FeedbackSoloRequest.builder() + .interviewStatus("COMPLETED") + .sessionId("sess-1") + .interviewId("1") + .userId("7") + .questions(List.of()) + .answers(List.of()) + .build(); + + JsonNode json = objectMapper.readTree(objectMapper.writeValueAsString(request)); + + assertThat(json.get("interviewStatus").stringValue()).isEqualTo("COMPLETED"); + }); + } } diff --git a/src/test/java/repit/repit_api_server/domain/userdata/feedback/service/FeedbackServiceFinishedInterviewTest.java b/src/test/java/repit/repit_api_server/domain/userdata/feedback/service/FeedbackServiceFinishedInterviewTest.java index 7d576401..b7f9d348 100644 --- a/src/test/java/repit/repit_api_server/domain/userdata/feedback/service/FeedbackServiceFinishedInterviewTest.java +++ b/src/test/java/repit/repit_api_server/domain/userdata/feedback/service/FeedbackServiceFinishedInterviewTest.java @@ -12,6 +12,7 @@ import org.springframework.test.util.ReflectionTestUtils; import repit.repit_api_server.domain.userdata.answer.entity.AnswerEntity; import repit.repit_api_server.domain.userdata.answer.repository.AnswerRepository; +import repit.repit_api_server.domain.userdata.feedback.dto.request.FeedbackMultiRequest; import repit.repit_api_server.domain.userdata.feedback.dto.response.FeedbackAcceptedResponse; import repit.repit_api_server.domain.userdata.feedback.entity.FeedbackEntity; import repit.repit_api_server.domain.userdata.feedback.entity.enums.FeedbackStatus; @@ -187,12 +188,39 @@ private FeedbackEntity feedback(FeedbackStatus status) { } @Test - void 중단된_면접도_답한_데까지는_채점한다() { - when(interviewRepository.findById(3L)).thenReturn(Optional.of(interview(Status.ABANDONED, 5L))); + void N대1_채점_요청에도_면접_상태를_싣는다() { + when(interviewRepository.findById(3L)).thenReturn(Optional.of(interview(Status.COMPLETED, null))); + when(interviewPersonaRepository.findAllByInterviewIdOrderByPersonaOrderAsc(3L)).thenReturn(List.of( + InterviewPersonaEntity.builder().interviewId(3L).personaId(5L).personaOrder(0).build())); + when(personaRepository.findAllById(List.of(5L))).thenReturn(List.of(persona())); service.requestFeedbackForFinishedInterview(3L); - verify(aiServerClient).requestSoloFeedback(any()); + ArgumentCaptor sent = ArgumentCaptor.forClass(FeedbackMultiRequest.class); + verify(aiServerClient).requestMultiFeedback(sent.capture()); + assertThat(sent.getValue().getInterviewStatus()).isEqualTo("COMPLETED"); + } + + @Test + void 중단된_면접은_자동_채점하지_않는다() { + when(interviewRepository.findById(3L)).thenReturn(Optional.of(interview(Status.ABANDONED, 5L))); + + assertThatCode(() -> service.requestFeedbackForFinishedInterview(3L)).doesNotThrowAnyException(); + + verify(aiServerClient, never()).requestSoloFeedback(any()); + verify(aiServerClient, never()).requestMultiFeedback(any()); + verify(feedbackRepository, never()).save(any()); + } + + @Test + void 진행_중인_면접은_자동_채점하지_않는다() { + when(interviewRepository.findById(3L)).thenReturn(Optional.of(interview(Status.IN_PROGRESS, 5L))); + + assertThatCode(() -> service.requestFeedbackForFinishedInterview(3L)).doesNotThrowAnyException(); + + verify(aiServerClient, never()).requestSoloFeedback(any()); + verify(aiServerClient, never()).requestMultiFeedback(any()); + verify(feedbackRepository, never()).save(any()); } @Test diff --git a/src/test/java/repit/repit_api_server/domain/userdata/feedback/service/FeedbackServiceSoloRequestTest.java b/src/test/java/repit/repit_api_server/domain/userdata/feedback/service/FeedbackServiceSoloRequestTest.java index 54c0fb0c..6e34bf77 100644 --- a/src/test/java/repit/repit_api_server/domain/userdata/feedback/service/FeedbackServiceSoloRequestTest.java +++ b/src/test/java/repit/repit_api_server/domain/userdata/feedback/service/FeedbackServiceSoloRequestTest.java @@ -557,9 +557,41 @@ private FeedbackSoloRequest captureRequest() { when(answerRepository.findAllByInterviewIdOrderByAnswerIdAsc(3L)).thenReturn(List.of()); assertThatThrownBy(() -> service.requestFeedback(USER_ID, 3L)) - .isInstanceOf(BusinessException.class) + .isInstanceOfSatisfying(BusinessException.class, e -> assertThat(e.getStatus()).isEqualTo(HttpStatus.CONFLICT)) .hasMessageContaining("면접이 아직 끝나지 않았습니다"); verify(aiServerClient, never()).requestSoloFeedback(any()); } + + @Test + void 중단된_면접은_수동_요청도_409로_거절한다() { + when(interviewRepository.findById(3L)).thenReturn(Optional.of(interview(Status.ABANDONED, 5L))); + + assertThatThrownBy(() -> service.requestFeedback(USER_ID, 3L)) + .isInstanceOfSatisfying(BusinessException.class, e -> assertThat(e.getStatus()).isEqualTo(HttpStatus.CONFLICT)) + .hasMessageContaining("중단된 면접은 피드백을 생성하지 않습니다"); + + verify(aiServerClient, never()).requestSoloFeedback(any()); + } + + @Test + void 중단된_면접은_실패한_피드백이_있어도_재채점하지_않는다() { + when(interviewRepository.findById(3L)).thenReturn(Optional.of(interview(Status.ABANDONED, 5L))); + FeedbackEntity failed = previousFeedback(FeedbackStatus.FAILED, LocalDateTime.now()); + when(feedbackRepository.findTopByInterviewIdOrderByCreatedAtDesc(3L)).thenReturn(Optional.of(failed)); + + assertThatThrownBy(() -> service.requestFeedback(USER_ID, 3L)) + .isInstanceOfSatisfying(BusinessException.class, e -> assertThat(e.getStatus()).isEqualTo(HttpStatus.CONFLICT)) + .hasMessageContaining("중단된 면접은 피드백을 생성하지 않습니다"); + + verify(feedbackRepository, never()).save(any()); + verify(aiServerClient, never()).requestSoloFeedback(any()); + } + + @Test + void 채점_요청에_면접_상태를_싣는다() { + service.requestFeedback(USER_ID, 3L); + + assertThat(captureRequest().getInterviewStatus()).isEqualTo("COMPLETED"); + } }