diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md index 5eee6a8..cfad844 100644 --- a/backend/CLAUDE.md +++ b/backend/CLAUDE.md @@ -485,6 +485,16 @@ docker compose up -d 재개된 세션은 IN_PROGRESS 라 그대로 통과한다. 그래서 `applyFollowup` 이 **이미 FAILED 인 placeholder 를 되살리지 않도록** 막는다 — 복구가 실패 확정 + 다음 질문까지 마친 뒤 늦은 콜백이 그 자리를 채우면 살아있는 질문이 두 개가 된다. POOL 은 `countBySessionId > 0` 로 이미 멱등. +- **STT 콜백 유실 복구 본 구현**: 음성 답변은 `(transcribing)` placeholder 로 먼저 저장되고 + `callback.voice` 가 도착해야 채워진다. 그 콜백이 유실되면(AI 크래시·DLQ 격리·브로커 단절) + 메시지가 그 상태로 남고 프론트 턴 판정상 **답변 차례가 오지 않아 면접이 멈춘다** — + 세션 시간 초과로 통째로 끝날 때까지. 질문 생성 쪽은 실패 신호로 이미 해결했지만 음성엔 + 대응이 없었다. `StaleTranscriptionSweeper`(기본 2분 주기)가 `interview.voice. + stale-transcription-minutes`(기본 5분)를 넘긴 placeholder 를 찾아 + `VoiceTranscriptionRecoveryService.failStaleTranscription` 으로 FAILED 확정한다 + (`STT_CALLBACK_TIMEOUT`). 그러면 기존 STT 실패 경로를 그대로 타서 사용자가 같은 질문에 + 텍스트로 다시 답할 수 있다. 목록 생성 후 콜백이 도착한 경우를 위해 확정 직전 상태를 다시 + 확인한다(완료된 답변을 실패로 되돌리지 않는다). - **Spring AI 미사용** — LLM·임베딩 호출은 모두 AI 서버 위임. Core는 RabbitMQ 발행만 담당. - **Redis 미사용** — 휘발성 데이터는 DB short-lived 레코드 또는 인메모리로. diff --git a/backend/src/main/java/com/stackup/stackup/session/application/StaleTranscriptionSweeper.java b/backend/src/main/java/com/stackup/stackup/session/application/StaleTranscriptionSweeper.java new file mode 100644 index 0000000..c662e8d --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/session/application/StaleTranscriptionSweeper.java @@ -0,0 +1,67 @@ +package com.stackup.stackup.session.application; + +import com.stackup.stackup.session.domain.InterviewMessage; +import com.stackup.stackup.session.domain.InterviewMessageRepository; +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +/** + * STT 콜백을 영영 못 받은 음성 답변을 정리한다. + * + *

음성 답변은 `(transcribing)` placeholder 로 먼저 저장되고 `callback.voice` 가 도착해야 + * 채워진다. 그 콜백이 유실되면(AI 크래시·DLQ 격리·브로커 단절) 메시지는 그 상태로 남고, + * 프론트의 턴 판정상 답변 차례가 오지 않아 면접이 그대로 멈춘다 — 세션 시간 초과로 + * 통째로 끝날 때까지. 질문 생성 쪽은 실패 신호(callback status=FAILED)로 이미 해결했지만 + * 음성에는 대응이 없었다. + * + *

여기서는 오래 멈춘 placeholder 를 FAILED 로 확정한다. 그러면 기존 STT 실패 경로를 그대로 + * 타서(프론트 `currentTurn` 의 INTERVIEWEE+FAILED 분기, 백엔드 `resolveAnswerParent`) + * 사용자는 같은 질문에 텍스트로 다시 답할 수 있다. + */ +@Component +@RequiredArgsConstructor +public class StaleTranscriptionSweeper { + + private static final Logger log = LoggerFactory.getLogger(StaleTranscriptionSweeper.class); + + private final InterviewMessageRepository messageRepository; + private final VoiceTranscriptionRecoveryService recoveryService; + + // 이 시간이 지나도 전사가 안 채워지면 유실로 본다. 배치 STT 는 보통 수십 초 안에 끝난다. + @Value("${interview.voice.stale-transcription-minutes:5}") + private long staleAfterMinutes = 5; + + @Transactional(propagation = Propagation.NOT_SUPPORTED) + @Scheduled( + fixedDelayString = "${interview.voice.sweep-interval-ms:120000}", + initialDelayString = "${interview.voice.sweep-initial-delay-ms:60000}") + public void sweep() { + Instant before = Instant.now().minus(Duration.ofMinutes(staleAfterMinutes)); + List stale = messageRepository.findStaleTranscribing( + InterviewMessage.VOICE_TRANSCRIPTION_PENDING_TEXT, before); + if (stale.isEmpty()) { + return; + } + int failed = 0; + for (InterviewMessage m : stale) { + try { + // 메시지마다 독립 트랜잭션 — 하나가 실패해도 나머지는 정리된다. + recoveryService.failStaleTranscription(m.getId()); + failed++; + } catch (RuntimeException e) { + log.warn("stale transcription recovery failed. messageId={}", m.getId(), e); + } + } + log.info("stale transcription sweeper failed {} of {} pending voice answer(s)", + failed, stale.size()); + } +} diff --git a/backend/src/main/java/com/stackup/stackup/session/application/VoiceTranscriptionRecoveryService.java b/backend/src/main/java/com/stackup/stackup/session/application/VoiceTranscriptionRecoveryService.java new file mode 100644 index 0000000..e53a331 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/session/application/VoiceTranscriptionRecoveryService.java @@ -0,0 +1,48 @@ +package com.stackup.stackup.session.application; + +import com.stackup.stackup.common.messaging.RealtimeNotifyEvent; +import com.stackup.stackup.common.sse.SseEventType; +import com.stackup.stackup.session.domain.InterviewMessage; +import com.stackup.stackup.session.domain.InterviewMessageRepository; +import com.stackup.stackup.session.domain.MessageStatus; +import lombok.RequiredArgsConstructor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +// STT 콜백 유실 복구. 스위퍼가 메시지마다 이 서비스를 호출한다(각자 독립 트랜잭션). +@Service +@RequiredArgsConstructor +public class VoiceTranscriptionRecoveryService { + + private static final Logger log = LoggerFactory.getLogger(VoiceTranscriptionRecoveryService.class); + private static final String ERROR_CODE = "STT_CALLBACK_TIMEOUT"; + + private final InterviewMessageRepository messageRepository; + private final ApplicationEventPublisher events; + + @Transactional + public void failStaleTranscription(Long messageId) { + InterviewMessage message = messageRepository.findById(messageId).orElse(null); + if (message == null) { + return; + } + // 스위퍼가 목록을 만든 뒤 콜백이 도착했을 수 있다 — 이미 채워졌으면 건드리지 않는다. + if (message.getStatus() != MessageStatus.CREATED + || !InterviewMessage.VOICE_TRANSCRIPTION_PENDING_TEXT.equals(message.getContent())) { + return; + } + message.failVoiceTranscription(); + + Long sessionId = message.getSession().getId(); + VoiceCallbackService.VoiceFailedNotice notice = new VoiceCallbackService.VoiceFailedNotice( + sessionId, message.getId(), ERROR_CODE, message.getContent()); + events.publishEvent(RealtimeNotifyEvent.session(sessionId, SseEventType.SESSION_MESSAGE, notice)); + events.publishEvent(RealtimeNotifyEvent.user(message.getSession().getUser().getId(), + SseEventType.SESSION_MESSAGE, notice)); + log.warn("voice answer stuck in transcription — marked FAILED so the turn unlocks. " + + "sessionId={}, messageId={}", sessionId, messageId); + } +} diff --git a/backend/src/main/java/com/stackup/stackup/session/domain/InterviewMessageRepository.java b/backend/src/main/java/com/stackup/stackup/session/domain/InterviewMessageRepository.java index bac3e08..97b51cb 100644 --- a/backend/src/main/java/com/stackup/stackup/session/domain/InterviewMessageRepository.java +++ b/backend/src/main/java/com/stackup/stackup/session/domain/InterviewMessageRepository.java @@ -31,6 +31,20 @@ public interface InterviewMessageRepository extends JpaRepository findBookmarkedByOwner(@Param("userId") Long userId); + // STT 콜백을 기다리다 멈춰 있는 음성 답변. content 가 아직 pending sentinel 이고 + // 진행 중 세션에 속한 것만. (callback.voice 가 유실되면 이 상태로 영구히 남는다.) + @Query(""" + select m from InterviewMessage m + where m.role = com.stackup.stackup.session.domain.MessageRole.INTERVIEWEE + and m.status = com.stackup.stackup.session.domain.MessageStatus.CREATED + and m.content = :pendingText + and m.createdAt < :before + and m.session.deleted = false + and m.session.status = com.stackup.stackup.session.domain.SessionStatus.IN_PROGRESS + """) + List findStaleTranscribing(@Param("pendingText") String pendingText, + @Param("before") java.time.Instant before); + // 질문에 달린 답변(있으면 1개). 오답노트에 '내 답변 + 코칭'을 함께 보여주기 위해. List findByParentMessage_IdIn(List parentMessageIds); } diff --git a/backend/src/test/java/com/stackup/stackup/session/application/VoiceTranscriptionRecoveryServiceTest.java b/backend/src/test/java/com/stackup/stackup/session/application/VoiceTranscriptionRecoveryServiceTest.java new file mode 100644 index 0000000..c09f898 --- /dev/null +++ b/backend/src/test/java/com/stackup/stackup/session/application/VoiceTranscriptionRecoveryServiceTest.java @@ -0,0 +1,90 @@ +package com.stackup.stackup.session.application; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.stackup.stackup.common.messaging.RealtimeNotifyEvent; +import com.stackup.stackup.session.domain.InterviewMessage; +import com.stackup.stackup.session.domain.InterviewMessageRepository; +import com.stackup.stackup.session.domain.InterviewSession; +import com.stackup.stackup.session.domain.JobCategory; +import com.stackup.stackup.session.domain.MessageStatus; +import com.stackup.stackup.session.domain.SessionMode; +import com.stackup.stackup.user.domain.User; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.test.util.ReflectionTestUtils; + +/** + * `callback.voice` 가 유실되면 음성 답변이 `(transcribing)` 으로 남고 답변 차례가 오지 않아 + * 면접이 멈춘다. 이를 FAILED 로 확정해 기존 STT 실패 경로(텍스트 재답변)를 타게 한다. + */ +@ExtendWith(MockitoExtension.class) +class VoiceTranscriptionRecoveryServiceTest { + + @Mock InterviewMessageRepository messageRepository; + @Mock ApplicationEventPublisher events; + @InjectMocks VoiceTranscriptionRecoveryService service; + + @Test + void failStaleTranscription_marksFailedAndNotifies() { + InterviewMessage pending = pendingVoiceAnswer(200L); + when(messageRepository.findById(200L)).thenReturn(Optional.of(pending)); + + service.failStaleTranscription(200L); + + assertThat(pending.getStatus()).isEqualTo(MessageStatus.FAILED); + // 내용이 실패 안내로 바뀌어야 프론트가 '텍스트로 다시 답변' 턴으로 인식한다. + assertThat(pending.getContent()) + .isNotEqualTo(InterviewMessage.VOICE_TRANSCRIPTION_PENDING_TEXT); + // 세션 채널 + 유저 채널 양쪽에 알린다(다른 탭에서도 턴이 풀려야 한다). + verify(events, times(2)).publishEvent(any(RealtimeNotifyEvent.class)); + } + + // 스위퍼가 목록을 만든 뒤 콜백이 도착했을 수 있다 — 완료된 답변을 실패로 되돌리면 안 된다. + @Test + void failStaleTranscription_skipsWhenTranscriptArrivedMeanwhile() { + InterviewMessage completed = pendingVoiceAnswer(200L); + completed.completeWithTranscript("실제로는 전사가 도착했습니다"); + when(messageRepository.findById(200L)).thenReturn(Optional.of(completed)); + + service.failStaleTranscription(200L); + + assertThat(completed.getStatus()).isEqualTo(MessageStatus.COMPLETED); + assertThat(completed.getContent()).isEqualTo("실제로는 전사가 도착했습니다"); + verify(events, never()).publishEvent(any()); + } + + @Test + void failStaleTranscription_isNoopWhenMessageMissing() { + when(messageRepository.findById(200L)).thenReturn(Optional.empty()); + + service.failStaleTranscription(200L); + + verify(events, never()).publishEvent(any()); + } + + private InterviewMessage pendingVoiceAnswer(Long id) { + User user = User.createGithubUser(1L, "u", null, null, "t"); + ReflectionTestUtils.setField(user, "id", 1L); + InterviewSession session = InterviewSession.create( + user, "면접", null, SessionMode.TECHNICAL, List.of(JobCategory.BACKEND), 5, 30, null, null); + ReflectionTestUtils.setField(session, "id", 10L); + session.start(); + + InterviewMessage question = InterviewMessage.interviewer(session, 1, "질문"); + InterviewMessage pending = InterviewMessage.voiceInterviewee(session, 2, question, null); + ReflectionTestUtils.setField(pending, "id", id); + return pending; + } +}