Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion backend/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -460,7 +460,14 @@ docker compose up -d
(`OK`|`FAILED`)·`errorCode`·`errorMessage`·`retriable` 추가(구버전 13-arg 생성자는 `status=OK`
위임 오버로드로 하위호환). `FeedbackCallbackService.apply` 가 저장 전에 `isFailed()` 를 확인:
실패면 저장 없이 `SseEventType.ERROR`(`SessionErrorNotice`, scope=`FEEDBACK`,
code=`FEEDBACK_GENERATION_FAILED`)를 세션/유저 채널에 발행하고 멱등 마킹만 한다. AI 의
code=`FEEDBACK_GENERATION_FAILED`)를 세션/유저 채널에 발행하고, **실패 마커를 영속화**한다
(`InterviewSession.markFeedbackFailed` — `feedback_failed_at`/`feedback_fail_retriable`, V29).
SSE 는 휘발성이라 그 순간 미접속 클라이언트를 위해 `SessionFeedbackQueryService.get` 이
피드백 없음 + 마커 존재 시 404 `FEEDBACK_GENERATION_FAILED`(details.retriable)를 반환해
"생성 중"(FEEDBACK_NOT_READY)과 구분한다(`ownedFeedback` 공유 경로도 동일 분기). 마커는
성공 콜백·재생성 요청에서 클리어하며, 재생성의 `generate.feedback` 발행은
`FeedbackRegenerateRequestedEvent` → AFTER_COMMIT 리스너로 — 마커 clear 커밋 전에 발행되는
역전(§"메시지 발행은 commit 이후" 규칙)을 막는다. AI 의
`errorMessage` 원문은 서버 로그에만 남기고 클라이언트에는 화이트리스트 문구만 보낸다
(QuestionsCallbackService 와 동일 원칙). AI 쪽 발행은 [`ai/CLAUDE.md`](../ai/CLAUDE.md) 참고.
- **문장 단위 TTS 세그먼트 프록시 본 구현 (Part B)**: `InterviewMessageService.streamAudioSegment` + `GET /api/sessions/{sid}/messages/{mid}/audio/segments/{seq}?ext=`. AI 가 휘발성으로 쓴 라이브 세그먼트를 규칙(`interview/tts/{sid}/{mid}/seg-{seq}.{ext}`)으로 재구성해 프록시(DB 미기록). 소유권+ext 화이트리스트+seq>=0 검증으로 임의 키 노출 차단.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ public enum ApiErrorCode {
SESSION_QUESTION_COUNT_CONFLICT(HttpStatus.BAD_REQUEST,
"총 질문 상한은 일반질문 수보다 작을 수 없습니다."),
FEEDBACK_NOT_READY(HttpStatus.NOT_FOUND, "피드백이 아직 생성되지 않았습니다."),
// 생성 실패 마커(interview_sessions.feedback_failed_at) 존재 — 대기 중단, 재생성 유도.
FEEDBACK_GENERATION_FAILED(HttpStatus.NOT_FOUND, "피드백 생성에 실패했습니다. 다시 생성을 요청해 주세요."),
FEEDBACK_NOT_FOUND(HttpStatus.NOT_FOUND, "공유된 피드백을 찾을 수 없습니다."),
FEEDBACK_ALREADY_EXISTS(HttpStatus.CONFLICT, "피드백이 이미 생성되어 있습니다."),
VOICE_EMPTY_FILE(HttpStatus.BAD_REQUEST, "음성 파일을 업로드할 수 없습니다."),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.stackup.stackup.common.exception.ApiErrorCode;
import com.stackup.stackup.common.messaging.domain.ProcessedMessage;
import com.stackup.stackup.common.messaging.domain.ProcessedMessageRepository;
import com.stackup.stackup.common.messaging.RealtimeNotifyEvent;
Expand Down Expand Up @@ -90,16 +91,17 @@ public void apply(FeedbackCallbackEnvelope envelope) {
keywordsToJson(payload.highlights()),
payload.reportS3Key()
);
try {
feedback = feedbackRepository.save(feedback);
} catch (DataIntegrityViolationException race) {
log.info("callback.feedback unique race — feedback inserted concurrently. sessionId={}", sessionId);
markProcessed(envelope.messageId());
return;
}
// 동시 콜백 경합의 unique 위반은 잡지 않는다 — IDENTITY 전략이라 save 가 즉시 INSERT 하고
// 실패 시 트랜잭션이 이미 rollback-only 라, catch 후 markProcessed 는 어차피 폐기되고
// 커밋에서 UnexpectedRollbackException 이 난다. 예외를 그대로 전파시키면 리스너 재시도가
// 위의 existsBySession_Id 체크에서 멱등 skip 으로 수렴한다.
feedback = feedbackRepository.save(feedback);

applyAnswerCoaching(sessionId, payload.answerCoaching());

// 이전 시도가 실패로 마킹돼 있었다면(재생성 성공·지연 도착 성공) 마커를 걷는다.
session.clearFeedbackFailure();

events.publishEvent(RealtimeNotifyEvent.session(sessionId, SseEventType.FEEDBACK_READY,
new SessionFeedbackNotice(sessionId, feedback.getId())));
events.publishEvent(RealtimeNotifyEvent.user(session.getUser().getId(), SseEventType.FEEDBACK_READY,
Expand Down Expand Up @@ -140,14 +142,19 @@ public record SessionFeedbackNotice(Long sessionId, Long feedbackId) {
private void applyFeedbackFailed(InterviewSession session, FeedbackCallbackPayload payload) {
log.warn("callback.feedback generation failed. sessionId={}, errorCode={}, retriable={}, message={}",
session.getId(), payload.errorCode(), payload.retriable(), payload.errorMessage());
// 영속 마커(V29) — SSE ERROR 는 휘발성이라, 그 순간 미접속 클라이언트도 GET 피드백에서
// 실패를 구분할 수 있게 남긴다 (dirty checking 으로 UPDATE).
session.markFeedbackFailed(payload.retriable());
QuestionsCallbackService.SessionErrorNotice notice = new QuestionsCallbackService.SessionErrorNotice(
session.getId(), "FEEDBACK", FEEDBACK_FAILED_CODE, FEEDBACK_FAILED_MESSAGE, payload.retriable());
events.publishEvent(RealtimeNotifyEvent.session(session.getId(), SseEventType.ERROR, notice));
events.publishEvent(RealtimeNotifyEvent.user(session.getUser().getId(), SseEventType.ERROR, notice));
}

private static final String FEEDBACK_FAILED_CODE = "FEEDBACK_GENERATION_FAILED";
private static final String FEEDBACK_FAILED_MESSAGE = "피드백 생성에 실패했습니다. 잠시 후 다시 시도해 주세요.";
// REST(GET 피드백)와 같은 코드·문구 — 채널에 따라 다른 안내가 나가지 않게 단일 출처로 묶는다.
private static final String FEEDBACK_FAILED_CODE = ApiErrorCode.FEEDBACK_GENERATION_FAILED.name();
private static final String FEEDBACK_FAILED_MESSAGE =
ApiErrorCode.FEEDBACK_GENERATION_FAILED.getDefaultMessage();

private String keywordsToJson(java.util.List<String> keywords) {
if (keywords == null) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,17 @@

import com.stackup.stackup.common.exception.ApiErrorCode;
import com.stackup.stackup.common.exception.DomainException;
import com.stackup.stackup.session.application.event.FeedbackRegenerateRequestedEvent;
import com.stackup.stackup.session.application.dto.FeedbackResult;
import com.stackup.stackup.session.domain.InterviewSession;
import com.stackup.stackup.session.domain.InterviewSessionRepository;
import com.stackup.stackup.session.domain.SessionFeedback;
import com.stackup.stackup.session.domain.SessionFeedbackRepository;
import com.stackup.stackup.session.domain.SessionStatus;
import java.util.Map;
import java.util.UUID;
import lombok.RequiredArgsConstructor;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

Expand All @@ -20,16 +23,28 @@ public class SessionFeedbackQueryService {

private final InterviewSessionRepository sessionRepository;
private final SessionFeedbackRepository feedbackRepository;
private final SessionFeedbackRequester feedbackRequester;
private final ApplicationEventPublisher events;

public FeedbackResult get(Long userId, Long sessionId) {
sessionRepository.findByIdAndUser_IdAndDeletedFalse(sessionId, userId)
InterviewSession session = sessionRepository.findByIdAndUser_IdAndDeletedFalse(sessionId, userId)
.orElseThrow(() -> new DomainException(ApiErrorCode.SESSION_NOT_FOUND));
SessionFeedback feedback = feedbackRepository.findBySession_Id(sessionId)
.orElseThrow(() -> new DomainException(ApiErrorCode.FEEDBACK_NOT_READY));
.orElseThrow(() -> notReadyOrFailed(session));
return FeedbackResult.of(feedback);
}

// 피드백 없음의 두 얼굴 구분: 실패 마커(V29)가 있으면 "생성 중"이 아니라 "실패"다 —
// SSE ERROR 를 놓친(새로고침·재접속) 클라이언트도 폴링 즉시 복구 UI 로 전환할 수 있게.
private DomainException notReadyOrFailed(InterviewSession session) {
if (session.hasFeedbackFailure()) {
Map<String, Object> details = session.getFeedbackFailRetriable() != null
? Map.of("retriable", session.getFeedbackFailRetriable())
: Map.of();
return new DomainException(ApiErrorCode.FEEDBACK_GENERATION_FAILED, details);
}
return new DomainException(ApiErrorCode.FEEDBACK_NOT_READY);
}

// 공유 활성화: 소유자 검증 후 토큰 보장(없으면 발급). 멱등 — 현재 토큰 반환.
@Transactional
public String enableShare(Long userId, Long sessionId) {
Expand Down Expand Up @@ -63,13 +78,18 @@ public void regenerate(Long userId, Long sessionId) {
if (feedbackRepository.existsBySession_Id(sessionId)) {
throw new DomainException(ApiErrorCode.FEEDBACK_ALREADY_EXISTS);
}
feedbackRequester.publishGenerateFeedback(userId, sessionId, "REGENERATE");
// 재생성 요청 = 새 시도 시작 — 실패 마커를 지워 다른 탭/폴링도 "생성 중"으로 복귀시킨다.
session.clearFeedbackFailure();
// 발행은 commit 이후(AFTER_COMMIT 리스너) — 마커 clear 가 커밋되기 전에 새 시도의
// 콜백이 도착하거나, 발행만 되고 커밋이 실패하는 역전을 막는다 (onSessionEnded 와 동일 규칙).
events.publishEvent(new FeedbackRegenerateRequestedEvent(userId, sessionId));
}

private SessionFeedback ownedFeedback(Long userId, Long sessionId) {
sessionRepository.findByIdAndUser_IdAndDeletedFalse(sessionId, userId)
InterviewSession session = sessionRepository.findByIdAndUser_IdAndDeletedFalse(sessionId, userId)
.orElseThrow(() -> new DomainException(ApiErrorCode.SESSION_NOT_FOUND));
// 실패 마커가 있으면 여기서도 "생성 중"이 아니라 "실패"로 응답 — GET 과 같은 계약.
return feedbackRepository.findBySession_Id(sessionId)
.orElseThrow(() -> new DomainException(ApiErrorCode.FEEDBACK_NOT_READY));
.orElseThrow(() -> notReadyOrFailed(session));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import com.stackup.stackup.session.application.dto.GenerateFeedbackPayload.MessageEvaluation;
import com.stackup.stackup.session.application.dto.GenerateFeedbackPayload.MessageItem;
import com.stackup.stackup.session.application.dto.GenerateFeedbackPayload.VoiceAnalysisSummary;
import com.stackup.stackup.session.application.event.FeedbackRegenerateRequestedEvent;
import com.stackup.stackup.session.application.event.SessionEndedEvent;
import com.stackup.stackup.session.domain.InterviewMessage;
import com.stackup.stackup.session.domain.InterviewMessageRepository;
Expand Down Expand Up @@ -54,6 +55,13 @@ public void onSessionEnded(SessionEndedEvent event) {
publishGenerateFeedback(event.userId(), event.sessionId(), event.reason());
}

// 재생성도 같은 규칙 — regenerate 트랜잭션(실패 마커 clear 포함) commit 후에만 발행.
@Transactional(propagation = Propagation.REQUIRES_NEW)
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onFeedbackRegenerateRequested(FeedbackRegenerateRequestedEvent event) {
publishGenerateFeedback(event.userId(), event.sessionId(), "REGENERATE");
}

// 재생성(regenerate) 경로에서도 재사용하는 발행 본체. 멱등 가드는 여기서 —
// 이벤트·API 어느 쪽으로 들어와도 피드백이 이미 있으면 발행하지 않는다.
@Transactional
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.stackup.stackup.session.application.event;

// 피드백 재생성 요청 commit 후 발화. FeedbackRequester 가 수신.
// regenerate 트랜잭션(실패 마커 clear 포함)이 커밋된 뒤에만 generate.feedback 이 발행되게 해
// "메시지 발행은 commit 이후" 규칙(backend/CLAUDE.md)을 지킨다 — 발행 후 커밋 실패로
// 마커가 남거나, clear 커밋 전에 새 시도의 콜백이 도착하는 역전을 막는다.
public record FeedbackRegenerateRequestedEvent(
Long userId,
Long sessionId
) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,15 @@ public class InterviewSession extends BaseSoftDeleteEntity {
@Column(name = "resumed_at")
private Instant resumedAt;

// 피드백 생성 실패 마커(V29). SSE ERROR 는 휘발성이라 그 순간 미접속 클라이언트가
// "생성 중"과 "실패"를 구분할 수 없다 — FAILED 콜백 수신 시 기록하고
// 성공 콜백·재생성 요청 시 클리어해 GET 피드백이 실패를 즉시 알리게 한다.
@Column(name = "feedback_failed_at")
private Instant feedbackFailedAt;

@Column(name = "feedback_fail_retriable")
private Boolean feedbackFailRetriable;

private InterviewSession(User user, String title, String memo, SessionMode mode,
List<JobCategory> jobCategories,
Integer maxQuestions, Integer maxDurationMinutes,
Expand Down Expand Up @@ -225,6 +234,20 @@ public void cancel() {
this.status = SessionStatus.CANCELLED;
}

public void markFeedbackFailed(Boolean retriable) {
this.feedbackFailedAt = Instant.now();
this.feedbackFailRetriable = retriable;
}

public void clearFeedbackFailure() {
this.feedbackFailedAt = null;
this.feedbackFailRetriable = null;
}

public boolean hasFeedbackFailure() {
return feedbackFailedAt != null;
}

// 메인(일반) 질문만 센다. 꼬리질문은 maxQuestions 한도에 포함하지 않는다
// (꼬리질문은 maxFollowupsPerQuestion 으로 별도 제한).
public void incrementQuestionCount() {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
-- 피드백 생성 실패의 영속 마커. callback.feedback FAILED 는 지금까지 SSE ERROR 로만 알려져
-- 그 순간 미접속(새로고침·탭 닫힘)이던 클라이언트는 실패를 알 길이 없었다 — GET 피드백이
-- "아직 없음"(FEEDBACK_NOT_READY)만 돌려줘 "생성 중"과 구분 불가, 폴링 예산(≈2분)까지 헛대기.
-- FAILED 수신 시 기록하고 성공 콜백 도착·재생성 요청 시 클리어해, REST 경로에서도
-- FEEDBACK_GENERATION_FAILED 로 즉시 구분할 수 있게 한다.
ALTER TABLE interview_sessions ADD COLUMN feedback_failed_at TIMESTAMPTZ;
ALTER TABLE interview_sessions ADD COLUMN feedback_fail_retriable BOOLEAN;
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,28 @@ void apply_failedCallbackSkipsSaveAndPushesErrorSse() {
assertThat(notice.retriable()).isTrue();
});
verify(processedMessageRepository).save(any());
// 영속 마커(V29) — SSE 를 놓친 클라이언트가 GET 피드백으로 실패를 구분하는 근거.
assertThat(session.hasFeedbackFailure()).isTrue();
assertThat(session.getFeedbackFailRetriable()).isTrue();
}

@Test
void apply_successClearsFailureMarker() {
// 이전 시도가 실패로 마킹된 뒤 재생성이 성공하면 마커를 걷는다.
InterviewSession session = sessionFixture(50L);
session.markFeedbackFailed(true);
FeedbackCallbackEnvelope env = envelope(50L, "fb-retry-ok",
new FeedbackCallbackPayload(50L, 80.0, null, null, null, null, null,
List.of(), List.of(), List.of(), List.of(), List.of(), null));
when(processedMessageRepository.existsById("fb-retry-ok")).thenReturn(false);
when(sessionRepository.findById(50L)).thenReturn(Optional.of(session));
when(feedbackRepository.existsBySession_Id(50L)).thenReturn(false);
when(feedbackRepository.save(any(SessionFeedback.class))).thenAnswer(inv -> inv.getArgument(0));

service.apply(env);

assertThat(session.hasFeedbackFailure()).isFalse();
verify(feedbackRepository).save(any(SessionFeedback.class));
}

@Test
Expand Down
Loading
Loading