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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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("이미 피드백을 생성하고 있습니다. 잠시 후 다시 확인해주세요.");
Expand All @@ -109,15 +113,22 @@ public FeedbackAcceptedResponse requestFeedback(Long userId, Long interviewId) {
* <p>채점 대상이 누구 것인지는 토큰이 아니라 면접에 적힌 소유자로 정한다. 면접을 시작할 때
* 이미 본인 확인을 거쳤고, 여기까지 오는 요청은 채팅 서버가 보내는 서버 간 호출뿐이다.
*
* <p>중단된 면접도 그대로 채점한다. 답한 데까지는 평가할 것이 남아 있고, 웹이 직접 요청하던
* 길도 상태를 가리지 않았다. 답변이 하나도 없는 면접은 {@code loadTranscript}가 막는다.
* <p>서버가 정상 완료(COMPLETED)로 확정한 면접만 채점한다. 중단·진행 중 면접은 외부 호출 없이
* 건너뛰고, 자동 채점 작업은 실패가 아니라 완료로 닫힌다.
*
* <p>이미 채점 중이거나 끝난 면접은 건너뛴다. 웹이 먼저 요청했거나 같은 기록이 두 번 넘어온
* 경우라, 여기서 거절로 취급하면 정상적인 흐름이 실패로 남는다.
*/
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={}",
Expand All @@ -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("면접을 찾을 수 없습니다"));
Expand All @@ -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 블록도 오지 않는다.
Expand Down Expand Up @@ -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()))
Expand Down Expand Up @@ -324,6 +357,7 @@ private FeedbackMultiRequest toMultiRequest(InterviewEntity interview, Transcrip
Map<Long, Long> 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()))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ class FeedbackMultiRequestSerializationTest {

private FeedbackMultiRequest request() {
return FeedbackMultiRequest.builder()
.interviewStatus("COMPLETED")
.sessionId("sess-1")
.interviewId("3")
.userId("7")
Expand Down Expand Up @@ -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");
});
}
}
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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")
Expand All @@ -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");
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<FeedbackMultiRequest> 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
}
Loading