From b4193c5ed0c69254083648fc7604e1d94611d395 Mon Sep 17 00:00:00 2001 From: jmj Date: Thu, 20 Aug 2026 17:17:09 +0900 Subject: [PATCH] =?UTF-8?q?fix(backend):=20AI=20=EB=82=B4=EB=B6=80=20?= =?UTF-8?q?=EC=98=88=EC=99=B8=20=EB=AC=B8=EC=9E=90=EC=97=B4=EC=9D=B4=20SSE?= =?UTF-8?q?=20=EB=A1=9C=20=EB=B8=8C=EB=9D=BC=EC=9A=B0=EC=A0=80=EA=B9=8C?= =?UTF-8?q?=EC=A7=80=20=EB=82=98=EA=B0=80=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 AI 서버는 질문 생성 실패 시 `error_message=str(exc)` 를 콜백에 담는다. Core 의 publishErrorEvent 가 이 값을 SessionErrorNotice 에 그대로 넣어 세션·유저 SSE 채널로 발행했다 — 즉 사용자의 브라우저까지 전달된다. `str(exc)` 에 들어올 수 있는 것: - LLM 게이트웨이 주소(llm_base_url, 사내 게이트웨이) — 연결 오류 메시지 - 업스트림 오류 본문의 조직 식별자·모델명·쿼터 상세 - 파싱 실패 시 모델 원문 게다가 프론트엔드는 errorCode·errorMessage·retriable 중 무엇도 읽지 않는다. 소비자도 없이 내부 정보만 나가고 있었다. 원문은 이미 같은 메서드 위 log.warn 이 서버에 남긴다. 클라이언트로는 우리가 정의한 코드에서 만든 안내 문구만 보낸다. errorCode 도 결국 AI 가 채우는 문자열이라 화이트리스트로 값 범위를 묶었다. --- .../application/QuestionsCallbackService.java | 27 +++++++++- .../QuestionsCallbackServiceTest.java | 53 +++++++++++++++++++ 2 files changed, 78 insertions(+), 2 deletions(-) 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 c09a333..00ca717 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 @@ -21,6 +21,7 @@ import com.stackup.stackup.session.domain.SessionStatus; import java.time.Instant; import java.util.List; +import java.util.Map; import lombok.RequiredArgsConstructor; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -325,9 +326,14 @@ private void applyFollowupFailed(InterviewSession session, QuestionsCallbackPayl advanceToNextGeneral(session.getId()); } + // AI 가 보내는 errorMessage 는 `str(exc)` 그대로다 — LLM 게이트웨이 주소·조직 식별자·쿼터 + // 상세, 파싱 실패 시엔 모델 원문까지 들어올 수 있다. 이걸 SSE 로 흘리면 브라우저까지 그대로 + // 나간다(프론트는 쓰지도 않는다). 원문은 위 log.warn 이 서버에 남기고, 클라이언트에는 + // 우리가 정의한 코드로만 만든 안내 문구를 보낸다. private void publishErrorEvent(InterviewSession session, String scope, QuestionsCallbackPayload payload) { SessionErrorNotice notice = new SessionErrorNotice( - session.getId(), scope, payload.errorCode(), payload.errorMessage(), payload.retriable()); + session.getId(), scope, safeErrorCode(payload.errorCode()), + userFacingMessage(payload.errorCode()), payload.retriable()); events.publishEvent(RealtimeNotifyEvent.session(session.getId(), SseEventType.ERROR, notice)); events.publishEvent(RealtimeNotifyEvent.user(session.getUser().getId(), SseEventType.ERROR, notice)); } @@ -371,8 +377,25 @@ private void markProcessed(String messageId) { public record SessionMessageNotice(Long sessionId, Long messageId, String reason) { } + // 알려진 코드만 통과시킨다. errorCode 도 결국 AI 가 채우는 문자열이라, 언젠가 예외 속성에서 + // 끌어다 쓰게 되면 같은 경로로 새어 나간다 — 화이트리스트로 값 범위를 묶어 둔다. + private static final Map ERROR_MESSAGES = Map.of( + "GENERATION_FAILED", "질문 생성에 실패했습니다. 잠시 후 다시 시도해 주세요.", + "GENERATION_SCHEMA_INVALID", "질문 생성 결과를 해석하지 못했습니다. 잠시 후 다시 시도해 주세요." + ); + private static final String UNKNOWN_ERROR_CODE = "GENERATION_FAILED"; + + private static String safeErrorCode(String code) { + return ERROR_MESSAGES.containsKey(code) ? code : UNKNOWN_ERROR_CODE; + } + + private static String userFacingMessage(String code) { + return ERROR_MESSAGES.getOrDefault(code, ERROR_MESSAGES.get(UNKNOWN_ERROR_CODE)); + } + + // errorMessage 가 아니라 message — 내부 원문이 아니라 사용자에게 보여줄 문구다. public record SessionErrorNotice( - Long sessionId, String scope, String errorCode, String errorMessage, Boolean retriable + Long sessionId, String scope, String errorCode, String message, Boolean retriable ) { } } 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 3840e09..9e85aa3 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 @@ -152,6 +152,59 @@ void apply_poolFailed_endsSessionGracefullyWithoutSeedingPool() { assertThat(e).isInstanceOf(com.stackup.stackup.session.application.event.SessionEndedEvent.class)); } + // AI 의 errorMessage 는 `str(exc)` 그대로다 — LLM 게이트웨이 주소·조직 식별자·쿼터 상세가 + // 들어올 수 있고, SSE 는 그걸 브라우저까지 실어 나른다. 원문은 서버 로그에만 남아야 한다. + @Test + void apply_followupFailedWithoutPlaceholder_doesNotLeakInternalErrorMessage() { + InterviewSession session = sessionFixture(21L, SessionStatus.IN_PROGRESS); + String internal = "Connection error: [Errno -2] cannot connect to " + + "https://factchat-cloud.mindlogic.ai/v1/gateway (org-abc123, quota 0/500)"; + QuestionsCallbackPayload payload = new QuestionsCallbackPayload( + 21L, "FOLLOWUP", List.of(), null, null, null, null, null, null, + "FAILED", "GENERATION_FAILED", internal, true + ); + QuestionsCallbackEnvelope env = new QuestionsCallbackEnvelope( + "m-fu-leak", "callback.questions", "1", "t", null, "ai", payload, null); + + when(processedMessageRepository.existsById("m-fu-leak")).thenReturn(false); + when(sessionRepository.findById(21L)).thenReturn(Optional.of(session)); + + service.apply(env); + + ArgumentCaptor ev = ArgumentCaptor.forClass(Object.class); + verify(events, atLeastOnce()).publishEvent(ev.capture()); + assertThat(ev.getAllValues()) + .filteredOn(e -> e instanceof RealtimeNotifyEvent) + .isNotEmpty() + .allSatisfy(e -> assertThat(e.toString()) + .doesNotContain("mindlogic") + .doesNotContain("org-abc123") + .doesNotContain("Errno")); + } + + // 모르는 코드가 와도 그대로 실어 보내지 않는다 — errorCode 역시 AI 가 채우는 문자열이다. + @Test + void apply_followupFailedWithUnknownErrorCode_fallsBackToKnownCode() { + InterviewSession session = sessionFixture(22L, SessionStatus.IN_PROGRESS); + QuestionsCallbackPayload payload = new QuestionsCallbackPayload( + 22L, "FOLLOWUP", List.of(), null, null, null, null, null, null, + "FAILED", "RateLimitError: org-secret exceeded", "boom", true + ); + QuestionsCallbackEnvelope env = new QuestionsCallbackEnvelope( + "m-fu-code", "callback.questions", "1", "t", null, "ai", payload, null); + + when(processedMessageRepository.existsById("m-fu-code")).thenReturn(false); + when(sessionRepository.findById(22L)).thenReturn(Optional.of(session)); + + service.apply(env); + + ArgumentCaptor ev = ArgumentCaptor.forClass(Object.class); + verify(events, atLeastOnce()).publishEvent(ev.capture()); + assertThat(ev.getAllValues()) + .filteredOn(e -> e instanceof RealtimeNotifyEvent) + .allSatisfy(e -> assertThat(e.toString()).doesNotContain("org-secret")); + } + @Test void apply_poolOkButEmpty_endsSessionGracefully() { // status=OK 인데 questions 가 비어 온 경우(예: 중복회피 필터링으로 전부 걸러짐) —