diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md index d6db46c..5eee6a8 100644 --- a/backend/CLAUDE.md +++ b/backend/CLAUDE.md @@ -481,6 +481,10 @@ docker compose up -d 정상 질문이면 그대로(답하면 됨) / "(생성 중)" placeholder 면 `failFollowup` + 다음 일반질문 / 자기소개 답변인데 풀이 0건이면 `SelfIntroAnsweredEvent` 재발행(넘기면 POOL_EXHAUSTED 로 세션이 끝나버린다) / 그 외 답변이면 다음 일반질문. + - **재개는 terminal 가드의 전제를 깬다.** 종료 세션에 늦게 온 콜백은 `isTerminal()` 로 드롭되지만, + 재개된 세션은 IN_PROGRESS 라 그대로 통과한다. 그래서 `applyFollowup` 이 **이미 FAILED 인 + placeholder 를 되살리지 않도록** 막는다 — 복구가 실패 확정 + 다음 질문까지 마친 뒤 늦은 콜백이 + 그 자리를 채우면 살아있는 질문이 두 개가 된다. POOL 은 `countBySessionId > 0` 로 이미 멱등. - **Spring AI 미사용** — LLM·임베딩 호출은 모두 AI 서버 위임. Core는 RabbitMQ 발행만 담당. - **Redis 미사용** — 휘발성 데이터는 DB short-lived 레코드 또는 인메모리로. diff --git a/backend/src/main/java/com/stackup/stackup/session/application/QuestionsCallbackService.java b/backend/src/main/java/com/stackup/stackup/session/application/QuestionsCallbackService.java index 1132023..c09a333 100644 --- a/backend/src/main/java/com/stackup/stackup/session/application/QuestionsCallbackService.java +++ b/backend/src/main/java/com/stackup/stackup/session/application/QuestionsCallbackService.java @@ -15,6 +15,7 @@ import com.stackup.stackup.session.domain.InterviewSession; import com.stackup.stackup.session.domain.InterviewSessionRepository; import com.stackup.stackup.session.domain.MessageRole; +import com.stackup.stackup.session.domain.MessageStatus; import com.stackup.stackup.session.domain.SessionQuestionPool; import com.stackup.stackup.session.domain.SessionQuestionPoolRepository; import com.stackup.stackup.session.domain.SessionStatus; @@ -248,6 +249,16 @@ private void applyFollowup(InterviewSession session, QuestionsCallbackPayload pa ? null : messageRepository.findById(payload.followupMessageId()).orElse(null); + // 이미 실패로 확정된 placeholder 면 그 턴은 지나갔다 — 이어하기 복구 + // (SessionResumeService.recoverTurn)가 실패 처리하고 다음 일반질문으로 넘긴 뒤, + // 늦게 도착한 콜백이 그 자리를 되살리면 살아있는 질문이 두 개가 된다. + // 종료 세션은 terminal 가드가 막지만, 재개된 세션은 IN_PROGRESS 라 여기까지 온다. + if (placeholder != null && placeholder.getStatus() == MessageStatus.FAILED) { + log.info("callback.questions FOLLOWUP dropped — placeholder already failed (turn moved on). " + + "sessionId={}, msg={}", session.getId(), placeholder.getId()); + return; + } + // 모르겠음 → 이 주제 그만, 다음 일반질문. placeholder 는 삭제(seq 연속성 유지). if ("DONT_KNOW".equalsIgnoreCase(intent)) { recordAnswerEvaluation(payload); diff --git a/backend/src/test/java/com/stackup/stackup/session/application/QuestionsCallbackServiceTest.java b/backend/src/test/java/com/stackup/stackup/session/application/QuestionsCallbackServiceTest.java index 4a1df2e..3840e09 100644 --- a/backend/src/test/java/com/stackup/stackup/session/application/QuestionsCallbackServiceTest.java +++ b/backend/src/test/java/com/stackup/stackup/session/application/QuestionsCallbackServiceTest.java @@ -329,6 +329,39 @@ void apply_followupNormal_updatesPlaceholderInPlaceWithoutCounting() { assertThat(cap.getValue()).isSameAs(placeholder); } + // 이어하기 복구가 placeholder 를 실패로 확정하고 다음 일반질문으로 넘긴 뒤, 늦게 도착한 + // 콜백이 그 자리를 되살리면 살아있는 질문이 두 개가 된다. 종료 세션은 terminal 가드가 + // 막지만 재개된 세션은 IN_PROGRESS 라 여기까지 온다. + @Test + void apply_followupOnFailedPlaceholder_isDropped() { + InterviewSession session = sessionFixture(25L, SessionStatus.IN_PROGRESS); + InterviewMessage placeholder = InterviewMessage.followupPlaceholder( + session, 3, parentMessageFixture(session)); + ReflectionTestUtils.setField(placeholder, "id", 305L); + placeholder.failFollowup(); // 재개 복구가 이미 실패로 확정한 상태 + + QuestionsCallbackPayload payload = new QuestionsCallbackPayload( + 25L, "FOLLOWUP", null, 200L, null, "뒤늦게 도착한 꼬리질문?", + null, "NORMAL", 305L + ); + QuestionsCallbackEnvelope env = new QuestionsCallbackEnvelope( + "m-late-followup", "callback.questions", "1", "t", null, "ai", payload, null); + + when(processedMessageRepository.existsById("m-late-followup")).thenReturn(false); + when(sessionRepository.findById(25L)).thenReturn(Optional.of(session)); + when(messageRepository.findById(200L)).thenReturn(Optional.of(parentMessageFixture(session))); + when(messageRepository.findById(305L)).thenReturn(Optional.of(placeholder)); + + service.apply(env); + + // 실패 상태·문구가 그대로여야 한다(되살아나면 안 된다). + assertThat(placeholder.getStatus()) + .isEqualTo(com.stackup.stackup.session.domain.MessageStatus.FAILED); + assertThat(placeholder.getContent()) + .isEqualTo(InterviewMessage.FOLLOWUP_GENERATION_FAILED_TEXT); + verify(messageRepository, never()).save(any(InterviewMessage.class)); + } + @Test void apply_followupClarification_updatesPlaceholderWithoutCounting() { InterviewSession session = sessionFixture(21L, SessionStatus.IN_PROGRESS);