From 0c581fb7a0d4075afb434a184c51085d53d09476 Mon Sep 17 00:00:00 2001 From: jmj Date: Wed, 19 Aug 2026 15:23:49 +0900 Subject: [PATCH] =?UTF-8?q?fix(backend):=20=EC=9E=AC=EA=B0=9C=20=ED=9B=84?= =?UTF-8?q?=20=EB=8A=A6=EA=B2=8C=20=EC=98=A8=20=EA=BC=AC=EB=A6=AC=EC=A7=88?= =?UTF-8?q?=EB=AC=B8=20=EC=BD=9C=EB=B0=B1=EC=9D=B4=20=EC=82=B4=EC=95=84?= =?UTF-8?q?=EC=9E=88=EB=8A=94=20=EC=A7=88=EB=AC=B8=EC=9D=84=20=EB=91=98?= =?UTF-8?q?=EB=A1=9C=20=EB=A7=8C=EB=93=A4=EB=8D=98=20=EB=AC=B8=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 이어하기(#180)가 terminal 가드의 전제를 깼다. 종료 세션에 늦게 도착한 콜백은 `isTerminal()` 로 드롭되지만, **재개된 세션은 IN_PROGRESS 라 그대로 통과한다.** 경합: 1. 꼬리질문 요청 → placeholder("(생성 중)") 생성 2. 사용자 중단 → INTERRUPTED 3. 사용자 이어하기 → 복구가 placeholder 를 FAILED 로 확정하고 다음 일반질문 발행 4. **뒤늦게 AI 콜백 도착** → 세션이 IN_PROGRESS 라 terminal 가드를 통과 → `completeFollowup` 이 FAILED placeholder 를 덮어써 질문으로 되살림 결과적으로 살아있는 질문이 두 개가 되고(실패로 표시됐던 꼬리질문 + 복구가 발행한 일반질문), TTS 도 그 자리에 붙는다. `applyFollowup` 에서 placeholder 가 이미 FAILED 면 그 턴은 지나간 것으로 보고 드롭한다. POOL 경로는 `countBySessionId > 0` 로 이미 멱등이라 같은 문제가 없다(재개 시 풀 재요청이 있어도 중복 저장되지 않는다). 테스트: `apply_followupOnFailedPlaceholder_isDropped` — 실패 상태·문구가 유지되고 save 가 호출되지 않는지 확인. --- backend/CLAUDE.md | 4 +++ .../application/QuestionsCallbackService.java | 11 +++++++ .../QuestionsCallbackServiceTest.java | 33 +++++++++++++++++++ 3 files changed, 48 insertions(+) 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);