diff --git a/src/apis/myPage.ts b/src/apis/myPage.ts index f0c3f84..d532cea 100644 --- a/src/apis/myPage.ts +++ b/src/apis/myPage.ts @@ -1,6 +1,7 @@ import type { TDeleteRequest, TDeleteResponse, + TInterviewSummaryResponse, TMyFeedbackDetailResponse, TMyFeedbackRequest, TMyFeedbackResponse, @@ -10,6 +11,9 @@ import type { TPatchProfileRequest, TPatchProfileResponse, TPatchRequest, + TQuestionAnswersResponse, + TQuestionFeedbackResponse, + TRandomQuestionsResponse, } from '@/types/myPage'; import { axiosInstance } from '@/apis/axiosInstance'; @@ -46,3 +50,24 @@ export const putProfile = async (param: TPatchProfileRequest): Promise('/api/myarchive/myprofile', param); return data; }; + +// 나의 면접 상세 API +export const getInterviewSummary = async (interviewId: number): Promise => { + const { data } = await axiosInstance.get(`/api/myarchive/myinterviews/${interviewId}/summary`); + return data; +}; + +export const getQuestionAnswers = async (questionId: number): Promise => { + const { data } = await axiosInstance.get(`/api/myarchive/myinterviews/questions/${questionId}/answers`); + return data; +}; + +export const getQuestionFeedback = async (questionId: number): Promise => { + const { data } = await axiosInstance.get(`/api/myarchive/myinterviews/questions/${questionId}/feedback`); + return data; +}; + +export const getRandomQuestions = async (questionId: number): Promise => { + const { data } = await axiosInstance.get(`/api/myarchive/myinterviews/questions/${questionId}/random-questions`); + return data; +}; diff --git a/src/components/RandomQuestion.tsx b/src/components/RandomQuestion.tsx index 29d63d4..b5ce193 100644 --- a/src/components/RandomQuestion.tsx +++ b/src/components/RandomQuestion.tsx @@ -1,16 +1,21 @@ +// src/components/RandomQuestion.tsx import { useEffect, useRef, useState } from 'react'; +import { + getRandomQuestion, + subscribeToNotifications, + uploadFeedbackRecordingAndGetResult, + type IRandomQuestion, + type IRandomNotificationPayload, +} from '@/services/randomQuestionApi'; + +type TNotification = IRandomNotificationPayload; export default function RandomQuestion() { const [showPopup, setShowPopup] = useState(false); - const [currentQuestion, setCurrentQuestion] = useState(0); - - // 질문 데이터 - const questions = [ - { id: 1, main: '메인질문', sub: '간단히 자기소개를 해주세요.' }, - { id: 2, main: '메인질문', sub: '이 직무를 선택한 이유는 무엇인가요?' }, - { id: 3, main: '메인질문', sub: '본인의 강점은 무엇이라고 생각하나요?' }, - { id: 4, main: '메인질문', sub: '입사 후 목표는 무엇인가요?' }, - ]; + const [notification, setNotification] = useState(null); + const [questionDetail, setQuestionDetail] = useState(null); + const [loadingQuestion, setLoadingQuestion] = useState(false); + const [errorMessage, setErrorMessage] = useState(null); // ===== 녹음 상태 ===== const [isRecording, setIsRecording] = useState(false); @@ -22,6 +27,7 @@ export default function RandomQuestion() { const audioChunksRef = useRef([]); const streamRef = useRef(null); const recordTimerRef = useRef(null); + const latestAudioBlobRef = useRef(null); // ===== 재생 상태 ===== const audioRef = useRef(null); @@ -29,19 +35,46 @@ export default function RandomQuestion() { const [playbackTime, setPlaybackTime] = useState(0); const [playbackDuration, setPlaybackDuration] = useState(0); - // ===== 팝업 랜덤 등장 ===== + // 제출 중 상태 + const [isSubmitting, setIsSubmitting] = useState(false); + + // ===== SSE로 랜덤 팝업 알림 구독 ===== useEffect(() => { - const timeout: number = window.setTimeout( - () => { - const randomIndex = Math.floor(Math.random() * questions.length); - setCurrentQuestion(randomIndex); - setShowPopup(true); + const eventSource = subscribeToNotifications( + async (event) => { + try { + const data = JSON.parse(event.data) as TNotification; + // SSE로 알림이 오면 팝업을 띄우고, 해당 peerFeedbackId로 질문 조회 + setNotification(data); + setShowPopup(true); + setErrorMessage(null); + setQuestionDetail(null); + setRecordingTime(0); + 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); }, - Math.random() * 5000 + 3000, - ); // 3~8초 + ); - return () => clearTimeout(timeout); - }, [questions.length]); + return () => { + eventSource.close(); + }; + }, []); // ===== 유틸 ===== const formatTime = (s: number) => { @@ -51,15 +84,6 @@ export default function RandomQuestion() { return `${m}:${r.toString().padStart(2, '0')}`; }; - const navigateTo = (path: string) => { - try { - window.history.pushState({}, '', path); - window.dispatchEvent(new PopStateEvent('popstate')); - } catch { - window.location.href = path; - } - }; - // ===== 녹음 타이머 ===== useEffect(() => { if (isRecording && !isPausedRec) { @@ -83,6 +107,7 @@ export default function RandomQuestion() { URL.revokeObjectURL(recordedAudio); setRecordedAudio(null); } + latestAudioBlobRef.current = null; setPlaybackTime(0); setPlaybackDuration(0); setIsPlaying(false); @@ -101,6 +126,8 @@ export default function RandomQuestion() { 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); @@ -158,7 +185,8 @@ export default function RandomQuestion() { setRecordedAudio(null); } setRecordingTime(0); - startRecording(); + latestAudioBlobRef.current = null; + void startRecording(); }; // ===== 재생 제어 ===== @@ -209,25 +237,38 @@ export default function RandomQuestion() { }; }, [recordedAudio]); - // ===== 팝업 제어/페이지 이동 ===== + // ===== 팝업 닫기 ===== const handleClose = () => { if (isRecording) stopRecording(); if (audioRef.current) audioRef.current.pause(); setShowPopup(false); }; - const handleGoToInterview = () => { - if (isRecording) stopRecording(); - if (audioRef.current) audioRef.current.pause(); - navigateTo('/upload'); - setShowPopup(false); - }; - const handleNext = () => { - if (currentQuestion < questions.length - 1) { - setCurrentQuestion((i) => i + 1); - } else { - // 마지막이면 면접 페이지로 이동 (기존 동작 유지) - handleGoToInterview(); + // ===== 답변 제출 (녹음 업로드 + 피드백 생성) ===== + const handleSubmit = async () => { + if (!questionDetail?.question?.questionId) { + alert('질문 정보를 불러오지 못했습니다.'); + return; + } + if (!latestAudioBlobRef.current) { + alert('먼저 답변을 녹음해주세요.'); + return; + } + + try { + setIsSubmitting(true); + const feedback = await uploadFeedbackRecordingAndGetResult(questionDetail.question.questionId, latestAudioBlobRef.current); + + // 일단은 간단히 alert로 AI 피드백만 보여주기 + alert(`AI 피드백이 도착했어요.\n\n${feedback.aiFeedback}`); + + // 필요하면 여기에서 feedback.selfFeedback 등도 활용 가능 + setShowPopup(false); + } catch (err) { + console.error('랜덤 팝업 답변 제출 실패:', err); + alert('답변 제출에 실패했습니다. 잠시 후 다시 시도해주세요.'); + } finally { + setIsSubmitting(false); } }; @@ -245,9 +286,11 @@ export default function RandomQuestion() { if (!showPopup) return null; - const progress = ((currentQuestion + 1) / questions.length) * 100; const playbackPercent = playbackDuration > 0 ? Math.min(100, Math.max(0, (playbackTime / playbackDuration) * 100)) : 0; + // 진행바는 한 개 질문이라 100%로 고정(디자인 유지용) + const progress = 100; + return (
@@ -258,30 +301,47 @@ export default function RandomQuestion() { - {/* 질문 카드 */} -

- {questions[currentQuestion].id}. ({questions[currentQuestion].main}) -

-

- {questions[currentQuestion].id}-1. {questions[currentQuestion].sub} + {/* 헤더 - 알림 정보 */} +

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

+

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

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

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

+ ) : errorMessage ? ( +

{errorMessage}

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

맥락이 되는 질문

+

{questionDetail.context.questionText}

+ {questionDetail.context.sttText &&

{questionDetail.context.sttText}

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

{questionDetail.question.questionText}

+ + ) : ( +

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

+ )} {/* 이미지 */}
- 면접관 + 면접관
- {/* 질문 진행바 */} + {/* 질문 진행바 (디자인 유지용) */}
-

- 질문 {currentQuestion + 1} / {questions.length} -

+

랜덤 팝업 질문

- {/* 녹음/재생 영역 */} + {/* 녹음 / 재생 영역 */}
{!recordedAudio ? ( // === 녹음 UI === @@ -377,10 +437,15 @@ export default function RandomQuestion() {
@@ -393,7 +458,6 @@ export default function RandomQuestion() { .border-coral-500 { border-color: #ff7f66; } .hover\\:bg-coral-50:hover { background-color: #fff5f5; } .hover\\:bg-coral-600:hover { background-color: #ff6b52; } - .hover\\:text-coral-500:hover { color: #ff7f66; } `}
); diff --git a/src/hooks/useGetInterviewSummary.ts b/src/hooks/useGetInterviewSummary.ts new file mode 100644 index 0000000..da0bb4d --- /dev/null +++ b/src/hooks/useGetInterviewSummary.ts @@ -0,0 +1,9 @@ +import { getInterviewSummary } from '@/apis/myPage'; +import { useCoreQuery } from '@/hooks/customQuery'; + +export default function useGetInterviewSummary(interviewId: number) { + const { data, isLoading, error } = useCoreQuery(['getInterviewSummary', interviewId], () => getInterviewSummary(interviewId), { + enabled: !!interviewId, + }); + return { data, isLoading, error }; +} diff --git a/src/hooks/useGetQuestionAnswers.ts b/src/hooks/useGetQuestionAnswers.ts new file mode 100644 index 0000000..9c051e0 --- /dev/null +++ b/src/hooks/useGetQuestionAnswers.ts @@ -0,0 +1,9 @@ +import { getQuestionAnswers } from '@/apis/myPage'; +import { useCoreQuery } from '@/hooks/customQuery'; + +export default function useGetQuestionAnswers(questionId: number | null) { + const { data, isLoading, error } = useCoreQuery(['getQuestionAnswers', questionId], () => getQuestionAnswers(questionId!), { + enabled: !!questionId, + }); + return { data, isLoading, error }; +} diff --git a/src/hooks/useGetQuestionFeedback.ts b/src/hooks/useGetQuestionFeedback.ts new file mode 100644 index 0000000..cc7c243 --- /dev/null +++ b/src/hooks/useGetQuestionFeedback.ts @@ -0,0 +1,9 @@ +import { getQuestionFeedback } from '@/apis/myPage'; +import { useCoreQuery } from '@/hooks/customQuery'; + +export default function useGetQuestionFeedback(questionId: number | null) { + const { data, isLoading, error } = useCoreQuery(['getQuestionFeedback', questionId], () => getQuestionFeedback(questionId!), { + enabled: !!questionId, + }); + return { data, isLoading, error }; +} diff --git a/src/hooks/useGetRandomQuestions.ts b/src/hooks/useGetRandomQuestions.ts new file mode 100644 index 0000000..84e90e1 --- /dev/null +++ b/src/hooks/useGetRandomQuestions.ts @@ -0,0 +1,9 @@ +import { getRandomQuestions } from '@/apis/myPage'; +import { useCoreQuery } from '@/hooks/customQuery'; + +export default function useGetRandomQuestions(questionId: number | null) { + const { data, isLoading, error } = useCoreQuery(['getRandomQuestions', questionId], () => getRandomQuestions(questionId!), { + enabled: !!questionId, + }); + return { data, isLoading, error }; +} diff --git a/src/pages/Interview/feedback_result.tsx b/src/pages/Interview/feedback_result.tsx index 40499df..af97f27 100644 --- a/src/pages/Interview/feedback_result.tsx +++ b/src/pages/Interview/feedback_result.tsx @@ -3,7 +3,7 @@ import { useEffect, useState } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; import InterviewLayout from '@/layouts/InterviewLayout'; -import type { IFeedbackItem, IFinalFeedbackResponse } from '@/services/interviewApi'; +import type { IFinalFeedbackResponse } from '@/services/interviewApi'; import { getFinalFeedback } from '@/services/interviewApi'; interface IQuestionState { @@ -27,13 +27,39 @@ export default function FeedbackResult() { navigate('/upload'); return; } + const fetchFeedback = async () => { try { setIsLoading(true); + console.log('📊 최종 피드백 조회:', sessionId); + const response = await getFinalFeedback(sessionId); - setFeedbackData(response); - const states = response.feedbacks.map((_, idx) => ({ id: idx + 1, showAnswer: false })); - setQuestionStates(states); + + console.log('✅ 피드백 응답:', response); + + // feedbackProgressStatus 확인 + if (response.feedbackProgressStatus === 'WORKING') { + // 피드백 생성 중 - 5초 후 재시도 + setTimeout(fetchFeedback, 5000); + return; + } + + if (response.feedbackProgressStatus === 'FAILED') { + setError('피드백 생성에 실패했습니다.'); + setIsLoading(false); + return; + } + + if (response.interviewSummary) { + setFeedbackData(response); + + // 질문 상태 초기화 + const states = response.interviewSummary.questionSummaries.map((_, idx) => ({ + id: idx + 1, + showAnswer: false, + })); + setQuestionStates(states); + } } catch (err) { console.error('❌ 피드백 조회 실패:', err); setError('피드백을 불러오는데 실패했습니다.'); @@ -41,6 +67,7 @@ export default function FeedbackResult() { setIsLoading(false); } }; + void fetchFeedback(); }, [navigate, sessionId]); @@ -64,7 +91,7 @@ export default function FeedbackResult() { ); } - if (error || !feedbackData) { + if (error || !feedbackData || !feedbackData.interviewSummary) { return (
@@ -79,61 +106,69 @@ export default function FeedbackResult() { ); } - const { feedbacks, totalQuestions, timeoutCount } = feedbackData; + const { interviewSummary } = feedbackData; return (

- 총 {totalQuestions}문항에 대한 최종 피드백 + {interviewSummary.interviewTitle}에 대한 최종 + 피드백

- {timeoutCount > 0 && ( + {interviewSummary.timeoutQuestionNumber > 0 && (

시간 초과로 답변하지 못한 질문{' '} - {timeoutCount}개 + {interviewSummary.timeoutQuestionNumber}개

)}
- {feedbacks.map((item: IFeedbackItem, index: number) => { + {interviewSummary.questionSummaries.map((summary, index) => { const isShowingAnswer = questionStates.find((q) => q.id === index + 1)?.showAnswer || false; - const isPositive = item.feedbackType === 'positive'; - const feedbackTypeLabel = isPositive ? 'AI 피드백(긍정)' : 'AI 피드백(개선)'; - const hasAnswer = !!item.answer && item.answer.trim().length > 0; + + // AI 피드백과 셀프 피드백 중 표시할 것 선택 + const feedbackText = summary.aiFeedback || summary.selfFeedback; + const feedbackType = summary.aiFeedback ? 'AI 피드백' : summary.selfFeedback ? '셀프 피드백' : '피드백 없음'; + + // 답변 텍스트 (Q&A 턴에서 ANSWER만 추출) + const answerTurns = summary.qnaTurns.filter((turn) => turn.turn === 'ANSWER'); + const hasAnswer = answerTurns.length > 0; return ( -
+
+ {/* 카드 헤더 */}

- {index + 1}. {item.question} + {summary.questionNumber}. {summary.rootQuestion}

-

{feedbackTypeLabel}

+

{feedbackType}

+ {/* 카드 내용 (스크롤 가능) */}
{isShowingAnswer ? ( hasAnswer ? (
-
-

답변:

-

{item.answer}

-
+ {summary.qnaTurns.map((turn, turnIndex) => ( +
+

{turn.turn === 'QUESTION' ? '질문:' : '답변:'}

+

{turn.content}

+
+ ))}
) : ( -

{item.timeout ? '시간 초과로 답변하지 못했습니다.' : '답변이 제공되지 않았습니다.'}

+

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

) - ) : item.feedback ? ( -

{item.feedback}

+ ) : feedbackText ? ( +

{feedbackText}

) : (

피드백이 생성되지 않았습니다.

)}
+ {/* 버튼 */}
); diff --git a/src/pages/Interview/question_loading.tsx b/src/pages/Interview/question_loading.tsx index b19f35c..fbc59f5 100644 --- a/src/pages/Interview/question_loading.tsx +++ b/src/pages/Interview/question_loading.tsx @@ -1,11 +1,12 @@ -import { useEffect } from 'react'; +// src/pages/Interview/question_loading.tsx +import { useEffect, useRef } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; import InterviewLayout from '@/layouts/InterviewLayout'; import type { ICreateInterviewSessionResponse } from '@/services/interviewApi'; import { createInterviewSession } from '@/services/interviewApi'; -const ANSWER_ROUTE = '/main-answer'; // 프로젝트 라우트에 맞게 조정 +const ANSWER_ROUTE = '/main-answer'; type TLocationState = { fileName?: string; @@ -18,7 +19,14 @@ export default function QuestionLoading() { const navigate = useNavigate(); const location = useLocation() as { state?: TLocationState }; + // ✅ 부트스트랩이 중복 실행되는 것을 막기 위한 ref 가드 + const bootstrappedRef = useRef(false); + useEffect(() => { + // 이미 실행된 적 있으면 다시 실행하지 않음 + if (bootstrappedRef.current) return; + bootstrappedRef.current = true; + const bootstrap = async () => { try { const fileName = location.state?.fileName ?? '자소서'; @@ -32,14 +40,14 @@ export default function QuestionLoading() { return; } - // 스펙에 맞게 요청 + // 스펙에 맞게 세션 생성 요청 const resp: ICreateInterviewSessionResponse = await createInterviewSession({ resumeKey, jobTitle, interviewType, }); - // 성공 → 답변 페이지로 이동 (필요값 전달) + // ✅ 성공 → 답변 페이지로 이동 navigate(ANSWER_ROUTE, { replace: true, state: { @@ -60,7 +68,8 @@ export default function QuestionLoading() { }; void bootstrap(); - }, [location.state, navigate]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [navigate]); // location.state는 초기 한 번만 쓰고, 재실행을 막기 위해 deps에서 제외 return ( @@ -83,8 +92,13 @@ export default function QuestionLoading() { ); diff --git a/src/pages/myInterviews.tsx b/src/pages/myInterviews.tsx index 32d2106..37c401a 100644 --- a/src/pages/myInterviews.tsx +++ b/src/pages/myInterviews.tsx @@ -1,8 +1,275 @@ -export default function MyInterview() { +import { useEffect, useRef, useState } from 'react'; +import { useParams } from 'react-router-dom'; + +import useGetInterviewSummary from '@/hooks/useGetInterviewSummary'; +import useGetQuestionAnswers from '@/hooks/useGetQuestionAnswers'; +import useGetQuestionFeedback from '@/hooks/useGetQuestionFeedback'; +import useGetRandomQuestions from '@/hooks/useGetRandomQuestions'; + +import ClockFrog from '@/assets/clockFrog.svg?react'; + +type TabType = 'answer' | 'feedback' | 'random'; + +export default function MyInterviews() { + const { id } = useParams<{ id: string }>(); + const interviewId = id ? parseInt(id) : 0; + + const [selectedQuestionId, setSelectedQuestionId] = useState(null); + const [activeTab, setActiveTab] = useState('answer'); + const [playingAudio, setPlayingAudio] = useState(null); + const audioRef = useRef(null); + + const { data: summaryData, isLoading: summaryLoading } = useGetInterviewSummary(interviewId); + const { data: answersData, isLoading: answersLoading } = useGetQuestionAnswers(activeTab === 'answer' ? selectedQuestionId : null); + const { data: feedbackData, isLoading: feedbackLoading } = useGetQuestionFeedback(activeTab === 'feedback' ? selectedQuestionId : null); + const { data: randomData, isLoading: randomLoading } = useGetRandomQuestions(activeTab === 'random' ? selectedQuestionId : null); + + // 첫 번째 질문 자동 선택 + useEffect(() => { + if (summaryData?.result.questionCards && summaryData.result.questionCards.length > 0 && !selectedQuestionId) { + setSelectedQuestionId(summaryData.result.questionCards[0].questionId); + } + }, [summaryData, selectedQuestionId]); + + const handleAudioPlay = (url: string) => { + if (playingAudio === url) { + audioRef.current?.pause(); + setPlayingAudio(null); + } else { + if (audioRef.current) { + audioRef.current.pause(); + } + audioRef.current = new Audio(url); + audioRef.current.play(); + setPlayingAudio(url); + + audioRef.current.onended = () => { + setPlayingAudio(null); + }; + } + }; + + if (summaryLoading) { + return ( +
+
+ +

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

+
+
+ ); + } + + if (!summaryData?.result) { + return ( +
+

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

+
+ ); + } + + const { title, timedOutCount, questionCards } = summaryData.result; + const isLoading = answersLoading || feedbackLoading || randomLoading; + return ( -
-

interview

-

hi

+
+
+ {/* 헤더 */} +
+

{title}

+ {timedOutCount > 0 && ( +

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

+ )} +
+ + {/* 질문 카드 리스트 */} +
+

면접 질문

+
+ {questionCards.map((card) => ( + + ))} +
+
+ + {/* 탭 메뉴 */} +
+
+ + + +
+ + {/* 탭 콘텐츠 */} +
+ {isLoading ? ( +
+ +
+ ) : ( + <> + {/* 답변 확인 탭 */} + {activeTab === 'answer' && answersData?.result && ( +
+ {answersData.result.map((item) => ( +
+
+ + 질문 {item.order} + +

{item.question}

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

{item.answerText}

+ + + ) : ( +

답변이 없습니다.

+ )} +
+ ))} +
+ )} + + {/* 피드백 확인 탭 */} + {activeTab === 'feedback' && feedbackData?.result && ( +
+ {/* AI 피드백 */} + {feedbackData.result.aiFeedback && ( +
+

🤖 AI 피드백

+

{feedbackData.result.aiFeedback}

+
+ )} + + {/* 셀프 피드백 */} + {feedbackData.result.selfFeedback && ( +
+

✍️ 셀프 피드백

+

{feedbackData.result.selfFeedback}

+
+ )} + + {/* 동료 피드백 */} + {feedbackData.result.peerItems && feedbackData.result.peerItems.length > 0 && ( +
+

👥 동료 피드백

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

피드백이 없습니다.

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

{item.question}

+
+ +
+

답변:

+

{item.answerText}

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

AI 피드백

+

{item.aiFeedback}

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

셀프 피드백

+

{item.selfFeedback}

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

랜덤 질문이 없습니다.

+ )} +
+ )} + + )} +
+
+
); } diff --git a/src/pages/myPage.tsx b/src/pages/myPage.tsx index d10611f..95508f1 100644 --- a/src/pages/myPage.tsx +++ b/src/pages/myPage.tsx @@ -83,7 +83,7 @@ export default function MyPage() {

{title}

{description}

- + window.location.replace(title === '나의 면접,' ? route.myInterviews : route.evaluate)} /> diff --git a/src/routes/index.tsx b/src/routes/index.tsx index 43f0d0e..68cbb3a 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -32,7 +32,7 @@ export const router = createBrowserRouter([ { path: 'community/write', element: }, { path: 'community/edit/:id', element: }, { path: 'community/detail/:id', element: }, - { path: 'myInterview/:id', element: }, + { path: 'myInterviews/:id', element: }, { path: 'myEvaluate/:id', element: }, { path: 'upload', element: }, // ← myInterview에서 upload로 변경 { path: 'upload-done', element: }, diff --git a/src/services/api.ts b/src/services/api.ts index 0fae877..d78c34c 100644 --- a/src/services/api.ts +++ b/src/services/api.ts @@ -34,6 +34,17 @@ apiClient.interceptors.response.use( return response; }, (error) => { + // 특별 처리: 500 에러여도 result 데이터가 있으면 성공으로 처리 + // (백엔드 버그: 500 코드를 보내지만 정상 데이터도 함께 보냄) + if ( + error.response?.status === 500 && + error.response?.data && + (error.response.data.result || error.response.data.sessionId || error.response.data.interviewSummary) + ) { + // 조용히 성공으로 처리 (에러 로그 없이) + return error.response; + } + // 에러 처리 if (error.response) { // 서버가 응답을 반환한 경우 diff --git a/src/services/interviewApi.ts b/src/services/interviewApi.ts index bcf7cf4..5d57955 100644 --- a/src/services/interviewApi.ts +++ b/src/services/interviewApi.ts @@ -142,29 +142,54 @@ export interface ICreateInterviewSessionResponse { export const createInterviewSession = async ( data: ICreateInterviewSessionRequest, ): Promise => { - const resumeId = extractResumeId(data.resumeKey); - const mode: InterviewMode = data.interviewType === 'pressure' ? 'HARD' : 'NORMAL'; + try { + console.log('🎬 API 호출: /api/interview-sessions'); + console.log('- resumeKey:', data.resumeKey); + console.log('- jobTitle:', data.jobTitle); + console.log('- interviewType:', data.interviewType); - const payload: ICreateInterviewSessionPayload = { - mode, - jobRole: data.jobTitle, - resumeId, - }; + const resumeId = extractResumeId(data.resumeKey); + const mode: InterviewMode = data.interviewType === 'pressure' ? 'HARD' : 'NORMAL'; - const response = await apiClient.post('/api/interview-sessions', payload); - const apiResult = unwrapResult(response.data); + const payload: ICreateInterviewSessionPayload = { + mode, + jobRole: data.jobTitle, + resumeId, + }; - const firstQuestion: IQuestion = { - questionId: String(apiResult.firstQuestionId), - mainQuestion: apiResult.firstQuestionText, - subQuestion: '', - order: 1, - }; + console.log('📤 요청 payload:', payload); - return { - sessionId: String(apiResult.sessionId), - firstQuestion, - }; + 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 = { + sessionId: String(apiResult.sessionId), + firstQuestion, + }; + + 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); + throw error; + } }; // ===================================================== @@ -407,10 +432,10 @@ export interface IFinalFeedbackResponse { interviewSummary: IInterviewSummary | null; // WORKING일 때는 null } -/** 최종 피드백 조회 (GET /api/interview-sesisons/{sessionId}) */ +/** 최종 피드백 조회 (GET /api/interview-sessions/{sessionId}) */ export const getFinalFeedback = async ( sessionId: string | number, ): Promise => { - const response = await apiClient.get(`/api/interview-sesisons/${sessionId}`); + const response = await apiClient.get(`/api/interview-sessions/${sessionId}`); return unwrapResult(response.data); }; diff --git a/src/services/randomQuestionApi.ts b/src/services/randomQuestionApi.ts index 5ddff10..587a0b4 100644 --- a/src/services/randomQuestionApi.ts +++ b/src/services/randomQuestionApi.ts @@ -1,91 +1,164 @@ import apiClient from './api'; +// result만 뽑아주는 헬퍼 +const unwrapResult = (data: any): T => { + if (data && typeof data === 'object' && 'result' in data) { + return data.result as T; + } + return data as T; +}; + // ==================== 타입 정의 ==================== +// 1) SSE 알림 payload (팝업 알림용) — 팝업 알림을 위한 SSE 구독용 API +export interface IRandomNotificationPayload { + jobName: string; // 직무 이름 + interviewName: string; // 인터뷰 제목 + questionNumber: number; // 해당 인터뷰의 몇 번째 질문인지 + peerFeedbackId: number; // = peerAnswerId (랜덤 질문 조회에 사용) +} + +// 2) 랜덤 팝업 질문 조회 응답 — 랜덤 팝업 질문 조회 API +export interface IRandomQuestionContext { + questionId: number; + questionText: string; + presignedRecordingGetUrl: string; + sttText: string; +} + export interface IRandomQuestion { - questionId: string; - question: string; - peerAnswerId: string; + question: { + questionId: number; + questionText: string; + }; + context: IRandomQuestionContext; +} + +// 3) presign 응답 — 랜덤 팝업 질문 - 녹음 업로드용 프리사인 URL 발급 +export interface IPresignUrlResponse { + uploadUrl: string; // S3 PUT presigned URL + key: string; // 업로드될 S3 오브젝트 경로 + requiredHeaders: Record; // PUT 시 함께 보내야 할 헤더들 } +// recordingKey 요청용 타입은 더 이상 사용 안 하지만, 남겨둠 (호환용) export interface IRandomQuestionRecordingRequest { recordingKey: string; } +// 4) 녹음 저장 응답 — 랜덤 질문에 대한 recording 저장 및 피드백 생성 API (비동기) export interface IFeedbackRecordingResponse { - recordingId: string; - status: 'processing' | 'completed' | 'failed'; + recordingId: number; + status: 'UPLOADED'; // 비동기 작업이 큐에 올라갔다는 뜻 } -export interface IFeedbackResultResponse { - status: 'processing' | 'completed' | 'failed'; - feedback?: string; +// 5) 피드백 조회 응답 — 랜덤 질문에 대한 피드백 확인 API (polling) +export type FeedbackProgressStatus = 'WORKING' | 'READY' | 'FAILED'; + +export interface IFeedbackResult { + questionId: number; + questionText: string; + aiFeedback: string; + selfFeedback: string; + presignedRecordingGetUrl: string; + sttText: string; } -export interface IPresignUrlResponse { - presignedUrl: string; - fileKey: string; +export interface IFeedbackResultResponse { + progressStatus: FeedbackProgressStatus; + result: IFeedbackResult | null; // WORKING/FAILED일 때는 null } // ==================== API 함수들 ==================== /** * 1. 랜덤 팝업 질문 조회 + * GET /api/random-questions/peer/{peerAnswerId} + * peerAnswerId = SSE 알림의 peerFeedbackId */ -export const getRandomQuestion = async (peerAnswerId: string): Promise => { +export const getRandomQuestion = async ( + peerAnswerId: number | string, +): Promise => { const response = await apiClient.get(`/api/random-questions/peer/${peerAnswerId}`); - return response.data; + return unwrapResult(response.data); }; /** * 2. 랜덤 팝업 질문 - 녹음 업로드용 프리사인 URL 발급 + * POST /api/presign/recording/feedback-question + * Body: { questionId: Long, contentType: String } */ -export const getFeedbackRecordingPresignUrl = async (fileName: string): Promise => { - const response = await apiClient.post('/api/presign/recording/feedback-question', { fileName }); - return response.data; +export const getFeedbackRecordingPresignUrl = async ( + questionId: number, + contentType: string, +): Promise => { + const response = await apiClient.post('/api/presign/recording/feedback-question', { + questionId, + contentType, + }); + return unwrapResult(response.data); }; /** - * 3. S3에 파일 업로드 + * 3. S3에 파일 업로드 (프리사인 URL 사용) */ -export const uploadToS3 = async (presignedUrl: string, file: Blob): Promise => { +export const uploadToS3 = async ( + presignedUrl: string, + file: Blob, + extraHeaders: Record = {}, +): Promise => { await fetch(presignedUrl, { method: 'PUT', body: file, headers: { 'Content-Type': file.type || 'audio/webm', + ...extraHeaders, }, }); }; /** * 4. 랜덤 질문에 대한 recording 저장 및 피드백 생성 (비동기) + * POST /api/random-questions/peer/questions/{questionId} + * Body 없음, path param으로 questionId만 넘김 */ -export const saveFeedbackRecording = async (questionId: string, data: IRandomQuestionRecordingRequest): Promise => { - const response = await apiClient.get(`/api/random-questions/peer/questions/${questionId}`, { - params: data, - }); - return response.data; +export const saveFeedbackRecording = async ( + questionId: number, +): Promise => { + const response = await apiClient.post( + `/api/random-questions/peer/questions/${questionId}`, + ); + return unwrapResult(response.data); }; /** * 5. 랜덤 질문에 대한 피드백 확인 (polling) + * GET /api/random-questions/peer/recordings/{recordingId}/feedbacks */ -export const getFeedbackResult = async (recordingId: string): Promise => { - const response = await apiClient.get(`/api/random-questions/peer/recordings/${recordingId}/feedbacks`); - return response.data; +export const getFeedbackResult = async ( + recordingId: number, +): Promise => { + const response = await apiClient.get( + `/api/random-questions/peer/recordings/${recordingId}/feedbacks`, + ); + return unwrapResult(response.data); }; /** * 6. Polling 헬퍼 함수 + * progressStatus 가 READY / FAILED 가 될 때까지 조회 */ -export const pollFeedbackResult = async (recordingId: string, maxAttempts: number = 60, interval: number = 5000): Promise => { +export const pollFeedbackResult = async ( + recordingId: number, + maxAttempts: number = 60, + interval: number = 5000, +): Promise => { let attempts = 0; while (attempts < maxAttempts) { const result = await getFeedbackResult(recordingId); - if (result.status === 'completed' || result.status === 'failed') { + if (result.progressStatus === 'READY' || result.progressStatus === 'FAILED') { return result; } @@ -98,9 +171,14 @@ export const pollFeedbackResult = async (recordingId: string, maxAttempts: numbe /** * 7. SSE 구독 (Server-Sent Events) + * GET /api/subscribe */ -export const subscribeToNotifications = (onMessage: (event: MessageEvent) => void, onError?: (error: Event) => void): EventSource => { - const eventSource = new EventSource(`${apiClient.defaults.baseURL}/api/subscribe`); +export const subscribeToNotifications = ( + onMessage: (event: MessageEvent) => void, + onError?: (error: Event) => void, +): EventSource => { + const baseURL = apiClient.defaults.baseURL ?? ''; + const eventSource = new EventSource(`${baseURL}/api/subscribe`); eventSource.onmessage = onMessage; @@ -115,32 +193,41 @@ export const subscribeToNotifications = (onMessage: (event: MessageEvent) => voi /** * 랜덤 질문 녹음 업로드 및 피드백 받기 전체 플로우 + * + * 1) 프리사인 URL 발급 + * 2) S3 업로드 + * 3) recording 저장 (비동기 큐에 올리기) + * 4) 피드백 READY 될 때까지 polling + * 5) IFeedbackResult 리턴 (aiFeedback, selfFeedback 등 포함) */ -export const uploadFeedbackRecordingAndGetResult = async (questionId: string, audioBlob: Blob): Promise => { +export const uploadFeedbackRecordingAndGetResult = async ( + questionId: number, + audioBlob: Blob, +): Promise => { // 1. 프리사인 URL 받기 - const fileName = `feedback-${questionId}-${Date.now()}.webm`; - const { presignedUrl, fileKey } = await getFeedbackRecordingPresignUrl(fileName); + const contentType = audioBlob.type || 'audio/webm'; + const { uploadUrl, requiredHeaders } = await getFeedbackRecordingPresignUrl( + questionId, + contentType, + ); // 2. S3에 업로드 - await uploadToS3(presignedUrl, audioBlob); + await uploadToS3(uploadUrl, audioBlob, requiredHeaders); - // 3. 피드백 생성 시작 - const { recordingId, status } = await saveFeedbackRecording(questionId, { - recordingKey: fileKey, - }); + // 3. 녹음 저장 & 비동기 피드백 생성 트리거 + const { recordingId, status } = await saveFeedbackRecording(questionId); - // 4. 즉시 완료된 경우 - if (status === 'completed') { - const result = await getFeedbackResult(recordingId); - return result.feedback || '피드백을 받지 못했습니다.'; + if (status !== 'UPLOADED') { + throw new Error(`예상치 못한 recording 상태입니다: ${status}`); } - // 5. 처리 중이면 polling + // 4. 피드백 생성 상태 polling const result = await pollFeedbackResult(recordingId); - if (result.status === 'failed') { + if (result.progressStatus === 'FAILED' || !result.result) { throw new Error('피드백 생성에 실패했습니다.'); } - return result.feedback || '피드백을 받지 못했습니다.'; + // 5. 최종 피드백 결과 리턴 + return result.result; }; diff --git a/src/types/myPage.ts b/src/types/myPage.ts index ab9d01c..56379cc 100644 --- a/src/types/myPage.ts +++ b/src/types/myPage.ts @@ -96,3 +96,73 @@ export const GROWTH_TAG_LABELS: Record = { export const getExperienceHashTags = (tags?: TExperienceTags[]) => (tags ?? []).map((tag) => `# ${EXPERIENCE_TAG_LABELS[tag]}`); export const getGrowthHashTags = (tags?: TGrowthTags[]) => (tags ?? []).map((tag) => `# ${GROWTH_TAG_LABELS[tag]}`); + +// 나의 면접 상세 - 전체 요약 API +export type TInterviewSummaryRequest = { + interviewId: number; +}; + +export type TQuestionCard = { + order: number; + questionId: number; +}; + +export type TAnswerCheckItem = { + order: number; + questionId: number; + question: string; + answerText: string | null; + recordUrl: string; +}; + +export type TInterviewSummaryResponse = { + errorCode: null | string; + message: string; + result: { + title: string; + timedOutCount: number; + questionCards: TQuestionCard[]; + firstQuestionThread: TAnswerCheckItem[]; + }; +}; + +// 나의 면접 상세 - 질문별 답변 확인 API +export type TQuestionAnswersRequest = { + questionId: number; +}; + +export type TQuestionAnswersResponse = { + errorCode: null | string; + message: string; + result: TAnswerCheckItem[]; +}; + +// 나의 면접 상세 - 질문별 피드백 조회 API +export type TQuestionFeedbackRequest = { + questionId: number; +}; + +export type TQuestionFeedbackResponse = { + errorCode: null | string; + message: string; + result: { + aiFeedback: string; + selfFeedback: string; + peerItems: string[]; + }; +}; + +// 나의 면접 상세 - 질문별 랜덤 질문 답변 확인 API +export type TRandomQuestionItem = { + question: string; + aiFeedback: string; + selfFeedback: string; + answerText: string; + recordingUrl: string; +}; + +export type TRandomQuestionsResponse = { + errorCode: null | string; + message: string; + result: TRandomQuestionItem[]; +};