diff --git a/src/components/RandomQuestion.tsx b/src/components/RandomQuestion.tsx index c6341f5..e4878ef 100644 --- a/src/components/RandomQuestion.tsx +++ b/src/components/RandomQuestion.tsx @@ -43,46 +43,138 @@ export default function RandomQuestion() { // 제출 중 상태 const [isSubmitting, setIsSubmitting] = useState(false); - // ===== SSE로 랜덤 팝업 알림 구독 ===== - 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); - }, - ); + // ===== SSE & 요청 취소 컨트롤 ===== + const esRef = useRef(null); + const reconnectTimerRef = useRef(null); + const attemptsRef = useRef(0); + const fetchAbortRef = 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)); + attemptsRef.current += 1; + 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 {} + 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 parsed = JSON.parse(event.data); + if (!parsed || typeof parsed !== 'object' || parsed.peerFeedbackId == null) { + return; + } + const data = parsed as TNotification; + + // 새 팝업 초기화 + setNotification(data); + setShowPopup(true); + setQuestionDetail(null); + setRecordedAudio((prev) => { + if (prev) URL.revokeObjectURL(prev); + return null; + }); + latestAudioBlobRef.current = null; + setRecordingTime(0); + setRemainingTime(MAX_TIME); + + await fetchRandomQuestion(Number(data.peerFeedbackId)); + } catch { + // 무시(keepalive) + } + }; + + const openSSE = () => { + closeSSE(); + attemptsRef.current = 0; + + const es = subscribeToNotifications(handleMessage, (errorEvt) => { + console.error('SSE 연결 오류:', errorEvt); + scheduleReconnect('onerror'); + }); + + (es as any).onopen = () => { + attemptsRef.current = 0; + console.log('[SSE] opened'); + }; + + esRef.current = es; + }; + + useEffect(() => { + const onVis = () => { + if (document.visibilityState === 'visible') { + openSSE(); + } else { + closeSSE(); + } + }; + openSSE(); + document.addEventListener('visibilitychange', onVis); return () => { - eventSource.close(); + document.removeEventListener('visibilitychange', onVis); + closeSSE(); + 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 }, []); // ===== 유틸 ===== @@ -93,7 +185,7 @@ export default function RandomQuestion() { return `${m}:${r.toString().padStart(2, '0')}`; }; - // ===== 팝업 전체 제한시간 타이머 (팝업이 뜨는 순간부터 감소) ===== + // ===== 팝업 전체 제한시간 타이머 ===== useEffect(() => { if (!showPopup) { if (countdownTimerRef.current) { @@ -102,7 +194,6 @@ export default function RandomQuestion() { } return; } - countdownTimerRef.current = window.setInterval(() => { setRemainingTime((prev) => { if (prev <= 1) { @@ -124,29 +215,22 @@ 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(); - } catch { - /* noop */ - } + } catch {} 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 +252,6 @@ export default function RandomQuestion() { alert('시간이 종료되어 더 이상 녹음할 수 없습니다.'); return; } - try { if (recordedAudio) { URL.revokeObjectURL(recordedAudio); @@ -189,19 +272,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 +321,6 @@ export default function RandomQuestion() { alert('시간이 종료되어 다시 녹음할 수 없습니다.'); return; } - if (audioRef.current) { audioRef.current.pause(); audioRef.current.currentTime = 0; @@ -251,7 +328,6 @@ export default function RandomQuestion() { setIsPlaying(false); setPlaybackTime(0); setPlaybackDuration(0); - if (recordedAudio) { URL.revokeObjectURL(recordedAudio); setRecordedAudio(null); @@ -279,7 +355,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 +366,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 +388,7 @@ export default function RandomQuestion() { setShowPopup(false); }; - // ===== 답변 제출 (녹음 업로드 + 피드백 생성) ===== + // ===== 답변 제출 ===== const handleSubmit = async () => { if (isTimeOver) { alert('시간이 종료되어 답변을 제출할 수 없습니다.'); @@ -334,7 +406,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) { @@ -345,27 +416,9 @@ 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; - - // 진행바는 한 개 질문이라 100%로 고정(디자인 유지용) const progress = 100; return ( @@ -391,31 +444,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 +470,10 @@ export default function RandomQuestion() {

랜덤 팝업 질문

- {/* 녹음 / 재생 영역 */}
{!recordedAudio ? ( - // === 녹음 UI ===
{!isRecording ? ( - // 시작 버튼 (마이크 아이콘) - - {/* 녹음 시간 */} {formatTime(recordingTime)} - - {/* 정지 */} -
- {formatTime(playbackTime)} / {formatTime(playbackDuration)} -
)}
- {/* 버튼 그룹 */}
- ))} -
- )} +
+ {(questionCards ?? []).map((card) => ( + + ))} +
{/* 탭 메뉴 */} @@ -225,9 +217,9 @@ export default function MyInterviews() { ) : ( <> {/* 답변 확인 탭 */} - {activeTab === 'answer' && (answersData?.result ?? []).length > 0 && ( + {activeTab === 'answer' && (answerItems ?? []).length > 0 && (
- {(answersData?.result ?? []).map((item: any) => ( + {(answerItems ?? []).map((item) => (
질문 {item.order} @@ -254,9 +246,9 @@ export default function MyInterviews() { )} {/* 피드백 확인 탭 */} - {activeTab === 'feedback' && feedbackList.length > 0 && ( + {activeTab === 'feedback' && (feedbackList ?? []).length > 0 && (
- {feedbackList.map((item) => ( + {(feedbackList ?? []).map((item) => (
질문 {item.order} @@ -270,19 +262,17 @@ export default function MyInterviews() {

{item.aiFeedback}

)} - {item.selfFeedback && (

✍️ 셀프 피드백

{item.selfFeedback}

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

👥 동료 피드백

    - {item.peerItems.map((peer, index) => ( + {item.peerItems!.map((peer, index) => (
  • {peer} @@ -291,7 +281,6 @@ export default function MyInterviews() {
)} - {!item.aiFeedback && !item.selfFeedback && (item.peerItems?.length ?? 0) === 0 && (

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

)} @@ -300,55 +289,56 @@ export default function MyInterviews() { ))}
)} - - {activeTab === 'feedback' && feedbackList.length === 0 && !loadingFeedback && ( + {activeTab === 'feedback' && (feedbackList ?? []).length === 0 && !loadingFeedback && (

피드백이 없습니다.

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

{item.question}

-
- -
-

답변:

-

{item.answerText}

+ {(randomItems ?? []).length > 0 ? ( + (randomItems ?? []).map((item, index) => ( +
+
+ 랜덤 질문 {index + 1} +

{item.question}

+
+ +
+

답변:

+

{item.answerText}

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

AI 피드백

+

{item.aiFeedback}

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

셀프 피드백

+

{item.selfFeedback}

+
+ )} +
+ +
- -
- {item.aiFeedback && ( -
-

AI 피드백

-

{item.aiFeedback}

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

셀프 피드백

-

{item.selfFeedback}

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

랜덤 질문이 없습니다.

+ )}
)} - - {activeTab === 'random' && !(randomData?.result ?? []).length &&

랜덤 질문이 없습니다.

} )}
diff --git a/src/services/interviewApi.ts b/src/services/interviewApi.ts index 92ff194..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,24 +101,17 @@ 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: InterviewMode = data.interviewType === 'pressure' ? 'HARD' : 'NORMAL'; + const mode: TInterviewMode = data.interviewType === 'pressure' ? 'HARD' : 'NORMAL'; const payload: ICreateInterviewSessionPayload = { mode, @@ -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..d2a573d 100644 --- a/src/services/randomQuestionApi.ts +++ b/src/services/randomQuestionApi.ts @@ -1,61 +1,52 @@ +// 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; }; -// ==================== 타입 정의 ==================== +// ---------------------- 타입 정의 ---------------------- -// 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 시 함께 보내야 할 헤더들 -} - -// recordingKey 요청용 타입은 더 이상 사용 안 하지만, 남겨둠 (호환용) -export interface IRandomQuestionRecordingRequest { - recordingKey: string; + uploadUrl: string; + key: string; + requiredHeaders: Record; } -// 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 +55,187 @@ 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, + opts?: { noCache?: boolean; signal?: AbortSignal }, ): Promise => { - const response = await apiClient.get(`/api/random-questions/peer/${peerAnswerId}`); - return unwrapResult(response.data); + const params = opts?.noCache ? { _ts: Date.now() } : undefined; + + // 디버깅 참고 로그 + // eslint-disable-next-line no-console + 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' } + : undefined, + }); + return unwrapResult(resp.data); }; -/** - * 2. 랜덤 팝업 질문 - 녹음 업로드용 프리사인 URL 발급 - * POST /api/presign/recording/feedback-question - * Body: { questionId: Long, contentType: String } - */ +/** 2. 녹음 업로드용 프리사인 URL */ 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 업로드 */ 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. 녹음 저장 & 피드백 생성 트리거 */ 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. 피드백 조회 */ 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 헬퍼 */ 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 (VITE_SSE_PATH로 오버라이드 가능) + * - baseURL 및 path가 모두 /api를 포함해도 중복되지 않도록 정규화 + * - 절대 URL이 오면 그대로 사용 */ 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; + 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; + } - eventSource.onmessage = onMessage; + // base와 path 모두 상대라면 안전하게 합치기 + const normBase = (base || '').replace(/\/+$/, ''); // 끝 슬래시 제거 + let normPath = path.replace(/^\/+/, '/'); // 앞 슬래시는 하나만 유지 - if (onError) { - eventSource.onerror = onError; + // base가 /api 로 끝나고, path가 /api/...로 시작하면 path의 선두 /api 제거 + if (/\/api$/.test(normBase) && /^\/api\//.test(normPath)) { + normPath = normPath.replace(/^\/api/, ''); } - return eventSource; + // 최종 URL + const finalUrl = `${normBase}${normPath.startsWith('/') ? '' : '/'}${normPath || ''}` || '/api/subscribe'; + + // 디버깅 + // eslint-disable-next-line no-console + console.log('[SSE] connect', { base: normBase || '(relative)', rawPath: path, url: finalUrl, hasToken: !!localStorage.getItem('accessToken') }); + + const es = new (EventSourcePolyfill as any)( + appendToken(finalUrl), + esOptions(), + ) as EventSource; + + (es as any).onmessage = onMessage; + if (onError) (es as any).onerror = onError; + + 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, + }; +} + +// ---------------------- 업로드 → 피드백 전체 플로우 ---------------------- -/** - * 랜덤 질문 녹음 업로드 및 피드백 받기 전체 플로우 - * - * 1) 프리사인 URL 발급 - * 2) S3 업로드 - * 3) recording 저장 (비동기 큐에 올리기) - * 4) 피드백 READY 될 때까지 polling - * 5) IFeedbackResult 리턴 (aiFeedback, selfFeedback 등 포함) - */ 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; };