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");
+ }
}