diff --git a/docs/behavior-analysis.md b/docs/behavior-analysis.md new file mode 100644 index 0000000..87b1946 --- /dev/null +++ b/docs/behavior-analysis.md @@ -0,0 +1,43 @@ +# 행동 분석 연동에 대한 Chat 계약 + +## 역할 + +전체 면접 영상은 Web → API → S3 경로로 업로드한다. +API가 영상과 면접 식별자를 연결하고 Analysis에 분석을 요청한다. +Chat은 기존 질문·답변 기록을 API에 전달한다. +영상 업로드와 행동 분석 완료를 기다리는 단계는 Chat에 추가하지 않는다. +텍스트 면접도 동일한 종료 경로를 사용한다. + +## 식별자와 중복 준비 + +- 준비 요청의 `sessionId`, `interviewId`, `userId`를 세션과 준비 응답에 그대로 보존한다. +- SOLO와 MULTI 모두 종료 본문에 동일 식별자와 `mode`를 전달한다. +- 같은 세션과 같은 면접·사용자로 준비를 재시도하면 기존 진행 상태를 반환한다. +- 같은 `sessionId`에 다른 `interviewId`나 `userId`를 보내면 HTTP 409 `ALREADY_IN_USE`로 거절한다. +- 식별자가 충돌한 요청은 기존 세션을 덮어쓰거나 TTS를 다시 등록하지 않는다. + +## 종료와 전송 + +자동 종료·명시적 완료·중단은 기존 최종 기록 보존 경로를 사용한다. +마지막 답변을 저장한 후 최종 본문을 전송 대기 저장소에 보존한다. +API 타임아웃은 기존 정책에 따라 대기 기록을 보존하고 종료 응답을 반환한다. +재전송은 최초 직렬화 본문을 사용하므로 식별자와 답변 시각이 바뀌지 않는다. +중복 답변·완료·중단 요청은 기존 종료 상태를 반환한다. +인증 오류 등 재시도 불가능한 API 실패는 기존 오류 정책을 유지한다. + +종료 본문의 최상위 필드는 다음과 같다. +`sessionId`, `interviewId`, `userId`, `status`, `mode`, `interviewCreatedAt`, `qnaRequests`. +영상 URL·행동 지표·landmark·모델 버전을 Redis 세션이나 종료 본문·AI 프롬프트에 추가하지 않는다. + +## 회귀 검증 + +`ChatInterviewIdentityTest`의 8개 사례가 준비 식별자 보존·동일 요청 재시도·식별자 충돌 거절을 검증한다. +`BehaviorInterviewFlowTest`의 8개 사례가 SOLO/MULTI의 자동 종료·명시적 완료·중단과 타임아웃 재전송을 검증한다. +후자는 실제 Redis 직렬화기·Outbox·Delivery 서비스를 연결하고 Redis I/O와 API 클라이언트를 대역으로 사용한다. +기존 `InterviewResultRedisTest`는 실제 로컬 Redis를 사용하여 장애 복구와 잠금 동작을 검증한다. + +검증 명령은 Java 21에서 `./gradlew test`다. +이번 변경에서는 `./gradlew --offline --no-daemon test`로 전체 263개 테스트가 통과했고 실패·건너뜀은 없었다. +수정 전에는 식별자 충돌 거절 4개 사례가 실패하는 것도 확인했다. +S3 업로드 순서와 API 분석 요청·콜백·Web 중복 업로드 방지는 해당 서버의 통합 검증 대상이다. +질문별 영상 시간축과 실시간 행동 분석은 이번 Chat 계약에 포함하지 않는다. diff --git a/src/main/java/com/person/repit/interview/service/impl/ChatInterviewServiceImpl.java b/src/main/java/com/person/repit/interview/service/impl/ChatInterviewServiceImpl.java index 590aa11..b4ddf33 100644 --- a/src/main/java/com/person/repit/interview/service/impl/ChatInterviewServiceImpl.java +++ b/src/main/java/com/person/repit/interview/service/impl/ChatInterviewServiceImpl.java @@ -19,6 +19,7 @@ import com.person.repit.interview.exception.InterviewQuestionMismatchException; import com.person.repit.interview.exception.InterviewQuestionNotFoundException; import com.person.repit.interview.exception.InterviewSessionDataException; +import com.person.repit.interview.exception.InterviewSessionAlreadyExistsException; import com.person.repit.interview.exception.InterviewSessionNotFoundException; import com.person.repit.interview.service.AiQuestionClient; import com.person.repit.interview.service.InterviewOperationLock; @@ -44,6 +45,7 @@ import java.util.HashMap; import java.util.HashSet; import java.util.Map; +import java.util.Objects; @Service @RequiredArgsConstructor @@ -120,7 +122,22 @@ public Mono prepareInterview(ChatInterviewPrepareRequest .flatMap(created -> Boolean.TRUE.equals(created) ? registerOriginalQuestions(session) .thenReturn(ChatInterviewResponse.from(session)) - : getSession(request.getSessionId()).map(ChatInterviewResponse::from)); + : existingPreparation(request)); + }); + } + + private Mono existingPreparation(ChatInterviewPrepareRequest request) { + return getSession(request.getSessionId()).flatMap(existing -> { + boolean sameIdentity = Objects.equals(existing.getSessionId(), request.getSessionId()) + && Objects.equals(existing.getInterviewId(), request.getInterviewId()) + && Objects.equals(existing.getUserId(), request.getUserId()); + if (!sameIdentity) { + // A session ID cannot silently resolve to another interview or user. + return Mono.error(new InterviewSessionAlreadyExistsException( + "이미 다른 면접 식별자로 준비된 세션입니다." + )); + } + return Mono.just(ChatInterviewResponse.from(existing)); }); } diff --git a/src/test/java/com/person/repit/interview/service/impl/BehaviorInterviewFlowTest.java b/src/test/java/com/person/repit/interview/service/impl/BehaviorInterviewFlowTest.java new file mode 100644 index 0000000..8c7c16d --- /dev/null +++ b/src/test/java/com/person/repit/interview/service/impl/BehaviorInterviewFlowTest.java @@ -0,0 +1,176 @@ +package com.person.repit.interview.service.impl; + +import com.fasterxml.jackson.databind.JsonNode; +import com.person.repit.common.config.JacksonConfig; +import com.person.repit.common.metrics.RepitMetrics; +import com.person.repit.interview.domain.ChatInterviewSession; +import com.person.repit.interview.domain.ChatQuestion; +import com.person.repit.interview.dto.request.ChatAnswerRequest; +import com.person.repit.interview.dto.response.ChatProgressResponse; +import com.person.repit.interview.service.*; +import com.person.repit.interview.type.*; +import com.person.repit.tts.service.InterviewTtsScheduler; +import com.person.repit.tts.service.PersonaTtsProfileResolver; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.EnumSource; +import org.springframework.data.redis.core.ReactiveRedisTemplate; +import org.springframework.data.redis.core.ReactiveStringRedisTemplate; +import org.springframework.data.redis.core.ReactiveValueOperations; +import org.springframework.data.redis.serializer.RedisSerializer; +import reactor.core.publisher.Mono; + +import java.time.Duration; +import java.time.Instant; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeoutException; +import java.util.function.Supplier; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** Exercises service -> serialized session -> outbox -> delivery without video/analysis infrastructure. */ +@SuppressWarnings("unchecked") +class BehaviorInterviewFlowTest { + private final ReactiveRedisTemplate redis = mock(ReactiveRedisTemplate.class); + private final ReactiveValueOperations values = mock(ReactiveValueOperations.class); + private final ReactiveStringRedisTemplate strings = mock(ReactiveStringRedisTemplate.class); + private final ReactiveValueOperations stringValues = mock(ReactiveValueOperations.class); + private final Map sessions = new HashMap<>(); + private final Map records = new HashMap<>(); + private final List sent = new ArrayList<>(); + private final RedisSerializer serializer = RedisSerializer.json(); + private final ApiServerClient api = mock(ApiServerClient.class); + private final AiQuestionClient ai = mock(AiQuestionClient.class); + private final InterviewTtsScheduler tts = mock(InterviewTtsScheduler.class); + private final com.fasterxml.jackson.databind.ObjectMapper mapper = new JacksonConfig().objectMapper(); + private ChatInterviewServiceImpl service; + private InterviewResultOutbox outbox; + private InterviewResultDeliveryService delivery; + + @BeforeEach + void setUp() { + when(redis.opsForValue()).thenReturn(values); + when(strings.opsForValue()).thenReturn(stringValues); + when(values.get(anyString())).thenAnswer(call -> Mono.fromSupplier(() -> + serializer.deserialize(sessions.get(call.getArgument(0))))); + when(values.set(anyString(), any(), any(Duration.class))).thenAnswer(call -> saveSession(call)); + when(values.set(anyString(), any())).thenAnswer(call -> saveSession(call)); + when(redis.delete(anyString())).thenAnswer(call -> Mono.fromSupplier(() -> + sessions.remove(call.getArgument(0)) == null ? 0L : 1L)); + when(stringValues.get(anyString())).thenAnswer(call -> + Mono.fromSupplier(() -> records.get(call.getArgument(0)))); + when(stringValues.setIfAbsent(anyString(), anyString())).thenAnswer(call -> + Mono.fromSupplier(() -> records.putIfAbsent(call.getArgument(0), call.getArgument(1)) == null)); + when(stringValues.set(anyString(), anyString())).thenAnswer(call -> Mono.fromSupplier(() -> { + records.put(call.getArgument(0), call.getArgument(1)); + return true; + })); + when(api.saveInterviewPayload(anyString())).thenAnswer(call -> Mono.fromRunnable(() -> + sent.add(call.getArgument(0)))); + var lock = mock(InterviewOperationLock.class); + when(lock.execute(anyString(), any())).thenAnswer(call -> + ((Supplier>) call.getArgument(1)).get()); + var meters = new SimpleMeterRegistry(); + outbox = new InterviewResultOutbox(strings, mapper); + delivery = new InterviewResultDeliveryService(outbox, api, new InterviewResultRetryPolicy(), meters); + service = new ChatInterviewServiceImpl(ai, redis, delivery, lock, new RepitMetrics(meters), + mock(PersonaTtsProfileResolver.class), tts); + } + + @ParameterizedTest + @CsvSource({"SOLO,AUTO", "MULTI,AUTO", "SOLO,COMPLETE", "MULTI,COMPLETE", "SOLO,QUIT", "MULTI,QUIT"}) + void 영상_업로드나_분석없이_종료기록의_식별자를_보존한다(InterviewMode mode, String path) throws Exception { + prepareSession(mode); + Mono operation = switch (path) { + case "AUTO" -> service.submitAnswer("api-session", answer()); + case "QUIT" -> service.quitInterview("api-session"); + default -> service.completeInterview("api-session"); + }; + var expected = path.equals("QUIT") ? InterviewStatus.ABANDONED : InterviewStatus.COMPLETED; + assertThat(operation.block(Duration.ofSeconds(2)).getStatus()).isEqualTo(expected); + assertThat(sent).hasSize(1); + var payload = mapper.readTree(sent.getFirst()); + assertIdentity(payload, mode); + assertThat(payload.path("status").asText()).isEqualTo(expected.name()); + assertThat(payload.path("qnaRequests").get(0).path("question").path("id").asLong()).isEqualTo(303L); + assertThat(payload.path("qnaRequests").get(0).path("question").path("personaId").asLong()).isEqualTo(404L); + if (path.equals("AUTO")) { + assertThat(payload.path("qnaRequests").get(0).path("answer").path("answerContent").asText()) + .isEqualTo("마지막 답변"); + } + assertThat(sessions).isEmpty(); + verify(tts).cancelSession("api-session"); + verifyNoInteractions(ai); + // Duplicate completion / answer / quit all return the original completion status. + assertThat(service.completeInterview("api-session").block().getStatus()).isEqualTo(expected); + assertThat(service.quitInterview("api-session").block().getStatus()).isEqualTo(expected); + assertThat(service.submitAnswer("api-session", answer()).block().getStatus()).isEqualTo(expected); + verify(api).saveInterviewPayload(anyString()); + } + + @ParameterizedTest + @EnumSource(InterviewMode.class) + void API_타임아웃후에도_종료하고_같은_식별자와_최종답변으로_재전송한다(InterviewMode mode) throws Exception { + prepareSession(mode); + when(api.saveInterviewPayload(anyString())).thenAnswer(call -> { + sent.add(call.getArgument(0)); + return sent.size() == 1 ? Mono.error(new TimeoutException()) : Mono.empty(); + }); + assertThat(service.submitAnswer("api-session", answer()).block(Duration.ofSeconds(2)).getStatus()) + .isEqualTo(InterviewStatus.COMPLETED); + assertThat(sessions).isEmpty(); + var pending = outbox.find("api-session").block(); + assertThat(pending.getState()).isEqualTo(com.person.repit.interview.domain.InterviewResultDelivery.State.PENDING); + assertIdentity(mapper.readTree(pending.getPayload()), mode); + assertThat(pending.getPayload()).isEqualTo(sent.getFirst()).contains("마지막 답변"); + assertThat(service.completeInterview("api-session").block().getStatus()).isEqualTo(InterviewStatus.COMPLETED); + verify(api).saveInterviewPayload(anyString()); + pending.setNextAttemptAt(Instant.EPOCH); + delivery.deliver(pending).block(Duration.ofSeconds(2)); + assertThat(sent).hasSize(2); + assertThat(sent.get(1)).isEqualTo(sent.getFirst()); + var restored = outbox.find("api-session").block(); + assertThat(restored.getState()).isEqualTo(com.person.repit.interview.domain.InterviewResultDelivery.State.SENT); + assertThat(restored.getPayload()).isNull(); + verifyNoInteractions(ai); + } + + private Mono saveSession(org.mockito.invocation.InvocationOnMock call) { + return Mono.fromSupplier(() -> { + sessions.put(call.getArgument(0), serializer.serialize(call.getArgument(1))); + return true; + }); + } + + private void prepareSession(InterviewMode mode) { + var session = ChatInterviewSession.builder().sessionId("api-session").interviewId(101L).userId(202L) + .mode(mode).createdAt(LocalDateTime.of(2026, 10, 20, 10, 0)) + .questions(new ArrayList<>(List.of(ChatQuestion.builder().questionId(303L) + .askedByPersonaId(404L).type(QuestionType.ORIGINAL).content("질문").build()))) + .build(); + sessions.put("chat:interview:api-session", serializer.serialize(session)); + } + + private void assertIdentity(JsonNode payload, InterviewMode mode) { + assertThat(payload.path("sessionId").asText()).isEqualTo("api-session"); + assertThat(payload.path("interviewId").asLong()).isEqualTo(101L); + assertThat(payload.path("userId").asLong()).isEqualTo(202L); + assertThat(payload.path("mode").asText()).isEqualTo(mode.name()); + var fields = new ArrayList(); + payload.fieldNames().forEachRemaining(fields::add); + assertThat(fields).containsExactlyInAnyOrder("sessionId", "interviewId", "userId", "status", + "mode", "interviewCreatedAt", "qnaRequests"); + } + + private ChatAnswerRequest answer() { + return ChatAnswerRequest.builder().questionId(303L).content("마지막 답변").responseTime(12).build(); + } +} diff --git a/src/test/java/com/person/repit/interview/service/impl/ChatInterviewIdentityTest.java b/src/test/java/com/person/repit/interview/service/impl/ChatInterviewIdentityTest.java new file mode 100644 index 0000000..65c410c --- /dev/null +++ b/src/test/java/com/person/repit/interview/service/impl/ChatInterviewIdentityTest.java @@ -0,0 +1,122 @@ +package com.person.repit.interview.service.impl; + +import com.person.repit.common.metrics.RepitMetrics; +import com.person.repit.interview.domain.ChatInterviewSession; +import com.person.repit.interview.dto.request.ChatInterviewPrepareRequest; +import com.person.repit.interview.exception.InterviewSessionAlreadyExistsException; +import com.person.repit.interview.service.*; +import com.person.repit.interview.type.*; +import com.person.repit.tts.domain.AudioState; +import com.person.repit.tts.service.InterviewTtsScheduler; +import com.person.repit.tts.service.PersonaTtsProfileResolver; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.CsvSource; +import org.mockito.ArgumentCaptor; +import org.springframework.data.redis.core.ReactiveRedisTemplate; +import org.springframework.data.redis.core.ReactiveValueOperations; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import java.time.Duration; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@SuppressWarnings("unchecked") +class ChatInterviewIdentityTest { + private final ReactiveRedisTemplate redis = mock(ReactiveRedisTemplate.class); + private final ReactiveValueOperations values = mock(ReactiveValueOperations.class); + private final InterviewTtsScheduler tts = mock(InterviewTtsScheduler.class); + private final AiQuestionClient ai = mock(AiQuestionClient.class); + private ChatInterviewServiceImpl service; + + @BeforeEach + void setUp() { + when(redis.opsForValue()).thenReturn(values); + service = new ChatInterviewServiceImpl(ai, redis, mock(InterviewResultDeliveryService.class), + mock(InterviewOperationLock.class), new RepitMetrics(new SimpleMeterRegistry()), + mock(PersonaTtsProfileResolver.class), tts); + } + + @ParameterizedTest + @EnumSource(InterviewMode.class) + void 준비시_API_식별자를_세션과_응답에_그대로_보존한다(InterviewMode mode) { + var request = request(mode); + when(values.setIfAbsent(anyString(), any(), any(Duration.class))).thenReturn(Mono.just(true)); + when(tts.register(anyString(), any(), any(), anyInt(), anyInt())) + .thenReturn(Mono.just(AudioState.builder().build())); + + var response = service.prepareInterview(request).block(Duration.ofSeconds(2)); + + var saved = ArgumentCaptor.forClass(Object.class); + verify(values).setIfAbsent(eq("chat:interview:api-session"), saved.capture(), eq(Duration.ofHours(3))); + var session = (ChatInterviewSession) saved.getValue(); + assertThat(session.getSessionId()).isEqualTo(request.getSessionId()); + assertThat(session.getInterviewId()).isEqualTo(request.getInterviewId()); + assertThat(session.getUserId()).isEqualTo(request.getUserId()); + assertThat(session.getMode()).isEqualTo(mode); + assertThat(response.getSessionId()).isEqualTo(session.getSessionId()); + assertThat(response.getInterviewId()).isEqualTo(session.getInterviewId()); + assertThat(response.getUserId()).isEqualTo(session.getUserId()); + assertThat(response.getMode()).isEqualTo(mode); + verifyNoInteractions(ai); + } + + @ParameterizedTest + @EnumSource(InterviewMode.class) + void 동일한_준비요청_재시도는_기존_세션을_그대로_반환한다(InterviewMode mode) { + var existing = existing(mode, 101L, 202L); + mockExisting(existing); + var response = service.prepareInterview(request(mode)).block(Duration.ofSeconds(2)); + assertThat(response.getSessionId()).isEqualTo(existing.getSessionId()); + assertThat(response.getInterviewId()).isEqualTo(101L); + assertThat(response.getUserId()).isEqualTo(202L); + assertThat(response.getCurrentQuestionIndex()).isEqualTo(1); + verify(values, never()).set(anyString(), any(), any(Duration.class)); + verifyNoInteractions(tts, ai); + } + + @ParameterizedTest + @CsvSource({"SOLO,999,202", "SOLO,101,999", "MULTI,999,202", "MULTI,101,999"}) + void 같은_세션의_면접이나_사용자_ID가_다르면_거절한다(InterviewMode mode, long interviewId, long userId) { + mockExisting(existing(mode, interviewId, userId)); + StepVerifier.create(service.prepareInterview(request(mode))) + .expectError(InterviewSessionAlreadyExistsException.class).verify(Duration.ofSeconds(2)); + verify(values, never()).set(anyString(), any(), any(Duration.class)); + verifyNoInteractions(tts, ai); + } + + private void mockExisting(ChatInterviewSession existing) { + when(values.setIfAbsent(anyString(), any(), any(Duration.class))).thenReturn(Mono.just(false)); + when(values.get("chat:interview:api-session")).thenReturn(Mono.just(existing)); + } + + private ChatInterviewSession existing(InterviewMode mode, Long interviewId, Long userId) { + return ChatInterviewSession.builder().sessionId("api-session").interviewId(interviewId) + .userId(userId).mode(mode).currentQuestionIndex(1).build(); + } + + private ChatInterviewPrepareRequest request(InterviewMode mode) { + var request = new ChatInterviewPrepareRequest(); + request.setSessionId("api-session"); + request.setInterviewId(101L); + request.setUserId(202L); + request.setStatus(InterviewStatus.IN_PROGRESS); + request.setMode(mode); + request.setPersonality(InterviewType.FRIENDLY); + request.setTone(InterviewTone.GENTLE); + request.setMajor(InterviewMajor.BACKEND); + request.setLevel(InterviewLevel.NORMAL); + var question = new ChatInterviewPrepareRequest.Question(); + question.setId(303L); + question.setQuestion("질문"); + question.setPersonaId(404L); + request.setQuestions(List.of(question)); + return request; + } +}