diff --git a/backend/src/main/java/com/stackup/stackup/session/application/InterviewMessageService.java b/backend/src/main/java/com/stackup/stackup/session/application/InterviewMessageService.java index 29bbdb5..300b233 100644 --- a/backend/src/main/java/com/stackup/stackup/session/application/InterviewMessageService.java +++ b/backend/src/main/java/com/stackup/stackup/session/application/InterviewMessageService.java @@ -25,6 +25,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.context.ApplicationEventPublisher; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -167,10 +168,22 @@ public MessageResult submitAnswer(Long userId, Long sessionId, String content, S .orElseThrow(() -> new DomainException(ApiErrorCode.SESSION_INVALID_STATE)); InterviewMessage parentQuestion = resolveAnswerParent(latest); int nextSeq = latest.getSequenceNumber() + 1; - InterviewMessage answer = messageRepository.save( - InterviewMessage.interviewee(session, nextSeq, content, parentQuestion, - idempotencyKey != null && !idempotencyKey.isBlank() ? idempotencyKey : null) - ); + InterviewMessage answer; + try { + // saveAndFlush — INSERT 를 여기서 터뜨려야 아래에서 잡을 수 있다. 커밋 시점까지 + // 미루면 트랜잭션 밖에서 500 으로 새어 나간다. + answer = messageRepository.saveAndFlush( + InterviewMessage.interviewee(session, nextSeq, content, parentQuestion, + idempotencyKey != null && !idempotencyKey.isBlank() ? idempotencyKey : null) + ); + } catch (DataIntegrityViolationException e) { + // 같은 질문에 두 요청이 동시에 들어오면 둘 다 같은 nextSeq 를 계산한다. + // (session_id, sequence_number) UNIQUE 가 한쪽을 막는데, 그대로 두면 사용자에겐 + // 정체불명의 500 이 된다. 실제 의미는 '이 턴은 이미 답변됐다' 이므로 그렇게 알린다. + log.info("answer sequence conflict — turn already answered. sessionId={}, seq={}", + sessionId, nextSeq); + throw new DomainException(ApiErrorCode.SESSION_INVALID_STATE); + } events.publishEvent(new AnswerSubmittedEvent( userId, sessionId, parentQuestion.getId(), answer.getId() diff --git a/backend/src/main/java/com/stackup/stackup/session/application/VoiceAnswerUploadService.java b/backend/src/main/java/com/stackup/stackup/session/application/VoiceAnswerUploadService.java index 68c874b..86c78ba 100644 --- a/backend/src/main/java/com/stackup/stackup/session/application/VoiceAnswerUploadService.java +++ b/backend/src/main/java/com/stackup/stackup/session/application/VoiceAnswerUploadService.java @@ -16,6 +16,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.context.ApplicationEventPublisher; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -61,10 +62,19 @@ public VoicePlaceholder createVoicePlaceholder(Long userId, Long sessionId, Stri throw new DomainException(ApiErrorCode.SESSION_INVALID_STATE); } int nextSeq = latest.getSequenceNumber() + 1; - InterviewMessage placeholder = messageRepository.save( - InterviewMessage.voiceInterviewee(session, nextSeq, latest, - idempotencyKey != null && !idempotencyKey.isBlank() ? idempotencyKey : null) - ); + InterviewMessage placeholder; + try { + // 텍스트 답변(InterviewMessageService.submitAnswer)과 같은 이유로 saveAndFlush + + // 제약 위반 변환 — 같은 턴에 동시 제출이 들어오면 둘 다 같은 seq 를 계산한다. + placeholder = messageRepository.saveAndFlush( + InterviewMessage.voiceInterviewee(session, nextSeq, latest, + idempotencyKey != null && !idempotencyKey.isBlank() ? idempotencyKey : null) + ); + } catch (DataIntegrityViolationException e) { + log.info("voice answer sequence conflict — turn already answered. sessionId={}, seq={}", + sessionId, nextSeq); + throw new DomainException(ApiErrorCode.SESSION_INVALID_STATE); + } return new VoicePlaceholder(session, placeholder, latest); } diff --git a/backend/src/test/java/com/stackup/stackup/session/application/InterviewMessageServiceTest.java b/backend/src/test/java/com/stackup/stackup/session/application/InterviewMessageServiceTest.java index d611092..0f3c2cd 100644 --- a/backend/src/test/java/com/stackup/stackup/session/application/InterviewMessageServiceTest.java +++ b/backend/src/test/java/com/stackup/stackup/session/application/InterviewMessageServiceTest.java @@ -9,6 +9,7 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import com.stackup.stackup.common.exception.ApiErrorCode; import com.stackup.stackup.common.exception.DomainException; import com.stackup.stackup.common.storage.ObjectStorageClient; import com.stackup.stackup.session.application.dto.MessageResult; @@ -81,7 +82,7 @@ void submitAnswer_insertsIntervieweeAndPublishesEvent() { when(sessionRepository.findByIdAndUser_IdAndDeletedFalse(10L, 1L)).thenReturn(Optional.of(session)); when(messageRepository.findFirstBySession_IdOrderBySequenceNumberDesc(10L)).thenReturn(Optional.of(question)); - when(messageRepository.save(any(InterviewMessage.class))).thenAnswer(inv -> { + when(messageRepository.saveAndFlush(any(InterviewMessage.class))).thenAnswer(inv -> { InterviewMessage m = inv.getArgument(0); ReflectionTestUtils.setField(m, "id", 200L); return m; @@ -111,6 +112,27 @@ void submitAnswer_returnsExistingWhenIdempotencyKeyHit() { verify(events, never()).publishEvent(any(AnswerSubmittedEvent.class)); } + // 같은 턴에 두 요청이 동시에 들어오면 둘 다 같은 seq 를 계산한다. + // (session_id, sequence_number) UNIQUE 가 한쪽을 막는데, 500 이 아니라 '이미 답변된 턴'이어야 한다. + @Test + void submitAnswer_translatesSequenceConflictToInvalidState() { + InterviewSession session = sessionInProgress(10L); + InterviewMessage question = InterviewMessage.interviewer(session, 1, "Q1?"); + ReflectionTestUtils.setField(question, "id", 100L); + + when(sessionRepository.findByIdAndUser_IdAndDeletedFalse(10L, 1L)).thenReturn(Optional.of(session)); + when(messageRepository.findFirstBySession_IdOrderBySequenceNumberDesc(10L)).thenReturn(Optional.of(question)); + when(messageRepository.saveAndFlush(any(InterviewMessage.class))) + .thenThrow(new org.springframework.dao.DataIntegrityViolationException( + "duplicate key value violates unique constraint")); + + assertThatThrownBy(() -> service.submitAnswer(1L, 10L, "answer", null)) + .isInstanceOfSatisfying(DomainException.class, e -> + assertThat(e.getErrorCode()).isEqualTo(ApiErrorCode.SESSION_INVALID_STATE)); + + verify(events, never()).publishEvent(any(AnswerSubmittedEvent.class)); + } + @Test void submitAnswer_rejectsWhenPreviousMessageNotInterviewer() { InterviewSession session = sessionInProgress(10L); @@ -135,7 +157,7 @@ void submitAnswer_allowsTextRetryAfterFailedVoiceAnswer() { when(sessionRepository.findByIdAndUser_IdAndDeletedFalse(10L, 1L)).thenReturn(Optional.of(session)); when(messageRepository.findFirstBySession_IdOrderBySequenceNumberDesc(10L)).thenReturn(Optional.of(failedVoice)); - when(messageRepository.save(any(InterviewMessage.class))).thenAnswer(inv -> { + when(messageRepository.saveAndFlush(any(InterviewMessage.class))).thenAnswer(inv -> { InterviewMessage m = inv.getArgument(0); ReflectionTestUtils.setField(m, "id", 201L); return m; diff --git a/backend/src/test/java/com/stackup/stackup/session/application/VoiceAnswerUploadServiceTest.java b/backend/src/test/java/com/stackup/stackup/session/application/VoiceAnswerUploadServiceTest.java index b24f2e1..7f0b4e8 100644 --- a/backend/src/test/java/com/stackup/stackup/session/application/VoiceAnswerUploadServiceTest.java +++ b/backend/src/test/java/com/stackup/stackup/session/application/VoiceAnswerUploadServiceTest.java @@ -52,7 +52,7 @@ void createVoicePlaceholder_insertsAfterLatestQuestion() { when(sessionRepository.findByIdAndUser_IdAndDeletedFalse(10L, 1L)).thenReturn(Optional.of(session)); when(messageRepository.findFirstBySession_IdOrderBySequenceNumberDesc(10L)) .thenReturn(Optional.of(question)); - when(messageRepository.save(any(InterviewMessage.class))).thenAnswer(inv -> { + when(messageRepository.saveAndFlush(any(InterviewMessage.class))).thenAnswer(inv -> { InterviewMessage m = inv.getArgument(0); ReflectionTestUtils.setField(m, "id", 200L); return m; @@ -135,6 +135,24 @@ void createVoicePlaceholder_rejectsWhenLastMessageIsNotQuestion() { verify(messageRepository, never()).save(any()); } + // 텍스트 답변과 동일 — 동시 제출로 seq 가 충돌하면 500 이 아니라 '이미 답변된 턴'. + @Test + void createVoicePlaceholder_translatesSequenceConflictToInvalidState() { + InterviewSession session = sessionInProgress(10L); + InterviewMessage question = InterviewMessage.interviewer(session, 1, "Q1?"); + ReflectionTestUtils.setField(question, "id", 100L); + + when(sessionRepository.findByIdAndUser_IdAndDeletedFalse(10L, 1L)).thenReturn(Optional.of(session)); + when(messageRepository.findFirstBySession_IdOrderBySequenceNumberDesc(10L)) + .thenReturn(Optional.of(question)); + when(messageRepository.saveAndFlush(any(InterviewMessage.class))) + .thenThrow(new org.springframework.dao.DataIntegrityViolationException("duplicate key")); + + assertThatThrownBy(() -> service.createVoicePlaceholder(1L, 10L, null)) + .isInstanceOfSatisfying(DomainException.class, e -> + assertThat(e.getErrorCode()).isEqualTo(ApiErrorCode.SESSION_INVALID_STATE)); + } + // ── attachAudioAndRequestAnalysis ───────────────────────────────────────── // analyze.voice 는 여기서 직접 발행하지 않는다 — 이벤트만 내고 AFTER_COMMIT 리스너가 발행한다. diff --git a/frontend/src/features/history/ui/SessionCard.test.tsx b/frontend/src/features/history/ui/SessionCard.test.tsx new file mode 100644 index 0000000..0f370e4 --- /dev/null +++ b/frontend/src/features/history/ui/SessionCard.test.tsx @@ -0,0 +1,62 @@ +import { describe, it, expect } from 'vitest' +import { render, screen } from '@testing-library/react' +import { MemoryRouter } from 'react-router-dom' +import { SessionCard } from './SessionCard' +import type { Session } from '../api/historyApi' + +const base: Session = { + id: 7, + title: '백엔드 모의면접', + status: 'COMPLETED', + mode: 'TECHNICAL', + jobCategory: 'BACKEND', + totalQuestionCount: 5, + createdAt: '2026-08-18T00:00:00Z', +} + +function renderCard(session: Partial) { + return render( + + + , + ) +} + +describe('SessionCard', () => { + it('완료 세션은 피드백 리포트로 간다', () => { + renderCard({ status: 'COMPLETED' }) + + expect(screen.getByRole('link')).toHaveAttribute('href', '/sessions/7/feedback') + expect(screen.getByText('리포트 →')).toBeInTheDocument() + }) + + // 중단된 면접은 피드백이 없다. 문답 기록으로 갈 경로가 없으면 한 말이 통째로 사라진다. + it('중단 세션은 세션 화면(기록)으로 간다', () => { + renderCard({ status: 'INTERRUPTED' }) + + expect(screen.getByRole('link')).toHaveAttribute('href', '/sessions/7') + expect(screen.getByText('기록 보기 →')).toBeInTheDocument() + }) + + // 진행 중 면접에서 이탈했을 때 돌아갈 경로. + it('진행 중 세션은 이어서 진행할 수 있다', () => { + renderCard({ status: 'IN_PROGRESS' }) + + expect(screen.getByRole('link')).toHaveAttribute('href', '/sessions/7') + expect(screen.getByText('이어서 →')).toBeInTheDocument() + }) + + it('준비 상태 세션은 로비로 간다', () => { + renderCard({ status: 'READY' }) + + expect(screen.getByRole('link')).toHaveAttribute('href', '/sessions/7') + }) + + // 시작 전 취소라 보여줄 문답이 없다 — 링크를 만들지 않는다. + it('취소 세션은 눌리지 않는다', () => { + renderCard({ status: 'CANCELLED' }) + + expect(screen.queryByRole('link')).not.toBeInTheDocument() + expect(screen.getByText('취소됨')).toBeInTheDocument() + }) +}) diff --git a/frontend/src/features/history/ui/SessionCard.tsx b/frontend/src/features/history/ui/SessionCard.tsx index 9a49569..cb5adce 100644 --- a/frontend/src/features/history/ui/SessionCard.tsx +++ b/frontend/src/features/history/ui/SessionCard.tsx @@ -25,9 +25,21 @@ const JOB: Record = { DBA: 'DBA', } +/** + * 상태별로 눌렀을 때 갈 곳. 예전에는 COMPLETED 만 링크였고 나머지는 아예 눌리지 않아, + * 중단된 면접의 문답을 다시 볼 방법도 · 진행 중이던 면접으로 돌아갈 방법도 없었다. + * (CANCELLED 는 시작 전 취소라 볼 것이 없어 그대로 둔다.) + */ +const LINK: Record string; cta: string }> = { + COMPLETED: { to: (id) => `/sessions/${id}/feedback`, cta: '리포트 →' }, + INTERRUPTED: { to: (id) => `/sessions/${id}`, cta: '기록 보기 →' }, + IN_PROGRESS: { to: (id) => `/sessions/${id}`, cta: '이어서 →' }, + READY: { to: (id) => `/sessions/${id}`, cta: '시작하기 →' }, +} + export function SessionCard({ session }: { session: Session }) { const status = session.status ? STATUS[session.status] : undefined - const completed = session.status === 'COMPLETED' + const link = session.status ? LINK[session.status] : undefined const jobs = session.jobCategories?.length ? session.jobCategories : session.jobCategory @@ -48,15 +60,17 @@ export function SessionCard({ session }: { session: Session }) {
{status && {status.label}} - {completed && ( - 리포트 → + {link && ( + + {link.cta} + )}
) - return completed ? ( - {body} + return link && session.id != null ? ( + {body} ) : ( body ) diff --git a/frontend/src/features/interview/ui/live/SessionEndedPanel.tsx b/frontend/src/features/interview/ui/live/SessionEndedPanel.tsx index e2c91c3..7f9f211 100644 --- a/frontend/src/features/interview/ui/live/SessionEndedPanel.tsx +++ b/frontend/src/features/interview/ui/live/SessionEndedPanel.tsx @@ -2,6 +2,7 @@ import { Link } from 'react-router-dom' import { Button } from '@/shared/ui/Button' import { Eyebrow } from '@/shared/ui' import type { SessionStatus } from '@/domain/session' +import { InterviewTranscript } from '../InterviewTranscript' const messageByStatus: Partial> = { COMPLETED: '면접이 종료되었습니다. 피드백을 준비 중입니다.', @@ -9,6 +10,12 @@ const messageByStatus: Partial> = { CANCELLED: '면접이 취소되었습니다.', } +// 중단된 면접은 피드백이 만들어지지 않는다. 그렇다고 주고받은 문답까지 못 보게 하면 +// 사용자가 한 말이 통째로 사라진 것처럼 보이므로, 여기서 기록을 그대로 노출한다. +// (취소된 세션은 시작 전이라 자기소개 질문 하나뿐 — 보여줄 게 없다.) +const showsTranscript = (status: SessionStatus) => + status === 'COMPLETED' || status === 'INTERRUPTED' + export function SessionEndedPanel({ status, sessionId, @@ -17,24 +24,32 @@ export function SessionEndedPanel({ sessionId: number }) { return ( -
- 면접 종료 -

- {messageByStatus[status] ?? '면접이 종료되었습니다.'} -

-
- {status === 'COMPLETED' && ( - - +
+
+ 면접 종료 +

+ {messageByStatus[status] ?? '면접이 종료되었습니다.'} +

+
+ {status === 'COMPLETED' && ( + + + + )} + + - )} - - - +
+ + {showsTranscript(status) && ( +
+ +
+ )}
) }