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 가 비어 온 경우(예: 중복회피 필터링으로 전부 걸러짐) —