From 3c0eb484b8bf1d53e6ef8722681c2899311399b5 Mon Sep 17 00:00:00 2001 From: Hello-Worldismine Date: Mon, 17 Nov 2025 07:38:46 +0900 Subject: [PATCH] =?UTF-8?q?=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/RandomQuestion.tsx | 439 +++++++++++++----------- src/pages/Interview/feedback_result.tsx | 12 +- src/pages/Interview/main_answer.tsx | 200 ++++++----- src/pages/Interview/question_done.tsx | 3 +- src/services/randomQuestionApi.ts | 2 +- 5 files changed, 365 insertions(+), 291 deletions(-) diff --git a/src/components/RandomQuestion.tsx b/src/components/RandomQuestion.tsx index e4878ef..7e64cf9 100644 --- a/src/components/RandomQuestion.tsx +++ b/src/components/RandomQuestion.tsx @@ -1,3 +1,4 @@ +// src/components/RandomQuestion.tsx import { useEffect, useRef, useState } from 'react'; import { getRandomQuestion, @@ -9,7 +10,15 @@ import { import clockFrog from '@/assets/clockFrog.svg'; type TNotification = IRandomNotificationPayload; -const MAX_TIME = 180; // 팝업 질문 제한 시간(초) +const MAX_TIME = 180; +const isDev = import.meta.env.DEV; + +// 🔎 알림 페이로드에서 id를 안전하게 뽑아오기 (peerAnswerId 우선, 없으면 peerFeedbackId) +const extractPeerAnswerId = (payload: any): number | null => { + const raw = payload?.peerAnswerId ?? payload?.peerFeedbackId; + const n = Number(raw); + return Number.isFinite(n) ? n : null; +}; export default function RandomQuestion() { const [showPopup, setShowPopup] = useState(false); @@ -40,14 +49,15 @@ export default function RandomQuestion() { const [playbackTime, setPlaybackTime] = useState(0); const [playbackDuration, setPlaybackDuration] = useState(0); - // 제출 중 상태 const [isSubmitting, setIsSubmitting] = useState(false); // ===== SSE & 요청 취소 컨트롤 ===== const esRef = useRef(null); const reconnectTimerRef = useRef(null); const attemptsRef = useRef(0); - const fetchAbortRef = useRef(null); // ✅ 이전 요청 취소용 + const fetchAbortRef = useRef(null); + + const shouldTickPopup = showPopup && remainingTime > 0 && (isRecording || (!recordedAudio && !isPlaying)); const clearReconnectTimer = () => { if (reconnectTimerRef.current) { @@ -77,8 +87,8 @@ export default function RandomQuestion() { } }; - const fetchRandomQuestion = async (peerFeedbackId: number) => { - // 이전 요청 취소 + // ✅ 스펙에 맞춰 peerAnswerId로만 호출 (옵션객체 제거해 TS 오류 방지) + const fetchRandomQuestion = async (peerAnswerId: number) => { fetchAbortRef.current?.abort(); fetchAbortRef.current = new AbortController(); @@ -86,18 +96,16 @@ export default function RandomQuestion() { setErrorMessage(null); try { - console.log('[RQ] fetch start', { peerFeedbackId }); - const q = await getRandomQuestion(peerFeedbackId, { - noCache: true, // ✅ 캐시 우회 - signal: fetchAbortRef.current.signal, // ✅ 이전 요청 취소 대응 - }); - console.log('[RQ] fetch ok', q); + const q = await getRandomQuestion(peerAnswerId); setQuestionDetail(q); } catch (err: any) { if (err?.name === 'CanceledError' || err?.name === 'AbortError') { - console.log('[RQ] fetch aborted'); + // ignore + } else if (err?.response?.data?.errorCode === 'PEER_FEEDBACK_NOT_FOUND') { + setErrorMessage('해당 피드백 정보를 찾을 수 없습니다.\n다른 peerAnswerId를 시도해주세요.'); + } else if (err?.response?.data?.errorCode === 'INTERNAL_ERROR') { + setErrorMessage('서버 내부 오류가 발생했습니다.\n잠시 후 다시 시도해주세요.'); } else { - console.error('[RQ] fetch error', err); setErrorMessage('팝업 질문을 불러오지 못했습니다.'); } } finally { @@ -106,18 +114,16 @@ export default function RandomQuestion() { }; const handleMessage = async (event: MessageEvent) => { - // 서버가 보내는 ping/keepalive 등 비 JSON은 무시 try { const parsed = JSON.parse(event.data); - if (!parsed || typeof parsed !== 'object' || parsed.peerFeedbackId == null) { - return; - } - const data = parsed as TNotification; + const id = extractPeerAnswerId(parsed); + if (id == null) return; - // 새 팝업 초기화 + const data = parsed as TNotification; setNotification(data); setShowPopup(true); setQuestionDetail(null); + setRecordedAudio((prev) => { if (prev) URL.revokeObjectURL(prev); return null; @@ -126,9 +132,9 @@ export default function RandomQuestion() { setRecordingTime(0); setRemainingTime(MAX_TIME); - await fetchRandomQuestion(Number(data.peerFeedbackId)); + await fetchRandomQuestion(id); } catch { - // 무시(keepalive) + // keepalive 등 무시 } }; @@ -139,7 +145,7 @@ export default function RandomQuestion() { const es = subscribeToNotifications(handleMessage, (errorEvt) => { console.error('SSE 연결 오류:', errorEvt); scheduleReconnect('onerror'); - }); + }) as unknown as EventSource; (es as any).onopen = () => { attemptsRef.current = 0; @@ -149,13 +155,58 @@ export default function RandomQuestion() { esRef.current = es; }; + // 🔧 테스트 버튼 + const triggerTestPopup = async () => { + const inputId = prompt('테스트할 peerAnswerId를 입력하세요 (Mock: -1):', '1'); + if (!inputId || inputId.trim() === '') { + alert('peerAnswerId를 입력해주세요.'); + return; + } + const peerAnswerId = parseInt(inputId.trim(), 10); + if (isNaN(peerAnswerId)) { + alert('유효한 숫자를 입력해주세요.'); + return; + } + + const testData: any = { + jobName: '백엔드 개발자', + interviewName: '테스트 면접', + questionNumber: 1, + peerAnswerId, // ✅ 스펙 필드명 + }; + + setNotification(testData); + setShowPopup(true); + setQuestionDetail(null); + + if (recordedAudio) URL.revokeObjectURL(recordedAudio); + latestAudioBlobRef.current = null; + setRecordingTime(0); + setRemainingTime(MAX_TIME); + + if (peerAnswerId === -1) { + setLoadingQuestion(true); + setTimeout(() => { + setQuestionDetail({ + question: { questionId: 999, questionText: '그럼 이걸 실제 서비스에서 어떻게 검증했나요?' }, + context: { + questionId: 998, + questionText: '프로젝트에서 사용한 기술 스택에 대해 설명해주세요.', + presignedRecordingGetUrl: '', + sttText: '저희 프로젝트는 React와 TypeScript, 백엔드는 Spring Boot를 사용했습니다.', + }, + }); + setLoadingQuestion(false); + }, 500); + } else { + await fetchRandomQuestion(peerAnswerId); + } + }; + useEffect(() => { const onVis = () => { - if (document.visibilityState === 'visible') { - openSSE(); - } else { - closeSSE(); - } + if (document.visibilityState === 'visible') openSSE(); + else closeSSE(); }; openSSE(); document.addEventListener('visibilitychange', onVis); @@ -177,7 +228,6 @@ export default function RandomQuestion() { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - // ===== 유틸 ===== const formatTime = (s: number) => { const secs = Math.max(0, Math.floor(s || 0)); const m = Math.floor(secs / 60); @@ -185,9 +235,9 @@ export default function RandomQuestion() { return `${m}:${r.toString().padStart(2, '0')}`; }; - // ===== 팝업 전체 제한시간 타이머 ===== + // 🔁 카운트다운 useEffect(() => { - if (!showPopup) { + if (!shouldTickPopup) { if (countdownTimerRef.current) { clearInterval(countdownTimerRef.current); countdownTimerRef.current = null; @@ -197,6 +247,11 @@ export default function RandomQuestion() { countdownTimerRef.current = window.setInterval(() => { setRemainingTime((prev) => { if (prev <= 1) { + try { + mediaRecorderRef.current?.stop(); + } catch {} + setIsRecording(false); + setIsPausedRec(false); if (countdownTimerRef.current) { clearInterval(countdownTimerRef.current); countdownTimerRef.current = null; @@ -206,31 +261,17 @@ export default function RandomQuestion() { return prev - 1; }); }, 1000); - return () => { if (countdownTimerRef.current) { clearInterval(countdownTimerRef.current); countdownTimerRef.current = null; } }; - }, [showPopup]); - - // 시간 종료 시 부가 처리 - useEffect(() => { - if (!showPopup || remainingTime > 0) return; - if (isRecording && mediaRecorderRef.current) { - try { - mediaRecorderRef.current.stop(); - } catch {} - setIsRecording(false); - setIsPausedRec(false); - } - if (audioRef.current) audioRef.current.pause(); - }, [remainingTime, showPopup, isRecording]); + }, [shouldTickPopup]); const isTimeOver = remainingTime <= 0; - // ===== 녹음 타이머 ===== + // ⏱ 녹음 시간 타이머 useEffect(() => { if (isRecording && !isPausedRec) { recordTimerRef.current = window.setInterval(() => setRecordingTime((t) => t + 1), 1000); @@ -246,7 +287,6 @@ export default function RandomQuestion() { }; }, [isRecording, isPausedRec]); - // ===== 녹음 제어 ===== const startRecording = async () => { if (isTimeOver) { alert('시간이 종료되어 더 이상 녹음할 수 없습니다.'); @@ -317,10 +357,6 @@ export default function RandomQuestion() { }; const handleRetry = () => { - if (isTimeOver) { - alert('시간이 종료되어 다시 녹음할 수 없습니다.'); - return; - } if (audioRef.current) { audioRef.current.pause(); audioRef.current.currentTime = 0; @@ -334,10 +370,11 @@ export default function RandomQuestion() { } setRecordingTime(0); latestAudioBlobRef.current = null; + audioChunksRef.current = []; + setRemainingTime(MAX_TIME); void startRecording(); }; - // ===== 재생 제어 ===== const toggleAudioPlayback = () => { const el = audioRef.current; if (!el) return; @@ -351,7 +388,6 @@ export default function RandomQuestion() { } }; - // ===== 오디오 이벤트 ===== useEffect(() => { const el = audioRef.current; if (!el) return; @@ -381,14 +417,16 @@ export default function RandomQuestion() { }; }, [recordedAudio]); - // ===== 팝업 닫기 ===== const handleClose = () => { if (isRecording) stopRecording(); if (audioRef.current) audioRef.current.pause(); setShowPopup(false); + if (countdownTimerRef.current) { + clearInterval(countdownTimerRef.current); + countdownTimerRef.current = null; + } }; - // ===== 답변 제출 ===== const handleSubmit = async () => { if (isTimeOver) { alert('시간이 종료되어 답변을 제출할 수 없습니다.'); @@ -416,173 +454,178 @@ export default function RandomQuestion() { } }; - if (!showPopup) return null; - const playbackPercent = playbackDuration > 0 ? Math.min(100, Math.max(0, (playbackTime / playbackDuration) * 100)) : 0; - const progress = 100; + const progressPercent = Math.max(0, Math.min(100, (remainingTime / MAX_TIME) * 100)); return ( -
-
- {/* 닫기 */} - - - {/* 헤더 - 알림 정보 */} -

- {notification ? `${notification.jobName} · ${notification.interviewName} · 질문 ${notification.questionNumber}번` : '랜덤 팝업 질문'} -

-

랜덤 팝업 질문이 도착했어요 🔔

- - {/* 질문/맥락 */} - {loadingQuestion ? ( -

질문을 불러오는 중입니다...

- ) : errorMessage ? ( -

{errorMessage}

- ) : questionDetail ? ( - <> -
-

맥락이 되는 질문

-

{questionDetail.context.questionText}

- {questionDetail.context.sttText &&

{questionDetail.context.sttText}

} + )} + + {!showPopup ? null : ( +
+
+ + +

+ {notification + ? `${(notification as any).jobName} · ${(notification as any).interviewName} · 질문 ${(notification as any).questionNumber}번` + : '랜덤 팝업 질문'} +

+

랜덤 팝업 질문이 도착했어요 🔔

+ + {loadingQuestion ? ( +

질문을 불러오는 중입니다...

+ ) : errorMessage ? ( +

{errorMessage}

+ ) : questionDetail ? ( + <> +
+

맥락이 되는 질문

+

{questionDetail.context.questionText}

+ {questionDetail.context.sttText &&

{questionDetail.context.sttText}

} +
+

{questionDetail.question.questionText}

+ + ) : ( +

질문 정보를 불러오지 못했습니다.

+ )} + +
+ 면접관
-

{questionDetail.question.questionText}

- - ) : ( -

질문 정보를 불러오지 못했습니다.

- )} - -
- 면접관 -
-

- {remainingTime > 0 ? `답변 가능 시간이 ${remainingTime}초 남았습니다.` : '시간이 종료되었습니다.'} -

+

+ {remainingTime > 0 ? `답변 가능 시간이 ${remainingTime}초 남았습니다.` : '시간이 종료되었습니다.'} +

-
-
-
-
-

랜덤 팝업 질문

-
+
+
+
+
+

랜덤 팝업 질문

+
-
- {!recordedAudio ? ( -
- {!isRecording ? ( - +
+ {!recordedAudio ? ( +
+ {!isRecording ? ( + + ) : ( + <> + + {formatTime(recordingTime)} + + + )} +
) : ( - <> +
- {formatTime(recordingTime)} - - +
+
+
+
+
+ + {formatTime(playbackTime)} / {formatTime(playbackDuration)} + +
)}
- ) : ( -
+ +
+ + -
-
-
-
-
- - {formatTime(playbackTime)} / {formatTime(playbackDuration)} - -
- )} -
+
-
- - - +
-
- - -
+ )} + ); } diff --git a/src/pages/Interview/feedback_result.tsx b/src/pages/Interview/feedback_result.tsx index af97f27..f0258a8 100644 --- a/src/pages/Interview/feedback_result.tsx +++ b/src/pages/Interview/feedback_result.tsx @@ -132,6 +132,12 @@ export default function FeedbackResult() { const feedbackText = summary.aiFeedback || summary.selfFeedback; const feedbackType = summary.aiFeedback ? 'AI 피드백' : summary.selfFeedback ? '셀프 피드백' : '피드백 없음'; + // 실제 timeout 여부 확인 (feedbacks 배열에서 해당 질문 찾기) + const correspondingFeedback = feedbackData.feedbacks?.find( + (fb) => fb.questionId === summary.questionNumber || fb.question === summary.rootQuestion + ); + const isTimeout = correspondingFeedback?.timeout ?? false; + // 답변 텍스트 (Q&A 턴에서 ANSWER만 추출) const answerTurns = summary.qnaTurns.filter((turn) => turn.turn === 'ANSWER'); const hasAnswer = answerTurns.length > 0; @@ -149,7 +155,9 @@ export default function FeedbackResult() { {/* 카드 내용 (스크롤 가능) */}
{isShowingAnswer ? ( - hasAnswer ? ( + isTimeout ? ( +

시간 초과로 답변하지 못했습니다.

+ ) : hasAnswer ? (
{summary.qnaTurns.map((turn, turnIndex) => (
@@ -159,7 +167,7 @@ export default function FeedbackResult() { ))}
) : ( -

시간 초과로 답변하지 못했습니다.

+

답변 데이터를 불러올 수 없습니다.

) ) : feedbackText ? (

{feedbackText}

diff --git a/src/pages/Interview/main_answer.tsx b/src/pages/Interview/main_answer.tsx index 1ed0bf6..3e54d8a 100644 --- a/src/pages/Interview/main_answer.tsx +++ b/src/pages/Interview/main_answer.tsx @@ -4,8 +4,12 @@ import { useLocation, useNavigate } from 'react-router-dom'; import InterviewLayout from '@/layouts/InterviewLayout'; import type { IQuestion } from '@/services/interviewApi'; -import { sendTimeout, uploadRecordingAndGetNext } from '@/services/interviewApi'; +import { uploadRecordingAndGetNext, timeoutAndGetNextQuestion } from '@/services/interviewApi'; + import clockFrog from '@/assets/clockFrog.svg'; +import orangeFrog from '@/assets/orangeFrog.svg'; + +const MAX_TIME = 180; export default function AnswerQuestion() { const navigate = useNavigate(); @@ -23,19 +27,18 @@ export default function AnswerQuestion() { const { fileName = '자소서', jobTitle, interviewType = 'normal', resumeKey, sessionId, firstQuestion } = location.state || {}; - /** ---------------- 상태 ---------------- */ const [currentQuestion, setCurrentQuestion] = useState(firstQuestion ?? null); const [showCompleteModal, setShowCompleteModal] = useState(false); - // 탭(질문{order}) – 서버에서 오는 Question.order를 기반으로 생성/유지 const [ordersSeen, setOrdersSeen] = useState(firstQuestion ? [firstQuestion.order] : []); - // 녹음 관련 상태 const [isRecording, setIsRecording] = useState(false); const [isPaused, setIsPaused] = useState(false); const [recordedAudioUrl, setRecordedAudioUrl] = useState(null); const [recordingTime, setRecordingTime] = useState(0); - const [remainingTime, setRemainingTime] = useState(180); + + const [remainingTime, setRemainingTime] = useState(MAX_TIME); + const [retryCount, setRetryCount] = useState(1); const [isSubmitting, setIsSubmitting] = useState(false); @@ -43,26 +46,26 @@ export default function AnswerQuestion() { const audioChunksRef = useRef([]); const latestAudioBlobRef = useRef(null); - // 재생 const audioRef = useRef(null); const [isPlaying, setIsPlaying] = useState(false); const [playbackTime, setPlaybackTime] = useState(0); const [playbackDuration, setPlaybackDuration] = useState(0); + // 남은 시간이 줄어들어야 하는지 여부: + // - 질문 노출 & 완료모달 아님 & 남은시간 > 0 + // - (녹음 중) 또는 (아직 녹음본이 없음 = 최초 진입 상태) + // - 재생 중(isPlaying)에는 줄어들면 안 됨 + const shouldTick = !!currentQuestion && !showCompleteModal && remainingTime > 0 && (isRecording || (!recordedAudioUrl && !isPlaying)); - /** ---------------- 초기 유효성 ---------------- */ useEffect(() => { if (!firstQuestion) { - // question_loading에서 세션 생성 후 오도록 설계됨 navigate('/question-loading', { replace: true, state: { fileName, jobTitle, interviewType, resumeKey }, }); } - // 의도적으로 최초 마운트 시에만 검증 // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - /** ---------------- 공통 초기화 함수(호출 위치보다 위 선언) ---------------- */ function resetForNext() { if (audioRef.current) { audioRef.current.pause(); @@ -74,84 +77,84 @@ export default function AnswerQuestion() { setRecordedAudioUrl(null); latestAudioBlobRef.current = null; setRecordingTime(0); - setRemainingTime(180); // 다음 질문용 기본 180초 + + setRemainingTime(MAX_TIME); // 다음 질문에서만 초기화 setRetryCount(1); } - /** ---------------- 녹음 중지 ---------------- */ const stopRecording = useCallback(() => { if (mediaRecorderRef.current && isRecording) { try { mediaRecorderRef.current.stop(); - } catch { - /* noop */ - } + } catch {} setIsRecording(false); setIsPaused(false); + // ⛔ 정지 후에는 카운트다운이 더 줄지 않음(useEffect로 interval이 중단됨) } }, [isRecording]); - /** ---------------- 시간초과 처리 ---------------- */ - const handleTimeout = async (questionId: string) => { - try { - const next = await sendTimeout(questionId); // ← 다음 질문 시도 - resetForNext(); - applyNext(next); // ← next가 있으면 다음으로, 없으면 내부에서 완료 모달 - } catch (e) { - console.error('시간초과 처리 실패:', e); - alert('시간이 초과되었습니다. 다음 질문으로 넘어갑니다.'); - } - }; + const handleTimeout = useCallback( + async (questionId: string) => { + try { + const next = await timeoutAndGetNextQuestion(questionId); + alert('시간초과로 답변하지 못했습니다. 다음 질문으로 넘어갑니다.'); + resetForNext(); + applyNext(next); + } catch (e) { + console.error('시간초과 처리 실패:', e); + alert('시간이 초과되었습니다. 다음 질문으로 넘어갑니다.'); + resetForNext(); + applyNext(null); + } + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [], + ); - /** ---------------- 녹음 시간 타이머 (녹음 중일 때만 증가) ---------------- */ + // ⏱ 녹음 시간(녹음 중에만 증가) useEffect(() => { if (isRecording && !isPaused) { - const id = window.setInterval(() => { - setRecordingTime((prev) => prev + 1); - }, 1000); - - return () => { - clearInterval(id); - }; + const id = window.setInterval(() => setRecordingTime((prev) => prev + 1), 1000); + return () => clearInterval(id); } - - // 녹음 중이 아니면 타이머 없음 return undefined; }, [isRecording, isPaused]); - /** ---------------- 180초 카운트다운 (질문 뜨는 순간부터 시작) ---------------- */ + // ✅ 카운트다운: 최초 진입(아직 녹음본 없음)에도 감소, + // 녹음 중에도 감소, 재생 중/정지 후에는 멈춤 useEffect(() => { - // 질문이 없거나, 이미 완료 모달이 떠 있으면 타이머 돌리지 않음 - if (!currentQuestion || showCompleteModal) return; - - // 질문이 화면에 노출되는 순간부터 1초마다 remainingTime 감소 - const id = window.setInterval(() => { - setRemainingTime((prev) => { - if (prev <= 1) { - clearInterval(id); - - // 녹음 중이면 강제로 정지 - if (isRecording) { - stopRecording(); + if (!currentQuestion) return; + + let id: number | null = null; + + if (shouldTick) { + id = window.setInterval(() => { + setRemainingTime((prev) => { + if (prev <= 1) { + if (id) clearInterval(id); + + // 시간 끝나면 녹음 중지 + 서버 timeout 처리 + if (isRecording) { + try { + mediaRecorderRef.current?.stop(); + } catch {} + } + + if (currentQuestion?.questionId) { + void handleTimeout(currentQuestion.questionId); + } + return 0; } + return prev - 1; + }); + }, 1000); + } - if (currentQuestion?.questionId) { - void handleTimeout(currentQuestion.questionId); - } - - return 0; - } - return prev - 1; - }); - }, 1000); - - // 질문이 바뀌거나, 컴포넌트 언마운트 시 타이머 정리 return () => { - clearInterval(id); + if (id) clearInterval(id); }; - }, [currentQuestion?.questionId, isRecording, stopRecording, handleTimeout, showCompleteModal]); + }, [shouldTick, currentQuestion?.questionId, isRecording, handleTimeout]); - /** ---------------- 녹음 제어 ---------------- */ const startRecording = async () => { try { const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); @@ -161,9 +164,7 @@ export default function AnswerQuestion() { latestAudioBlobRef.current = null; mediaRecorder.ondataavailable = (event) => { - if (event.data.size > 0) { - audioChunksRef.current.push(event.data); - } + if (event.data.size > 0) audioChunksRef.current.push(event.data); }; mediaRecorder.onstop = () => { @@ -174,11 +175,8 @@ export default function AnswerQuestion() { const audioUrl = URL.createObjectURL(audioBlob); setRecordedAudioUrl(audioUrl); - // 재생 상태 초기화 setIsPlaying(false); setPlaybackTime(0); - - // 스트림 정리 stream.getTracks().forEach((track) => track.stop()); }; @@ -186,9 +184,8 @@ export default function AnswerQuestion() { setIsRecording(true); setIsPaused(false); setRecordedAudioUrl(null); - - // 녹음 시간은 새로 시작 setRecordingTime(0); + // ⏱ 남은 시간은 녹음 중일 때만 줄어듦 } catch (error) { console.error('마이크 접근 오류:', error); alert('마이크 접근 권한이 필요합니다.'); @@ -208,7 +205,6 @@ export default function AnswerQuestion() { const handleRetry = () => { if (retryCount > 0) { - // 재생 중이면 멈춤 if (audioRef.current) { audioRef.current.pause(); audioRef.current.currentTime = 0; @@ -217,15 +213,25 @@ export default function AnswerQuestion() { setPlaybackTime(0); setPlaybackDuration(0); + // 이전 녹음 데이터 완전히 제거 + if (recordedAudioUrl) { + URL.revokeObjectURL(recordedAudioUrl); + } setRecordedAudioUrl(null); setRecordingTime(0); - setRemainingTime(180); + + // 🔧 중요: 이전 오디오 blob 초기화 + latestAudioBlobRef.current = null; + audioChunksRef.current = []; + + // ✅ 다시 녹음하기: 시간 초기화 + setRemainingTime(MAX_TIME); + setRetryCount((c) => c - 1); void startRecording(); } }; - /** ---------------- 재생 제어 ---------------- */ const toggleAudioPlayback = () => { if (!audioRef.current) return; if (audioRef.current.paused) { @@ -273,7 +279,6 @@ export default function AnswerQuestion() { }; }, [recordedAudioUrl, recordingTime]); - /** ---------------- 다음 질문 ---------------- */ const applyNext = (next: IQuestion | null) => { if (!next) { setShowCompleteModal(true); @@ -290,26 +295,43 @@ export default function AnswerQuestion() { return; } + // 중복 제출 방지 + if (isSubmitting) { + console.log('이미 제출 중입니다.'); + return; + } + setIsSubmitting(true); try { const next = await uploadRecordingAndGetNext(currentQuestion.questionId, latestAudioBlobRef.current); resetForNext(); applyNext(next); - } catch (e) { + } catch (e: any) { console.error('다음 질문 처리 실패:', e); - alert('녹음 처리에 실패했습니다. 잠시 후 다시 시도해주세요.'); + + // 에러 타입별 처리 + if (e?.response?.data?.errorCode === 'ALREADY_IN_QUEUE_OR_DONE') { + alert('이미 처리된 녹음입니다. 다음 질문으로 넘어갑니다.'); + // 다음 질묬을 가져오기 위해 재시도 + try { + // timeout 처리로 다음 질묬 가져오기 + const next = await timeoutAndGetNextQuestion(currentQuestion.questionId); + resetForNext(); + applyNext(next); + } catch (err) { + console.error('다음 질묬 조회 실패:', err); + alert('다음 질묬으로 넘어갑니다.'); + resetForNext(); + applyNext(null); + } + } else { + alert('녹음 처리에 실패했습니다. 잠시 후 다시 시도해주세요.'); + } } finally { setIsSubmitting(false); } }; - /** ---------------- 탭 이동(표시용) ---------------- */ - const handleOrderTabClick = useCallback(() => { - // 서버가 특정 order의 과거 질문을 다시 불러오는 API를 제공하지 않음. - // 탭은 "표시용"으로 유지. - }, []); - - /** ---------------- 기타 ---------------- */ const handleFinalFeedback = () => { if (sessionId) { navigate('/feedback-result', { state: { sessionId } }); @@ -336,12 +358,12 @@ export default function AnswerQuestion() {

제한 시간 내에 면접질문에 답변해주세요.

- {/* 질문 탭(표시용: order) */} + {/* 질문 탭 */}
{ordersSeen.map((o) => ( diff --git a/src/pages/Interview/question_done.tsx b/src/pages/Interview/question_done.tsx index f8a920f..4e03530 100644 --- a/src/pages/Interview/question_done.tsx +++ b/src/pages/Interview/question_done.tsx @@ -1,6 +1,7 @@ import { useNavigate } from 'react-router-dom'; import InterviewLayout from '@/layouts/InterviewLayout'; +import Frog from '@/assets/frog.svg'; export default function QuestionDone() { const navigate = useNavigate(); @@ -23,7 +24,7 @@ export default function QuestionDone() { {/* 오른쪽 캐릭터 이미지 */}
- 리뷰캐릭터 + 리뷰캐릭터