diff --git a/eslint.config.mjs b/eslint.config.mjs index 4210b6f..8c50df7 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -8,6 +8,8 @@ import importConfig from './eslint/import.mjs'; import reactConfig from './eslint/react.mjs'; import tsConfig from './eslint/typescript.mjs'; +import reactHooks from 'eslint-plugin-react-hooks'; + export default [ { ignores: ['src/vite-env.d.ts'], @@ -41,5 +43,14 @@ export default [ defaultConfig, importConfig, reactConfig, + + { + plugins: { 'react-hooks': reactHooks }, + rules: { + 'react-hooks/rules-of-hooks': 'error', + 'react-hooks/exhaustive-deps': 'warn', + }, + }, + prettierPluginRecommended, ]; diff --git a/package-lock.json b/package-lock.json index 0e8f28b..7c0c646 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,7 +35,7 @@ "@types/react-dom": "^19.0.4", "@vitejs/plugin-react": "^4.3.4", "eslint": "^9.35.0", - "eslint-plugin-react-hooks": "^5.1.0", + "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.19", "globals": "^15.15.0", "typescript": "~5.7.2", @@ -3916,7 +3916,6 @@ "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz", "integrity": "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==", "dev": true, - "license": "MIT", "engines": { "node": ">=10" }, diff --git a/package.json b/package.json index 107fb11..03f3bac 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,7 @@ "@types/react-dom": "^19.0.4", "@vitejs/plugin-react": "^4.3.4", "eslint": "^9.35.0", - "eslint-plugin-react-hooks": "^5.1.0", + "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.19", "globals": "^15.15.0", "typescript": "~5.7.2", diff --git a/src/components/RandomQuestion.tsx b/src/components/RandomQuestion.tsx index a4a4c25..29d63d4 100644 --- a/src/components/RandomQuestion.tsx +++ b/src/components/RandomQuestion.tsx @@ -41,7 +41,7 @@ export default function RandomQuestion() { ); // 3~8초 return () => clearTimeout(timeout); - }, []); + }, [questions.length]); // ===== 유틸 ===== const formatTime = (s: number) => { diff --git a/src/hooks/useGetCommunityDetail.ts b/src/hooks/useGetCommunityDetail.ts index 9dc82ea..e0256f1 100644 --- a/src/hooks/useGetCommunityDetail.ts +++ b/src/hooks/useGetCommunityDetail.ts @@ -4,7 +4,7 @@ import { getCommunityDetail } from '@/apis/community'; import { useCoreQuery } from '@/hooks/customQuery'; -export default function usegetCommunityDetail(param: TCommumityDetailRequest) { +export default function useGetCommunityDetail(param: TCommumityDetailRequest) { const { data } = useCoreQuery(['getCommunityDetail', param.pageId], () => getCommunityDetail(param)); return { data }; } diff --git a/src/pages/Interview/feedback_result.tsx b/src/pages/Interview/feedback_result.tsx index 6b9a45b..6a8bb9f 100644 --- a/src/pages/Interview/feedback_result.tsx +++ b/src/pages/Interview/feedback_result.tsx @@ -1,6 +1,10 @@ -import { useState } from 'react'; +// src/pages/Interview/feedback_result.tsx +import { useEffect, useState } from 'react'; +import { useLocation, useNavigate } from 'react-router-dom'; import InterviewLayout from '@/layouts/InterviewLayout'; +import type { IFinalFeedbackResponse } from '@/services/interviewApi'; +import { getFinalFeedback } from '@/services/interviewApi'; interface IQuestionState { id: number; @@ -8,91 +12,131 @@ interface IQuestionState { } export default function FeedbackResult() { - const [questionStates, setQuestionStates] = useState([ - { id: 1, showAnswer: false }, - { id: 2, showAnswer: false }, - { id: 3, showAnswer: false }, - { id: 4, showAnswer: false }, - ]); + const navigate = useNavigate(); + const location = useLocation() as { state?: { sessionId?: string } }; + const { sessionId } = location.state || {}; - const feedbacks = [ - { - id: 1, - title: '간단히 자기소개를 해주세요', - type: '긍정적 피드백', - feedback: - '예상 질문은 잘 알겠지만 꼬리 질문에서 말이 막혀 아쉽습니다. 실제 면접이라고 생각하고 진행한 탓에 말이 빨라졌고, 직무 관련 경험을 구체적으로 설명했을 때는 면접관이 긍정적인 반응을 보였습니다. 다만 마지막 자기소개를 준비하지 못해 아쉬움이 남...예상 질문은 잘 알겠지만 꼬리 질문에서 말이 막혀 아쉽습니다. 실제 면접이라고 생각하고 진행한 탓에 말이 빨라졌고, 직무 관련 경험을 구체적으로 설명했을 때는 면접관이 긍정적인 반응을 보였습니다. 다만 마지막 자기소개를 준비하지 못해 아쉬움이 남...', - answer: - '내 답변 내용 내 답변 내용 내 답변 내용 내 답변 내용 내 답변 내용 내 답변 내용 내 답변 내용 내 답변 내용 내 답변 내용 내 답변 내용 내 답변 내용 내 답변 내용 내 답변 내용 내 답변 내용 내 답변 내용 내 답변 내용 내 답변 내용 내 답변 내용 내 답변 내용 내 답변 내용 내 답변 내용 내 답변 내용 내 답변 내용 내 답변 내용 내 답변 내용 내 답변 내용 내 답변 내용 내 답변 내용 내 답변 내용 내 답변 내용 내 답변 내용 내 답변 내용 내 답변 내용 내 답변 내용 내 답변 내용 내 답변 내용 내 답변 내용 내 답변 내용 내 답변 내용 내 답변 내용 내 답변 내용 내 답변 내용', - }, - { - id: 2, - title: '간단히 자기소개를 해주세요', - type: '긍정적 피드백', - feedback: - '예상 질문은 잘 알겠지만 꼬리 질문에서 말이 막혀 아쉽습니다. 실제 면접이라고 생각하고 진행한 탓에 말이 빨라졌고, 직무 관련 경험을 구체적으로 설명했을 때는 면접관이 긍정적인 반응을 보였습니다. 다만 마지막 자기소개를 준비하지 못해 아쉬움이 남...예상 질문은 잘 알겠지만 꼬리 질문에서 말이 막혀 아쉽습니다. 실제 면접이라고 생각하고 진행한 탓에 말이 빨라졌고, 직무 관련 경험을 구체적으로 설명했을 때는 면접관이 긍정적인 반응을 보였습니다. 다만 마지막 자기소개를 준비하지 못해 아쉬움이 남...', - answer: '내 답변 내용입니다.', - }, - { - id: 3, - title: '간단히 자기소개를 해주세요', - type: '부정적 피드백', - feedback: - '예상 질문은 잘 알겠지만 꼬리 질문에서 말이 막혀 아쉽습니다. 실제 면접이라고 생각하고 진행한 탓에 말이 빨라졌고, 직무 관련 경험을 구체적으로 설명했을 때는 면접관이 긍정적인 반응을 보였습니다. 다만 마지막 자기소개를 준비하지 못해 아쉬움이 남...예상 질문은 잘 알겠지만 꼬리 질문에서 말이 막혀 아쉽습니다. 실제 면접이라고 생각하고 진행한 탓에 말이 빨라졌고, 직무 관련 경험을 구체적으로 설명했을 때는 면접관이 긍정적인 반응을 보였습니다. 다만 마지막 자기소개를 준비하지 못해 아쉬움이 남...', - answer: '내 답변 내용입니다.', - }, - { - id: 4, - title: '간단히 자기소개를 해주세요', - type: '부정적 피드백', - feedback: - '예상 질문은 잘 알겠지만 꼬리 질문에서 말이 막혀 아쉽습니다. 실제 면접이라고 생각하고 진행한 탓에 말이 빨라졌고, 직무 관련 경험을 구체적으로 설명했을 때는 면접관이 긍정적인 반응을 보였습니다. 다만 마지막 자기소개를 준비하지 못해 아쉬움이 남...', - answer: '내 답변 내용입니다.', - }, - ]; + const [feedbackData, setFeedbackData] = useState(null); + const [questionStates, setQuestionStates] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + if (!sessionId) { + alert('세션 정보가 없습니다.'); + navigate('/upload'); + return; + } + const fetchFeedback = async () => { + try { + setIsLoading(true); + const response = await getFinalFeedback(sessionId); + setFeedbackData(response); + const states = response.feedbacks.map((_, idx: number) => ({ id: idx + 1, showAnswer: false })); + setQuestionStates(states); + } catch (err) { + console.error('❌ 피드백 조회 실패:', err); + setError('피드백을 불러오는데 실패했습니다.'); + } finally { + setIsLoading(false); + } + }; + void fetchFeedback(); + }, [navigate, sessionId]); const toggleAnswer = (id: number) => { setQuestionStates((prev) => prev.map((q) => (q.id === id ? { ...q, showAnswer: !q.showAnswer } : q))); }; + if (isLoading) { + return ( + +
+
+
+ 로딩 +
+

피드백을 생성하고 있습니다...

+

최대 5분 정도 소요될 수 있습니다.

+
+
+
+ ); + } + + if (error || !feedbackData) { + return ( + +
+
+

{error || '피드백 데이터를 불러올 수 없습니다.'}

+ +
+
+
+ ); + } + + const { feedbacks, totalQuestions, timeoutCount } = feedbackData; + return ( - {/* 중앙 컨텐츠 영역 */}
- {/* 상단 정보 */}

- 2025년_3월_자소서에 대한 최종 피드백 + 총 {totalQuestions}문항에 대한 최종 피드백

-

- 시간 초과로 답변하지 못한 질문 n개 -

+ {timeoutCount > 0 && ( +

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

+ )}
- {/* 질문 카드 그리드 */}
- {feedbacks.map((item) => { - const isShowingAnswer = questionStates.find((q) => q.id === item.id)?.showAnswer; + {feedbacks.map((item, index: number) => { + 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; return ( -
- {/* 카드 헤더 */} +

- {item.id}. {item.title} + {index + 1}. {item.question}

-

AI 피드백 | {item.type}

+

{feedbackTypeLabel}

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

{isShowingAnswer ? item.answer : item.feedback}

+ {isShowingAnswer ? ( + hasAnswer ? ( +
+
+

답변:

+

{item.answer}

+
+
+ ) : ( +

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

+ ) + ) : item.feedback ? ( +

{item.feedback}

+ ) : ( +

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

+ )}
- {/* 버튼 */}
); diff --git a/src/pages/Interview/main_answer.tsx b/src/pages/Interview/main_answer.tsx index 9284113..9ca8d29 100644 --- a/src/pages/Interview/main_answer.tsx +++ b/src/pages/Interview/main_answer.tsx @@ -1,63 +1,113 @@ -import { useEffect, useRef, useState } from 'react'; +// src/pages/Interview/main_answer.tsx +import { useCallback, useEffect, useRef, useState } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; import InterviewLayout from '@/layouts/InterviewLayout'; - -interface IQuestion { - id: number; - main: string; - sub: string; -} +import type { IQuestion } from '@/services/interviewApi'; +import { sendTimeout, uploadRecordingAndGetNext } from '@/services/interviewApi'; export default function AnswerQuestion() { const navigate = useNavigate(); - const location = useLocation(); - const { fileName = '2025년_3월_자소서' } = location.state || {}; + const location = useLocation() as { + state?: { + fileName?: string; + jobTitle?: string; + interviewType?: 'normal' | 'pressure'; + resumeKey?: string; + sessionId?: string; + firstQuestion?: IQuestion; + fromLoading?: boolean; + }; + }; + + const { fileName = '자소서', jobTitle, interviewType = 'normal', resumeKey, sessionId, firstQuestion } = location.state || {}; - const [currentQuestion, setCurrentQuestion] = useState(1); + /** ---------------- 상태 ---------------- */ + const [currentQuestion, setCurrentQuestion] = useState(firstQuestion ?? null); const [showCompleteModal, setShowCompleteModal] = useState(false); + // 탭(질문{order}) – 서버에서 오는 Question.order를 기반으로 생성/유지 + const [ordersSeen, setOrdersSeen] = useState(firstQuestion ? [firstQuestion.order] : []); + // 녹음 관련 상태 const [isRecording, setIsRecording] = useState(false); const [isPaused, setIsPaused] = useState(false); - const [recordedAudio, setRecordedAudio] = useState(null); + const [recordedAudioUrl, setRecordedAudioUrl] = useState(null); const [recordingTime, setRecordingTime] = useState(0); - const [remainingTime, setRemainingTime] = useState(180); // 3분 = 180초 + const [remainingTime, setRemainingTime] = useState(180); const [retryCount, setRetryCount] = useState(1); + const [isSubmitting, setIsSubmitting] = useState(false); const mediaRecorderRef = useRef(null); const audioChunksRef = useRef([]); + const latestAudioBlobRef = useRef(null); const timerRef = useRef(null); - // 재생 관련 상태/참조 (실시간 진행 상황) + // 재생 const audioRef = useRef(null); const [isPlaying, setIsPlaying] = useState(false); const [playbackTime, setPlaybackTime] = useState(0); const [playbackDuration, setPlaybackDuration] = useState(0); - const questions: IQuestion[] = [ - { id: 1, main: '메인질문', sub: '간단히 자기소개를 해주세요.' }, - { id: 2, main: '메인질문', sub: '이 직무를 선택한 이유는 무엇인가요?' }, - { id: 3, main: '메인질문', sub: '본인의 강점은 무엇이라고 생각하나요?' }, - { id: 4, main: '메인질문', sub: '입사 후 목표는 무엇인가요?' }, - ]; + /** ---------------- 초기 유효성 ---------------- */ + useEffect(() => { + if (!firstQuestion) { + // question_loading에서 세션 생성 후 오도록 설계됨 + navigate('/question-loading', { + replace: true, + state: { fileName, jobTitle, interviewType, resumeKey }, + }); + } + // 의도적으로 최초 마운트 시에만 검증 + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); - // 녹음 중지 - const stopRecording = () => { + /** ---------------- 공통 초기화 함수(호출 위치보다 위 선언) ---------------- */ + 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(180); + setRetryCount(1); + } + + /** ---------------- 녹음 중지 / 타임아웃 핸들러 (deps 안전) ---------------- */ + const stopRecording = useCallback(() => { if (mediaRecorderRef.current && isRecording) { try { mediaRecorderRef.current.stop(); } catch { - setIsRecording(false); - setIsPaused(false); - } - if (timerRef.current) { - clearInterval(timerRef.current); - timerRef.current = null; + /* noop */ } + setIsRecording(false); + setIsPaused(false); } - }; - // 타이머 시작/정지 + if (timerRef.current) { + clearInterval(timerRef.current); + timerRef.current = null; + } + }, [isRecording]); + + const handleTimeout = useCallback(async (questionId: string) => { + try { + await sendTimeout(questionId); + // 현재 스펙에선 sendTimeout이 다음 질문을 주지 않으므로 종료 처리 + resetForNext(); + setShowCompleteModal(true); + } catch (e) { + console.error('시간초과 처리 실패:', e); + alert('시간초과 처리에 실패했습니다.'); + } + }, []); + + /** ---------------- 타이머 ---------------- */ useEffect(() => { if (isRecording && !isPaused) { timerRef.current = window.setInterval(() => { @@ -65,6 +115,9 @@ export default function AnswerQuestion() { setRemainingTime((prev) => { if (prev <= 1) { stopRecording(); + if (currentQuestion?.questionId) { + void handleTimeout(currentQuestion.questionId); + } return 0; } return prev - 1; @@ -81,15 +134,16 @@ export default function AnswerQuestion() { timerRef.current = null; } }; - }, [isRecording, isPaused]); + }, [isRecording, isPaused, currentQuestion?.questionId, stopRecording, handleTimeout]); - // 녹음 시작 + /** ---------------- 녹음 제어 ---------------- */ 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) { @@ -100,13 +154,14 @@ export default function AnswerQuestion() { mediaRecorder.onstop = () => { const mimeType = mediaRecorder.mimeType || 'audio/webm'; const audioBlob = new Blob(audioChunksRef.current, { type: mimeType }); + latestAudioBlobRef.current = audioBlob; + const audioUrl = URL.createObjectURL(audioBlob); - setRecordedAudio(audioUrl); + setRecordedAudioUrl(audioUrl); // 재생 상태 초기화 setIsPlaying(false); setPlaybackTime(0); - // duration은 loadedmetadata에서 확정 // 스트림 정리 stream.getTracks().forEach((track) => track.stop()); @@ -115,9 +170,9 @@ export default function AnswerQuestion() { mediaRecorder.start(); setIsRecording(true); setIsPaused(false); - setRecordedAudio(null); + setRecordedAudioUrl(null); - // 녹음 타이머 초기화 + // 타이머 초기화 setRecordingTime(0); setRemainingTime(180); } catch (error) { @@ -126,10 +181,8 @@ export default function AnswerQuestion() { } }; - // 녹음 일시정지/재개 const togglePause = () => { if (!mediaRecorderRef.current) return; - if (isPaused) { mediaRecorderRef.current.resume(); setIsPaused(false); @@ -139,7 +192,6 @@ export default function AnswerQuestion() { } }; - // 재녹음 const handleRetry = () => { if (retryCount > 0) { // 재생 중이면 멈춤 @@ -151,18 +203,17 @@ export default function AnswerQuestion() { setPlaybackTime(0); setPlaybackDuration(0); - setRecordedAudio(null); + setRecordedAudioUrl(null); setRecordingTime(0); setRemainingTime(180); - setRetryCount(retryCount - 1); - startRecording(); + setRetryCount((c) => c - 1); + void startRecording(); } }; - // 오디오 재생/정지 (실시간 진행 반영) + /** ---------------- 재생 제어 ---------------- */ const toggleAudioPlayback = () => { if (!audioRef.current) return; - if (audioRef.current.paused) { audioRef.current .play() @@ -174,7 +225,6 @@ export default function AnswerQuestion() { } }; - // 오디오 이벤트 연결 (진행바/시간 갱신) useEffect(() => { const audio = audioRef.current; if (!audio) return; @@ -182,22 +232,14 @@ export default function AnswerQuestion() { 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 handleTimeUpdate = () => setPlaybackTime(Math.floor(audio.currentTime || 0)); const handlePlay = () => setIsPlaying(true); const handlePause = () => setIsPlaying(false); const handleEnded = () => { setIsPlaying(false); - // 재생이 끝나면 진행바를 끝까지 유지했다가 0으로 초기화할지 선택 가능 - // 여기서는 끝난 뒤 0으로 초기화 setPlaybackTime(0); - // audio.currentTime = 0; // 필요하면 주석 해제 }; audio.addEventListener('loadedmetadata', handleLoadedMetadata); @@ -206,7 +248,6 @@ export default function AnswerQuestion() { audio.addEventListener('pause', handlePause); audio.addEventListener('ended', handleEnded); - // 이미 메타데이터가 로드된 경우 대비 if (audio.readyState >= 1) handleLoadedMetadata(); return () => { @@ -216,61 +257,53 @@ export default function AnswerQuestion() { audio.removeEventListener('pause', handlePause); audio.removeEventListener('ended', handleEnded); }; - // recordedAudio가 바뀔 때마다 새로 연결 - }, [recordedAudio, recordingTime]); + }, [recordedAudioUrl, recordingTime]); - // 다음 질문 - const handleNext = () => { - if (!recordedAudio) { - alert('답변을 녹음해주세요.'); - return; - } - - if (currentQuestion < 4) { - // 재생 중이면 멈춤 - if (audioRef.current) { - audioRef.current.pause(); - audioRef.current.currentTime = 0; - } - setIsPlaying(false); - setPlaybackTime(0); - setPlaybackDuration(0); - - setCurrentQuestion(currentQuestion + 1); - // 다음 질문으로 넘어갈 때 상태 초기화 - setRecordedAudio(null); - setRecordingTime(0); - setRemainingTime(180); - setRetryCount(1); - } else { + /** ---------------- 다음 질문 ---------------- */ + const applyNext = (next: IQuestion | null) => { + if (!next) { setShowCompleteModal(true); + return; } + setCurrentQuestion(next); + setOrdersSeen((prev) => (prev.includes(next.order) ? prev : [...prev, next.order].sort((a, b) => a - b))); }; - const handleQuestionClick = (questionNum: number) => { - // 질문 이동 시 녹음/재생 상태 정리 - stopRecording(); - if (audioRef.current) { - audioRef.current.pause(); - audioRef.current.currentTime = 0; + const handleNext = async () => { + if (!currentQuestion?.questionId) return; + if (!latestAudioBlobRef.current) { + alert('답변을 녹음해주세요.'); + return; } - setIsPlaying(false); - setPlaybackTime(0); - setPlaybackDuration(0); - - setRecordedAudio(null); - setRecordingTime(0); - setRemainingTime(180); - setRetryCount(1); - setCurrentQuestion(questionNum); + setIsSubmitting(true); + try { + const next = await uploadRecordingAndGetNext(currentQuestion.questionId, latestAudioBlobRef.current); + resetForNext(); + applyNext(next); + } catch (e) { + console.error('다음 질문 처리 실패:', e); + alert('녹음 처리에 실패했습니다. 잠시 후 다시 시도해주세요.'); + } finally { + setIsSubmitting(false); + } }; + /** ---------------- 탭 이동(표시용) ---------------- */ + const handleOrderTabClick = useCallback(() => { + // 서버가 특정 order의 과거 질문을 다시 불러오는 API를 제공하지 않음. + // 탭은 "표시용"으로 유지. + }, []); + + /** ---------------- 기타 ---------------- */ const handleFinalFeedback = () => { - navigate('/feedback-result'); + if (sessionId) { + navigate('/feedback-result', { state: { sessionId } }); + } else { + navigate('/feedback-result'); + } }; - // 시간 포맷팅 (초 -> MM:SS) const formatTime = (seconds: number) => { const s = Math.max(0, Math.floor(seconds || 0)); const mins = Math.floor(s / 60); @@ -289,32 +322,31 @@ export default function AnswerQuestion() {

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

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

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

+

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

- {currentQuestion}-1. {questions[currentQuestion - 1].sub} + {currentQuestion?.mainQuestion} + {currentQuestion?.subQuestion ? ` — ${currentQuestion.subQuestion}` : ''}

{/* 캐릭터 이미지 */} @@ -335,7 +367,7 @@ export default function AnswerQuestion() {
{/* 녹음 컨트롤 */} - {!recordedAudio ? ( + {!recordedAudioUrl ? (
{!isRecording ? ( @@ -380,7 +412,7 @@ export default function AnswerQuestion() {
) : ( - // 재생 UI (디자인 동일, 진행바/시간만 실시간 반영) + // 재생 UI
- {/* 우측 시간: 현재/전체 */} {formatTime(playbackTime)} / {formatTime(playbackDuration)}
-
)} - {/* 다시 녹음하기 버튼 */} - {recordedAudio && ( + {/* 다시 녹음하기 */} + {recordedAudioUrl && (
@@ -464,33 +493,15 @@ export default function AnswerQuestion() { )} ); diff --git a/src/pages/Interview/question_loading.tsx b/src/pages/Interview/question_loading.tsx index e480ecf..b19f35c 100644 --- a/src/pages/Interview/question_loading.tsx +++ b/src/pages/Interview/question_loading.tsx @@ -1,19 +1,66 @@ -import { useEffect } from 'react'; -import { useNavigate } from 'react-router-dom'; +import { useEffect } 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'; // 프로젝트 라우트에 맞게 조정 + +type TLocationState = { + fileName?: string; + jobTitle?: string; + interviewType?: 'normal' | 'pressure'; + resumeKey?: string; // S3 key +}; export default function QuestionLoading() { const navigate = useNavigate(); + const location = useLocation() as { state?: TLocationState }; useEffect(() => { - // 3초 후 자동으로 다음 페이지로 이동 - const timer = setTimeout(() => { - navigate('/question-done'); - }, 3000); + const bootstrap = async () => { + try { + const fileName = location.state?.fileName ?? '자소서'; + const jobTitle = location.state?.jobTitle; + const interviewType = location.state?.interviewType ?? 'normal'; + const resumeKey = location.state?.resumeKey; + + if (!jobTitle || !resumeKey) { + alert('면접 생성에 필요한 정보가 없습니다. (jobTitle/resumeKey)'); + navigate(-1); + return; + } + + // 스펙에 맞게 요청 + const resp: ICreateInterviewSessionResponse = await createInterviewSession({ + resumeKey, + jobTitle, + interviewType, + }); - return () => clearTimeout(timer); - }, [navigate]); + // 성공 → 답변 페이지로 이동 (필요값 전달) + navigate(ANSWER_ROUTE, { + replace: true, + state: { + fileName, + jobTitle, + interviewType, + resumeKey, + sessionId: resp.sessionId, + firstQuestion: resp.firstQuestion, + fromLoading: true, + }, + }); + } catch (e) { + console.error('질문 생성 실패:', e); + alert('맞춤형 질문 생성에 실패했습니다. 잠시 후 다시 시도해주세요.'); + navigate(-1); + } + }; + + void bootstrap(); + }, [location.state, navigate]); return ( @@ -35,20 +82,9 @@ export default function QuestionLoading() {
); diff --git a/src/pages/Interview/upload.tsx b/src/pages/Interview/upload.tsx index fc40f03..6f9baa5 100644 --- a/src/pages/Interview/upload.tsx +++ b/src/pages/Interview/upload.tsx @@ -5,7 +5,7 @@ import { Upload } from 'lucide-react'; import InterviewLayout from '@/layouts/InterviewLayout'; import { uploadResume } from '@/services/interviewApi'; -const ALLOWED_EXTENSIONS = ['.pdf', '.doc', '.docx', '.txt']; +const ALLOWED_EXTENSIONS = ['.pdf', '.docx']; const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB export default function MyInterview() { @@ -22,7 +22,7 @@ export default function MyInterview() { const extension = '.' + (targetFile.name.split('.').pop() ?? '').toLowerCase(); if (!ALLOWED_EXTENSIONS.includes(extension)) { - setError('PDF, DOC, DOCX, TXT 파일만 업로드 가능합니다.'); + setError('PDF, DOCX 파일만 업로드 가능합니다.'); return false; } @@ -131,7 +131,7 @@ export default function MyInterview() {
{isUploading ? '업로드 중...' : fileName} - +
diff --git a/src/pages/Interview/upload_check.tsx b/src/pages/Interview/upload_check.tsx index 0bb4a44..88e1659 100644 --- a/src/pages/Interview/upload_check.tsx +++ b/src/pages/Interview/upload_check.tsx @@ -7,6 +7,7 @@ export default function UploadCheck() { const navigate = useNavigate(); const location = useLocation(); const file = location.state?.file as File | undefined; + const resumeKey = location.state?.resumeKey as string | undefined; // resumeKey 추가 const [interviewType, setInterviewType] = useState<'normal' | 'pressure'>('normal'); const [showJobCard, setShowJobCard] = useState(false); @@ -14,10 +15,10 @@ export default function UploadCheck() { // 파일이 없으면 업로드 페이지로 리다이렉트 useEffect(() => { - if (!file) { + if (!file || !resumeKey) { navigate('/upload', { replace: true }); } - }, [file, navigate]); + }, [file, resumeKey, navigate]); const handleJobSelect = () => { setShowJobCard(true); @@ -32,7 +33,7 @@ export default function UploadCheck() { const handleStartInterview = () => { navigate('/question-loading', { state: { - file, + resumeKey, // resumeKey 전달 (중요!) jobTitle, interviewType, fileName: file?.name || '', @@ -40,7 +41,7 @@ export default function UploadCheck() { }); }; - if (!file) return null; + if (!file || !resumeKey) return null; return ( diff --git a/src/pages/Interview/upload_done.tsx b/src/pages/Interview/upload_done.tsx index 2c2c695..98b1442 100644 --- a/src/pages/Interview/upload_done.tsx +++ b/src/pages/Interview/upload_done.tsx @@ -7,20 +7,26 @@ export default function UploadDone() { const navigate = useNavigate(); const location = useLocation(); const file = location.state?.file as File | undefined; + const resumeKey = location.state?.resumeKey as string | undefined; // resumeKey 추가 - // 파일이 없으면 업로드 페이지로 리다이렉트 + // 파일이나 resumeKey가 없으면 업로드 페이지로 리다이렉트 useEffect(() => { - if (!file) { + if (!file || !resumeKey) { navigate('/upload', { replace: true }); } - }, [file, navigate]); + }, [file, resumeKey, navigate]); const handleConfirm = () => { - // 파일 정보를 upload-check 페이지로 전달 - navigate('/upload-check', { state: { file } }); + // 파일 정보와 resumeKey를 upload-check 페이지로 전달 + navigate('/upload-check', { + state: { + file, + resumeKey, // resumeKey 전달 + }, + }); }; - if (!file) return null; + if (!file || !resumeKey) return null; return ( diff --git a/src/pages/communityDetail.tsx b/src/pages/communityDetail.tsx index 3fb875c..d9860d0 100644 --- a/src/pages/communityDetail.tsx +++ b/src/pages/communityDetail.tsx @@ -2,7 +2,7 @@ import { useNavigate, useParams } from 'react-router-dom'; import { formatUpdatedAt } from '@/utils/time'; -import usegetCommunityDetail from '@/hooks/useGetCommunityDetail'; +import useGetCommunityDetail from '@/hooks/useGetCommunityDetail'; import type { TValues } from '@/components/community/writeForm'; import WriteForm from '@/components/community/writeForm'; @@ -12,7 +12,7 @@ import { route } from '@/routes/route'; export default function CommunityDetail() { const navigate = useNavigate(); const { id } = useParams<{ id: string }>(); - const { data } = usegetCommunityDetail({ pageId: Number(id) }); + const { data } = useGetCommunityDetail({ pageId: Number(id) }); const values: Partial = data ?? {}; return ( diff --git a/src/services/interviewApi.ts b/src/services/interviewApi.ts index f33dc80..b256161 100644 --- a/src/services/interviewApi.ts +++ b/src/services/interviewApi.ts @@ -1,25 +1,120 @@ import apiClient from './api'; -// ==================== 타입 정의 ==================== +// ===================================================== +// 공통: BE 응답(result 래핑 유무 모두 대응) +// ===================================================== +const unwrapResult = (data: any): T => { + if (data && typeof data === 'object' && 'result' in data) { + return (data as { result: T }).result; + } + return data as T; +}; -export interface IPresignUrlResponse { - presignedUrl: string; - fileKey: string; -} +// ===================================================== +// 1. 자소서 업로드용 Presigned URL & 업로드 +// ===================================================== -// 자소서 업로드용 presign 응답 타입 +/** 자소서 업로드용 Presigned URL 응답 (POST /api/presign/resume) */ export interface IResumePresignResponse { uploadUrl: string; key: string; - requiredHeaders: Record; + requiredHeaders: { [header: string]: string }; } +/** 자소서 업로드용 프리사인 URL 발급 */ +export const getResumePresignUrl = async (fileName: string): Promise => { + 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 => { + const baseHeaders: Record = {}; + + // Blob에 type이 있으면 기본 Content-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에서 내려준 헤더가 우선 + }, + }); + + 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; +}; + +/** 자소서 업로드 전체 플로우: 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 { uploadUrl, key, requiredHeaders } = await getResumePresignUrl(file.name); + + await uploadToS3(uploadUrl, file, requiredHeaders); + + return key; + } catch (error) { + console.error('❌ 자소서 업로드 실패:', error); + throw error; + } +}; + +// ===================================================== +// 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; - interviewType: 'normal' | 'pressure'; + /** 'normal' | 'pressure' → NORMAL | HARD 로 매핑 */ + interviewType: TInterviewType; +} + +/** BE에 실제로 보내는 payload (mode / jobRole / resumeId) */ +interface ICreateInterviewSessionPayload { + mode: TInterviewMode; + jobRole: string; + resumeId: string; +} + +/** BE 응답 원본 타입 (result 내부) */ +interface ICreateInterviewSessionApiResponse { + sessionId: number; + firstQuestionId: number; + firstQuestionText: string; } +/** 프론트에서 쓰기 좋은 형태 */ export interface IQuestion { questionId: string; mainQuestion: string; @@ -27,207 +122,269 @@ export interface IQuestion { order: number; } +/** 프론트에서 최종으로 받는 응답 타입 */ export interface ICreateInterviewSessionResponse { sessionId: string; firstQuestion: IQuestion; } -export interface ISaveRecordingRequest { - recordingKey: string; -} +/** 자소서 기반 질문 생성 및 첫번째 질문 조회 */ +export const createInterviewSession = async (data: ICreateInterviewSessionRequest): Promise => { + const resumeId = extractResumeId(data.resumeKey); + const mode: TInterviewMode = data.interviewType === 'pressure' ? 'HARD' : 'NORMAL'; + + const payload: ICreateInterviewSessionPayload = { + mode, + jobRole: data.jobTitle, + resumeId, + }; + + const response = await apiClient.post('/api/interview-sessions', payload); + const apiResult = unwrapResult(response.data); + + const firstQuestion: IQuestion = { + questionId: String(apiResult.firstQuestionId), + mainQuestion: apiResult.firstQuestionText, + subQuestion: '', + order: 1, + }; + + return { + sessionId: String(apiResult.sessionId), + firstQuestion, + }; +}; -export interface ISaveRecordingResponse { - recordingId: string; - status: 'processing' | 'completed' | 'failed'; - nextQuestion?: IQuestion; -} +// ===================================================== +// 3. 녹음 업로드 Presign URL (POST /api/presign/recording) +// ===================================================== -export interface IRecordingResultResponse { - status: 'processing' | 'completed' | 'failed'; - nextQuestion?: IQuestion; - feedback?: string; +/** 녹음 Presign 요청 */ +interface IRecordingPresignRequest { + questionId: number; + contentType: string; } -export interface IFeedbackItem { - questionId: string; - question: string; - answer: string; - feedback: string; - feedbackType: 'positive' | 'negative'; - timeout: boolean; -} - -export interface IFinalFeedbackResponse { - sessionId: string; - feedbacks: IFeedbackItem[]; - totalQuestions: number; - timeoutCount: number; +/** 녹음 Presign 응답 */ +export interface IRecordingPresignResponse { + uploadUrl: string; + key: string; + requiredHeaders: Record; } -// ==================== API 함수들 ==================== +/** 녹음 업로드용 프리사인 URL 발급 */ +export const getRecordingPresignUrl = async (questionId: number, contentType: string): Promise => { + const payload: IRecordingPresignRequest = { questionId, contentType }; -/** - * 1. 자소서 업로드용 프리사인 URL 발급 - */ -export const getResumePresignUrl = async (fileName: string): Promise => { - const response = await apiClient.post('/api/presign/resume', { fileName }); - return response.data; + const response = await apiClient.post('/api/presign/recording', payload); + return unwrapResult(response.data); }; -/** - * 2. 녹음 업로드용 프리사인 URL 발급 - */ -export const getRecordingPresignUrl = async (fileName: string): Promise => { - const response = await apiClient.post('/api/presign/recording', { fileName }); - return response.data; -}; +// ===================================================== +// 4. recording 저장 (비동기 트리거) & Polling 타입들 +// ===================================================== -/** - * 3. S3에 파일 업로드 (프리사인 URL 사용) - */ -export const uploadToS3 = async (presignedUrl: string, file: File | Blob): Promise => { - await fetch(presignedUrl, { - method: 'PUT', - body: file, - headers: { - 'Content-Type': file.type || 'application/octet-stream', - }, - }); -}; +/** 녹음 제출 API 응답 status (현재 스펙상 UPLOADED 고정) */ +export type TRecordingEnqueueStatus = 'UPLOADED'; -/** - * 4. 자소서 기반 질문 생성 및 첫번째 질문 조회 - */ -export const createInterviewSession = async (data: ICreateInterviewSessionRequest): Promise => { - const response = await apiClient.post('/api/interview-sessions', data); - return response.data; -}; +/** 녹음 저장 응답 (POST /api/questions/{questionId}/recordings) */ +export interface ISaveRecordingResponse { + recordingId: number; + status: TRecordingEnqueueStatus; +} -/** - * 5. recording 저장 및 프리질문 생성 (비동기) - */ -export const saveRecording = async (questionId: string, data: ISaveRecordingRequest): Promise => { - const response = await apiClient.post(`/api/questions/${questionId}/recordings`, data); - return response.data; +/** 녹음 저장 및 꼬리질문 생성 API (비동기, 바로 응답) */ +export const saveRecording = async (questionId: number): Promise => { + const response = await apiClient.post(`/api/questions/${questionId}/recordings`); + return unwrapResult(response.data); }; -/** - * 6. recording 저장 및 프리질문 생성 상태 Polling - */ -export const getRecordingResult = async (recordingId: string): Promise => { +/** 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; + nextQuestionText: string | null; + rootId: number | null; + rootText: string | null; + 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 response.data; + return unwrapResult(response.data); }; -/** - * 7. Polling 헬퍼 함수 (자동으로 상태 확인) - */ -export const pollRecordingResult = async ( - recordingId: string, - maxAttempts: number = 60, // 최대 60번 (5분) - interval: number = 5000, // 5초마다 -): Promise => { +/** next 객체 → 프론트에서 쓰는 IQuestion 로 매핑 */ +const mapNextToQuestion = (next: IRecordingResultNext | null): IQuestion | 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 ?? '') // 꼬리질문 텍스트 + : '', + order: next.rootIndex ?? 0, + }; +}; + +/** Polling 헬퍼: READY/FAILED 될 때까지 반복 조회 */ +export const pollRecordingResult = async (recordingId: number, maxAttempts: number = 60, intervalMs: number = 3000): Promise => { let attempts = 0; while (attempts < maxAttempts) { const result = await getRecordingResult(recordingId); - if (result.status === 'completed' || result.status === 'failed') { + if (result.status === 'READY' || result.status === 'FAILED') { return result; } - // processing 상태면 대기 후 재시도 - await new Promise((resolve) => setTimeout(resolve, interval)); - attempts++; + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + attempts += 1; } - throw new Error('Polling timeout - 처리 시간이 너무 오래 걸립니다.'); + throw new Error('Polling timeout - 녹음 처리 대기 시간이 너무 깁니다.'); }; -/** - * 8. 사용자가 시간초과로 답변하지 못한 경우 - */ -export const sendTimeout = async (questionId: string): Promise => { - await apiClient.post(`/api/questions/${questionId}/timeout`); -}; +// ===================================================== +// 5. 녹음 파일 업로드 & 다음 질문 받기 전체 플로우 +// ===================================================== /** - * 9. 최종 피드백 조회 + * 녹음 파일 업로드 + recording 저장 + polling 후 + * 다음 질문(IQuestion) 혹은 null(세션 종료) 반환 */ -export const getFinalFeedback = async (sessionId: string): Promise => { - const response = await apiClient.get(`/api/interview-sessions/${sessionId}`); - return response.data; -}; +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 입니다.'); + } -/** - * 자소서 업로드 전체 플로우 - */ -export const uploadResume = async (file: File): Promise => { - try { - // 1단계: Presigned URL 받기 - console.log('🚀 1단계 - Presigned URL 요청:', file.name); + const contentType = (audioBlob as any).type || 'audio/webm'; - const presignResponse = await apiClient.post('/api/presign/resume', { - fileName: file.name, - }); + // 1) Presign 발급 + const { uploadUrl, requiredHeaders } = await getRecordingPresignUrl(numericQuestionId, contentType); - console.log('✅ Presigned URL 발급 성공'); + // 2) S3 업로드 + await uploadToS3(uploadUrl, audioBlob, requiredHeaders); - const { uploadUrl, key, requiredHeaders } = presignResponse.data; + // 3) 녹음 저장 요청 (비동기 작업 트리거) + const { recordingId } = await saveRecording(numericQuestionId); - // 2단계: S3에 실제 파일 업로드 (PUT) - console.log('📤 2단계 - S3 업로드 시작'); + // 4) Polling 으로 꼬리질문/다음 루트질문 생성 완료까지 대기 + const result = await pollRecordingResult(recordingId); - const uploadResponse = await fetch(uploadUrl, { - method: 'PUT', - headers: { - ...requiredHeaders, // Content-Type 등 필수 헤더 - }, - body: file, - }); + if (result.status === 'FAILED') { + throw new Error('녹음 처리 중 오류가 발생했습니다.'); + } - if (!uploadResponse.ok) { - throw new Error(`S3 업로드 실패: ${uploadResponse.status}`); - } + // 5) next 객체 → IQuestion 으로 변환 (없으면 null) + return mapNextToQuestion(result.next); +}; - console.log('✅ S3 업로드 완료! Key:', key); +// ===================================================== +// 6. 시간초과 시 Timeout API +// ===================================================== - return key; - } catch (error) { - console.error('❌ 자소서 업로드 실패:', error); - throw error; - } +/** Timeout API 응답 (status는 항상 READY) */ +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 → 더 이상 질문 없음 (최종 피드백 조회) */ -export const uploadRecordingAndGetNext = async (questionId: string, audioBlob: Blob): Promise => { - // 1. 프리사인 URL 받기 - const fileName = `recording-${questionId}-${Date.now()}.webm`; - const { presignedUrl, fileKey } = await getRecordingPresignUrl(fileName); +export const timeoutAndGetNextQuestion = async (questionId: string | number): Promise => { + const result = await sendTimeout(questionId); + + if (!result.next || result.next.type === 'NONE') { + return null; + } - // 2. S3에 업로드 - await uploadToS3(presignedUrl, audioBlob); + return mapNextToQuestion(result.next); +}; - // 3. 녹음 저장 및 처리 시작 - const { recordingId, status, nextQuestion } = await saveRecording(questionId, { - recordingKey: fileKey, - }); +// ===================================================== +// 7. 최종 피드백 조회 API +// ===================================================== - // 4. 즉시 완료된 경우 - if (status === 'completed') { - return nextQuestion || null; - } +/** 피드백 생성 진행 상태 */ +export type TFeedbackProgressStatus = 'WORKING' | 'READY' | 'FAILED'; - // 5. 처리 중이면 polling - const result = await pollRecordingResult(recordingId); +/** QnA 턴 타입 */ +export type TFeedbackTurnType = 'QUESTION' | 'ANSWER'; - if (result.status === 'failed') { - throw new Error('녹음 처리에 실패했습니다.'); - } +/** 한 턴 (질문 / 답변) */ +export interface IQnaTurn { + turn: TFeedbackTurnType; + content: string; +} + +/** 한 루트 질문에 대한 요약 정보 */ +export interface IQuestionSummary { + questionNumber: number; + rootQuestion: string; + aiFeedback: string | null; + selfFeedback: string | null; + qnaTurns: IQnaTurn[]; +} + +/** 인터뷰 전체 요약 */ +export interface IInterviewSummary { + interviewTitle: string; + timeoutQuestionNumber: number; + questionSummaries: IQuestionSummary[]; +} + +/** 최종 피드백 조회 응답 */ +export interface IFinalFeedbackResponse { + feedbackProgressStatus: TFeedbackProgressStatus; + interviewSummary: IInterviewSummary | null; // WORKING일 때는 null + feedbacks: { + feedbackType: 'positive' | 'improvement'; + answer: string; + timeout: boolean; + feedback: string; + questionId: number; + question: string; + }[]; + totalQuestions: number; + timeoutCount: number; +} - return result.nextQuestion || null; +/** 최종 피드백 조회 (GET /api/interview-sesisons/{sessionId}) */ +export const getFinalFeedback = async (sessionId: string | number): Promise => { + const response = await apiClient.get(`/api/interview-sesisons/${sessionId}`); + return unwrapResult(response.data); };