From b2722878c92cd61f11b7b5188082ff82e8f4e2fe Mon Sep 17 00:00:00 2001 From: Hello-Worldismine Date: Sun, 16 Nov 2025 20:24:25 +0900 Subject: [PATCH 01/12] =?UTF-8?q?=ED=99=95=EC=9E=A5=EC=9E=90=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/Interview/upload.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/pages/Interview/upload.tsx b/src/pages/Interview/upload.tsx index b13477e..d53f0cc 100644 --- a/src/pages/Interview/upload.tsx +++ b/src/pages/Interview/upload.tsx @@ -4,7 +4,7 @@ import { useNavigate } from 'react-router-dom'; 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() { @@ -21,7 +21,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; } @@ -130,7 +130,7 @@ export default function MyInterview() {
{isUploading ? '업로드 중...' : fileName} - +
From 435ae9e6161e6eb3e23d013a86715dad86045595 Mon Sep 17 00:00:00 2001 From: Hello-Worldismine Date: Sun, 16 Nov 2025 21:08:39 +0900 Subject: [PATCH 02/12] =?UTF-8?q?requestparam=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/services/interviewApi.ts | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/src/services/interviewApi.ts b/src/services/interviewApi.ts index ab4a24f..c5b10e7 100644 --- a/src/services/interviewApi.ts +++ b/src/services/interviewApi.ts @@ -189,14 +189,23 @@ export const getFinalFeedback = async ( */ export const uploadResume = async (file: File): Promise => { try { - // 1단계: Presigned URL 받기 - console.log('🚀 1단계 - Presigned URL 요청:', file.name); + // 파일명 유효성 검사 + const extension = file.name.split('.').pop()?.toLowerCase(); + if (!extension || !['pdf', 'docx'].includes(extension)) { + throw new Error('PDF 또는 DOCX 파일만 업로드 가능합니다.'); + } + // 1단계: Presigned URL 받기 (Query Parameter 방식으로 변경!) + console.log('🚀 1단계 - Presigned URL 요청:', file.name); + const presignResponse = await apiClient.post( '/api/presign/resume', + null, // ← body는 null { - fileName: file.name, - }, + params: { // ← params 옵션으로 Query Parameter 전달 + fileName: file.name + } + } ); console.log('✅ Presigned URL 발급 성공'); @@ -209,7 +218,7 @@ export const uploadResume = async (file: File): Promise => { const uploadResponse = await fetch(uploadUrl, { method: 'PUT', headers: { - ...requiredHeaders, // Content-Type 등 필수 헤더 + ...requiredHeaders, }, body: file, }); From 80be9d1eb5657df97c8127301967d44264b98229 Mon Sep 17 00:00:00 2001 From: Hello-Worldismine Date: Sun, 16 Nov 2025 21:57:37 +0900 Subject: [PATCH 03/12] =?UTF-8?q?=EB=82=98=EC=9D=98=EB=A9=B4=EC=A0=91=20ap?= =?UTF-8?q?i=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/Interview/feedback_result.tsx | 238 ++++++++--- src/pages/Interview/main_answer.tsx | 371 ++++++++++++----- src/pages/Interview/question_loading.tsx | 97 ++++- src/pages/Interview/upload_check.tsx | 9 +- src/pages/Interview/upload_done.tsx | 18 +- src/services/interviewApi.ts | 483 +++++++++++++++-------- 6 files changed, 870 insertions(+), 346 deletions(-) diff --git a/src/pages/Interview/feedback_result.tsx b/src/pages/Interview/feedback_result.tsx index 6b9a45b..f94a57b 100644 --- a/src/pages/Interview/feedback_result.tsx +++ b/src/pages/Interview/feedback_result.tsx @@ -1,6 +1,8 @@ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; +import { useLocation, useNavigate } from 'react-router-dom'; import InterviewLayout from '@/layouts/InterviewLayout'; +import { getFinalFeedback, FinalFeedbackResponse } from '@/services/interviewApi'; interface IQuestionState { id: number; @@ -8,53 +10,117 @@ 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 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 navigate = useNavigate(); + const location = useLocation(); + const { sessionId } = location.state || {}; + + 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); + console.log('📊 최종 피드백 조회 시작:', sessionId); + + const response = await getFinalFeedback(sessionId); + + // feedbackProgressStatus 확인 + if (response.feedbackProgressStatus === 'WORKING') { + // 피드백 생성 중 - 재시도 또는 메시지 표시 + setTimeout(fetchFeedback, 5000); // 5초 후 재시도 + return; + } + + if (response.feedbackProgressStatus === 'FAILED') { + setError('피드백 생성에 실패했습니다.'); + return; + } + + if (response.interviewSummary) { + setFeedbackData(response); + + // 질문 상태 초기화 + const states = response.interviewSummary.questionSummaries.map((_, index) => ({ + id: index + 1, + showAnswer: false, + })); + setQuestionStates(states); + } + } catch (err) { + console.error('❌ 피드백 조회 실패:', err); + setError('피드백을 불러오는데 실패했습니다.'); + } finally { + setIsLoading(false); + } + }; + + fetchFeedback(); + }, [sessionId, navigate]); const toggleAnswer = (id: number) => { - setQuestionStates((prev) => prev.map((q) => (q.id === id ? { ...q, showAnswer: !q.showAnswer } : q))); + setQuestionStates((prev) => + prev.map((q) => (q.id === id ? { ...q, showAnswer: !q.showAnswer } : q)) + ); }; + // 로딩 중 + if (isLoading) { + return ( + +
+
+
+ 로딩 +
+

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

+

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

+
+
+
+ ); + } + + // 에러 + if (error || !feedbackData || !feedbackData.interviewSummary) { + return ( + +
+
+

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

+ +
+
+
+ ); + } + + const { interviewSummary } = feedbackData; + return ( {/* 중앙 컨텐츠 영역 */} @@ -62,39 +128,99 @@ export default function FeedbackResult() { {/* 상단 정보 */}

- 2025년_3월_자소서에 대한 최종 피드백 + + {interviewSummary.interviewTitle} + + 에 대한 최종 피드백

-

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

+ {interviewSummary.timeoutQuestionNumber > 0 && ( +

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

+ )}
{/* 질문 카드 그리드 */}
- {feedbacks.map((item) => { - const isShowingAnswer = questionStates.find((q) => q.id === item.id)?.showAnswer; + {interviewSummary.questionSummaries.map((summary, index) => { + const isShowingAnswer = + questionStates.find((q) => q.id === index + 1)?.showAnswer || false; + + // AI 피드백과 셀프 피드백 중 표시할 것 선택 + const feedbackText = summary.aiFeedback || summary.selfFeedback; + const feedbackType = summary.aiFeedback + ? 'AI 피드백' + : summary.selfFeedback + ? '셀프 피드백' + : '피드백 없음'; + + // 답변 텍스트 (Q&A 턴에서 ANSWER만 추출) + const answerText = summary.qnaTurns + .filter((turn) => turn.turn === 'ANSWER') + .map((turn) => turn.content) + .join('\n\n'); + + // 타임아웃으로 답변 못한 질문인지 확인 + const hasAnswer = answerText.length > 0; return ( -
+
{/* 카드 헤더 */}

- {item.id}. {item.title} + {summary.questionNumber}. {summary.rootQuestion}

-

AI 피드백 | {item.type}

+

{feedbackType}

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

{isShowingAnswer ? item.answer : item.feedback}

+ {isShowingAnswer ? ( + hasAnswer ? ( +
+ {summary.qnaTurns.map((turn, turnIndex) => ( +
+

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

+

+ {turn.content} +

+
+ ))} +
+ ) : ( +

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

+ ) + ) : feedbackText ? ( +

+ {feedbackText} +

+ ) : ( +

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

+ )}
{/* 버튼 */}
))}
@@ -309,17 +436,21 @@ export default function AnswerQuestion() { {/* 질문 카드 */}

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

- {currentQuestion}-1. {questions[currentQuestion - 1].sub} + {currentQuestion.questionText}

{/* 캐릭터 이미지 */}
- 면접관 + 면접관
{/* 타이머 & 녹음 컨트롤 */} @@ -327,10 +458,15 @@ export default function AnswerQuestion() { {/* 타이머 */}
-
+

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

@@ -341,9 +477,18 @@ export default function AnswerQuestion() { {!isRecording ? ( - {formatTime(recordingTime)} + + {formatTime(recordingTime)} + @@ -380,21 +539,32 @@ export default function AnswerQuestion() {
) : ( - // 재생 UI (디자인 동일, 진행바/시간만 실시간 반영) + // 재생 UI
@@ -453,10 +631,19 @@ export default function AnswerQuestion() {
- 완료 + 완료
-

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

-
diff --git a/src/pages/Interview/question_loading.tsx b/src/pages/Interview/question_loading.tsx index e480ecf..ab26aae 100644 --- a/src/pages/Interview/question_loading.tsx +++ b/src/pages/Interview/question_loading.tsx @@ -1,32 +1,97 @@ -import { useEffect } from 'react'; -import { useNavigate } from 'react-router-dom'; +import { useEffect, useState } from 'react'; +import { useLocation, useNavigate } from 'react-router-dom'; import InterviewLayout from '@/layouts/InterviewLayout'; +import { createInterviewSession, extractResumeId } from '@/services/interviewApi'; export default function QuestionLoading() { const navigate = useNavigate(); + const location = useLocation(); + + const { + resumeKey, + jobTitle, + interviewType, + } = location.state || {}; + + const [error, setError] = useState(''); useEffect(() => { - // 3초 후 자동으로 다음 페이지로 이동 - const timer = setTimeout(() => { - navigate('/question-done'); - }, 3000); + // 필수 데이터 체크 + if (!resumeKey || !jobTitle || !interviewType) { + navigate('/upload', { replace: true }); + return; + } + + const createSession = async () => { + try { + console.log('🎬 면접 세션 생성 시작'); + console.log('- Resume Key:', resumeKey); + console.log('- Job Title:', jobTitle); + console.log('- Interview Type:', interviewType); + + // S3 key에서 resumeId 추출 + const resumeId = extractResumeId(resumeKey); + console.log('- Resume ID:', resumeId); + + // 면접 세션 생성 + const session = await createInterviewSession({ + mode: interviewType === 'pressure' ? 'HARD' : 'NORMAL', + jobRole: jobTitle, + resumeId: resumeId, + }); - return () => clearTimeout(timer); - }, [navigate]); + console.log('✅ 면접 세션 생성 성공:', session); + + // 면접 페이지로 이동 (약간의 딜레이 후) + setTimeout(() => { + navigate('/main-answer', { + state: { + sessionId: session.sessionId, + firstQuestionId: session.firstQuestionId, + firstQuestionText: session.firstQuestionText, + resumeKey, + jobTitle, + interviewType, + }, + replace: true, + }); + }, 1000); + } catch (err) { + console.error('❌ 면접 세션 생성 실패:', err); + setError('면접 세션 생성에 실패했습니다. 다시 시도해주세요.'); + + // 에러 시 3초 후 업로드 페이지로 이동 + setTimeout(() => { + navigate('/upload', { replace: true }); + }, 3000); + } + }; + + createSession(); + }, [navigate, resumeKey, jobTitle, interviewType]); return ( {/* 중앙 컨텐츠 영역 */}
-

AI가 맞춤형 질문을 생성중입니다 ...

- - {/* 로딩 애니메이션 - 점 3개 */} -
-
-
-
-
+ {error ? ( +
+

{error}

+

업로드 페이지로 돌아갑니다...

+
+ ) : ( + <> +

AI가 맞춤형 질문을 생성중입니다 ...

+ + {/* 로딩 애니메이션 - 점 3개 */} +
+
+
+
+
+ + )}
{/* 오른쪽 캐릭터 이미지 */} 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..d7949e1 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/services/interviewApi.ts b/src/services/interviewApi.ts index c5b10e7..1f15677 100644 --- a/src/services/interviewApi.ts +++ b/src/services/interviewApi.ts @@ -1,161 +1,298 @@ -import apiClient from './api'; +import { apiClient } from './api'; // ==================== 타입 정의 ==================== -export interface PresignUrlResponse { - presignedUrl: string; - fileKey: string; -} - -// 자소서 업로드용 presign 응답 타입 +/** + * 자소서 업로드용 Presigned URL 응답 + */ export interface ResumePresignResponse { uploadUrl: string; key: string; - requiredHeaders: Record; + requiredHeaders: { + 'Content-Type': string; + }; } -export interface CreateInterviewSessionRequest { - resumeKey: string; - jobTitle: string; - interviewType: 'normal' | 'pressure'; +/** + * 녹음 업로드용 Presigned URL 요청 + */ +export interface RecordingPresignRequest { + questionId: number; + contentType: string; // 'audio/webm', 'audio/mpeg', etc. } -export interface Question { - questionId: string; - mainQuestion: string; - subQuestion: string; - order: number; +/** + * 녹음 업로드용 Presigned URL 응답 + */ +export interface RecordingPresignResponse { + uploadUrl: string; + key: string; + requiredHeaders: { + [key: string]: string; + }; } -export interface CreateInterviewSessionResponse { - sessionId: string; - firstQuestion: Question; +/** + * 면접 세션 생성 요청 + */ +export interface CreateInterviewSessionRequest { + mode: 'NORMAL' | 'HARD'; // HARD = 압박면접 + jobRole: string; + resumeId: string; // S3 key에서 추출한 ID } -export interface SaveRecordingRequest { - recordingKey: string; +/** + * 면접 세션 생성 응답 + */ +export interface CreateInterviewSessionResponse { + sessionId: number; + firstQuestionId: number; + firstQuestionText: string; } +/** + * 녹음 저장 응답 (비동기) + */ export interface SaveRecordingResponse { - recordingId: string; - status: 'processing' | 'completed' | 'failed'; - nextQuestion?: Question; + recordingId: number; + status: 'UPLOADED'; // 비동기 작업 큐에 등록됨 +} + +/** + * 다음 질문 객체 + */ +export interface NextQuestion { + type: 'FOLLOW_UP' | 'ROOT' | 'NONE'; + nextQuestionId: number | null; + nextQuestionText: string | null; + rootId: number; + rootText: string; + rootIndex: number; } -export interface RecordingResultResponse { - status: 'processing' | 'completed' | 'failed'; - nextQuestion?: Question; - feedback?: string; +/** + * Polling 결과 응답 + */ +export interface PollingResultResponse { + sessionId: number; + status: 'WORKING' | 'READY' | 'FAILED'; + next: NextQuestion | null; } -export interface FeedbackItem { - questionId: string; - question: string; - answer: string; - feedback: string; - feedbackType: 'positive' | 'negative'; - timeout: boolean; +/** + * Timeout 처리 응답 + */ +export interface TimeoutResponse { + sessionId: number; + status: 'READY'; + next: NextQuestion | null; } -export interface FinalFeedbackResponse { - sessionId: string; - feedbacks: FeedbackItem[]; - totalQuestions: number; - timeoutCount: number; +/** + * Q&A 턴 + */ +export interface QnATurn { + turn: 'QUESTION' | 'ANSWER'; + content: string; } -// ==================== API 함수들 ==================== +/** + * 질문 요약 + */ +export interface QuestionSummary { + questionNumber: number; + rootQuestion: string; + aiFeedback: string | null; + selfFeedback: string | null; + qnaTurns: QnATurn[]; +} /** - * 1. 자소서 업로드용 프리사인 URL 발급 + * 면접 요약 */ -export const getResumePresignUrl = async ( - fileName: string, -): Promise => { - const response = await apiClient.post('/api/presign/resume', { fileName }); - return response.data; -}; +export interface InterviewSummary { + interviewTitle: string; + timeoutQuestionNumber: number; + questionSummaries: QuestionSummary[]; +} /** - * 2. 녹음 업로드용 프리사인 URL 발급 + * 최종 피드백 응답 */ -export const getRecordingPresignUrl = async ( - fileName: string, -): Promise => { - const response = await apiClient.post('/api/presign/recording', { fileName }); - return response.data; -}; +export interface FinalFeedbackResponse { + feedbackProgressStatus: 'WORKING' | 'READY' | 'FAILED'; + interviewSummary: InterviewSummary | null; +} + +// ==================== API 함수들 ==================== /** - * 3. S3에 파일 업로드 (프리사인 URL 사용) + * 1. 자소서 업로드 (전체 플로우) + * - Presigned URL 받기 (RequestParam 방식) + * - S3에 직접 업로드 */ -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', - }, - }); +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 파일만 업로드 가능합니다.'); + } + + console.log('🚀 1단계 - Presigned URL 요청:', file.name); + + // 1단계: Presigned URL 받기 (RequestParam으로 전달!) + const presignResponse = await apiClient.post( + '/api/presign/resume', + null, // body는 null + { + params: { + fileName: file.name, // Query Parameter로 전달 + }, + } + ); + + console.log('✅ Presigned URL 발급 성공'); + + const { uploadUrl, key, requiredHeaders } = presignResponse.data; + + // 2단계: S3에 실제 파일 업로드 (PUT) + console.log('📤 2단계 - S3 업로드 시작'); + + const uploadResponse = await fetch(uploadUrl, { + method: 'PUT', + headers: { + ...requiredHeaders, + }, + body: file, + }); + + if (!uploadResponse.ok) { + throw new Error(`S3 업로드 실패: ${uploadResponse.status}`); + } + + console.log('✅ S3 업로드 완료! Key:', key); + + return key; + } catch (error) { + console.error('❌ 자소서 업로드 실패:', error); + throw error; + } }; /** - * 4. 자소서 기반 질문 생성 및 첫번째 질문 조회 + * 2. 면접 세션 생성 및 첫 질문 받기 */ export const createInterviewSession = async ( - data: CreateInterviewSessionRequest, + data: CreateInterviewSessionRequest ): Promise => { - const response = await apiClient.post('/api/interview-sessions', data); - return response.data; + try { + console.log('🎬 면접 세션 생성 요청:', data); + + const response = await apiClient.post( + '/api/interview-sessions', + data + ); + + console.log('✅ 면접 세션 생성 성공:', response.data); + + return response.data; + } catch (error) { + console.error('❌ 면접 세션 생성 실패:', error); + throw error; + } }; /** - * 5. recording 저장 및 프리질문 생성 (비동기) + * 3. 녹음 업로드용 Presigned URL 받기 + */ +export const getRecordingPresignUrl = async ( + questionId: number, + contentType: string = 'audio/webm' +): Promise => { + try { + console.log('🎤 녹음 Presigned URL 요청:', { questionId, contentType }); + + const response = await apiClient.post( + '/api/presign/recording', + { + questionId, + contentType, + } + ); + + console.log('✅ 녹음 Presigned URL 발급 성공'); + + return response.data; + } catch (error) { + console.error('❌ 녹음 Presigned URL 발급 실패:', error); + throw error; + } +}; + +/** + * 4. 녹음 저장 (비동기 처리 시작) */ export const saveRecording = async ( - questionId: string, - data: SaveRecordingRequest, + questionId: number ): Promise => { - const response = await apiClient.post( - `/api/questions/${questionId}/recordings`, - data, - ); - return response.data; + try { + console.log('💾 녹음 저장 요청:', questionId); + + const response = await apiClient.post( + `/api/questions/${questionId}/recordings` + ); + + console.log('✅ 녹음 저장 성공:', response.data); + + return response.data; + } catch (error) { + console.error('❌ 녹음 저장 실패:', error); + throw error; + } }; /** - * 6. recording 저장 및 프리질문 생성 상태 Polling + * 5. 녹음 처리 상태 Polling (한 번만 조회) */ export const getRecordingResult = async ( - recordingId: string, -): Promise => { - const response = await apiClient.get( - `/api/recordings/${recordingId}/results`, - ); - return response.data; + recordingId: number +): Promise => { + try { + const response = await apiClient.get( + `/api/recordings/${recordingId}/results` + ); + + return response.data; + } catch (error) { + console.error('❌ Polling 조회 실패:', error); + throw error; + } }; /** - * 7. Polling 헬퍼 함수 (자동으로 상태 확인) + * 6. Polling 헬퍼 (자동으로 READY 상태까지 대기) */ export const pollRecordingResult = async ( - recordingId: string, + recordingId: number, maxAttempts: number = 60, // 최대 60번 (5분) - interval: number = 5000, // 5초마다 -): Promise => { + interval: number = 5000 // 5초마다 +): Promise => { let attempts = 0; + console.log('🔄 Polling 시작:', recordingId); + while (attempts < maxAttempts) { const result = await getRecordingResult(recordingId); - if (result.status === 'completed' || result.status === 'failed') { + console.log(`📊 Polling ${attempts + 1}/${maxAttempts}:`, result.status); + + if (result.status === 'READY' || result.status === 'FAILED') { + console.log('✅ Polling 완료:', result.status); return result; } - // processing 상태면 대기 후 재시도 + // WORKING 상태면 대기 후 재시도 await new Promise((resolve) => setTimeout(resolve, interval)); attempts++; } @@ -164,111 +301,113 @@ export const pollRecordingResult = async ( }; /** - * 8. 사용자가 시간초과로 답변하지 못한 경우 - */ -export const sendTimeout = async (questionId: string): Promise => { - await apiClient.post(`/api/questions/${questionId}/timeout`); -}; - -/** - * 9. 최종 피드백 조회 + * 7. 녹음 업로드 전체 플로우 (Presigned URL + S3 업로드 + 저장 + Polling) */ -export const getFinalFeedback = async ( - sessionId: string, -): Promise => { - const response = await apiClient.get( - `/api/interview-sessions/${sessionId}`, - ); - return response.data; -}; - -// ==================== 전체 플로우 헬퍼 함수 ==================== - -/** - * 자소서 업로드 전체 플로우 - */ -export const uploadResume = async (file: File): Promise => { +export const uploadRecordingAndGetNext = async ( + questionId: number, + audioBlob: Blob +): Promise => { try { - // 파일명 유효성 검사 - const extension = file.name.split('.').pop()?.toLowerCase(); - if (!extension || !['pdf', 'docx'].includes(extension)) { - throw new Error('PDF 또는 DOCX 파일만 업로드 가능합니다.'); - } - - // 1단계: Presigned URL 받기 (Query Parameter 방식으로 변경!) - console.log('🚀 1단계 - Presigned URL 요청:', file.name); - - const presignResponse = await apiClient.post( - '/api/presign/resume', - null, // ← body는 null - { - params: { // ← params 옵션으로 Query Parameter 전달 - fileName: file.name - } - } + // 1단계: Presigned URL 받기 + const { uploadUrl, requiredHeaders } = await getRecordingPresignUrl( + questionId, + audioBlob.type || 'audio/webm' ); - console.log('✅ Presigned URL 발급 성공'); - - const { uploadUrl, key, requiredHeaders } = presignResponse.data; - - // 2단계: S3에 실제 파일 업로드 (PUT) - console.log('📤 2단계 - S3 업로드 시작'); + // 2단계: S3에 업로드 + console.log('📤 녹음 S3 업로드 시작'); const uploadResponse = await fetch(uploadUrl, { method: 'PUT', headers: { ...requiredHeaders, }, - body: file, + body: audioBlob, }); if (!uploadResponse.ok) { - throw new Error(`S3 업로드 실패: ${uploadResponse.status}`); + throw new Error(`녹음 업로드 실패: ${uploadResponse.status}`); } - console.log('✅ S3 업로드 완료! Key:', key); + console.log('✅ 녹음 S3 업로드 완료'); - return key; + // 3단계: 녹음 저장 (비동기 처리 시작) + const { recordingId, status } = await saveRecording(questionId); + + console.log('💾 녹음 저장 완료. RecordingId:', recordingId, 'Status:', status); + + // 4단계: Polling으로 다음 질문 대기 + const result = await pollRecordingResult(recordingId); + + if (result.status === 'FAILED') { + throw new Error('녹음 처리에 실패했습니다.'); + } + + return result.next; } catch (error) { - console.error('❌ 자소서 업로드 실패:', error); + console.error('❌ 녹음 업로드 전체 플로우 실패:', error); throw error; } }; /** - * 녹음 파일 업로드 및 다음 질문 받기 전체 플로우 + * 8. 시간초과 처리 */ -export const uploadRecordingAndGetNext = async ( - questionId: string, - audioBlob: Blob, -): Promise => { - // 1. 프리사인 URL 받기 - const fileName = `recording-${questionId}-${Date.now()}.webm`; - const { presignedUrl, fileKey } = await getRecordingPresignUrl(fileName); - - // 2. S3에 업로드 - await uploadToS3(presignedUrl, audioBlob); - - // 3. 녹음 저장 및 처리 시작 - const { recordingId, status, nextQuestion } = await saveRecording( - questionId, - { - recordingKey: fileKey, - }, - ); - - // 4. 즉시 완료된 경우 - if (status === 'completed') { - return nextQuestion || null; +export const sendTimeout = async ( + questionId: number +): Promise => { + try { + console.log('⏱️ Timeout 처리:', questionId); + + const response = await apiClient.post( + `/api/questions/${questionId}/timeout` + ); + + console.log('✅ Timeout 처리 완료:', response.data); + + return response.data; + } catch (error) { + console.error('❌ Timeout 처리 실패:', error); + throw error; } +}; - // 5. 처리 중이면 polling - const result = await pollRecordingResult(recordingId); +/** + * 9. 최종 피드백 조회 + */ +export const getFinalFeedback = async ( + sessionId: number +): Promise => { + try { + console.log('📊 최종 피드백 조회:', sessionId); + + const response = await apiClient.get( + `/api/interview-sessions/${sessionId}` + ); + + console.log('✅ 피드백 조회 성공'); - if (result.status === 'failed') { - throw new Error('녹음 처리에 실패했습니다.'); + return response.data; + } catch (error) { + console.error('❌ 피드백 조회 실패:', error); + throw error; } +}; - return result.nextQuestion || null; +/** + * 10. S3 Key에서 resumeId 추출 헬퍼 함수 + */ +export const extractResumeId = (key: string): string => { + // key 예시: "resume/123/fc749c97-b991-4d4b-ac39-19fb8e3ee91f.docx" + // resumeId: "fc749c97-b991-4d4b-ac39-19fb8e3ee91f" + + const parts = key.split('/'); + if (parts.length < 3) { + throw new Error('Invalid resume key format'); + } + + const fileNameWithExt = parts[parts.length - 1]; // "fc749c97-b991-4d4b-ac39-19fb8e3ee91f.docx" + const resumeId = fileNameWithExt.split('.')[0]; // "fc749c97-b991-4d4b-ac39-19fb8e3ee91f" + + return resumeId; }; From 1bafbcae1dfe91ac1a1d8fded23a071163f34715 Mon Sep 17 00:00:00 2001 From: Hello-Worldismine Date: Sun, 16 Nov 2025 22:36:54 +0900 Subject: [PATCH 04/12] =?UTF-8?q?=EC=98=A4=EB=A5=98=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/Interview/feedback_result.tsx | 12 +- src/pages/Interview/upload_done.tsx | 6 +- src/services/interviewApi.ts | 460 ++++++++++-------------- 3 files changed, 205 insertions(+), 273 deletions(-) diff --git a/src/pages/Interview/feedback_result.tsx b/src/pages/Interview/feedback_result.tsx index f94a57b..16e61bb 100644 --- a/src/pages/Interview/feedback_result.tsx +++ b/src/pages/Interview/feedback_result.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; import InterviewLayout from '@/layouts/InterviewLayout'; -import { getFinalFeedback, FinalFeedbackResponse } from '@/services/interviewApi'; +import { getFinalFeedback, type FinalFeedbackResponse, type QuestionSummary, type QnaTurn } from '@/services/interviewApi'; interface IQuestionState { id: number; @@ -50,7 +50,7 @@ export default function FeedbackResult() { setFeedbackData(response); // 질문 상태 초기화 - const states = response.interviewSummary.questionSummaries.map((_, index) => ({ + const states = response.interviewSummary.questionSummaries.map((_summary: QuestionSummary, index: number) => ({ id: index + 1, showAnswer: false, })); @@ -145,7 +145,7 @@ export default function FeedbackResult() { {/* 질문 카드 그리드 */}
- {interviewSummary.questionSummaries.map((summary, index) => { + {interviewSummary.questionSummaries.map((summary: QuestionSummary, index: number) => { const isShowingAnswer = questionStates.find((q) => q.id === index + 1)?.showAnswer || false; @@ -159,8 +159,8 @@ export default function FeedbackResult() { // 답변 텍스트 (Q&A 턴에서 ANSWER만 추출) const answerText = summary.qnaTurns - .filter((turn) => turn.turn === 'ANSWER') - .map((turn) => turn.content) + .filter((turn: QnaTurn) => turn.turn === 'ANSWER') + .map((turn: QnaTurn) => turn.content) .join('\n\n'); // 타임아웃으로 답변 못한 질문인지 확인 @@ -186,7 +186,7 @@ export default function FeedbackResult() { {isShowingAnswer ? ( hasAnswer ? (
- {summary.qnaTurns.map((turn, turnIndex) => ( + {summary.qnaTurns.map((turn: QnaTurn, turnIndex: number) => (

{turn.turn === 'QUESTION' ? '질문:' : '답변:'} diff --git a/src/pages/Interview/upload_done.tsx b/src/pages/Interview/upload_done.tsx index d7949e1..98b1442 100644 --- a/src/pages/Interview/upload_done.tsx +++ b/src/pages/Interview/upload_done.tsx @@ -18,11 +18,11 @@ export default function UploadDone() { const handleConfirm = () => { // 파일 정보와 resumeKey를 upload-check 페이지로 전달 - navigate('/upload-check', { - state: { + navigate('/upload-check', { + state: { file, resumeKey, // resumeKey 전달 - } + }, }); }; diff --git a/src/services/interviewApi.ts b/src/services/interviewApi.ts index 1f15677..9e87b7c 100644 --- a/src/services/interviewApi.ts +++ b/src/services/interviewApi.ts @@ -1,114 +1,115 @@ -import { apiClient } from './api'; +// src/services/interviewApi.ts +import apiClient from './api'; // ==================== 타입 정의 ==================== /** * 자소서 업로드용 Presigned URL 응답 + * (POST /api/presign/resume) */ export interface ResumePresignResponse { uploadUrl: string; key: string; requiredHeaders: { - 'Content-Type': string; + [header: string]: string; }; } /** - * 녹음 업로드용 Presigned URL 요청 + * 녹음 업로드용 Presigned URL 응답 + * (POST /api/presign/recording) + * presignedUrl 로 PUT 업로드, fileKey 는 S3 key */ -export interface RecordingPresignRequest { - questionId: number; - contentType: string; // 'audio/webm', 'audio/mpeg', etc. +export interface PresignUrlResponse { + presignedUrl: string; + fileKey: string; } /** - * 녹음 업로드용 Presigned URL 응답 + * 면접 세션 생성 요청 */ -export interface RecordingPresignResponse { - uploadUrl: string; - key: string; - requiredHeaders: { - [key: string]: string; - }; +export interface CreateInterviewSessionRequest { + resumeKey: string; + jobTitle: string; + interviewType: 'normal' | 'pressure'; } /** - * 면접 세션 생성 요청 + * 질문 정보 */ -export interface CreateInterviewSessionRequest { - mode: 'NORMAL' | 'HARD'; // HARD = 압박면접 - jobRole: string; - resumeId: string; // S3 key에서 추출한 ID +export interface Question { + questionId: string; + mainQuestion: string; + subQuestion: string; + order: number; } /** * 면접 세션 생성 응답 */ export interface CreateInterviewSessionResponse { - sessionId: number; - firstQuestionId: number; - firstQuestionText: string; + sessionId: string; + firstQuestion: Question; } /** - * 녹음 저장 응답 (비동기) + * 녹음 저장 요청 */ -export interface SaveRecordingResponse { - recordingId: number; - status: 'UPLOADED'; // 비동기 작업 큐에 등록됨 +export interface SaveRecordingRequest { + recordingKey: string; } /** - * 다음 질문 객체 + * 녹음 저장 응답 */ -export interface NextQuestion { - type: 'FOLLOW_UP' | 'ROOT' | 'NONE'; - nextQuestionId: number | null; - nextQuestionText: string | null; - rootId: number; - rootText: string; - rootIndex: number; +export interface SaveRecordingResponse { + recordingId: string; + status: 'processing' | 'completed' | 'failed'; + nextQuestion?: Question; } /** - * Polling 결과 응답 + * 녹음 처리 결과 */ -export interface PollingResultResponse { - sessionId: number; - status: 'WORKING' | 'READY' | 'FAILED'; - next: NextQuestion | null; +export interface RecordingResultResponse { + status: 'processing' | 'completed' | 'failed'; + nextQuestion?: Question; + feedback?: string; } /** - * Timeout 처리 응답 + * 피드백 한 항목 */ -export interface TimeoutResponse { - sessionId: number; - status: 'READY'; - next: NextQuestion | null; +export interface FeedbackItem { + questionId: string; + question: string; + answer: string; + feedback: string; + feedbackType: 'positive' | 'negative'; + timeout: boolean; } /** - * Q&A 턴 + * Q&A 턴 정보 */ -export interface QnATurn { +export interface QnaTurn { turn: 'QUESTION' | 'ANSWER'; content: string; } /** - * 질문 요약 + * 질문 요약 정보 */ export interface QuestionSummary { questionNumber: number; rootQuestion: string; - aiFeedback: string | null; - selfFeedback: string | null; - qnaTurns: QnATurn[]; + aiFeedback?: string; + selfFeedback?: string; + qnaTurns: QnaTurn[]; } /** - * 면접 요약 + * 면접 요약 정보 */ export interface InterviewSummary { interviewTitle: string; @@ -120,179 +121,119 @@ export interface InterviewSummary { * 최종 피드백 응답 */ export interface FinalFeedbackResponse { - feedbackProgressStatus: 'WORKING' | 'READY' | 'FAILED'; - interviewSummary: InterviewSummary | null; + sessionId: string; + feedbacks: FeedbackItem[]; + totalQuestions: number; + timeoutCount: number; + feedbackProgressStatus?: 'WORKING' | 'FAILED' | 'COMPLETED'; + interviewSummary?: InterviewSummary; } // ==================== API 함수들 ==================== /** - * 1. 자소서 업로드 (전체 플로우) - * - Presigned URL 받기 (RequestParam 방식) - * - S3에 직접 업로드 + * 1. 자소서 업로드용 프리사인 URL 발급 + * (스펙: POST /api/presign/resume, JSON body { fileName }) */ -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 파일만 업로드 가능합니다.'); - } - - console.log('🚀 1단계 - Presigned URL 요청:', file.name); - - // 1단계: Presigned URL 받기 (RequestParam으로 전달!) - const presignResponse = await apiClient.post( - '/api/presign/resume', - null, // body는 null - { - params: { - fileName: file.name, // Query Parameter로 전달 - }, - } - ); - - console.log('✅ Presigned URL 발급 성공'); - - const { uploadUrl, key, requiredHeaders } = presignResponse.data; - - // 2단계: S3에 실제 파일 업로드 (PUT) - console.log('📤 2단계 - S3 업로드 시작'); - - const uploadResponse = await fetch(uploadUrl, { - method: 'PUT', - headers: { - ...requiredHeaders, - }, - body: file, - }); - - if (!uploadResponse.ok) { - throw new Error(`S3 업로드 실패: ${uploadResponse.status}`); - } - - console.log('✅ S3 업로드 완료! Key:', key); - - return key; - } catch (error) { - console.error('❌ 자소서 업로드 실패:', error); - throw error; - } +export const getResumePresignUrl = async ( + fileName: string, +): Promise => { + const response = await apiClient.post( + '/api/presign/resume', + { fileName }, + ); + return response.data; }; /** - * 2. 면접 세션 생성 및 첫 질문 받기 + * 2. 녹음 업로드용 프리사인 URL 발급 + * (POST /api/presign/recording, body { fileName } – 기존 방식 유지) */ -export const createInterviewSession = async ( - data: CreateInterviewSessionRequest -): Promise => { - try { - console.log('🎬 면접 세션 생성 요청:', data); - - const response = await apiClient.post( - '/api/interview-sessions', - data - ); - - console.log('✅ 면접 세션 생성 성공:', response.data); - - return response.data; - } catch (error) { - console.error('❌ 면접 세션 생성 실패:', error); - throw error; - } +export const getRecordingPresignUrl = async ( + fileName: string, +): Promise => { + const response = await apiClient.post( + '/api/presign/recording', + { fileName }, + ); + return response.data; }; /** - * 3. 녹음 업로드용 Presigned URL 받기 + * 3. S3에 파일 업로드 (프리사인 URL 사용) */ -export const getRecordingPresignUrl = async ( - questionId: number, - contentType: string = 'audio/webm' -): Promise => { - try { - console.log('🎤 녹음 Presigned URL 요청:', { questionId, contentType }); - - const response = await apiClient.post( - '/api/presign/recording', - { - questionId, - contentType, - } - ); - - console.log('✅ 녹음 Presigned URL 발급 성공'); +export const uploadToS3 = async ( + presignedUrl: string, + file: File | Blob, + extraHeaders: Record = {}, +): Promise => { + await fetch(presignedUrl, { + method: 'PUT', + body: file, + headers: { + 'Content-Type': file.type || 'application/octet-stream', + ...extraHeaders, + }, + }); +}; - return response.data; - } catch (error) { - console.error('❌ 녹음 Presigned URL 발급 실패:', error); - throw error; - } +/** + * 4. 자소서 기반 질문 생성 및 첫번째 질문 조회 + */ +export const createInterviewSession = async ( + data: CreateInterviewSessionRequest, +): Promise => { + const response = await apiClient.post( + '/api/interview-sessions', + data, + ); + return response.data; }; /** - * 4. 녹음 저장 (비동기 처리 시작) + * 5. recording 저장 및 프리질문 생성 (비동기) */ export const saveRecording = async ( - questionId: number + questionId: string, + data: SaveRecordingRequest, ): Promise => { - try { - console.log('💾 녹음 저장 요청:', questionId); - - const response = await apiClient.post( - `/api/questions/${questionId}/recordings` - ); - - console.log('✅ 녹음 저장 성공:', response.data); - - return response.data; - } catch (error) { - console.error('❌ 녹음 저장 실패:', error); - throw error; - } + const response = await apiClient.post( + `/api/questions/${questionId}/recordings`, + data, + ); + return response.data; }; /** - * 5. 녹음 처리 상태 Polling (한 번만 조회) + * 6. recording 저장 및 프리질문 생성 상태 Polling */ export const getRecordingResult = async ( - recordingId: number -): Promise => { - try { - const response = await apiClient.get( - `/api/recordings/${recordingId}/results` - ); - - return response.data; - } catch (error) { - console.error('❌ Polling 조회 실패:', error); - throw error; - } + recordingId: string, +): Promise => { + const response = await apiClient.get( + `/api/recordings/${recordingId}/results`, + ); + return response.data; }; /** - * 6. Polling 헬퍼 (자동으로 READY 상태까지 대기) + * 7. Polling 헬퍼 함수 (자동으로 상태 확인) */ export const pollRecordingResult = async ( - recordingId: number, + recordingId: string, maxAttempts: number = 60, // 최대 60번 (5분) - interval: number = 5000 // 5초마다 -): Promise => { + interval: number = 5000, // 5초마다 +): Promise => { let attempts = 0; - console.log('🔄 Polling 시작:', recordingId); - while (attempts < maxAttempts) { const result = await getRecordingResult(recordingId); - console.log(`📊 Polling ${attempts + 1}/${maxAttempts}:`, result.status); - - if (result.status === 'READY' || result.status === 'FAILED') { - console.log('✅ Polling 완료:', result.status); + if (result.status === 'completed' || result.status === 'failed') { return result; } - // WORKING 상태면 대기 후 재시도 + // processing 상태면 대기 후 재시도 await new Promise((resolve) => setTimeout(resolve, interval)); attempts++; } @@ -301,113 +242,104 @@ export const pollRecordingResult = async ( }; /** - * 7. 녹음 업로드 전체 플로우 (Presigned URL + S3 업로드 + 저장 + Polling) + * 8. 사용자가 시간초과로 답변하지 못한 경우 */ -export const uploadRecordingAndGetNext = async ( - questionId: number, - audioBlob: Blob -): Promise => { +export const sendTimeout = async (questionId: string): Promise => { + await apiClient.post(`/api/questions/${questionId}/timeout`); +}; + +/** + * 9. 최종 피드백 조회 + */ +export const getFinalFeedback = async ( + sessionId: string, +): Promise => { + const response = await apiClient.get( + `/api/interview-sessions/${sessionId}`, + ); + return response.data; +}; + +// ==================== 전체 플로우 헬퍼 함수 ==================== + +/** + * 자소서 업로드 전체 플로우 + * - 확장자 검증(pdf/docx) + * - presign URL 발급 + * - S3 PUT 업로드 + * - S3 key 반환 + */ +export const uploadResume = async (file: File): Promise => { try { + // 0. 확장자 검증 (pdf, docx만) + const extension = file.name.split('.').pop()?.toLowerCase(); + if (!extension || !['pdf', 'docx'].includes(extension)) { + throw new Error('PDF 또는 DOCX 파일만 업로드 가능합니다.'); + } + // 1단계: Presigned URL 받기 - const { uploadUrl, requiredHeaders } = await getRecordingPresignUrl( - questionId, - audioBlob.type || 'audio/webm' + console.log('🚀 1단계 - Presigned URL 요청:', file.name); + + const { uploadUrl, key, requiredHeaders } = await getResumePresignUrl( + file.name, ); - // 2단계: S3에 업로드 - console.log('📤 녹음 S3 업로드 시작'); + console.log('✅ Presigned URL 발급 성공'); + + // 2단계: S3에 실제 파일 업로드 (PUT) + console.log('📤 2단계 - S3 업로드 시작'); const uploadResponse = await fetch(uploadUrl, { method: 'PUT', headers: { - ...requiredHeaders, + ...requiredHeaders, // Content-Type 등 필수 헤더 }, - body: audioBlob, + body: file, }); if (!uploadResponse.ok) { - throw new Error(`녹음 업로드 실패: ${uploadResponse.status}`); + throw new Error(`S3 업로드 실패: ${uploadResponse.status}`); } - console.log('✅ 녹음 S3 업로드 완료'); - - // 3단계: 녹음 저장 (비동기 처리 시작) - const { recordingId, status } = await saveRecording(questionId); - - console.log('💾 녹음 저장 완료. RecordingId:', recordingId, 'Status:', status); - - // 4단계: Polling으로 다음 질문 대기 - const result = await pollRecordingResult(recordingId); - - if (result.status === 'FAILED') { - throw new Error('녹음 처리에 실패했습니다.'); - } + console.log('✅ S3 업로드 완료! Key:', key); - return result.next; + return key; } catch (error) { - console.error('❌ 녹음 업로드 전체 플로우 실패:', error); + console.error('❌ 자소서 업로드 실패:', error); throw error; } }; /** - * 8. 시간초과 처리 + * 녹음 파일 업로드 및 다음 질문 받기 전체 플로우 */ -export const sendTimeout = async ( - questionId: number -): Promise => { - try { - console.log('⏱️ Timeout 처리:', questionId); - - const response = await apiClient.post( - `/api/questions/${questionId}/timeout` - ); - - console.log('✅ Timeout 처리 완료:', response.data); - - return response.data; - } catch (error) { - console.error('❌ Timeout 처리 실패:', error); - throw error; +export const uploadRecordingAndGetNext = async ( + questionId: string, + audioBlob: Blob, +): Promise => { + // 1. 프리사인 URL 받기 + const fileName = `recording-${questionId}-${Date.now()}.webm`; + const { presignedUrl, fileKey } = await getRecordingPresignUrl(fileName); + + // 2. S3에 업로드 + await uploadToS3(presignedUrl, audioBlob); + + // 3. 녹음 저장 및 처리 시작 + const { recordingId, status, nextQuestion } = await saveRecording(questionId, { + recordingKey: fileKey, + }); + + // 4. 즉시 완료된 경우 + if (status === 'completed') { + return nextQuestion || null; } -}; - -/** - * 9. 최종 피드백 조회 - */ -export const getFinalFeedback = async ( - sessionId: number -): Promise => { - try { - console.log('📊 최종 피드백 조회:', sessionId); - - const response = await apiClient.get( - `/api/interview-sessions/${sessionId}` - ); - console.log('✅ 피드백 조회 성공'); + // 5. 처리 중이면 polling + const result = await pollRecordingResult(recordingId); - return response.data; - } catch (error) { - console.error('❌ 피드백 조회 실패:', error); - throw error; + if (result.status === 'failed') { + throw new Error('녹음 처리에 실패했습니다.'); } -}; -/** - * 10. S3 Key에서 resumeId 추출 헬퍼 함수 - */ -export const extractResumeId = (key: string): string => { - // key 예시: "resume/123/fc749c97-b991-4d4b-ac39-19fb8e3ee91f.docx" - // resumeId: "fc749c97-b991-4d4b-ac39-19fb8e3ee91f" - - const parts = key.split('/'); - if (parts.length < 3) { - throw new Error('Invalid resume key format'); - } - - const fileNameWithExt = parts[parts.length - 1]; // "fc749c97-b991-4d4b-ac39-19fb8e3ee91f.docx" - const resumeId = fileNameWithExt.split('.')[0]; // "fc749c97-b991-4d4b-ac39-19fb8e3ee91f" - - return resumeId; + return result.nextQuestion || null; }; From e70b9e77d6d2f2dfbd820c90e28657942fdcbdda Mon Sep 17 00:00:00 2001 From: Hello-Worldismine Date: Sun, 16 Nov 2025 23:24:37 +0900 Subject: [PATCH 05/12] =?UTF-8?q?=EB=B9=8C=EB=93=9C=EC=98=A4=EB=A5=98=20?= =?UTF-8?q?=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/Interview/feedback_result.tsx | 177 +++----- src/pages/Interview/main_answer.tsx | 512 ++++++++--------------- src/pages/Interview/question_loading.tsx | 145 +++---- src/services/interviewApi.ts | 42 +- 4 files changed, 290 insertions(+), 586 deletions(-) diff --git a/src/pages/Interview/feedback_result.tsx b/src/pages/Interview/feedback_result.tsx index 16e61bb..cbc321a 100644 --- a/src/pages/Interview/feedback_result.tsx +++ b/src/pages/Interview/feedback_result.tsx @@ -1,8 +1,10 @@ +// src/pages/Interview/feedback_result.tsx import { useEffect, useState } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; import InterviewLayout from '@/layouts/InterviewLayout'; -import { getFinalFeedback, type FinalFeedbackResponse, type QuestionSummary, type QnaTurn } from '@/services/interviewApi'; +import { getFinalFeedback } from '@/services/interviewApi'; +import type { FinalFeedbackResponse, FeedbackItem } from '@/services/interviewApi'; interface IQuestionState { id: number; @@ -11,7 +13,7 @@ interface IQuestionState { export default function FeedbackResult() { const navigate = useNavigate(); - const location = useLocation(); + const location = useLocation() as { state?: { sessionId?: string } }; const { sessionId } = location.state || {}; const [feedbackData, setFeedbackData] = useState(null); @@ -30,32 +32,15 @@ export default function FeedbackResult() { const fetchFeedback = async () => { try { setIsLoading(true); - console.log('📊 최종 피드백 조회 시작:', sessionId); - const response = await getFinalFeedback(sessionId); - - // feedbackProgressStatus 확인 - if (response.feedbackProgressStatus === 'WORKING') { - // 피드백 생성 중 - 재시도 또는 메시지 표시 - setTimeout(fetchFeedback, 5000); // 5초 후 재시도 - return; - } - - if (response.feedbackProgressStatus === 'FAILED') { - setError('피드백 생성에 실패했습니다.'); - return; - } - - if (response.interviewSummary) { - setFeedbackData(response); - - // 질문 상태 초기화 - const states = response.interviewSummary.questionSummaries.map((_summary: QuestionSummary, index: number) => ({ - id: index + 1, - showAnswer: false, - })); - setQuestionStates(states); - } + setFeedbackData(response); + + // 질문 상태 초기화: 피드백 항목 수 기준 + const states = response.feedbacks.map((_, index) => ({ + id: index + 1, + showAnswer: false, + })); + setQuestionStates(states); } catch (err) { console.error('❌ 피드백 조회 실패:', err); setError('피드백을 불러오는데 실패했습니다.'); @@ -64,13 +49,11 @@ export default function FeedbackResult() { } }; - fetchFeedback(); + void fetchFeedback(); }, [sessionId, navigate]); const toggleAnswer = (id: number) => { - setQuestionStates((prev) => - prev.map((q) => (q.id === id ? { ...q, showAnswer: !q.showAnswer } : q)) - ); + setQuestionStates((prev) => prev.map((q) => (q.id === id ? { ...q, showAnswer: !q.showAnswer } : q))); }; // 로딩 중 @@ -80,18 +63,10 @@ export default function FeedbackResult() {

- 로딩 + 로딩
-

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

-

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

+

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

+

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

@@ -99,18 +74,13 @@ export default function FeedbackResult() { } // 에러 - if (error || !feedbackData || !feedbackData.interviewSummary) { + if (error || !feedbackData) { return (
-

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

-
@@ -119,7 +89,7 @@ export default function FeedbackResult() { ); } - const { interviewSummary } = feedbackData; + const { feedbacks, totalQuestions, timeoutCount } = feedbackData; return ( @@ -128,57 +98,36 @@ export default function FeedbackResult() { {/* 상단 정보 */}

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

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

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

)}
{/* 질문 카드 그리드 */}
- {interviewSummary.questionSummaries.map((summary: QuestionSummary, index: number) => { - const isShowingAnswer = - questionStates.find((q) => q.id === index + 1)?.showAnswer || false; + {feedbacks.map((item: FeedbackItem, index: number) => { + const isShowingAnswer = questionStates.find((q) => q.id === index + 1)?.showAnswer || false; - // AI 피드백과 셀프 피드백 중 표시할 것 선택 - const feedbackText = summary.aiFeedback || summary.selfFeedback; - const feedbackType = summary.aiFeedback - ? 'AI 피드백' - : summary.selfFeedback - ? '셀프 피드백' - : '피드백 없음'; - - // 답변 텍스트 (Q&A 턴에서 ANSWER만 추출) - const answerText = summary.qnaTurns - .filter((turn: QnaTurn) => turn.turn === 'ANSWER') - .map((turn: QnaTurn) => turn.content) - .join('\n\n'); - - // 타임아웃으로 답변 못한 질문인지 확인 - const hasAnswer = answerText.length > 0; + const isPositive = item.feedbackType === 'positive'; + const feedbackTypeLabel = isPositive ? 'AI 피드백(긍정)' : 'AI 피드백(개선)'; + const hasAnswer = !!item.answer && item.answer.trim().length > 0; return (
{/* 카드 헤더 */}

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

-

{feedbackType}

+

{feedbackTypeLabel}

{/* 카드 내용 (스크롤 가능) */} @@ -186,30 +135,18 @@ export default function FeedbackResult() { {isShowingAnswer ? ( hasAnswer ? (
- {summary.qnaTurns.map((turn: QnaTurn, turnIndex: number) => ( -
-

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

-

- {turn.content} -

-
- ))} +
+

답변:

+

{item.answer}

+
) : ( -

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

+

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

) - ) : feedbackText ? ( -

- {feedbackText} -

+ ) : item.feedback ? ( +

{item.feedback}

) : ( -

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

+

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

)}
@@ -218,9 +155,7 @@ export default function FeedbackResult() {
); diff --git a/src/pages/Interview/main_answer.tsx b/src/pages/Interview/main_answer.tsx index 7dbc0bf..5f8fab0 100644 --- a/src/pages/Interview/main_answer.tsx +++ b/src/pages/Interview/main_answer.tsx @@ -1,96 +1,68 @@ +// src/pages/Interview/main_answer.tsx import { useEffect, useRef, useState } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; - import InterviewLayout from '@/layouts/InterviewLayout'; -import { - uploadRecordingAndGetNext, - sendTimeout, - NextQuestion, -} from '@/services/interviewApi'; - -interface QuestionData { - questionId: number; - questionText: string; - rootId: number; - rootText: string; - rootIndex: number; - type: 'ROOT' | 'FOLLOW_UP'; -} + +import { uploadRecordingAndGetNext, sendTimeout } from '@/services/interviewApi'; +import type { Question } from '@/services/interviewApi'; export default function AnswerQuestion() { const navigate = useNavigate(); - const location = useLocation(); - const { - sessionId, - firstQuestionId, - firstQuestionText, - resumeKey = '자소서', - } = location.state || {}; - - // 질문 관리 - const [currentQuestion, setCurrentQuestion] = useState(null); - const [questionHistory, setQuestionHistory] = useState([]); + const location = useLocation() as { + state?: { + fileName?: string; + jobTitle?: string; + interviewType?: 'normal' | 'pressure'; + resumeKey?: string; + sessionId?: string; + firstQuestion?: Question; + fromLoading?: boolean; + }; + }; + + const { fileName = '자소서', jobTitle, interviewType = 'normal', resumeKey, sessionId, firstQuestion } = location.state || {}; + + /** ---------------- 상태 ---------------- */ + const [currentQuestion, setCurrentQuestion] = useState(firstQuestion ?? null); const [showCompleteModal, setShowCompleteModal] = useState(false); - const [isProcessing, setIsProcessing] = 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 [recordedBlob, setRecordedBlob] = 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 isTimeoutProcessedRef = useRef(false); - // 재생 관련 상태/참조 + // 재생 const audioRef = useRef(null); const [isPlaying, setIsPlaying] = useState(false); const [playbackTime, setPlaybackTime] = useState(0); const [playbackDuration, setPlaybackDuration] = useState(0); - // 초기 질문 설정 + /** ---------------- 초기 유효성 ---------------- */ useEffect(() => { - if (!sessionId || !firstQuestionId || !firstQuestionText) { - alert('면접 세션 정보가 없습니다.'); - navigate('/upload'); - return; + if (!firstQuestion) { + // question_loading에서 세션 생성 후 오도록 설계됨 + // 직접 접근 시엔 안정적으로 뒤로 돌림 + navigate('/question-loading', { + replace: true, + state: { fileName, jobTitle, interviewType, resumeKey }, + }); } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); - const firstQuestion: QuestionData = { - questionId: firstQuestionId, - questionText: firstQuestionText, - rootId: firstQuestionId, - rootText: firstQuestionText, - rootIndex: 1, - type: 'ROOT', - }; - - setCurrentQuestion(firstQuestion); - setQuestionHistory([firstQuestion]); - }, [sessionId, firstQuestionId, firstQuestionText, navigate]); - - // 녹음 중지 - const stopRecording = () => { - if (mediaRecorderRef.current && isRecording) { - try { - mediaRecorderRef.current.stop(); - } catch { - setIsRecording(false); - setIsPaused(false); - } - if (timerRef.current) { - clearInterval(timerRef.current); - timerRef.current = null; - } - } - }; - - // 타이머 시작/정지 + /** ---------------- 타이머 ---------------- */ useEffect(() => { if (isRecording && !isPaused) { timerRef.current = window.setInterval(() => { @@ -98,6 +70,10 @@ export default function AnswerQuestion() { setRemainingTime((prev) => { if (prev <= 1) { stopRecording(); + // 현재 스펙에선 sendTimeout이 nextQuestion을 돌려주지 않으므로 완료 처리 + if (currentQuestion?.questionId) { + void handleTimeout(currentQuestion.questionId); + } return 0; } return prev - 1; @@ -114,23 +90,16 @@ export default function AnswerQuestion() { timerRef.current = null; } }; - }, [isRecording, isPaused]); - - // 시간 초과 처리 - useEffect(() => { - if (remainingTime === 0 && !isTimeoutProcessedRef.current && currentQuestion) { - isTimeoutProcessedRef.current = true; - handleTimeout(); - } - }, [remainingTime, currentQuestion]); + }, [isRecording, isPaused, currentQuestion?.questionId]); - // 녹음 시작 + /** ---------------- 녹음 제어 ---------------- */ 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) { @@ -141,9 +110,10 @@ 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); - setRecordedBlob(audioBlob); + setRecordedAudioUrl(audioUrl); // 재생 상태 초기화 setIsPlaying(false); @@ -156,23 +126,35 @@ export default function AnswerQuestion() { mediaRecorder.start(); setIsRecording(true); setIsPaused(false); - setRecordedAudio(null); - setRecordedBlob(null); + setRecordedAudioUrl(null); - // 녹음 타이머 초기화 + // 타이머 초기화 setRecordingTime(0); setRemainingTime(180); - isTimeoutProcessedRef.current = false; } catch (error) { console.error('마이크 접근 오류:', error); alert('마이크 접근 권한이 필요합니다.'); } }; - // 녹음 일시정지/재개 + const stopRecording = () => { + if (mediaRecorderRef.current && isRecording) { + try { + mediaRecorderRef.current.stop(); + } catch { + /* noop */ + } + setIsRecording(false); + setIsPaused(false); + } + if (timerRef.current) { + clearInterval(timerRef.current); + timerRef.current = null; + } + }; + const togglePause = () => { if (!mediaRecorderRef.current) return; - if (isPaused) { mediaRecorderRef.current.resume(); setIsPaused(false); @@ -182,7 +164,6 @@ export default function AnswerQuestion() { } }; - // 재녹음 const handleRetry = () => { if (retryCount > 0) { // 재생 중이면 멈춤 @@ -194,20 +175,17 @@ export default function AnswerQuestion() { setPlaybackTime(0); setPlaybackDuration(0); - setRecordedAudio(null); - setRecordedBlob(null); + setRecordedAudioUrl(null); setRecordingTime(0); setRemainingTime(180); - setRetryCount(retryCount - 1); - isTimeoutProcessedRef.current = false; - startRecording(); + setRetryCount((c) => c - 1); + void startRecording(); } }; - // 오디오 재생/정지 + /** ---------------- 재생 제어 ---------------- */ const toggleAudioPlayback = () => { if (!audioRef.current) return; - if (audioRef.current.paused) { audioRef.current .play() @@ -219,7 +197,6 @@ export default function AnswerQuestion() { } }; - // 오디오 이벤트 연결 useEffect(() => { const audio = audioRef.current; if (!audio) return; @@ -229,11 +206,7 @@ export default function AnswerQuestion() { 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 = () => { @@ -256,46 +229,11 @@ export default function AnswerQuestion() { audio.removeEventListener('pause', handlePause); audio.removeEventListener('ended', handleEnded); }; - }, [recordedAudio, recordingTime]); - - // 시간 초과 처리 - const handleTimeout = async () => { - if (!currentQuestion || isProcessing) return; - - try { - setIsProcessing(true); - console.log('⏱️ 시간 초과 처리 시작:', currentQuestion.questionId); - - const response = await sendTimeout(currentQuestion.questionId); - - if (response.next && response.next.type !== 'NONE') { - // 다음 질문으로 이동 - const nextQuestion: QuestionData = { - questionId: response.next.nextQuestionId!, - questionText: response.next.nextQuestionText!, - rootId: response.next.rootId, - rootText: response.next.rootText, - rootIndex: response.next.rootIndex, - type: response.next.type === 'ROOT' ? 'ROOT' : 'FOLLOW_UP', - }; - - setQuestionHistory((prev) => [...prev, nextQuestion]); - setCurrentQuestion(nextQuestion); - resetQuestionState(); - } else { - // 모든 질문 종료 - setShowCompleteModal(true); - } - } catch (error) { - console.error('❌ Timeout 처리 실패:', error); - alert('시간 초과 처리 중 오류가 발생했습니다.'); - } finally { - setIsProcessing(false); - } - }; + }, [recordedAudioUrl, recordingTime]); - // 질문 상태 초기화 - const resetQuestionState = () => { + /** ---------------- 다음 질문 ---------------- */ + const resetForNext = () => { + // 재생/녹음 상태 초기화 if (audioRef.current) { audioRef.current.pause(); audioRef.current.currentTime = 0; @@ -303,76 +241,71 @@ export default function AnswerQuestion() { setIsPlaying(false); setPlaybackTime(0); setPlaybackDuration(0); - setRecordedAudio(null); - setRecordedBlob(null); + setRecordedAudioUrl(null); + latestAudioBlobRef.current = null; setRecordingTime(0); setRemainingTime(180); setRetryCount(1); - isTimeoutProcessedRef.current = false; }; - // 다음 질문 (녹음 업로드 + Polling) + const applyNext = (next: Question | null) => { + if (!next) { + setShowCompleteModal(true); + return; + } + setCurrentQuestion(next); + setOrdersSeen((prev) => (prev.includes(next.order) ? prev : [...prev, next.order].sort((a, b) => a - b))); + }; + const handleNext = async () => { - if (!recordedBlob || !currentQuestion || isProcessing) { + if (!currentQuestion?.questionId) return; + if (!latestAudioBlobRef.current) { alert('답변을 녹음해주세요.'); return; } + setIsSubmitting(true); try { - setIsProcessing(true); - console.log('📤 녹음 업로드 시작:', currentQuestion.questionId); - - // 녹음 업로드 + Polling으로 다음 질문 받기 - const nextQuestion = await uploadRecordingAndGetNext( - currentQuestion.questionId, - recordedBlob - ); - - if (nextQuestion && nextQuestion.type !== 'NONE') { - // 다음 질문으로 이동 - const newQuestion: QuestionData = { - questionId: nextQuestion.nextQuestionId!, - questionText: nextQuestion.nextQuestionText!, - rootId: nextQuestion.rootId, - rootText: nextQuestion.rootText, - rootIndex: nextQuestion.rootIndex, - type: nextQuestion.type === 'ROOT' ? 'ROOT' : 'FOLLOW_UP', - }; - - setQuestionHistory((prev) => [...prev, newQuestion]); - setCurrentQuestion(newQuestion); - resetQuestionState(); - } else { - // 모든 질문 종료 - setShowCompleteModal(true); - } - } catch (error) { - console.error('❌ 다음 질문 처리 실패:', error); - alert('다음 질문을 불러오는데 실패했습니다.'); + const next = await uploadRecordingAndGetNext(currentQuestion.questionId, latestAudioBlobRef.current); + resetForNext(); + applyNext(next); + } catch (e) { + console.error('다음 질문 처리 실패:', e); + alert('녹음 처리에 실패했습니다. 잠시 후 다시 시도해주세요.'); } finally { - setIsProcessing(false); + setIsSubmitting(false); } }; - // 질문 클릭 (이전 질문으로 이동) - const handleQuestionClick = (index: number) => { - if (isProcessing) return; - - // 녹음/재생 상태 정리 - stopRecording(); - resetQuestionState(); + const handleTimeout = async (questionId: string) => { + try { + await sendTimeout(questionId); + // 현재 스펙에선 sendTimeout이 다음 질문을 주지 않으므로 종료 처리 + resetForNext(); + setShowCompleteModal(true); + } catch (e) { + console.error('시간초과 처리 실패:', e); + alert('시간초과 처리에 실패했습니다.'); + } + }; - setCurrentQuestion(questionHistory[index]); + /** ---------------- 탭 이동(보여주기 용도) ---------------- */ + const handleOrderTabClick = (order: number) => { + void order; + // 서버가 특정 order의 질문을 다시 불러오는 API를 제공하지 않음,. + // 탭은 보여주기 용도로 유지. (실제 질문 이동은 서버 응답에 따라갈 것) + // 필요 시 여기서 과거 질문 로깅/캐싱 구현 가능. }; - // 최종 피드백으로 이동 + /** ---------------- 기타 ---------------- */ const handleFinalFeedback = () => { - navigate('/feedback-result', { - state: { sessionId }, - }); + 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); @@ -380,77 +313,47 @@ export default function AnswerQuestion() { return `${mins}:${secs.toString().padStart(2, '0')}`; }; - const playbackPercent = - playbackDuration > 0 - ? Math.min(100, Math.max(0, (playbackTime / playbackDuration) * 100)) - : 0; - - const currentQuestionIndex = currentQuestion - ? questionHistory.findIndex((q) => q.questionId === currentQuestion.questionId) - : -1; - - if (!currentQuestion) { - return ( - -
-

질문을 불러오는 중...

-
-
- ); - } + const playbackPercent = playbackDuration > 0 ? Math.min(100, Math.max(0, (playbackTime / playbackDuration) * 100)) : 0; return (
{/* 상단 정보 */}
- - {resumeKey} - -

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

+ {fileName} +

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

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

- {currentQuestionIndex + 1}. ({currentQuestion.rootText}) -

+

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

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

{/* 캐릭터 이미지 */}
- 면접관 + 면접관
{/* 타이머 & 녹음 컨트롤 */} @@ -458,37 +361,23 @@ export default function AnswerQuestion() { {/* 타이머 */}
-
+

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

{/* 녹음 컨트롤 */} - {!recordedAudio ? ( + {!recordedAudioUrl ? (
{!isRecording ? ( - - {formatTime(recordingTime)} - + {formatTime(recordingTime)} @@ -544,27 +419,14 @@ export default function AnswerQuestion() {
@@ -631,19 +484,10 @@ export default function AnswerQuestion() {
- 완료 + 완료
-

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

-
@@ -651,33 +495,15 @@ export default function AnswerQuestion() { )} ); diff --git a/src/pages/Interview/question_loading.tsx b/src/pages/Interview/question_loading.tsx index ab26aae..79201c4 100644 --- a/src/pages/Interview/question_loading.tsx +++ b/src/pages/Interview/question_loading.tsx @@ -1,97 +1,80 @@ -import { useEffect, useState } from 'react'; +// src/pages/Interview/question_loading.tsx +import { useEffect } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; - import InterviewLayout from '@/layouts/InterviewLayout'; -import { createInterviewSession, extractResumeId } from '@/services/interviewApi'; + +import { createInterviewSession } from '@/services/interviewApi'; +import type { CreateInterviewSessionResponse } from '@/services/interviewApi'; + +const ANSWER_ROUTE = '/main-answer'; // 프로젝트 라우트에 맞게 조정하세요 export default function QuestionLoading() { const navigate = useNavigate(); - const location = useLocation(); - - const { - resumeKey, - jobTitle, - interviewType, - } = location.state || {}; - - const [error, setError] = useState(''); + const location = useLocation() as { + state?: { + fileName?: string; + jobTitle?: string; + interviewType?: 'normal' | 'pressure'; + resumeKey?: string; // S3 key + }; + }; useEffect(() => { - // 필수 데이터 체크 - if (!resumeKey || !jobTitle || !interviewType) { - navigate('/upload', { replace: true }); - return; - } - - const createSession = async () => { + const bootstrap = async () => { try { - console.log('🎬 면접 세션 생성 시작'); - console.log('- Resume Key:', resumeKey); - console.log('- Job Title:', jobTitle); - console.log('- Interview Type:', interviewType); + const fileName = location.state?.fileName ?? '자소서'; + const jobTitle = location.state?.jobTitle; + const interviewType = location.state?.interviewType ?? 'normal'; + const resumeKey = location.state?.resumeKey; - // S3 key에서 resumeId 추출 - const resumeId = extractResumeId(resumeKey); - console.log('- Resume ID:', resumeId); + if (!jobTitle || !resumeKey) { + alert('면접 생성에 필요한 정보가 없습니다. (jobTitle/resumeKey)'); + navigate(-1); + return; + } - // 면접 세션 생성 - const session = await createInterviewSession({ - mode: interviewType === 'pressure' ? 'HARD' : 'NORMAL', - jobRole: jobTitle, - resumeId: resumeId, + // 스펙에 맞게 그대로 전송 + const resp: CreateInterviewSessionResponse = await createInterviewSession({ + resumeKey, + jobTitle, + interviewType, // 'normal' | 'pressure' }); - console.log('✅ 면접 세션 생성 성공:', session); - - // 면접 페이지로 이동 (약간의 딜레이 후) - setTimeout(() => { - navigate('/main-answer', { - state: { - sessionId: session.sessionId, - firstQuestionId: session.firstQuestionId, - firstQuestionText: session.firstQuestionText, - resumeKey, - jobTitle, - interviewType, - }, - replace: true, - }); - }, 1000); - } catch (err) { - console.error('❌ 면접 세션 생성 실패:', err); - setError('면접 세션 생성에 실패했습니다. 다시 시도해주세요.'); - - // 에러 시 3초 후 업로드 페이지로 이동 - setTimeout(() => { - navigate('/upload', { replace: true }); - }, 3000); + // 성공 → 답변 페이지로 이동 (필요값 전달) + 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); } }; - createSession(); - }, [navigate, resumeKey, jobTitle, interviewType]); + void bootstrap(); + }, [location.state, navigate]); return ( {/* 중앙 컨텐츠 영역 */}
- {error ? ( -
-

{error}

-

업로드 페이지로 돌아갑니다...

-
- ) : ( - <> -

AI가 맞춤형 질문을 생성중입니다 ...

+

AI가 맞춤형 질문을 생성중입니다 ...

- {/* 로딩 애니메이션 - 점 3개 */} -
-
-
-
-
- - )} + {/* 로딩 애니메이션 - 점 3개 */} +
+
+
+
+
{/* 오른쪽 캐릭터 이미지 */} @@ -100,20 +83,12 @@ export default function QuestionLoading() {
); diff --git a/src/services/interviewApi.ts b/src/services/interviewApi.ts index 9e87b7c..e8f6555 100644 --- a/src/services/interviewApi.ts +++ b/src/services/interviewApi.ts @@ -89,34 +89,6 @@ export interface FeedbackItem { timeout: boolean; } -/** - * Q&A 턴 정보 - */ -export interface QnaTurn { - turn: 'QUESTION' | 'ANSWER'; - content: string; -} - -/** - * 질문 요약 정보 - */ -export interface QuestionSummary { - questionNumber: number; - rootQuestion: string; - aiFeedback?: string; - selfFeedback?: string; - qnaTurns: QnaTurn[]; -} - -/** - * 면접 요약 정보 - */ -export interface InterviewSummary { - interviewTitle: string; - timeoutQuestionNumber: number; - questionSummaries: QuestionSummary[]; -} - /** * 최종 피드백 응답 */ @@ -125,8 +97,6 @@ export interface FinalFeedbackResponse { feedbacks: FeedbackItem[]; totalQuestions: number; timeoutCount: number; - feedbackProgressStatus?: 'WORKING' | 'FAILED' | 'COMPLETED'; - interviewSummary?: InterviewSummary; } // ==================== API 함수들 ==================== @@ -177,6 +147,18 @@ export const uploadToS3 = async ( }); }; +/** + * S3 key에서 resumeId 추출하는 헬퍼 함수 + * 예: "resumes/user123/resume-abc123.pdf" -> "resume-abc123" + */ +export const extractResumeId = (resumeKey: string): string => { + // S3 key에서 파일명 추출 (확장자 제외) + const parts = resumeKey.split('/'); + const fileName = parts[parts.length - 1]; + const nameWithoutExt = fileName.split('.').slice(0, -1).join('.'); + return nameWithoutExt; +}; + /** * 4. 자소서 기반 질문 생성 및 첫번째 질문 조회 */ From ea0caa4dc8c59cfaab9729406c24330ff8913777 Mon Sep 17 00:00:00 2001 From: Hello-Worldismine Date: Sun, 16 Nov 2025 23:57:44 +0900 Subject: [PATCH 06/12] =?UTF-8?q?=EC=98=A4=EB=A5=98=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- eslint.config.mjs | 11 + package-lock.json | 3 +- package.json | 2 +- src/components/RandomQuestion.tsx | 2 +- src/pages/Interview/feedback_result.tsx | 29 +-- src/pages/Interview/main_answer.tsx | 122 +++++----- src/pages/Interview/question_loading.tsx | 36 ++- src/services/interviewApi.ts | 274 +++++------------------ 8 files changed, 156 insertions(+), 323 deletions(-) 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/pages/Interview/feedback_result.tsx b/src/pages/Interview/feedback_result.tsx index cbc321a..40499df 100644 --- a/src/pages/Interview/feedback_result.tsx +++ b/src/pages/Interview/feedback_result.tsx @@ -3,8 +3,8 @@ 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 { getFinalFeedback } from '@/services/interviewApi'; -import type { FinalFeedbackResponse, FeedbackItem } from '@/services/interviewApi'; interface IQuestionState { id: number; @@ -16,30 +16,23 @@ export default function FeedbackResult() { const location = useLocation() as { state?: { sessionId?: string } }; const { sessionId } = location.state || {}; - const [feedbackData, setFeedbackData] = useState(null); + 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((_, index) => ({ - id: index + 1, - showAnswer: false, - })); + const states = response.feedbacks.map((_, idx) => ({ id: idx + 1, showAnswer: false })); setQuestionStates(states); } catch (err) { console.error('❌ 피드백 조회 실패:', err); @@ -48,15 +41,13 @@ export default function FeedbackResult() { setIsLoading(false); } }; - void fetchFeedback(); - }, [sessionId, navigate]); + }, [navigate, sessionId]); const toggleAnswer = (id: number) => { setQuestionStates((prev) => prev.map((q) => (q.id === id ? { ...q, showAnswer: !q.showAnswer } : q))); }; - // 로딩 중 if (isLoading) { return ( @@ -73,7 +64,6 @@ export default function FeedbackResult() { ); } - // 에러 if (error || !feedbackData) { return ( @@ -93,9 +83,7 @@ export default function FeedbackResult() { return ( - {/* 중앙 컨텐츠 영역 */}
- {/* 상단 정보 */}

총 {totalQuestions}문항에 대한 최종 피드백 @@ -108,11 +96,9 @@ export default function FeedbackResult() { )}

- {/* 질문 카드 그리드 */}
- {feedbacks.map((item: FeedbackItem, index: number) => { + {feedbacks.map((item: IFeedbackItem, 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; @@ -122,7 +108,6 @@ export default function FeedbackResult() { key={`${item.questionId}-${index}`} className={`rounded-2xl p-6 shadow-sm transition-colors ${isShowingAnswer ? 'bg-gray-200' : 'bg-white'}`} > - {/* 카드 헤더 */}

{index + 1}. {item.question} @@ -130,7 +115,6 @@ export default function FeedbackResult() {

{feedbackTypeLabel}

- {/* 카드 내용 (스크롤 가능) */}
{isShowingAnswer ? ( hasAnswer ? ( @@ -150,7 +134,6 @@ export default function FeedbackResult() { )}
- {/* 버튼 */}