From 07e3724a2d948ced649a833555993cc959ec29d0 Mon Sep 17 00:00:00 2001 From: Hello-Worldismine Date: Mon, 17 Nov 2025 04:34:56 +0900 Subject: [PATCH 1/5] =?UTF-8?q?=EC=8B=9C=EA=B0=84=ED=9D=90=EB=A6=84=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/myInterviews.tsx | 848 +++++++++++++++++++++++-------------- 1 file changed, 538 insertions(+), 310 deletions(-) diff --git a/src/pages/myInterviews.tsx b/src/pages/myInterviews.tsx index bf75fd5..f8b0d52 100644 --- a/src/pages/myInterviews.tsx +++ b/src/pages/myInterviews.tsx @@ -1,359 +1,587 @@ -// src/pages/myInterviews.tsx -import { useEffect, useRef, useState } from 'react'; -import { useParams } from 'react-router-dom'; - -import useGetInterviewSummary from '@/hooks/useGetInterviewSummary'; -import useGetQuestionAnswers from '@/hooks/useGetQuestionAnswers'; -import useGetRandomQuestions from '@/hooks/useGetRandomQuestions'; -import { getQuestionFeedback } from '@/apis/myPage'; -// import type { TQuestionFeedbackResponse } from '@/types/myPage'; // 미사용이면 주석 - -import ClockFrog from '@/assets/clockFrog.svg?react'; - -type TabType = 'answer' | 'feedback' | 'random'; - -type FeedbackItem = { - order: number; - questionId: number; - question: string; - aiFeedback: string; - selfFeedback: string; - peerItems: string[]; -}; - -export default function MyInterviews() { - const { id } = useParams<{ id: string }>(); - const interviewId = Number(id) || 0; - - const [selectedQuestionId, setSelectedQuestionId] = useState(null); - const [activeTab, setActiveTab] = useState('answer'); - const [playingAudio, setPlayingAudio] = useState(null); - const [feedbackList, setFeedbackList] = useState([]); - const [loadingFeedback, setLoadingFeedback] = useState(false); - const audioRef = useRef(null); +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useLocation, useNavigate } from 'react-router-dom'; + +import InterviewLayout from '@/layouts/InterviewLayout'; +import type { IQuestion } from '@/services/interviewApi'; +import { timeoutAndGetNextQuestion, uploadRecordingAndGetNext } from '@/services/interviewApi'; +import clockFrog from '@/assets/clockFrog.svg'; +import orangeFrog from '@/assets/orangeFrog.svg'; + +const MAX_SECONDS = 180; +const MAX_QUESTIONS = 4; + +export default function AnswerQuestion() { + const navigate = useNavigate(); + const location = useLocation() as { + state?: { + fileName?: string; + jobTitle?: string; + interviewType?: 'normal' | 'pressure'; + resumeKey?: string; + sessionId?: string; + firstQuestion?: IQuestion; + fromLoading?: boolean; + }; + }; - const { data: summaryData, isLoading: summaryLoading } = useGetInterviewSummary(interviewId); + const { fileName = '자소서', jobTitle, interviewType = 'normal', resumeKey, sessionId, firstQuestion } = location.state || {}; - // 현재 탭에 따라 질문/랜덤질문 데이터 - const { data: answersData, isLoading: answersLoading } = useGetQuestionAnswers( - activeTab === 'answer' || activeTab === 'feedback' ? selectedQuestionId : null, - ); - const { data: randomData, isLoading: randomLoading } = useGetRandomQuestions(activeTab === 'random' ? selectedQuestionId : null); + // ---------------- 상태 ---------------- + const [currentQuestion, setCurrentQuestion] = useState(firstQuestion ?? null); + const [showCompleteModal, setShowCompleteModal] = useState(false); + 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(MAX_SECONDS); + + // 제출/재시도/대기 + const [retryCount, setRetryCount] = useState(1); + const [isSubmitting, setIsSubmitting] = useState(false); + const [isWaitingNext, setIsWaitingNext] = useState(false); + + const mediaRecorderRef = useRef(null); + const audioChunksRef = useRef([]); + const latestAudioBlobRef = useRef(null); + const nextPollTimerRef = useRef(null); - // 첫 번째 질문 자동 선택 (널 가드 + 기본값) + // 재생 + const audioRef = useRef(null); + const [isPlaying, setIsPlaying] = useState(false); + const [playbackTime, setPlaybackTime] = useState(0); + const [playbackDuration, setPlaybackDuration] = useState(0); + + // ---------------- 초기 유효성 ---------------- useEffect(() => { - const questionCards = summaryData?.result?.questionCards ?? []; - if (questionCards.length > 0 && !selectedQuestionId) { - setSelectedQuestionId(questionCards[0].questionId); + if (!firstQuestion) { + navigate('/question-loading', { + replace: true, + state: { fileName, jobTitle, interviewType, resumeKey }, + }); } - }, [summaryData, selectedQuestionId]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); - // 피드백 탭일 때 모든 질문의 피드백 조회 - useEffect(() => { - if (activeTab !== 'feedback') return; - const answers = answersData?.result ?? []; - if (answers.length === 0) { - setFeedbackList([]); + // ---------------- 공통 초기화 ---------------- + function resetForNext() { + if (audioRef.current) { + audioRef.current.pause(); + audioRef.current.currentTime = 0; + } + setIsPlaying(false); + setPlaybackTime(0); + setPlaybackDuration(0); + setRecordedAudioUrl(null); + latestAudioBlobRef.current = null; + setRecordingTime(0); + setRemainingTime(MAX_SECONDS); + setRetryCount(1); + } + + // ---------------- 녹음 중지 ---------------- + const stopRecording = useCallback(() => { + if (mediaRecorderRef.current && isRecording) { + try { + mediaRecorderRef.current.stop(); + } catch { + /* noop */ + } + setIsRecording(false); + setIsPaused(false); + } + }, [isRecording]); + + // ---------------- 다음 질문 적용 ---------------- + const applyNext = (next: IQuestion | null) => { + if (!next) { + // next가 없더라도 여기서 즉시 종료하지 않음. + // 종료 여부는 handleTimeout/폴링 로직에서 판단. return; } + setCurrentQuestion(next); + setOrdersSeen((prev) => (prev.includes(next.order) ? prev : [...prev, next.order].sort((a, b) => a - b))); + }; - const fetchAllFeedback = async () => { - setLoadingFeedback(true); + // ---------------- 타임아웃 후 next를 폴링로 대기 ---------------- + const waitForNextAfterTimeout = async (questionId: string, retries = 10, intervalMs = 3000) => { + setIsWaitingNext(true); + // 안전: 기존 폴링 타이머 정리 + if (nextPollTimerRef.current) { + clearInterval(nextPollTimerRef.current); + nextPollTimerRef.current = null; + } + + let attempts = 0; + const tryFetch = async () => { + attempts += 1; try { - const feedbackPromises = answers.map(async (answer) => { - try { - const feedback = await getQuestionFeedback(answer.questionId); - return { - order: answer.order, - questionId: answer.questionId, - question: answer.question, - aiFeedback: feedback.result?.aiFeedback ?? '', - selfFeedback: feedback.result?.selfFeedback ?? '', - peerItems: feedback.result?.peerItems ?? [], - } as FeedbackItem; - } catch (error) { - console.error(`질문 ${answer.questionId} 피드백 조회 실패:`, error); - return { - order: answer.order, - questionId: answer.questionId, - question: answer.question, - aiFeedback: '', - selfFeedback: '', - peerItems: [], - } as FeedbackItem; + const next = await timeoutAndGetNextQuestion(questionId); + if (next) { + setIsWaitingNext(false); + resetForNext(); + applyNext(next); + if (nextPollTimerRef.current) { + clearInterval(nextPollTimerRef.current); + nextPollTimerRef.current = null; + } + } else if (attempts >= retries) { + // 폴링 종료 시점: 마지막 문항이면 종료, 아니면 안내만 + setIsWaitingNext(false); + if ((currentQuestion?.order ?? 0) >= MAX_QUESTIONS) { + setShowCompleteModal(true); + } else { + // 다음 생성 지연 안내(유지) + alert('다음 질문 생성이 지연되고 있습니다. 잠시 후 다시 시도해 주세요.'); + } + if (nextPollTimerRef.current) { + clearInterval(nextPollTimerRef.current); + nextPollTimerRef.current = null; + } + } + } catch (e) { + console.error('타임아웃 후 다음 질문 폴링 실패:', e); + if (attempts >= retries) { + setIsWaitingNext(false); + alert('다음 질문을 가져오지 못했습니다. 잠시 후 다시 시도해 주세요.'); + if (nextPollTimerRef.current) { + clearInterval(nextPollTimerRef.current); + nextPollTimerRef.current = null; } - }); - - const allFeedback = await Promise.all(feedbackPromises); - setFeedbackList(allFeedback); - } catch (error) { - console.error('피드백 조회 중 오류:', error); - setFeedbackList([]); - } finally { - setLoadingFeedback(false); + } } }; - void fetchAllFeedback(); - }, [activeTab, answersData]); + // 최초 한 번 즉시 시도 + void tryFetch(); + // 이후 interval 폴링 + nextPollTimerRef.current = window.setInterval(tryFetch, intervalMs); + }; - // 오디오 재생/정지 - const handleAudioPlay = (url: string) => { - if (playingAudio === url) { - audioRef.current?.pause(); - setPlayingAudio(null); - return; - } - if (audioRef.current) { - audioRef.current.pause(); + // ---------------- 시간초과 처리 ---------------- + const handleTimeout = async (questionId: string) => { + try { + // 녹음 중이면 강제 정지 + if (isRecording) stopRecording(); + + // 안내: 시간초과 + alert('시간초과로 답변하지 못하였습니다.'); + + // 서버에 타임아웃 알림 + 다음 질문 시도 + const next = await timeoutAndGetNextQuestion(questionId); + + if (next) { + resetForNext(); + applyNext(next); + return; + } + + // next가 아직 없으면: 폴링으로 다음 질문 생성 대기 + // 단, 현재 문항이 마지막(4번)이면 종료 + if ((currentQuestion?.order ?? 0) >= MAX_QUESTIONS) { + setShowCompleteModal(true); + return; + } + + await waitForNextAfterTimeout(questionId); + } catch (e) { + console.error('시간초과 처리 실패:', e); + alert('시간초과 처리에 실패했습니다.'); } - const audio = new Audio(url); - audioRef.current = audio; - audio.play().catch(() => setPlayingAudio(null)); - setPlayingAudio(url); - audio.onended = () => setPlayingAudio(null); }; - // 정리: 언마운트 시 오디오 정지 + // ---------------- 녹음 시간 타이머 ---------------- useEffect(() => { - return () => { + if (isRecording && !isPaused) { + const id = window.setInterval(() => setRecordingTime((prev) => prev + 1), 1000); + return () => clearInterval(id); + } + return undefined; + }, [isRecording, isPaused]); + + // ---------------- 180초 카운트다운 (화면 뜨는 순간부터) ---------------- + useEffect(() => { + if (!currentQuestion || showCompleteModal) return; + + const id = window.setInterval(() => { + setRemainingTime((prev) => { + if (prev <= 1) { + clearInterval(id); + if (currentQuestion?.questionId) { + void handleTimeout(currentQuestion.questionId); + } + return 0; + } + return prev - 1; + }); + }, 1000); + + return () => clearInterval(id); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [currentQuestion?.questionId, showCompleteModal]); + + // ---------------- 녹음 제어 ---------------- + const startRecording = async () => { + try { + const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + const mediaRecorder = new MediaRecorder(stream); + mediaRecorderRef.current = mediaRecorder; + audioChunksRef.current = []; + latestAudioBlobRef.current = null; + + mediaRecorder.ondataavailable = (event) => { + if (event.data.size > 0) audioChunksRef.current.push(event.data); + }; + + mediaRecorder.onstop = () => { + const mimeType = mediaRecorder.mimeType || 'audio/webm'; + const audioBlob = new Blob(audioChunksRef.current, { type: mimeType }); + latestAudioBlobRef.current = audioBlob; + + const audioUrl = URL.createObjectURL(audioBlob); + setRecordedAudioUrl(audioUrl); + + setIsPlaying(false); + setPlaybackTime(0); + stream.getTracks().forEach((track) => track.stop()); + }; + + mediaRecorder.start(); + setIsRecording(true); + setIsPaused(false); + setRecordedAudioUrl(null); + setRecordingTime(0); + } catch (error) { + console.error('마이크 접근 오류:', error); + alert('마이크 접근 권한이 필요합니다.'); + } + }; + + const togglePause = () => { + if (!mediaRecorderRef.current) return; + if (isPaused) { + mediaRecorderRef.current.resume(); + setIsPaused(false); + } else { + mediaRecorderRef.current.pause(); + setIsPaused(true); + } + }; + + const handleRetry = () => { + if (retryCount > 0) { if (audioRef.current) { audioRef.current.pause(); - audioRef.current = null; + audioRef.current.currentTime = 0; } + setIsPlaying(false); + setPlaybackTime(0); + setPlaybackDuration(0); + + setRecordedAudioUrl(null); + setRecordingTime(0); + setRemainingTime(MAX_SECONDS); + setRetryCount((c) => c - 1); + void startRecording(); + } + }; + + // ---------------- 재생 제어 ---------------- + const toggleAudioPlayback = () => { + if (!audioRef.current) return; + if (audioRef.current.paused) { + audioRef.current + .play() + .then(() => setIsPlaying(true)) + .catch(() => setIsPlaying(false)); + } else { + audioRef.current.pause(); + setIsPlaying(false); + } + }; + + useEffect(() => { + const audio = audioRef.current; + if (!audio) return; + + const handleLoadedMetadata = () => { + const dur = isFinite(audio.duration) ? audio.duration : recordingTime || 0; + setPlaybackDuration(Math.floor(dur)); + setPlaybackTime(Math.floor(audio.currentTime || 0)); + }; + const handleTimeUpdate = () => setPlaybackTime(Math.floor(audio.currentTime || 0)); + const handlePlay = () => setIsPlaying(true); + const handlePause = () => setIsPlaying(false); + const handleEnded = () => { + setIsPlaying(false); + setPlaybackTime(0); }; - }, []); - // 로딩/에러 처리 - if (summaryLoading) { - return ( -
-
- -

데이터를 불러오는 중...

-
-
- ); - } + audio.addEventListener('loadedmetadata', handleLoadedMetadata); + audio.addEventListener('timeupdate', handleTimeUpdate); + audio.addEventListener('play', handlePlay); + audio.addEventListener('pause', handlePause); + audio.addEventListener('ended', handleEnded); - if (!summaryData?.result) { - return ( -
-

면접 데이터를 찾을 수 없습니다.

-
- ); - } + if (audio.readyState >= 1) handleLoadedMetadata(); - const title = summaryData.result.title ?? '면접'; - const timedOutCount = summaryData.result.timedOutCount ?? 0; - const questionCards = summaryData.result.questionCards ?? []; // ← 핵심 가드 + return () => { + audio.removeEventListener('loadedmetadata', handleLoadedMetadata); + audio.removeEventListener('timeupdate', handleTimeUpdate); + audio.removeEventListener('play', handlePlay); + audio.removeEventListener('pause', handlePause); + audio.removeEventListener('ended', handleEnded); + }; + }, [recordedAudioUrl, recordingTime]); - const isLoading = answersLoading || loadingFeedback || randomLoading; + // ---------------- 다음 질문(버튼) ---------------- + const handleNext = async () => { + if (!currentQuestion?.questionId) return; + if (!latestAudioBlobRef.current) { + alert('답변을 녹음해주세요.'); + return; + } - return ( -
-
- {/* 헤더 */} -
-

{title}

- {timedOutCount > 0 && ( -

- 시간 초과로 답변하지 못한 질문{' '} - {timedOutCount}개 -

- )} -
+ setIsSubmitting(true); + try { + const next = await uploadRecordingAndGetNext(currentQuestion.questionId, latestAudioBlobRef.current); + resetForNext(); + applyNext(next); + // next가 없더라도 여기서는 종료하지 않음 (마지막은 타임아웃/폴링 로직에서) + if (!next && (currentQuestion.order ?? 0) >= MAX_QUESTIONS) { + setShowCompleteModal(true); + } + } catch (e) { + console.error('다음 질문 처리 실패:', e); + alert('녹음 처리에 실패했습니다. 잠시 후 다시 시도해주세요.'); + } finally { + setIsSubmitting(false); + } + }; - {/* 질문 카드 리스트 */} -
-

면접 질문

+ // ---------------- 기타 ---------------- + const handleFinalFeedback = () => { + if (sessionId) navigate('/feedback-result', { state: { sessionId } }); + else navigate('/feedback-result'); + }; - {questionCards.length === 0 ? ( -

질문 카드가 없습니다.

- ) : ( -
- {questionCards.map((card: any) => ( - - ))} -
- )} + const formatTime = (seconds: number) => { + const s = Math.max(0, Math.floor(seconds || 0)); + const mins = Math.floor(s / 60); + const secs = s % 60; + return `${mins}:${secs.toString().padStart(2, '0')}`; + }; + + const playbackPercent = playbackDuration > 0 ? Math.min(100, Math.max(0, (playbackTime / playbackDuration) * 100)) : 0; + + // ---------------- UI ---------------- + return ( + +
+ {/* 상단 정보 */} +
+ {fileName} +

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

- {/* 탭 메뉴 */} -
-
+ {/* 질문 탭(표시용: order) */} +
+ {ordersSeen.map((o) => ( - - + ))} +
+ + {/* 질문 카드 */} +
+

{currentQuestion ? `질문${currentQuestion.order}` : '질문'}

+
+ +

+ {currentQuestion?.mainQuestion} + {currentQuestion?.subQuestion ? ` — ${currentQuestion.subQuestion}` : ''} +

+ + {/* 캐릭터 이미지 */} +
+ 면접관
- {/* 탭 콘텐츠 */} -
- {isLoading ? ( -
- + {/* 타이머 & 녹음 컨트롤 */} +
+ {/* 타이머 */} +
+
+
- ) : ( - <> - {/* 답변 확인 탭 */} - {activeTab === 'answer' && (answersData?.result ?? []).length > 0 && ( -
- {(answersData?.result ?? []).map((item: any) => ( -
-
- 질문 {item.order} -

{item.question}

-
- {item.answerText ? ( - <> -

{item.answerText}

- - +

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

+
+ + {isWaitingNext &&

다음 질문을 준비하고 있어요…

} + + {/* 녹음 컨트롤 */} + {!recordedAudioUrl ? ( +
+
+ {!isRecording ? ( + + ) : ( + <> +
- ))} -
- )} - - {/* 피드백 확인 탭 */} - {activeTab === 'feedback' && feedbackList.length > 0 && ( -
- {feedbackList.map((item) => ( -
-
- 질문 {item.order} -

{item.question}

-
- -
- {item.aiFeedback && ( -
-

🤖 AI 피드백

-

{item.aiFeedback}

-
- )} - - {item.selfFeedback && ( -
-

✍️ 셀프 피드백

-

{item.selfFeedback}

-
- )} - - {item.peerItems && item.peerItems.length > 0 && ( -
-

👥 동료 피드백

-
    - {item.peerItems.map((peer, index) => ( -
  • - - {peer} -
  • - ))} -
-
- )} - - {!item.aiFeedback && !item.selfFeedback && (item.peerItems?.length ?? 0) === 0 && ( -

이 질문에 대한 피드백이 없습니다.

- )} -
-
- ))} -
- )} - - {activeTab === 'feedback' && feedbackList.length === 0 && !loadingFeedback && ( -

피드백이 없습니다.

- )} - - {/* 랜덤 질문 탭 */} - {activeTab === 'random' && (randomData?.result ?? []).length > 0 && ( -
- {(randomData?.result ?? []).map((item: any, index: number) => ( -
-
- 랜덤 질문 {index + 1} -

{item.question}

-
- -
-

답변:

-

{item.answerText}

-
- -
- {item.aiFeedback && ( -
-

AI 피드백

-

{item.aiFeedback}

-
- )} - {item.selfFeedback && ( -
-

셀프 피드백

-

{item.selfFeedback}

-
- )} -
- - -
- ))} + + + {formatTime(recordingTime)} + + + + )} +
+
+ ) : ( + // 재생 UI +
+
+ + +
+
+
+
- )} - {activeTab === 'random' && !(randomData?.result ?? []).length &&

랜덤 질문이 없습니다.

} - + + {formatTime(playbackTime)} / {formatTime(playbackDuration)} + +
+
+ )} + + {/* 다시 녹음하기 */} + {recordedAudioUrl && ( +
+ +
)}
+ + {/* 다음 버튼 */} +
+ +
-
+ + {/* 완료 모달 */} + {showCompleteModal && ( +
+
+
+ 완료 +
+

모든 질문에 완벽히 답했어요!

+ +
+
+ )} + + + ); } From 8a2de5c1d9ce257f8e7c88ecec384e89478e7602 Mon Sep 17 00:00:00 2001 From: Hello-Worldismine Date: Mon, 17 Nov 2025 04:42:02 +0900 Subject: [PATCH 2/5] =?UTF-8?q?=EC=8B=9C=EA=B0=84=ED=9D=90=EB=A6=84=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/services/interviewApi.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/services/interviewApi.ts b/src/services/interviewApi.ts index 92ff194..b2c66af 100644 --- a/src/services/interviewApi.ts +++ b/src/services/interviewApi.ts @@ -139,7 +139,7 @@ export const createInterviewSession = async ( console.log('- interviewType:', data.interviewType); const resumeId = extractResumeId(data.resumeKey); - const mode: InterviewMode = data.interviewType === 'pressure' ? 'HARD' : 'NORMAL'; + const mode: TInterviewMode = data.interviewType === 'pressure' ? 'HARD' : 'NORMAL'; const payload: ICreateInterviewSessionPayload = { mode, From 00365b08228694117a70380c8c836a773e970cf1 Mon Sep 17 00:00:00 2001 From: Hello-Worldismine Date: Mon, 17 Nov 2025 05:44:59 +0900 Subject: [PATCH 3/5] =?UTF-8?q?=EB=B2=84=EA=B7=B8=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 | 214 ++++--- src/pages/Interview/main_answer.tsx | 16 +- src/pages/myInterviews.tsx | 846 ++++++++++------------------ src/services/interviewApi.ts | 290 ++++------ src/services/randomQuestionApi.ts | 191 +++---- 5 files changed, 619 insertions(+), 938 deletions(-) diff --git a/src/components/RandomQuestion.tsx b/src/components/RandomQuestion.tsx index c6341f5..d8666ba 100644 --- a/src/components/RandomQuestion.tsx +++ b/src/components/RandomQuestion.tsx @@ -1,3 +1,4 @@ +// RandomQuestion.tsx (SSE 안정화 버전) import { useEffect, useRef, useState } from 'react'; import { getRandomQuestion, @@ -43,46 +44,133 @@ export default function RandomQuestion() { // 제출 중 상태 const [isSubmitting, setIsSubmitting] = useState(false); - // ===== SSE로 랜덤 팝업 알림 구독 ===== + // ===== SSE 안정화: 재연결/중복방지/가시성 대응 ===== + const esRef = useRef(null); + const reconnectTimerRef = useRef(null); + const attemptsRef = useRef(0); + const lastPeerIdRef = useRef(null); // 중복 방지 + const lastPingAtRef = useRef(Date.now()); + const pingWatchRef = useRef(null); + + const clearReconnectTimer = () => { + if (reconnectTimerRef.current) { + clearTimeout(reconnectTimerRef.current); + reconnectTimerRef.current = null; + } + }; + + const scheduleReconnect = (why: string) => { + if (reconnectTimerRef.current) return; + const wait = Math.min(30000, 1000 * Math.pow(2, attemptsRef.current)); // 1s,2s,4s..최대 30s + attemptsRef.current += 1; + // eslint-disable-next-line no-console + console.warn(`[SSE] reconnect in ${wait}ms (${why})`); + reconnectTimerRef.current = window.setTimeout(() => { + reconnectTimerRef.current = null; + openSSE(); + }, wait); + }; + + const closeSSE = () => { + clearReconnectTimer(); + if (esRef.current) { + try { + esRef.current.close(); + } catch { + /* noop */ + } + esRef.current = null; + } + }; + + const handleMessage = async (event: MessageEvent) => { + try { + const data = JSON.parse(event.data) as TNotification; + // 중복 차단 + if (lastPeerIdRef.current === data.peerFeedbackId) return; + lastPeerIdRef.current = data.peerFeedbackId; + + // 새 팝업 초기화 + setNotification(data); + setShowPopup(true); + setErrorMessage(null); + setQuestionDetail(null); + setRecordingTime(0); + setRemainingTime(MAX_TIME); + + setRecordedAudio((prev) => { + if (prev) URL.revokeObjectURL(prev); + return null; + }); + latestAudioBlobRef.current = null; + + setLoadingQuestion(true); + const q = await getRandomQuestion(data.peerFeedbackId); + setQuestionDetail(q); + } catch (err) { + console.error('랜덤 팝업 질문 처리 중 오류:', err); + setErrorMessage('팝업 질문을 불러오지 못했습니다.'); + } finally { + setLoadingQuestion(false); + } + }; + + const openSSE = () => { + closeSSE(); // 중복 방지 + attemptsRef.current = 0; // 성공 시 초기화 예정 + + const es = subscribeToNotifications(handleMessage, (errorEvt) => { + console.error('SSE 연결 오류:', errorEvt); + scheduleReconnect('onerror'); + }); + // 선택: 서버가 event: ping 을 보낸다면 하트비트 갱신 + try { + (es as any).addEventListener?.('ping', () => { + lastPingAtRef.current = Date.now(); + }); + } catch { + /* noop */ + } + + // 연결 성공 감지 + (es as any).onopen = () => { + attemptsRef.current = 0; + lastPingAtRef.current = Date.now(); + // eslint-disable-next-line no-console + console.log('[SSE] opened'); + }; + + esRef.current = es; + + // 클라이언트 측 하트비트 감시(서버 ping 미수신 시 재연결) + if (pingWatchRef.current) clearInterval(pingWatchRef.current); + pingWatchRef.current = window.setInterval(() => { + const diff = Date.now() - lastPingAtRef.current; + // 90초 이상 활동 없으면 재연결 시도 + if (diff > 90000) { + scheduleReconnect('no-activity'); + } + }, 15000); + }; + + // 최초 구독 + 탭 가시성 대응 useEffect(() => { - const eventSource = subscribeToNotifications( - async (event) => { - try { - const data = JSON.parse(event.data) as TNotification; - - // 새 팝업 도착: 상태 초기화 - setNotification(data); - setShowPopup(true); - setErrorMessage(null); - setQuestionDetail(null); - setRecordingTime(0); - setRemainingTime(MAX_TIME); - - // 이전 녹음 URL 제거 - setRecordedAudio((prev) => { - if (prev) URL.revokeObjectURL(prev); - return null; - }); - latestAudioBlobRef.current = null; - - setLoadingQuestion(true); - const q = await getRandomQuestion(data.peerFeedbackId); - setQuestionDetail(q); - } catch (err) { - console.error('랜덤 팝업 질문 처리 중 오류:', err); - setErrorMessage('팝업 질문을 불러오지 못했습니다.'); - } finally { - setLoadingQuestion(false); - } - }, - (error) => { - console.error('SSE 연결 오류:', error); - }, - ); + const onVis = () => { + if (document.visibilityState === 'visible') { + openSSE(); + } else { + closeSSE(); + } + }; + openSSE(); + document.addEventListener('visibilitychange', onVis); return () => { - eventSource.close(); + document.removeEventListener('visibilitychange', onVis); + closeSSE(); + if (pingWatchRef.current) clearInterval(pingWatchRef.current); }; + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // ===== 유틸 ===== @@ -93,7 +181,7 @@ export default function RandomQuestion() { return `${m}:${r.toString().padStart(2, '0')}`; }; - // ===== 팝업 전체 제한시간 타이머 (팝업이 뜨는 순간부터 감소) ===== + // ===== 팝업 전체 제한시간 타이머 ===== useEffect(() => { if (!showPopup) { if (countdownTimerRef.current) { @@ -102,7 +190,6 @@ export default function RandomQuestion() { } return; } - countdownTimerRef.current = window.setInterval(() => { setRemainingTime((prev) => { if (prev <= 1) { @@ -124,12 +211,9 @@ export default function RandomQuestion() { }; }, [showPopup]); - // 시간 종료 시 부가 처리 (녹음 중이면 정지 등) + // 시간 종료 시 부가 처리 useEffect(() => { - if (!showPopup) return; - if (remainingTime > 0) return; - - // 시간 끝났으면 녹음/재생 정지 + if (!showPopup || remainingTime > 0) return; if (isRecording && mediaRecorderRef.current) { try { mediaRecorderRef.current.stop(); @@ -139,14 +223,12 @@ export default function RandomQuestion() { setIsRecording(false); setIsPausedRec(false); } - if (audioRef.current) { - audioRef.current.pause(); - } + if (audioRef.current) audioRef.current.pause(); }, [remainingTime, showPopup, isRecording]); const isTimeOver = remainingTime <= 0; - // ===== 녹음 타이머 (녹음 중일 때만 증가) ===== + // ===== 녹음 타이머 ===== useEffect(() => { if (isRecording && !isPausedRec) { recordTimerRef.current = window.setInterval(() => setRecordingTime((t) => t + 1), 1000); @@ -168,7 +250,6 @@ export default function RandomQuestion() { alert('시간이 종료되어 더 이상 녹음할 수 없습니다.'); return; } - try { if (recordedAudio) { URL.revokeObjectURL(recordedAudio); @@ -189,19 +270,14 @@ export default function RandomQuestion() { mediaRecorder.ondataavailable = (e) => { if (e.data.size > 0) audioChunksRef.current.push(e.data); }; - mediaRecorder.onstop = () => { const mime = mediaRecorder.mimeType || 'audio/webm'; const blob = new Blob(audioChunksRef.current, { type: mime }); latestAudioBlobRef.current = blob; - const url = URL.createObjectURL(blob); setRecordedAudio(url); - - // 스트림 종료 stream.getTracks().forEach((t) => t.stop()); streamRef.current = null; - setIsRecording(false); setIsPausedRec(false); }; @@ -243,7 +319,6 @@ export default function RandomQuestion() { alert('시간이 종료되어 다시 녹음할 수 없습니다.'); return; } - if (audioRef.current) { audioRef.current.pause(); audioRef.current.currentTime = 0; @@ -251,7 +326,6 @@ export default function RandomQuestion() { setIsPlaying(false); setPlaybackTime(0); setPlaybackDuration(0); - if (recordedAudio) { URL.revokeObjectURL(recordedAudio); setRecordedAudio(null); @@ -279,7 +353,6 @@ export default function RandomQuestion() { useEffect(() => { const el = audioRef.current; if (!el) return; - const onLoadedMeta = () => { setPlaybackDuration(Math.floor(isFinite(el.duration) ? el.duration : 0)); setPlaybackTime(Math.floor(el.currentTime || 0)); @@ -291,15 +364,12 @@ export default function RandomQuestion() { setIsPlaying(false); setPlaybackTime(0); }; - el.addEventListener('loadedmetadata', onLoadedMeta); el.addEventListener('timeupdate', onTimeUpdate); el.addEventListener('play', onPlay); el.addEventListener('pause', onPause); el.addEventListener('ended', onEnded); - if (el.readyState >= 1) onLoadedMeta(); - return () => { el.removeEventListener('loadedmetadata', onLoadedMeta); el.removeEventListener('timeupdate', onTimeUpdate); @@ -316,7 +386,7 @@ export default function RandomQuestion() { setShowPopup(false); }; - // ===== 답변 제출 (녹음 업로드 + 피드백 생성) ===== + // ===== 답변 제출 ===== const handleSubmit = async () => { if (isTimeOver) { alert('시간이 종료되어 답변을 제출할 수 없습니다.'); @@ -334,7 +404,6 @@ export default function RandomQuestion() { try { setIsSubmitting(true); const feedback = await uploadFeedbackRecordingAndGetResult(questionDetail.question.questionId, latestAudioBlobRef.current); - alert(`AI 피드백이 도착했어요.\n\n${feedback.aiFeedback}`); setShowPopup(false); } catch (err) { @@ -364,9 +433,7 @@ export default function RandomQuestion() { if (!showPopup) return null; const playbackPercent = playbackDuration > 0 ? Math.min(100, Math.max(0, (playbackTime / playbackDuration) * 100)) : 0; - - // 진행바는 한 개 질문이라 100%로 고정(디자인 유지용) - const progress = 100; + const progress = 100; // 한 개 질문이라 100% return (
@@ -391,31 +458,25 @@ export default function RandomQuestion() {

{errorMessage}

) : questionDetail ? ( <> - {/* 맥락이 되는 질문 + STT */}

맥락이 되는 질문

{questionDetail.context.questionText}

{questionDetail.context.sttText &&

{questionDetail.context.sttText}

}
- - {/* 실제 답변해야 할 질문 */}

{questionDetail.question.questionText}

) : (

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

)} - {/* 이미지 (import 사용) */}
면접관
- {/* 팝업 제한시간 표시 */}

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

- {/* 질문 진행바 (디자인 유지용) */}
@@ -423,13 +484,10 @@ export default function RandomQuestion() {

랜덤 팝업 질문

- {/* 녹음 / 재생 영역 */}
{!recordedAudio ? ( - // === 녹음 UI ===
{!isRecording ? ( - // 시작 버튼 (마이크 아이콘) - - {/* 녹음 시간 */} {formatTime(recordingTime)} - - {/* 정지 */} -
- {formatTime(playbackTime)} / {formatTime(playbackDuration)} -
)}
- {/* 버튼 그룹 */}
+ ))} +
- {/* 질문 탭(표시용: order) */} -
- {ordersSeen.map((o) => ( + {/* 탭 메뉴 */} +
+
+ + - ))} -
- - {/* 질문 카드 */} -
-

{currentQuestion ? `질문${currentQuestion.order}` : '질문'}

-
- -

- {currentQuestion?.mainQuestion} - {currentQuestion?.subQuestion ? ` — ${currentQuestion.subQuestion}` : ''} -

- - {/* 캐릭터 이미지 */} -
- 면접관
- {/* 타이머 & 녹음 컨트롤 */} -
- {/* 타이머 */} -
-
-
+ {/* 탭 콘텐츠 */} +
+ {isLoading ? ( +
+
-

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

-
- - {isWaitingNext &&

다음 질문을 준비하고 있어요…

} - - {/* 녹음 컨트롤 */} - {!recordedAudioUrl ? ( -
-
- {!isRecording ? ( - - ) : ( - <> - + ) : ( - - - +

답변이 없습니다.

)} - - - {formatTime(recordingTime)} - - - - )} -
-
- ) : ( - // 재생 UI -
-
-
+ ))} +
+ )} + + {/* 피드백 확인 탭 */} + {activeTab === 'feedback' && (feedbackList ?? []).length > 0 && ( +
+ {(feedbackList ?? []).map((item) => ( +
+
+ 질문 {item.order} +

{item.question}

+
+ +
+ {item.aiFeedback && ( +
+

🤖 AI 피드백

+

{item.aiFeedback}

+
+ )} + {item.selfFeedback && ( +
+

✍️ 셀프 피드백

+

{item.selfFeedback}

+
+ )} + {(item.peerItems?.length ?? 0) > 0 && ( +
+

👥 동료 피드백

+
    + {item.peerItems!.map((peer, index) => ( +
  • + + {peer} +
  • + ))} +
+
+ )} + {!item.aiFeedback && !item.selfFeedback && (item.peerItems?.length ?? 0) === 0 && ( +

이 질문에 대한 피드백이 없습니다.

+ )} +
+
+ ))} +
+ )} + {activeTab === 'feedback' && (feedbackList ?? []).length === 0 && !loadingFeedback && ( +

피드백이 없습니다.

+ )} + + {/* 랜덤 질문 탭 */} + {activeTab === 'random' && ( +
+ {(randomItems ?? []).length > 0 ? ( + (randomItems ?? []).map((item, index) => ( +
+
+ 랜덤 질문 {index + 1} +

{item.question}

+
+ +
+

답변:

+

{item.answerText}

+
+ +
+ {item.aiFeedback && ( +
+

AI 피드백

+

{item.aiFeedback}

+
+ )} + {item.selfFeedback && ( +
+

셀프 피드백

+

{item.selfFeedback}

+
+ )} +
+ + +
+ )) ) : ( - - - +

랜덤 질문이 없습니다.

)} - - -
-
-
-
- - - {formatTime(playbackTime)} / {formatTime(playbackDuration)} - -
-
- )} - - {/* 다시 녹음하기 */} - {recordedAudioUrl && ( -
- -
+ )} + )}
- - {/* 다음 버튼 */} -
- -
- - {/* 완료 모달 */} - {showCompleteModal && ( -
-
-
- 완료 -
-

모든 질문에 완벽히 답했어요!

- -
-
- )} - - - +
); } diff --git a/src/services/interviewApi.ts b/src/services/interviewApi.ts index b2c66af..cb8332d 100644 --- a/src/services/interviewApi.ts +++ b/src/services/interviewApi.ts @@ -1,8 +1,9 @@ +// src/services/interviewApi.ts import apiClient from './api'; -// ===================================================== -// 공통: BE 응답(result 래핑 유무 모두 대응) -// ===================================================== +/* ==================================================== + 공통: BE 응답(result 래핑 유무 모두 대응) +==================================================== */ const unwrapResult = (data: any): T => { if (data && typeof data === 'object' && 'result' in data) { return (data as { result: T }).result; @@ -10,9 +11,9 @@ const unwrapResult = (data: any): T => { return data as T; }; -// ===================================================== -// 1. 자소서 업로드용 Presigned URL & 업로드 -// ===================================================== +/* ==================================================== + 1) 자소서 업로드용 Presigned URL & 업로드 +==================================================== */ /** 자소서 업로드용 Presigned URL 응답 (POST /api/presign/resume) */ export interface IResumePresignResponse { @@ -23,98 +24,76 @@ export interface IResumePresignResponse { /** 자소서 업로드용 프리사인 URL 발급 */ export const getResumePresignUrl = async (fileName: string): Promise => { - const response = await apiClient.post('/api/presign/resume', null, { - params: { fileName }, - }); - + const response = await apiClient.post('/api/presign/resume', null, { params: { fileName } }); return unwrapResult(response.data); }; -/** S3 공통 업로드 헬퍼 (resume / recording 둘 다 사용) */ -export const uploadToS3 = async (presignedUrl: string, file: File | Blob, extraHeaders: Record = {}): Promise => { +/** S3 공통 업로드 헬퍼 (resume / recording 공용) */ +export const uploadToS3 = async ( + presignedUrl: string, + file: File | Blob, + extraHeaders: Record = {}, +): Promise => { const baseHeaders: Record = {}; - - // Blob에 type이 있으면 기본 Content-Type으로 사용 - if ((file as any).type) { - baseHeaders['Content-Type'] = (file as any).type; - } + if ((file as any).type) baseHeaders['Content-Type'] = (file as any).type; const uploadResponse = await fetch(presignedUrl, { method: 'PUT', body: file, - headers: { - ...baseHeaders, - ...extraHeaders, // presign에서 내려준 헤더가 우선 - }, + headers: { ...baseHeaders, ...extraHeaders }, }); - - if (!uploadResponse.ok) { - throw new Error(`S3 업로드 실패: ${uploadResponse.status}`); - } + if (!uploadResponse.ok) throw new Error(`S3 업로드 실패: ${uploadResponse.status}`); }; /** S3 key에서 resumeId 추출 (예: resume/123/aaa-bbb.docx → aaa-bbb) */ export const extractResumeId = (resumeKey: string): string => { const parts = resumeKey.split('/'); const fileName = parts[parts.length - 1]; - const nameWithoutExt = fileName.split('.').slice(0, -1).join('.'); - return nameWithoutExt; + return fileName.split('.').slice(0, -1).join('.'); }; /** 자소서 업로드 전체 플로우: key 반환 */ export const uploadResume = async (file: File): Promise => { try { - const extension = file.name.split('.').pop()?.toLowerCase(); - if (!extension || !['pdf', 'docx'].includes(extension)) { - throw new Error('PDF 또는 DOCX 파일만 업로드 가능합니다.'); - } - + const ext = file.name.split('.').pop()?.toLowerCase(); + if (!ext || !['pdf', 'docx'].includes(ext)) throw new Error('PDF 또는 DOCX 파일만 업로드 가능합니다.'); const { uploadUrl, key, requiredHeaders } = await getResumePresignUrl(file.name); - await uploadToS3(uploadUrl, file, requiredHeaders); - return key; - } catch (error) { - console.error('❌ 자소서 업로드 실패:', error); - throw error; + } catch (e) { + console.error('❌ 자소서 업로드 실패:', e); + throw e; } }; -// ===================================================== -// 2. 인터뷰 세션 생성 (자소서 기반 첫 질문 생성) -// ===================================================== +/* ==================================================== + 2) 인터뷰 세션 생성 (자소서 기반 첫 질문 생성) +==================================================== */ -/** 프론트에서 쓰는 인터뷰 타입 */ export type TInterviewType = 'normal' | 'pressure'; - -/** BE에서 사용하는 면접 모드 */ export type TInterviewMode = 'NORMAL' | 'HARD'; -/** 프론트에서 쓰는 요청 타입 */ export interface ICreateInterviewSessionRequest { - /** S3 objectKey (예: resume/123/abcd-efgh.docx) */ - resumeKey: string; - /** 직무 이름 */ - jobTitle: string; - /** 'normal' | 'pressure' → NORMAL | HARD 로 매핑 */ - interviewType: TInterviewType; + resumeKey: string; // S3 objectKey + jobTitle: string; // 직무명 + interviewType: TInterviewType; // 'normal' | 'pressure' } -/** BE에 실제로 보내는 payload (mode / jobRole / resumeId) */ +/** BE에 보내는 실제 payload */ interface ICreateInterviewSessionPayload { mode: TInterviewMode; jobRole: string; resumeId: string; } -/** BE 응답 원본 타입 (result 내부) */ +/** BE 원본 응답(result 내부) */ interface ICreateInterviewSessionApiResponse { sessionId: number; firstQuestionId: number; firstQuestionText: string; } -/** 프론트에서 쓰기 좋은 형태 */ +/** 프론트 질문 타입 */ export interface IQuestion { questionId: string; mainQuestion: string; @@ -122,22 +101,15 @@ export interface IQuestion { order: number; } -/** 프론트에서 최종으로 받는 응답 타입 */ export interface ICreateInterviewSessionResponse { sessionId: string; firstQuestion: IQuestion; } -/** 자소서 기반 질문 생성 및 첫번째 질문 조회 */ export const createInterviewSession = async ( data: ICreateInterviewSessionRequest, ): Promise => { try { - console.log('🎬 API 호출: /api/interview-sessions'); - console.log('- resumeKey:', data.resumeKey); - console.log('- jobTitle:', data.jobTitle); - console.log('- interviewType:', data.interviewType); - const resumeId = extractResumeId(data.resumeKey); const mode: TInterviewMode = data.interviewType === 'pressure' ? 'HARD' : 'NORMAL'; @@ -147,92 +119,66 @@ export const createInterviewSession = async ( resumeId, }; - console.log('📤 요청 payload:', payload); - const response = await apiClient.post('/api/interview-sessions', payload); - - console.log('📥 원본 응답:', response.data); - const apiResult = unwrapResult(response.data); - console.log('✅ unwrap 후 결과:', apiResult); - - const firstQuestion: IQuestion = { - questionId: String(apiResult.firstQuestionId), - mainQuestion: apiResult.firstQuestionText, - subQuestion: '', - order: 1, - }; - - const result = { + return { sessionId: String(apiResult.sessionId), - firstQuestion, + firstQuestion: { + questionId: String(apiResult.firstQuestionId), + mainQuestion: apiResult.firstQuestionText, + subQuestion: '', + order: 1, + }, }; - - console.log('✅ 최종 반환값:', result); - - return result; } catch (error: any) { - console.error('❌ createInterviewSession 에러:'); - console.error('- 에러 객체:', error); - console.error('- 에러 메시지:', error.message); - console.error('- 응답 데이터:', error.response?.data); - console.error('- 응답 상태:', error.response?.status); + console.error('❌ createInterviewSession 에러:', { + message: error?.message, + data: error?.response?.data, + status: error?.response?.status, + }); throw error; } }; -// ===================================================== -// 3. 녹음 업로드 Presign URL (POST /api/presign/recording) -// ===================================================== - -/** 녹음 Presign 요청 */ +/* ==================================================== + 3) 녹음 업로드 Presign URL +==================================================== */ interface IRecordingPresignRequest { questionId: number; contentType: string; } - -/** 녹음 Presign 응답 */ export interface IRecordingPresignResponse { uploadUrl: string; key: string; requiredHeaders: Record; } -/** 녹음 업로드용 프리사인 URL 발급 */ -export const getRecordingPresignUrl = async (questionId: number, contentType: string): Promise => { +export const getRecordingPresignUrl = async ( + questionId: number, + contentType: string, +): Promise => { const payload: IRecordingPresignRequest = { questionId, contentType }; - const response = await apiClient.post('/api/presign/recording', payload); return unwrapResult(response.data); }; -// ===================================================== -// 4. recording 저장 (비동기 트리거) & Polling 타입들 -// ===================================================== - -/** 녹음 제출 API 응답 status (현재 스펙상 UPLOADED 고정) */ +/* ==================================================== + 4) recording 저장 & Polling 타입 +==================================================== */ export type TRecordingEnqueueStatus = 'UPLOADED'; - -/** 녹음 저장 응답 (POST /api/questions/{questionId}/recordings) */ export interface ISaveRecordingResponse { recordingId: number; status: TRecordingEnqueueStatus; } - -/** 녹음 저장 및 꼬리질문 생성 API (비동기, 바로 응답) */ export const saveRecording = async (questionId: number): Promise => { const response = await apiClient.post(`/api/questions/${questionId}/recordings`); return unwrapResult(response.data); }; -/** Polling API status */ export type TRecordingResultStatus = 'WORKING' | 'READY' | 'FAILED'; - -/** next.type */ export type TNextQuestionType = 'FOLLOW_UP' | 'ROOT' | 'NONE'; -/** Polling/Timeout 공통 next 객체 타입 */ export interface IRecordingResultNext { type: TNextQuestionType; nextQuestionId: number | null; @@ -242,143 +188,102 @@ export interface IRecordingResultNext { rootIndex: number | null; } -/** Polling API 응답 타입 (GET /api/recordings/{recordingId}/results) */ export interface IRecordingResultResponse { sessionId: number; status: TRecordingResultStatus; next: IRecordingResultNext | null; } -/** Polling API - 한 번 조회 */ export const getRecordingResult = async (recordingId: number): Promise => { const response = await apiClient.get(`/api/recordings/${recordingId}/results`); return unwrapResult(response.data); }; -/** next 객체 → 프론트에서 쓰는 IQuestion 로 매핑 */ +/** next 객체 → IQuestion 매핑 */ const mapNextToQuestion = (next: IRecordingResultNext | null): IQuestion | null => { - if (!next || next.type === 'NONE' || next.nextQuestionId == null) { - return null; - } - + if (!next || next.type === 'NONE' || next.nextQuestionId == null) return null; const isFollowUp = next.type === 'FOLLOW_UP'; - return { questionId: String(next.nextQuestionId), - mainQuestion: isFollowUp - ? (next.rootText ?? '') // 꼬리질문이면 rootText를 메인 질문으로 - : (next.nextQuestionText ?? ''), // 루트 질문이면 그대로 - subQuestion: isFollowUp - ? (next.nextQuestionText ?? '') // 꼬리질문 텍스트 - : '', + mainQuestion: isFollowUp ? (next.rootText ?? '') : (next.nextQuestionText ?? ''), + subQuestion: isFollowUp ? (next.nextQuestionText ?? '') : '', order: next.rootIndex ?? 0, }; }; -/** Polling 헬퍼: READY/FAILED 될 때까지 반복 조회 */ -export const pollRecordingResult = async (recordingId: number, maxAttempts: number = 60, intervalMs: number = 3000): Promise => { +/** Polling: READY/FAILED까지 반복 조회 */ +export const pollRecordingResult = async ( + recordingId: number, + maxAttempts = 60, + intervalMs = 3000, +): Promise => { let attempts = 0; - while (attempts < maxAttempts) { const result = await getRecordingResult(recordingId); - - if (result.status === 'READY' || result.status === 'FAILED') { - return result; - } - - await new Promise((resolve) => setTimeout(resolve, intervalMs)); + if (result.status === 'READY' || result.status === 'FAILED') return result; + await new Promise((r) => setTimeout(r, intervalMs)); attempts += 1; } - throw new Error('Polling timeout - 녹음 처리 대기 시간이 너무 깁니다.'); }; -// ===================================================== -// 5. 녹음 파일 업로드 & 다음 질문 받기 전체 플로우 -// ===================================================== - -/** - * 녹음 파일 업로드 + recording 저장 + polling 후 - * 다음 질문(IQuestion) 혹은 null(세션 종료) 반환 - */ -export const uploadRecordingAndGetNext = async (questionId: string | number, audioBlob: Blob): Promise => { - const numericQuestionId = typeof questionId === 'string' ? Number(questionId) : questionId; - - if (Number.isNaN(numericQuestionId)) { - throw new Error('유효하지 않은 questionId 입니다.'); - } +/* ==================================================== + 5) 녹음 파일 업로드 & 다음 질문 받기 (통합 플로우) +==================================================== */ +export const uploadRecordingAndGetNext = async ( + questionId: string | number, + audioBlob: Blob, +): Promise => { + const numericId = typeof questionId === 'string' ? Number(questionId) : questionId; + if (Number.isNaN(numericId)) throw new Error('유효하지 않은 questionId 입니다.'); const contentType = (audioBlob as any).type || 'audio/webm'; - - // 1) Presign 발급 - const { uploadUrl, requiredHeaders } = await getRecordingPresignUrl(numericQuestionId, contentType); - - // 2) S3 업로드 + const { uploadUrl, requiredHeaders } = await getRecordingPresignUrl(numericId, contentType); await uploadToS3(uploadUrl, audioBlob, requiredHeaders); - // 3) 녹음 저장 요청 (비동기 작업 트리거) - const { recordingId } = await saveRecording(numericQuestionId); - - // 4) Polling 으로 꼬리질문/다음 루트질문 생성 완료까지 대기 + const { recordingId } = await saveRecording(numericId); const result = await pollRecordingResult(recordingId); + if (result.status === 'FAILED') throw new Error('녹음 처리 중 오류가 발생했습니다.'); - if (result.status === 'FAILED') { - throw new Error('녹음 처리 중 오류가 발생했습니다.'); - } - - // 5) next 객체 → IQuestion 으로 변환 (없으면 null) return mapNextToQuestion(result.next); }; -// ===================================================== -// 6. 시간초과 시 Timeout API -// ===================================================== - -/** Timeout API 응답 (status는 항상 READY) */ +/* ==================================================== + 6) 시간초과 Timeout +==================================================== */ +/** BE Timeout 원본 응답 */ export interface ITimeoutResponse { sessionId: number; status: 'READY'; next: IRecordingResultNext | null; } -/** 사용자가 시간초과로 답변하지 못한 경우 - Timeout API 호출 */ -export const sendTimeout = async (questionId: string | number): Promise => { - const response = await apiClient.post(`/api/questions/${questionId}/timeout`); - return unwrapResult(response.data); -}; - /** - * Timeout 처리 후 바로 다음 질문(IQuestion) 혹은 null(세션 종료) 반환하는 헬퍼 - * - next.type === ROOT → 다음 루트 질문 - * - next.type === NONE → 더 이상 질문 없음 (최종 피드백 조회) + * 시간초과 처리: 바로 다음 질문(IQuestion) 또는 null(세션 종료) 반환 + * - 서버가 next.type === ROOT / FOLLOW_UP / NONE 형태로 내려줌 */ -export const timeoutAndGetNextQuestion = async (questionId: string | number): Promise => { - const result = await sendTimeout(questionId); - - if (!result.next || result.next.type === 'NONE') { - return null; - } - - return mapNextToQuestion(result.next); +export const sendTimeout = async (questionId: string | number): Promise => { + const response = await apiClient.post(`/api/questions/${questionId}/timeout`); + const data = unwrapResult(response.data); + return mapNextToQuestion(data.next); // ← 핵심: 다음 질문 매핑 }; -// ===================================================== -// 7. 최종 피드백 조회 API -// ===================================================== +/** 기존 헬퍼와의 호환 (별칭) */ +export const timeoutAndGetNextQuestion = async ( + questionId: string | number, +): Promise => sendTimeout(questionId); -/** 피드백 생성 진행 상태 */ +/* ==================================================== + 7) 최종 피드백 조회 +==================================================== */ export type TFeedbackProgressStatus = 'WORKING' | 'READY' | 'FAILED'; - -/** QnA 턴 타입 */ export type TFeedbackTurnType = 'QUESTION' | 'ANSWER'; -/** 한 턴 (질문 / 답변) */ export interface IQnaTurn { turn: TFeedbackTurnType; content: string; } -/** 한 루트 질문에 대한 요약 정보 */ export interface IQuestionSummary { questionNumber: number; rootQuestion: string; @@ -387,17 +292,15 @@ export interface IQuestionSummary { qnaTurns: IQnaTurn[]; } -/** 인터뷰 전체 요약 */ export interface IInterviewSummary { interviewTitle: string; timeoutQuestionNumber: number; questionSummaries: IQuestionSummary[]; } -/** 최종 피드백 조회 응답 */ export interface IFinalFeedbackResponse { feedbackProgressStatus: TFeedbackProgressStatus; - interviewSummary: IInterviewSummary | null; // WORKING일 때는 null + interviewSummary: IInterviewSummary | null; feedbacks: { feedbackType: 'positive' | 'improvement'; answer: string; @@ -410,7 +313,6 @@ export interface IFinalFeedbackResponse { timeoutCount: number; } -/** 최종 피드백 조회 (GET /api/interview-sessions/{sessionId}) */ export const getFinalFeedback = async ( sessionId: string | number, ): Promise => { diff --git a/src/services/randomQuestionApi.ts b/src/services/randomQuestionApi.ts index e4ef732..41ca5d3 100644 --- a/src/services/randomQuestionApi.ts +++ b/src/services/randomQuestionApi.ts @@ -1,61 +1,60 @@ +// src/services/randomQuestionApi.ts import apiClient from './api'; import { EventSourcePolyfill } from 'event-source-polyfill'; -// result만 뽑아주는 헬퍼 +// ---------------------- 공통 유틸 ---------------------- const unwrapResult = (data: any): T => { if (data && typeof data === 'object' && 'result' in data) { - return data.result as T; + return (data as { result: T }).result; } return data as T; }; -// ==================== 타입 정의 ==================== +const joinUrl = (base = '', path = '') => + `${base.replace(/\/+$/, '')}/${path.replace(/^\/+/, '')}`; -// 1) SSE 알림 payload (팝업 알림용) — 팝업 알림을 위한 SSE 구독용 API +// ---------------------- 타입 정의 ---------------------- + +// 1) SSE 알림 payload export interface IRandomNotificationPayload { - jobName: string; // 직무 이름 - interviewName: string; // 인터뷰 제목 - questionNumber: number; // 해당 인터뷰의 몇 번째 질문인지 - peerFeedbackId: number; // = peerAnswerId (랜덤 질문 조회에 사용) + jobName: string; + interviewName: string; + questionNumber: number; + peerFeedbackId: number; // = peerAnswerId } -// 2) 랜덤 팝업 질문 조회 응답 — 랜덤 팝업 질문 조회 API +// 2) 랜덤 팝업 질문 조회 응답 export interface IRandomQuestionContext { questionId: number; questionText: string; presignedRecordingGetUrl: string; sttText: string; } - export interface IRandomQuestion { - question: { - questionId: number; - questionText: string; - }; + question: { questionId: number; questionText: string }; context: IRandomQuestionContext; } -// 3) presign 응답 — 랜덤 팝업 질문 - 녹음 업로드용 프리사인 URL 발급 +// 3) presign 응답 export interface IPresignUrlResponse { - uploadUrl: string; // S3 PUT presigned URL - key: string; // 업로드될 S3 오브젝트 경로 - requiredHeaders: Record; // PUT 시 함께 보내야 할 헤더들 + uploadUrl: string; + key: string; + requiredHeaders: Record; } -// recordingKey 요청용 타입은 더 이상 사용 안 하지만, 남겨둠 (호환용) +// (호환용, 현재 미사용) export interface IRandomQuestionRecordingRequest { recordingKey: string; } -// 4) 녹음 저장 응답 — 랜덤 질문에 대한 recording 저장 및 피드백 생성 API (비동기) +// 4) 녹음 저장 응답 export interface IFeedbackRecordingResponse { recordingId: number; - status: 'UPLOADED'; // 비동기 작업이 큐에 올라갔다는 뜻 + status: 'UPLOADED'; } -// 5) 피드백 조회 응답 — 랜덤 질문에 대한 피드백 확인 API (polling) -export type FeedbackProgressStatus = 'WORKING' | 'READY' | 'FAILED'; - +// 5) 피드백 조회 응답 +export type TFeedbackProgressStatus = 'WORKING' | 'READY' | 'FAILED'; export interface IFeedbackResult { questionId: number; questionText: string; @@ -64,173 +63,143 @@ export interface IFeedbackResult { presignedRecordingGetUrl: string; sttText: string; } - export interface IFeedbackResultResponse { - progressStatus: FeedbackProgressStatus; - result: IFeedbackResult | null; // WORKING/FAILED일 때는 null + progressStatus: TFeedbackProgressStatus; + result: IFeedbackResult | null; } -// ==================== API 함수들 ==================== +// ---------------------- API 함수들 ---------------------- -/** - * 1. 랜덤 팝업 질문 조회 - * GET /api/random-questions/peer/{peerAnswerId} - * peerAnswerId = SSE 알림의 peerFeedbackId - */ +/** 1. 랜덤 팝업 질문 조회 (GET /api/random-questions/peer/{peerAnswerId}) */ export const getRandomQuestion = async ( peerAnswerId: number | string, ): Promise => { - const response = await apiClient.get(`/api/random-questions/peer/${peerAnswerId}`); - return unwrapResult(response.data); + const resp = await apiClient.get(`/api/random-questions/peer/${peerAnswerId}`); + return unwrapResult(resp.data); }; -/** - * 2. 랜덤 팝업 질문 - 녹음 업로드용 프리사인 URL 발급 - * POST /api/presign/recording/feedback-question - * Body: { questionId: Long, contentType: String } - */ +/** 2. 녹음 업로드용 프리사인 URL (POST /api/presign/recording/feedback-question) */ export const getFeedbackRecordingPresignUrl = async ( questionId: number, contentType: string, ): Promise => { - const response = await apiClient.post('/api/presign/recording/feedback-question', { + const resp = await apiClient.post('/api/presign/recording/feedback-question', { questionId, contentType, }); - return unwrapResult(response.data); + return unwrapResult(resp.data); }; -/** - * 3. S3에 파일 업로드 (프리사인 URL 사용) - */ +/** 3. S3 업로드 (PUT presigned URL) */ export const uploadToS3 = async ( presignedUrl: string, file: Blob, extraHeaders: Record = {}, ): Promise => { - await fetch(presignedUrl, { + const r = await fetch(presignedUrl, { method: 'PUT', body: file, headers: { - 'Content-Type': file.type || 'audio/webm', + 'Content-Type': (file as any).type || 'audio/webm', ...extraHeaders, }, }); + if (!r.ok) throw new Error(`S3 업로드 실패: ${r.status}`); }; -/** - * 4. 랜덤 질문에 대한 recording 저장 및 피드백 생성 (비동기) - * POST /api/random-questions/peer/questions/{questionId} - * Body 없음, path param으로 questionId만 넘김 - */ +/** 4. 녹음 저장 & 피드백 생성 트리거 (POST /api/random-questions/peer/questions/{questionId}) */ export const saveFeedbackRecording = async ( questionId: number, ): Promise => { - const response = await apiClient.post( - `/api/random-questions/peer/questions/${questionId}`, - ); - return unwrapResult(response.data); + const resp = await apiClient.post(`/api/random-questions/peer/questions/${questionId}`); + return unwrapResult(resp.data); }; -/** - * 5. 랜덤 질문에 대한 피드백 확인 (polling) - * GET /api/random-questions/peer/recordings/{recordingId}/feedbacks - */ +/** 5. 피드백 조회 (GET /api/random-questions/peer/recordings/{recordingId}/feedbacks) */ export const getFeedbackResult = async ( recordingId: number, ): Promise => { - const response = await apiClient.get( + const resp = await apiClient.get( `/api/random-questions/peer/recordings/${recordingId}/feedbacks`, ); - return unwrapResult(response.data); + return unwrapResult(resp.data); }; -/** - * 6. Polling 헬퍼 함수 - * progressStatus 가 READY / FAILED 가 될 때까지 조회 - */ +/** 6. Polling 헬퍼 (READY/FAILED 될 때까지) */ export const pollFeedbackResult = async ( recordingId: number, - maxAttempts: number = 60, - interval: number = 5000, + maxAttempts = 60, + intervalMs = 5000, ): Promise => { let attempts = 0; - while (attempts < maxAttempts) { const result = await getFeedbackResult(recordingId); - if (result.progressStatus === 'READY' || result.progressStatus === 'FAILED') { return result; } - - await new Promise((resolve) => setTimeout(resolve, interval)); - attempts++; + await new Promise((res) => setTimeout(res, intervalMs)); + attempts += 1; } - throw new Error('Polling timeout - 피드백 생성 시간이 너무 오래 걸립니다.'); }; +// ---------------------- SSE 구독 ---------------------- + /** - * 7. SSE 구독 (Server-Sent Events) - * GET /api/subscribe + * 7. SSE 구독 + * - 기본 경로: /api/subscribe (env 로 오버라이드 가능: VITE_SSE_PATH) + * - Authorization 필요 시 헤더와 쿼리 모두 지원 */ export const subscribeToNotifications = ( - onMessage: (event: MessageEvent) => void, - onError?: (error: Event) => void, + onMessage: (event: MessageEvent) => void, + onError?: (error: unknown) => void, ): EventSource => { - const baseURL = apiClient.defaults.baseURL ?? ''; - const eventSource = new EventSourcePolyfill(`${baseURL}/api/subscribe`, { - withCredentials: true, - }) as EventSource; - - eventSource.onmessage = onMessage; + const base = apiClient.defaults.baseURL ?? ''; // 예: '/api' + const ssePath = import.meta.env.VITE_SSE_PATH ?? '/api/subscribe'; + const url = joinUrl(base, ssePath); + + const token = localStorage.getItem('accessToken') ?? ''; + + // 헤더가 필요한 경우 폴리필 사용 (쿼리 파라미터로도 함께 전달) + const es = new EventSourcePolyfill( + token ? `${url}?token=${encodeURIComponent(token)}` : url, + { + withCredentials: true, + heartbeatTimeout: 120_000, // 서버 keep-alive 가 뜸해도 버퍼링 여유 + headers: token ? { Authorization: `Bearer ${token}` } : undefined, + }, + ) as EventSource; - if (onError) { - eventSource.onerror = onError; - } + es.onmessage = onMessage as any; + if (onError) es.onerror = onError as any; - return eventSource; + return es; // 호출부에서 .close()로 정리 }; -// ==================== 전체 플로우 헬퍼 함수 ==================== +// ---------------------- 전체 플로우 ---------------------- /** - * 랜덤 질문 녹음 업로드 및 피드백 받기 전체 플로우 - * - * 1) 프리사인 URL 발급 - * 2) S3 업로드 - * 3) recording 저장 (비동기 큐에 올리기) - * 4) 피드백 READY 될 때까지 polling - * 5) IFeedbackResult 리턴 (aiFeedback, selfFeedback 등 포함) + * 8. 업로드 → 저장 트리거 → Polling → 최종 피드백 반환 */ export const uploadFeedbackRecordingAndGetResult = async ( questionId: number, audioBlob: Blob, ): Promise => { - // 1. 프리사인 URL 받기 - const contentType = audioBlob.type || 'audio/webm'; + const contentType = (audioBlob as any).type || 'audio/webm'; + const { uploadUrl, requiredHeaders } = await getFeedbackRecordingPresignUrl( questionId, contentType, ); - // 2. S3에 업로드 await uploadToS3(uploadUrl, audioBlob, requiredHeaders); - // 3. 녹음 저장 & 비동기 피드백 생성 트리거 const { recordingId, status } = await saveFeedbackRecording(questionId); + if (status !== 'UPLOADED') throw new Error(`예상치 못한 recording 상태: ${status}`); - if (status !== 'UPLOADED') { - throw new Error(`예상치 못한 recording 상태입니다: ${status}`); - } - - // 4. 피드백 생성 상태 polling - const result = await pollFeedbackResult(recordingId); - - if (result.progressStatus === 'FAILED' || !result.result) { + const polled = await pollFeedbackResult(recordingId); + if (polled.progressStatus !== 'READY' || !polled.result) { throw new Error('피드백 생성에 실패했습니다.'); } - - // 5. 최종 피드백 결과 리턴 - return result.result; + return polled.result; }; From ff9ff4d4fde973a6355270e36197fde7d93db5e0 Mon Sep 17 00:00:00 2001 From: Hello-Worldismine Date: Mon, 17 Nov 2025 05:47:18 +0900 Subject: [PATCH 4/5] =?UTF-8?q?=EB=B2=84=EA=B7=B8=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 | 130 +++++++++++++----------------- src/services/randomQuestionApi.ts | 89 ++++++++++++-------- 2 files changed, 113 insertions(+), 106 deletions(-) diff --git a/src/components/RandomQuestion.tsx b/src/components/RandomQuestion.tsx index d8666ba..e4878ef 100644 --- a/src/components/RandomQuestion.tsx +++ b/src/components/RandomQuestion.tsx @@ -1,4 +1,3 @@ -// RandomQuestion.tsx (SSE 안정화 버전) import { useEffect, useRef, useState } from 'react'; import { getRandomQuestion, @@ -44,13 +43,11 @@ export default function RandomQuestion() { // 제출 중 상태 const [isSubmitting, setIsSubmitting] = useState(false); - // ===== SSE 안정화: 재연결/중복방지/가시성 대응 ===== + // ===== SSE & 요청 취소 컨트롤 ===== const esRef = useRef(null); const reconnectTimerRef = useRef(null); const attemptsRef = useRef(0); - const lastPeerIdRef = useRef(null); // 중복 방지 - const lastPingAtRef = useRef(Date.now()); - const pingWatchRef = useRef(null); + const fetchAbortRef = useRef(null); // ✅ 이전 요청 취소용 const clearReconnectTimer = () => { if (reconnectTimerRef.current) { @@ -61,9 +58,8 @@ export default function RandomQuestion() { const scheduleReconnect = (why: string) => { if (reconnectTimerRef.current) return; - const wait = Math.min(30000, 1000 * Math.pow(2, attemptsRef.current)); // 1s,2s,4s..최대 30s + const wait = Math.min(30000, 1000 * Math.pow(2, attemptsRef.current)); attemptsRef.current += 1; - // eslint-disable-next-line no-console console.warn(`[SSE] reconnect in ${wait}ms (${why})`); reconnectTimerRef.current = window.setTimeout(() => { reconnectTimerRef.current = null; @@ -76,84 +72,83 @@ export default function RandomQuestion() { if (esRef.current) { try { esRef.current.close(); - } catch { - /* noop */ - } + } catch {} esRef.current = null; } }; + const fetchRandomQuestion = async (peerFeedbackId: number) => { + // 이전 요청 취소 + fetchAbortRef.current?.abort(); + fetchAbortRef.current = new AbortController(); + + setLoadingQuestion(true); + 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); + setQuestionDetail(q); + } catch (err: any) { + if (err?.name === 'CanceledError' || err?.name === 'AbortError') { + console.log('[RQ] fetch aborted'); + } else { + console.error('[RQ] fetch error', err); + setErrorMessage('팝업 질문을 불러오지 못했습니다.'); + } + } finally { + setLoadingQuestion(false); + } + }; + const handleMessage = async (event: MessageEvent) => { + // 서버가 보내는 ping/keepalive 등 비 JSON은 무시 try { - const data = JSON.parse(event.data) as TNotification; - // 중복 차단 - if (lastPeerIdRef.current === data.peerFeedbackId) return; - lastPeerIdRef.current = data.peerFeedbackId; + const parsed = JSON.parse(event.data); + if (!parsed || typeof parsed !== 'object' || parsed.peerFeedbackId == null) { + return; + } + const data = parsed as TNotification; // 새 팝업 초기화 setNotification(data); setShowPopup(true); - setErrorMessage(null); setQuestionDetail(null); - setRecordingTime(0); - setRemainingTime(MAX_TIME); - setRecordedAudio((prev) => { if (prev) URL.revokeObjectURL(prev); return null; }); latestAudioBlobRef.current = null; + setRecordingTime(0); + setRemainingTime(MAX_TIME); - setLoadingQuestion(true); - const q = await getRandomQuestion(data.peerFeedbackId); - setQuestionDetail(q); - } catch (err) { - console.error('랜덤 팝업 질문 처리 중 오류:', err); - setErrorMessage('팝업 질문을 불러오지 못했습니다.'); - } finally { - setLoadingQuestion(false); + await fetchRandomQuestion(Number(data.peerFeedbackId)); + } catch { + // 무시(keepalive) } }; const openSSE = () => { - closeSSE(); // 중복 방지 - attemptsRef.current = 0; // 성공 시 초기화 예정 + closeSSE(); + attemptsRef.current = 0; const es = subscribeToNotifications(handleMessage, (errorEvt) => { console.error('SSE 연결 오류:', errorEvt); scheduleReconnect('onerror'); }); - // 선택: 서버가 event: ping 을 보낸다면 하트비트 갱신 - try { - (es as any).addEventListener?.('ping', () => { - lastPingAtRef.current = Date.now(); - }); - } catch { - /* noop */ - } - // 연결 성공 감지 (es as any).onopen = () => { attemptsRef.current = 0; - lastPingAtRef.current = Date.now(); - // eslint-disable-next-line no-console console.log('[SSE] opened'); }; esRef.current = es; - - // 클라이언트 측 하트비트 감시(서버 ping 미수신 시 재연결) - if (pingWatchRef.current) clearInterval(pingWatchRef.current); - pingWatchRef.current = window.setInterval(() => { - const diff = Date.now() - lastPingAtRef.current; - // 90초 이상 활동 없으면 재연결 시도 - if (diff > 90000) { - scheduleReconnect('no-activity'); - } - }, 15000); }; - // 최초 구독 + 탭 가시성 대응 useEffect(() => { const onVis = () => { if (document.visibilityState === 'visible') { @@ -164,11 +159,20 @@ export default function RandomQuestion() { }; openSSE(); document.addEventListener('visibilitychange', onVis); - return () => { document.removeEventListener('visibilitychange', onVis); closeSSE(); - if (pingWatchRef.current) clearInterval(pingWatchRef.current); + fetchAbortRef.current?.abort(); + if (recordedAudio) URL.revokeObjectURL(recordedAudio); + if (recordTimerRef.current) clearInterval(recordTimerRef.current); + if (streamRef.current) { + streamRef.current.getTracks().forEach((t) => t.stop()); + streamRef.current = null; + } + if (countdownTimerRef.current) { + clearInterval(countdownTimerRef.current); + countdownTimerRef.current = null; + } }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -217,9 +221,7 @@ export default function RandomQuestion() { if (isRecording && mediaRecorderRef.current) { try { mediaRecorderRef.current.stop(); - } catch { - /* noop */ - } + } catch {} setIsRecording(false); setIsPausedRec(false); } @@ -414,26 +416,10 @@ export default function RandomQuestion() { } }; - // 정리 - useEffect(() => { - return () => { - if (recordedAudio) URL.revokeObjectURL(recordedAudio); - if (recordTimerRef.current) clearInterval(recordTimerRef.current); - if (streamRef.current) { - streamRef.current.getTracks().forEach((t) => t.stop()); - streamRef.current = null; - } - if (countdownTimerRef.current) { - clearInterval(countdownTimerRef.current); - countdownTimerRef.current = null; - } - }; - }, [recordedAudio]); - if (!showPopup) return null; const playbackPercent = playbackDuration > 0 ? Math.min(100, Math.max(0, (playbackTime / playbackDuration) * 100)) : 0; - const progress = 100; // 한 개 질문이라 100% + const progress = 100; return (
diff --git a/src/services/randomQuestionApi.ts b/src/services/randomQuestionApi.ts index 41ca5d3..6e3eaea 100644 --- a/src/services/randomQuestionApi.ts +++ b/src/services/randomQuestionApi.ts @@ -1,4 +1,3 @@ -// src/services/randomQuestionApi.ts import apiClient from './api'; import { EventSourcePolyfill } from 'event-source-polyfill'; @@ -14,8 +13,6 @@ const joinUrl = (base = '', path = '') => `${base.replace(/\/+$/, '')}/${path.replace(/^\/+/, '')}`; // ---------------------- 타입 정의 ---------------------- - -// 1) SSE 알림 payload export interface IRandomNotificationPayload { jobName: string; interviewName: string; @@ -23,7 +20,6 @@ export interface IRandomNotificationPayload { peerFeedbackId: number; // = peerAnswerId } -// 2) 랜덤 팝업 질문 조회 응답 export interface IRandomQuestionContext { questionId: number; questionText: string; @@ -35,25 +31,17 @@ export interface IRandomQuestion { context: IRandomQuestionContext; } -// 3) presign 응답 export interface IPresignUrlResponse { uploadUrl: string; key: string; requiredHeaders: Record; } -// (호환용, 현재 미사용) -export interface IRandomQuestionRecordingRequest { - recordingKey: string; -} - -// 4) 녹음 저장 응답 export interface IFeedbackRecordingResponse { recordingId: number; status: 'UPLOADED'; } -// 5) 피드백 조회 응답 export type TFeedbackProgressStatus = 'WORKING' | 'READY' | 'FAILED'; export interface IFeedbackResult { questionId: number; @@ -73,12 +61,27 @@ export interface IFeedbackResultResponse { /** 1. 랜덤 팝업 질문 조회 (GET /api/random-questions/peer/{peerAnswerId}) */ export const getRandomQuestion = async ( peerAnswerId: number | string, + opts?: { noCache?: boolean; signal?: AbortSignal }, ): Promise => { - const resp = await apiClient.get(`/api/random-questions/peer/${peerAnswerId}`); + const params = opts?.noCache ? { _ts: Date.now() } : undefined; // 캐시 우회 + // 디버깅 로그 + // eslint-disable-next-line no-console + console.log('[RQ] GET random question', { peerAnswerId, params }); + + const resp = await apiClient.get(`/api/random-questions/peer/${peerAnswerId}`, { + params, + signal: opts?.signal as any, + headers: opts?.noCache + ? { + 'Cache-Control': 'no-cache', + Pragma: 'no-cache', + } + : undefined, + }); return unwrapResult(resp.data); }; -/** 2. 녹음 업로드용 프리사인 URL (POST /api/presign/recording/feedback-question) */ +/** 2. 녹음 업로드용 프리사인 URL */ export const getFeedbackRecordingPresignUrl = async ( questionId: number, contentType: string, @@ -90,7 +93,7 @@ export const getFeedbackRecordingPresignUrl = async ( return unwrapResult(resp.data); }; -/** 3. S3 업로드 (PUT presigned URL) */ +/** 3. S3 업로드 */ export const uploadToS3 = async ( presignedUrl: string, file: Blob, @@ -107,7 +110,7 @@ export const uploadToS3 = async ( if (!r.ok) throw new Error(`S3 업로드 실패: ${r.status}`); }; -/** 4. 녹음 저장 & 피드백 생성 트리거 (POST /api/random-questions/peer/questions/{questionId}) */ +/** 4. 녹음 저장 & 피드백 생성 트리거 */ export const saveFeedbackRecording = async ( questionId: number, ): Promise => { @@ -115,7 +118,7 @@ export const saveFeedbackRecording = async ( return unwrapResult(resp.data); }; -/** 5. 피드백 조회 (GET /api/random-questions/peer/recordings/{recordingId}/feedbacks) */ +/** 5. 피드백 조회 */ export const getFeedbackResult = async ( recordingId: number, ): Promise => { @@ -125,7 +128,7 @@ export const getFeedbackResult = async ( return unwrapResult(resp.data); }; -/** 6. Polling 헬퍼 (READY/FAILED 될 때까지) */ +/** 6. Polling 헬퍼 */ export const pollFeedbackResult = async ( recordingId: number, maxAttempts = 60, @@ -147,51 +150,69 @@ export const pollFeedbackResult = async ( /** * 7. SSE 구독 - * - 기본 경로: /api/subscribe (env 로 오버라이드 가능: VITE_SSE_PATH) - * - Authorization 필요 시 헤더와 쿼리 모두 지원 + * - 기본 경로를 'subscribe'로 두고, baseURL이 '/api'면 최종 '/api/subscribe' + * - 절대주소(https://...)가 오면 그대로 사용 */ export const subscribeToNotifications = ( onMessage: (event: MessageEvent) => void, onError?: (error: unknown) => void, ): EventSource => { - const base = apiClient.defaults.baseURL ?? ''; // 예: '/api' - const ssePath = import.meta.env.VITE_SSE_PATH ?? '/api/subscribe'; - const url = joinUrl(base, ssePath); + const base = apiClient.defaults.baseURL ?? ''; // ex) 'https://api.re-view-me.shop' 또는 '/api' + // 기본 경로를 '/api/subscribe'로 고정(ENV로 덮어쓸 수 있음) + const rawPath = import.meta.env.VITE_SSE_PATH ?? '/api/subscribe'; + + const buildSseUrl = (baseUrl: string, p: string) => { + // 절대 URL이면 그대로 사용 + if (/^https?:\/\//.test(p)) return p; + + // base가 .../api, path가 /api/... 인 경우 중복 api 제거 + const baseHasApi = /\/api\/?$/.test(baseUrl); + const pathHasApi = /^\/?api\//.test(p); + let path = p; + if (baseHasApi && pathHasApi) { + path = p.replace(/^\/?api\//, ''); // 선두 api/ 제거 + } + // join + const normBase = baseUrl.replace(/\/+$/, ''); + const normPath = path.replace(/^\/+/, ''); + return `${normBase}/${normPath}`; + }; + + const url = buildSseUrl(base, rawPath); const token = localStorage.getItem('accessToken') ?? ''; - // 헤더가 필요한 경우 폴리필 사용 (쿼리 파라미터로도 함께 전달) - const es = new EventSourcePolyfill( + // 디버깅 로그 + // eslint-disable-next-line no-console + console.log('[SSE] connect', { url, base, rawPath, hasToken: !!token }); + + const es = new (EventSourcePolyfill as any)( token ? `${url}?token=${encodeURIComponent(token)}` : url, { withCredentials: true, - heartbeatTimeout: 120_000, // 서버 keep-alive 가 뜸해도 버퍼링 여유 + heartbeatTimeout: 120_000, headers: token ? { Authorization: `Bearer ${token}` } : undefined, }, ) as EventSource; - es.onmessage = onMessage as any; - if (onError) es.onerror = onError as any; + (es as any).onmessage = onMessage; + if (onError) (es as any).onerror = onError; - return es; // 호출부에서 .close()로 정리 + return es; }; + // ---------------------- 전체 플로우 ---------------------- -/** - * 8. 업로드 → 저장 트리거 → Polling → 최종 피드백 반환 - */ export const uploadFeedbackRecordingAndGetResult = async ( questionId: number, audioBlob: Blob, ): Promise => { const contentType = (audioBlob as any).type || 'audio/webm'; - const { uploadUrl, requiredHeaders } = await getFeedbackRecordingPresignUrl( questionId, contentType, ); - await uploadToS3(uploadUrl, audioBlob, requiredHeaders); const { recordingId, status } = await saveFeedbackRecording(questionId); From 8bbe1868406a0c8c7f9d358d86203f8818187658 Mon Sep 17 00:00:00 2001 From: Hello-Worldismine Date: Mon, 17 Nov 2025 05:53:45 +0900 Subject: [PATCH 5/5] =?UTF-8?q?=EB=B2=84=EA=B7=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/myPage/randomQuestionCard.tsx | 2 - src/services/randomQuestionApi.ts | 101 +++++++++++-------- 2 files changed, 58 insertions(+), 45 deletions(-) diff --git a/src/components/myPage/randomQuestionCard.tsx b/src/components/myPage/randomQuestionCard.tsx index df34f1d..1fae218 100644 --- a/src/components/myPage/randomQuestionCard.tsx +++ b/src/components/myPage/randomQuestionCard.tsx @@ -1,5 +1,3 @@ -import React from 'react'; - import OrangeFrog from '@/assets/orangeFrog.svg?react'; type TRandomQuestionCardProps = { diff --git a/src/services/randomQuestionApi.ts b/src/services/randomQuestionApi.ts index 6e3eaea..d2a573d 100644 --- a/src/services/randomQuestionApi.ts +++ b/src/services/randomQuestionApi.ts @@ -1,3 +1,4 @@ +// src/services/randomQuestionApi.ts import apiClient from './api'; import { EventSourcePolyfill } from 'event-source-polyfill'; @@ -9,10 +10,9 @@ const unwrapResult = (data: any): T => { return data as T; }; -const joinUrl = (base = '', path = '') => - `${base.replace(/\/+$/, '')}/${path.replace(/^\/+/, '')}`; - // ---------------------- 타입 정의 ---------------------- + +// 1) SSE 알림 payload export interface IRandomNotificationPayload { jobName: string; interviewName: string; @@ -20,6 +20,7 @@ export interface IRandomNotificationPayload { peerFeedbackId: number; // = peerAnswerId } +// 2) 랜덤 팝업 질문 조회 응답 export interface IRandomQuestionContext { questionId: number; questionText: string; @@ -31,17 +32,20 @@ export interface IRandomQuestion { context: IRandomQuestionContext; } +// 3) presign 응답 export interface IPresignUrlResponse { uploadUrl: string; key: string; requiredHeaders: Record; } +// 4) 녹음 저장 응답 export interface IFeedbackRecordingResponse { recordingId: number; status: 'UPLOADED'; } +// 5) 피드백 조회 응답 export type TFeedbackProgressStatus = 'WORKING' | 'READY' | 'FAILED'; export interface IFeedbackResult { questionId: number; @@ -63,19 +67,17 @@ export const getRandomQuestion = async ( peerAnswerId: number | string, opts?: { noCache?: boolean; signal?: AbortSignal }, ): Promise => { - const params = opts?.noCache ? { _ts: Date.now() } : undefined; // 캐시 우회 - // 디버깅 로그 + const params = opts?.noCache ? { _ts: Date.now() } : undefined; + + // 디버깅 참고 로그 // eslint-disable-next-line no-console - console.log('[RQ] GET random question', { peerAnswerId, params }); + console.log('[RQ] GET /api/random-questions/peer/:id', { peerAnswerId, params }); const resp = await apiClient.get(`/api/random-questions/peer/${peerAnswerId}`, { params, signal: opts?.signal as any, headers: opts?.noCache - ? { - 'Cache-Control': 'no-cache', - Pragma: 'no-cache', - } + ? { 'Cache-Control': 'no-cache', Pragma: 'no-cache' } : undefined, }); return unwrapResult(resp.data); @@ -150,49 +152,46 @@ export const pollFeedbackResult = async ( /** * 7. SSE 구독 - * - 기본 경로를 'subscribe'로 두고, baseURL이 '/api'면 최종 '/api/subscribe' - * - 절대주소(https://...)가 오면 그대로 사용 + * - 기본 경로: /api/subscribe (VITE_SSE_PATH로 오버라이드 가능) + * - baseURL 및 path가 모두 /api를 포함해도 중복되지 않도록 정규화 + * - 절대 URL이 오면 그대로 사용 */ export const subscribeToNotifications = ( onMessage: (event: MessageEvent) => void, onError?: (error: unknown) => void, ): EventSource => { - const base = apiClient.defaults.baseURL ?? ''; // ex) 'https://api.re-view-me.shop' 또는 '/api' - // 기본 경로를 '/api/subscribe'로 고정(ENV로 덮어쓸 수 있음) - const rawPath = import.meta.env.VITE_SSE_PATH ?? '/api/subscribe'; - - const buildSseUrl = (baseUrl: string, p: string) => { - // 절대 URL이면 그대로 사용 - if (/^https?:\/\//.test(p)) return p; - - // base가 .../api, path가 /api/... 인 경우 중복 api 제거 - const baseHasApi = /\/api\/?$/.test(baseUrl); - const pathHasApi = /^\/?api\//.test(p); - let path = p; - if (baseHasApi && pathHasApi) { - path = p.replace(/^\/?api\//, ''); // 선두 api/ 제거 - } - // join - const normBase = baseUrl.replace(/\/+$/, ''); - const normPath = path.replace(/^\/+/, ''); - return `${normBase}/${normPath}`; - }; + const base = apiClient.defaults.baseURL ?? ''; // 예: '', '/api', 'https://api.domain.com', 'https://api.domain.com/api' + let path = import.meta.env.VITE_SSE_PATH ?? '/api/subscribe'; + + // 절대 경로면 그대로 사용 + if (/^https?:\/\//.test(path)) { + // eslint-disable-next-line no-console + console.log('[SSE] connect (absolute)', { url: path, hasToken: !!localStorage.getItem('accessToken') }); + return new (EventSourcePolyfill as any)( + appendToken(path), + esOptions(), + ) as EventSource; + } - const url = buildSseUrl(base, rawPath); + // base와 path 모두 상대라면 안전하게 합치기 + const normBase = (base || '').replace(/\/+$/, ''); // 끝 슬래시 제거 + let normPath = path.replace(/^\/+/, '/'); // 앞 슬래시는 하나만 유지 - const token = localStorage.getItem('accessToken') ?? ''; + // base가 /api 로 끝나고, path가 /api/...로 시작하면 path의 선두 /api 제거 + if (/\/api$/.test(normBase) && /^\/api\//.test(normPath)) { + normPath = normPath.replace(/^\/api/, ''); + } - // 디버깅 로그 + // 최종 URL + const finalUrl = `${normBase}${normPath.startsWith('/') ? '' : '/'}${normPath || ''}` || '/api/subscribe'; + + // 디버깅 // eslint-disable-next-line no-console - console.log('[SSE] connect', { url, base, rawPath, hasToken: !!token }); + console.log('[SSE] connect', { base: normBase || '(relative)', rawPath: path, url: finalUrl, hasToken: !!localStorage.getItem('accessToken') }); const es = new (EventSourcePolyfill as any)( - token ? `${url}?token=${encodeURIComponent(token)}` : url, - { - withCredentials: true, - heartbeatTimeout: 120_000, - headers: token ? { Authorization: `Bearer ${token}` } : undefined, - }, + appendToken(finalUrl), + esOptions(), ) as EventSource; (es as any).onmessage = onMessage; @@ -201,8 +200,24 @@ export const subscribeToNotifications = ( return es; }; +// 토큰을 쿼리스트링으로 추가(백엔드가 허용하는 경우) +function appendToken(url: string) { + const token = localStorage.getItem('accessToken') ?? ''; + if (!token) return url; + const sep = url.includes('?') ? '&' : '?'; + return `${url}${sep}token=${encodeURIComponent(token)}`; +} + +function esOptions() { + const token = localStorage.getItem('accessToken') ?? ''; + return { + withCredentials: true, + heartbeatTimeout: 120_000, + headers: token ? { Authorization: `Bearer ${token}` } : undefined, + }; +} -// ---------------------- 전체 플로우 ---------------------- +// ---------------------- 업로드 → 피드백 전체 플로우 ---------------------- export const uploadFeedbackRecordingAndGetResult = async ( questionId: number,