From 4066a1ca3b7f5f81a8bcd011461ff146a8ad6391 Mon Sep 17 00:00:00 2001 From: Hello-Worldismine Date: Mon, 17 Nov 2025 02:02:46 +0900 Subject: [PATCH 01/18] =?UTF-8?q?=EC=84=B8=EB=B6=80=20=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/Interview/feedback_result.tsx | 115 ++++++++++++++++------- src/pages/Interview/question_loading.tsx | 45 +++++++-- src/services/interviewApi.ts | 67 ++++++++----- 3 files changed, 164 insertions(+), 63 deletions(-) diff --git a/src/pages/Interview/feedback_result.tsx b/src/pages/Interview/feedback_result.tsx index 40499df..af97f27 100644 --- a/src/pages/Interview/feedback_result.tsx +++ b/src/pages/Interview/feedback_result.tsx @@ -3,7 +3,7 @@ import { useEffect, useState } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; import InterviewLayout from '@/layouts/InterviewLayout'; -import type { IFeedbackItem, IFinalFeedbackResponse } from '@/services/interviewApi'; +import type { IFinalFeedbackResponse } from '@/services/interviewApi'; import { getFinalFeedback } from '@/services/interviewApi'; interface IQuestionState { @@ -27,13 +27,39 @@ export default function FeedbackResult() { navigate('/upload'); return; } + const fetchFeedback = async () => { try { setIsLoading(true); + console.log('๐Ÿ“Š ์ตœ์ข… ํ”ผ๋“œ๋ฐฑ ์กฐํšŒ:', sessionId); + const response = await getFinalFeedback(sessionId); - setFeedbackData(response); - const states = response.feedbacks.map((_, idx) => ({ id: idx + 1, showAnswer: false })); - setQuestionStates(states); + + console.log('โœ… ํ”ผ๋“œ๋ฐฑ ์‘๋‹ต:', response); + + // feedbackProgressStatus ํ™•์ธ + if (response.feedbackProgressStatus === 'WORKING') { + // ํ”ผ๋“œ๋ฐฑ ์ƒ์„ฑ ์ค‘ - 5์ดˆ ํ›„ ์žฌ์‹œ๋„ + setTimeout(fetchFeedback, 5000); + return; + } + + if (response.feedbackProgressStatus === 'FAILED') { + setError('ํ”ผ๋“œ๋ฐฑ ์ƒ์„ฑ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.'); + setIsLoading(false); + return; + } + + if (response.interviewSummary) { + setFeedbackData(response); + + // ์งˆ๋ฌธ ์ƒํƒœ ์ดˆ๊ธฐํ™” + const states = response.interviewSummary.questionSummaries.map((_, idx) => ({ + id: idx + 1, + showAnswer: false, + })); + setQuestionStates(states); + } } catch (err) { console.error('โŒ ํ”ผ๋“œ๋ฐฑ ์กฐํšŒ ์‹คํŒจ:', err); setError('ํ”ผ๋“œ๋ฐฑ์„ ๋ถˆ๋Ÿฌ์˜ค๋Š”๋ฐ ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.'); @@ -41,6 +67,7 @@ export default function FeedbackResult() { setIsLoading(false); } }; + void fetchFeedback(); }, [navigate, sessionId]); @@ -64,7 +91,7 @@ export default function FeedbackResult() { ); } - if (error || !feedbackData) { + if (error || !feedbackData || !feedbackData.interviewSummary) { return (
@@ -79,61 +106,69 @@ export default function FeedbackResult() { ); } - const { feedbacks, totalQuestions, timeoutCount } = feedbackData; + const { interviewSummary } = feedbackData; return (

- ์ด {totalQuestions}๋ฌธํ•ญ์— ๋Œ€ํ•œ ์ตœ์ข… ํ”ผ๋“œ๋ฐฑ + {interviewSummary.interviewTitle}์— ๋Œ€ํ•œ ์ตœ์ข… + ํ”ผ๋“œ๋ฐฑ

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

์‹œ๊ฐ„ ์ดˆ๊ณผ๋กœ ๋‹ต๋ณ€ํ•˜์ง€ ๋ชปํ•œ ์งˆ๋ฌธ{' '} - {timeoutCount}๊ฐœ + {interviewSummary.timeoutQuestionNumber}๊ฐœ

)}
- {feedbacks.map((item: IFeedbackItem, index: number) => { + {interviewSummary.questionSummaries.map((summary, index) => { const isShowingAnswer = questionStates.find((q) => q.id === index + 1)?.showAnswer || false; - const isPositive = item.feedbackType === 'positive'; - const feedbackTypeLabel = isPositive ? 'AI ํ”ผ๋“œ๋ฐฑ(๊ธ์ •)' : 'AI ํ”ผ๋“œ๋ฐฑ(๊ฐœ์„ )'; - const hasAnswer = !!item.answer && item.answer.trim().length > 0; + + // AI ํ”ผ๋“œ๋ฐฑ๊ณผ ์…€ํ”„ ํ”ผ๋“œ๋ฐฑ ์ค‘ ํ‘œ์‹œํ•  ๊ฒƒ ์„ ํƒ + const feedbackText = summary.aiFeedback || summary.selfFeedback; + const feedbackType = summary.aiFeedback ? 'AI ํ”ผ๋“œ๋ฐฑ' : summary.selfFeedback ? '์…€ํ”„ ํ”ผ๋“œ๋ฐฑ' : 'ํ”ผ๋“œ๋ฐฑ ์—†์Œ'; + + // ๋‹ต๋ณ€ ํ…์ŠคํŠธ (Q&A ํ„ด์—์„œ ANSWER๋งŒ ์ถ”์ถœ) + const answerTurns = summary.qnaTurns.filter((turn) => turn.turn === 'ANSWER'); + const hasAnswer = answerTurns.length > 0; return ( -
+
+ {/* ์นด๋“œ ํ—ค๋” */}

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

-

{feedbackTypeLabel}

+

{feedbackType}

+ {/* ์นด๋“œ ๋‚ด์šฉ (์Šคํฌ๋กค ๊ฐ€๋Šฅ) */}
{isShowingAnswer ? ( hasAnswer ? (
-
-

๋‹ต๋ณ€:

-

{item.answer}

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

{turn.turn === 'QUESTION' ? '์งˆ๋ฌธ:' : '๋‹ต๋ณ€:'}

+

{turn.content}

+
+ ))}
) : ( -

{item.timeout ? '์‹œ๊ฐ„ ์ดˆ๊ณผ๋กœ ๋‹ต๋ณ€ํ•˜์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค.' : '๋‹ต๋ณ€์ด ์ œ๊ณต๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค.'}

+

์‹œ๊ฐ„ ์ดˆ๊ณผ๋กœ ๋‹ต๋ณ€ํ•˜์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค.

) - ) : item.feedback ? ( -

{item.feedback}

+ ) : feedbackText ? ( +

{feedbackText}

) : (

ํ”ผ๋“œ๋ฐฑ์ด ์ƒ์„ฑ๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค.

)}
+ {/* ๋ฒ„ํŠผ */}
); diff --git a/src/pages/Interview/question_loading.tsx b/src/pages/Interview/question_loading.tsx index b19f35c..17e5fcd 100644 --- a/src/pages/Interview/question_loading.tsx +++ b/src/pages/Interview/question_loading.tsx @@ -1,17 +1,17 @@ -๏ปฟimport { useEffect } from 'react'; +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'; // ํ”„๋กœ์ ํŠธ ๋ผ์šฐํŠธ์— ๋งž๊ฒŒ ์กฐ์ • +const ANSWER_ROUTE = '/main-answer'; type TLocationState = { fileName?: string; jobTitle?: string; interviewType?: 'normal' | 'pressure'; - resumeKey?: string; // S3 key + resumeKey?: string; }; export default function QuestionLoading() { @@ -26,20 +26,29 @@ export default function QuestionLoading() { const interviewType = location.state?.interviewType ?? 'normal'; const resumeKey = location.state?.resumeKey; + console.log('๐ŸŽฌ ๋ฉด์ ‘ ์„ธ์…˜ ์ƒ์„ฑ ์‹œ์ž‘'); + console.log('- fileName:', fileName); + console.log('- jobTitle:', jobTitle); + console.log('- interviewType:', interviewType); + console.log('- resumeKey:', resumeKey); + if (!jobTitle || !resumeKey) { alert('๋ฉด์ ‘ ์ƒ์„ฑ์— ํ•„์š”ํ•œ ์ •๋ณด๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค. (jobTitle/resumeKey)'); navigate(-1); return; } - // ์ŠคํŽ™์— ๋งž๊ฒŒ ์š”์ฒญ const resp: ICreateInterviewSessionResponse = await createInterviewSession({ resumeKey, jobTitle, interviewType, }); - // ์„ฑ๊ณต โ†’ ๋‹ต๋ณ€ ํŽ˜์ด์ง€๋กœ ์ด๋™ (ํ•„์š”๊ฐ’ ์ „๋‹ฌ) + console.log('โœ… ๋ฉด์ ‘ ์„ธ์…˜ ์ƒ์„ฑ ์„ฑ๊ณต:', resp); + console.log('- sessionId:', resp.sessionId); + console.log('- firstQuestion:', resp.firstQuestion); + + // ์„ฑ๊ณต โ†’ ๋‹ต๋ณ€ ํŽ˜์ด์ง€๋กœ ์ด๋™ navigate(ANSWER_ROUTE, { replace: true, state: { @@ -52,8 +61,12 @@ export default function QuestionLoading() { fromLoading: true, }, }); - } catch (e) { - console.error('์งˆ๋ฌธ ์ƒ์„ฑ ์‹คํŒจ:', e); + } catch (e: any) { + console.error('โŒ ์งˆ๋ฌธ ์ƒ์„ฑ ์‹คํŒจ:', e); + console.error('- ์—๋Ÿฌ ๋ฉ”์‹œ์ง€:', e.message); + console.error('- ์—๋Ÿฌ ์‘๋‹ต:', e.response?.data); + console.error('- ์—๋Ÿฌ ์ƒํƒœ:', e.response?.status); + alert('๋งž์ถคํ˜• ์งˆ๋ฌธ ์ƒ์„ฑ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค. ์ž ์‹œ ํ›„ ๋‹ค์‹œ ์‹œ๋„ํ•ด์ฃผ์„ธ์š”.'); navigate(-1); } @@ -82,9 +95,21 @@ export default function QuestionLoading() {
); diff --git a/src/services/interviewApi.ts b/src/services/interviewApi.ts index bcf7cf4..5d57955 100644 --- a/src/services/interviewApi.ts +++ b/src/services/interviewApi.ts @@ -142,29 +142,54 @@ export interface ICreateInterviewSessionResponse { export const createInterviewSession = async ( data: ICreateInterviewSessionRequest, ): Promise => { - const resumeId = extractResumeId(data.resumeKey); - const mode: InterviewMode = data.interviewType === 'pressure' ? 'HARD' : 'NORMAL'; + try { + console.log('๐ŸŽฌ API ํ˜ธ์ถœ: /api/interview-sessions'); + console.log('- resumeKey:', data.resumeKey); + console.log('- jobTitle:', data.jobTitle); + console.log('- interviewType:', data.interviewType); - const payload: ICreateInterviewSessionPayload = { - mode, - jobRole: data.jobTitle, - resumeId, - }; + const resumeId = extractResumeId(data.resumeKey); + const mode: InterviewMode = data.interviewType === 'pressure' ? 'HARD' : 'NORMAL'; - const response = await apiClient.post('/api/interview-sessions', payload); - const apiResult = unwrapResult(response.data); + const payload: ICreateInterviewSessionPayload = { + mode, + jobRole: data.jobTitle, + resumeId, + }; - const firstQuestion: IQuestion = { - questionId: String(apiResult.firstQuestionId), - mainQuestion: apiResult.firstQuestionText, - subQuestion: '', - order: 1, - }; + console.log('๐Ÿ“ค ์š”์ฒญ payload:', payload); - return { - sessionId: String(apiResult.sessionId), - firstQuestion, - }; + const response = await apiClient.post('/api/interview-sessions', payload); + + console.log('๐Ÿ“ฅ ์›๋ณธ ์‘๋‹ต:', response.data); + + const apiResult = unwrapResult(response.data); + + console.log('โœ… unwrap ํ›„ ๊ฒฐ๊ณผ:', apiResult); + + const firstQuestion: IQuestion = { + questionId: String(apiResult.firstQuestionId), + mainQuestion: apiResult.firstQuestionText, + subQuestion: '', + order: 1, + }; + + const result = { + sessionId: String(apiResult.sessionId), + firstQuestion, + }; + + console.log('โœ… ์ตœ์ข… ๋ฐ˜ํ™˜๊ฐ’:', result); + + return result; + } catch (error: any) { + console.error('โŒ createInterviewSession ์—๋Ÿฌ:'); + console.error('- ์—๋Ÿฌ ๊ฐ์ฒด:', error); + console.error('- ์—๋Ÿฌ ๋ฉ”์‹œ์ง€:', error.message); + console.error('- ์‘๋‹ต ๋ฐ์ดํ„ฐ:', error.response?.data); + console.error('- ์‘๋‹ต ์ƒํƒœ:', error.response?.status); + throw error; + } }; // ===================================================== @@ -407,10 +432,10 @@ export interface IFinalFeedbackResponse { interviewSummary: IInterviewSummary | null; // WORKING์ผ ๋•Œ๋Š” null } -/** ์ตœ์ข… ํ”ผ๋“œ๋ฐฑ ์กฐํšŒ (GET /api/interview-sesisons/{sessionId}) */ +/** ์ตœ์ข… ํ”ผ๋“œ๋ฐฑ ์กฐํšŒ (GET /api/interview-sessions/{sessionId}) */ export const getFinalFeedback = async ( sessionId: string | number, ): Promise => { - const response = await apiClient.get(`/api/interview-sesisons/${sessionId}`); + const response = await apiClient.get(`/api/interview-sessions/${sessionId}`); return unwrapResult(response.data); }; From 9f47ad9b6700c68050c234f41ac71b5b851d5b07 Mon Sep 17 00:00:00 2001 From: Hello-Worldismine Date: Mon, 17 Nov 2025 02:06:25 +0900 Subject: [PATCH 02/18] =?UTF-8?q?500=EC=97=90=EB=9F=AC=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/services/api.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/services/api.ts b/src/services/api.ts index 0fae877..0db2d7f 100644 --- a/src/services/api.ts +++ b/src/services/api.ts @@ -39,6 +39,17 @@ apiClient.interceptors.response.use( // ์„œ๋ฒ„๊ฐ€ ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ ๊ฒฝ์šฐ console.error('API Error:', error.response.data); + // ํŠน๋ณ„ ์ฒ˜๋ฆฌ: 500 ์—๋Ÿฌ์—ฌ๋„ result ๋ฐ์ดํ„ฐ๊ฐ€ ์žˆ์œผ๋ฉด ์„ฑ๊ณต์œผ๋กœ ์ฒ˜๋ฆฌ + // (๋ฐฑ์—”๋“œ ๋ฒ„๊ทธ: 500 ์ฝ”๋“œ๋ฅผ ๋ณด๋‚ด์ง€๋งŒ ์ •์ƒ ๋ฐ์ดํ„ฐ๋„ ํ•จ๊ป˜ ๋ณด๋ƒ„) + if ( + error.response.status === 500 && + error.response.data && + (error.response.data.result || error.response.data.sessionId) + ) { + console.warn('โš ๏ธ 500 ์—๋Ÿฌ์ง€๋งŒ ์ •์ƒ ๋ฐ์ดํ„ฐ๊ฐ€ ์žˆ์–ด ์„ฑ๊ณต์œผ๋กœ ์ฒ˜๋ฆฌํ•ฉ๋‹ˆ๋‹ค.'); + return error.response; + } + // 401 Unauthorized if (error.response.status === 401) { // ๋กœ๊ทธ์ธ ํŽ˜์ด์ง€๋กœ ๋ฆฌ๋‹ค์ด๋ ‰ํŠธ ๋“ฑ From 6a7922708cd8adb0fc04c317e37528583eca7d50 Mon Sep 17 00:00:00 2001 From: Hello-Worldismine Date: Mon, 17 Nov 2025 02:12:53 +0900 Subject: [PATCH 03/18] =?UTF-8?q?500=EC=97=90=EB=9F=AC=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/services/api.ts | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/src/services/api.ts b/src/services/api.ts index 0db2d7f..d78c34c 100644 --- a/src/services/api.ts +++ b/src/services/api.ts @@ -34,22 +34,22 @@ apiClient.interceptors.response.use( return response; }, (error) => { + // ํŠน๋ณ„ ์ฒ˜๋ฆฌ: 500 ์—๋Ÿฌ์—ฌ๋„ result ๋ฐ์ดํ„ฐ๊ฐ€ ์žˆ์œผ๋ฉด ์„ฑ๊ณต์œผ๋กœ ์ฒ˜๋ฆฌ + // (๋ฐฑ์—”๋“œ ๋ฒ„๊ทธ: 500 ์ฝ”๋“œ๋ฅผ ๋ณด๋‚ด์ง€๋งŒ ์ •์ƒ ๋ฐ์ดํ„ฐ๋„ ํ•จ๊ป˜ ๋ณด๋ƒ„) + if ( + error.response?.status === 500 && + error.response?.data && + (error.response.data.result || error.response.data.sessionId || error.response.data.interviewSummary) + ) { + // ์กฐ์šฉํžˆ ์„ฑ๊ณต์œผ๋กœ ์ฒ˜๋ฆฌ (์—๋Ÿฌ ๋กœ๊ทธ ์—†์ด) + return error.response; + } + // ์—๋Ÿฌ ์ฒ˜๋ฆฌ if (error.response) { // ์„œ๋ฒ„๊ฐ€ ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ ๊ฒฝ์šฐ console.error('API Error:', error.response.data); - // ํŠน๋ณ„ ์ฒ˜๋ฆฌ: 500 ์—๋Ÿฌ์—ฌ๋„ result ๋ฐ์ดํ„ฐ๊ฐ€ ์žˆ์œผ๋ฉด ์„ฑ๊ณต์œผ๋กœ ์ฒ˜๋ฆฌ - // (๋ฐฑ์—”๋“œ ๋ฒ„๊ทธ: 500 ์ฝ”๋“œ๋ฅผ ๋ณด๋‚ด์ง€๋งŒ ์ •์ƒ ๋ฐ์ดํ„ฐ๋„ ํ•จ๊ป˜ ๋ณด๋ƒ„) - if ( - error.response.status === 500 && - error.response.data && - (error.response.data.result || error.response.data.sessionId) - ) { - console.warn('โš ๏ธ 500 ์—๋Ÿฌ์ง€๋งŒ ์ •์ƒ ๋ฐ์ดํ„ฐ๊ฐ€ ์žˆ์–ด ์„ฑ๊ณต์œผ๋กœ ์ฒ˜๋ฆฌํ•ฉ๋‹ˆ๋‹ค.'); - return error.response; - } - // 401 Unauthorized if (error.response.status === 401) { // ๋กœ๊ทธ์ธ ํŽ˜์ด์ง€๋กœ ๋ฆฌ๋‹ค์ด๋ ‰ํŠธ ๋“ฑ From 54d75718628655191bd5ef5a6dfba023d362c432 Mon Sep 17 00:00:00 2001 From: Hello-Worldismine Date: Mon, 17 Nov 2025 02:15:00 +0900 Subject: [PATCH 04/18] =?UTF-8?q?500=EC=97=90=EB=9F=AC=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/Interview/question_loading.tsx | 49 +++++++++--------------- 1 file changed, 19 insertions(+), 30 deletions(-) diff --git a/src/pages/Interview/question_loading.tsx b/src/pages/Interview/question_loading.tsx index 17e5fcd..fbc59f5 100644 --- a/src/pages/Interview/question_loading.tsx +++ b/src/pages/Interview/question_loading.tsx @@ -1,4 +1,5 @@ -import { useEffect } from 'react'; +// src/pages/Interview/question_loading.tsx +import { useEffect, useRef } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; import InterviewLayout from '@/layouts/InterviewLayout'; @@ -11,14 +12,21 @@ type TLocationState = { fileName?: string; jobTitle?: string; interviewType?: 'normal' | 'pressure'; - resumeKey?: string; + resumeKey?: string; // S3 key }; export default function QuestionLoading() { const navigate = useNavigate(); const location = useLocation() as { state?: TLocationState }; + // โœ… ๋ถ€ํŠธ์ŠคํŠธ๋žฉ์ด ์ค‘๋ณต ์‹คํ–‰๋˜๋Š” ๊ฒƒ์„ ๋ง‰๊ธฐ ์œ„ํ•œ ref ๊ฐ€๋“œ + const bootstrappedRef = useRef(false); + useEffect(() => { + // ์ด๋ฏธ ์‹คํ–‰๋œ ์  ์žˆ์œผ๋ฉด ๋‹ค์‹œ ์‹คํ–‰ํ•˜์ง€ ์•Š์Œ + if (bootstrappedRef.current) return; + bootstrappedRef.current = true; + const bootstrap = async () => { try { const fileName = location.state?.fileName ?? '์ž์†Œ์„œ'; @@ -26,29 +34,20 @@ export default function QuestionLoading() { const interviewType = location.state?.interviewType ?? 'normal'; const resumeKey = location.state?.resumeKey; - console.log('๐ŸŽฌ ๋ฉด์ ‘ ์„ธ์…˜ ์ƒ์„ฑ ์‹œ์ž‘'); - console.log('- fileName:', fileName); - console.log('- jobTitle:', jobTitle); - console.log('- interviewType:', interviewType); - console.log('- resumeKey:', resumeKey); - if (!jobTitle || !resumeKey) { alert('๋ฉด์ ‘ ์ƒ์„ฑ์— ํ•„์š”ํ•œ ์ •๋ณด๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค. (jobTitle/resumeKey)'); navigate(-1); return; } + // ์ŠคํŽ™์— ๋งž๊ฒŒ ์„ธ์…˜ ์ƒ์„ฑ ์š”์ฒญ const resp: ICreateInterviewSessionResponse = await createInterviewSession({ resumeKey, jobTitle, interviewType, }); - console.log('โœ… ๋ฉด์ ‘ ์„ธ์…˜ ์ƒ์„ฑ ์„ฑ๊ณต:', resp); - console.log('- sessionId:', resp.sessionId); - console.log('- firstQuestion:', resp.firstQuestion); - - // ์„ฑ๊ณต โ†’ ๋‹ต๋ณ€ ํŽ˜์ด์ง€๋กœ ์ด๋™ + // โœ… ์„ฑ๊ณต โ†’ ๋‹ต๋ณ€ ํŽ˜์ด์ง€๋กœ ์ด๋™ navigate(ANSWER_ROUTE, { replace: true, state: { @@ -61,19 +60,16 @@ export default function QuestionLoading() { fromLoading: true, }, }); - } catch (e: any) { - console.error('โŒ ์งˆ๋ฌธ ์ƒ์„ฑ ์‹คํŒจ:', e); - console.error('- ์—๋Ÿฌ ๋ฉ”์‹œ์ง€:', e.message); - console.error('- ์—๋Ÿฌ ์‘๋‹ต:', e.response?.data); - console.error('- ์—๋Ÿฌ ์ƒํƒœ:', e.response?.status); - + } catch (e) { + console.error('์งˆ๋ฌธ ์ƒ์„ฑ ์‹คํŒจ:', e); alert('๋งž์ถคํ˜• ์งˆ๋ฌธ ์ƒ์„ฑ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค. ์ž ์‹œ ํ›„ ๋‹ค์‹œ ์‹œ๋„ํ•ด์ฃผ์„ธ์š”.'); navigate(-1); } }; void bootstrap(); - }, [location.state, navigate]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [navigate]); // location.state๋Š” ์ดˆ๊ธฐ ํ•œ ๋ฒˆ๋งŒ ์“ฐ๊ณ , ์žฌ์‹คํ–‰์„ ๋ง‰๊ธฐ ์œ„ํ•ด deps์—์„œ ์ œ์™ธ return ( @@ -95,17 +91,10 @@ export default function QuestionLoading() {
); diff --git a/src/hooks/useGetInterviewSummary.ts b/src/hooks/useGetInterviewSummary.ts new file mode 100644 index 0000000..da0bb4d --- /dev/null +++ b/src/hooks/useGetInterviewSummary.ts @@ -0,0 +1,9 @@ +import { getInterviewSummary } from '@/apis/myPage'; +import { useCoreQuery } from '@/hooks/customQuery'; + +export default function useGetInterviewSummary(interviewId: number) { + const { data, isLoading, error } = useCoreQuery(['getInterviewSummary', interviewId], () => getInterviewSummary(interviewId), { + enabled: !!interviewId, + }); + return { data, isLoading, error }; +} diff --git a/src/hooks/useGetQuestionAnswers.ts b/src/hooks/useGetQuestionAnswers.ts new file mode 100644 index 0000000..9c051e0 --- /dev/null +++ b/src/hooks/useGetQuestionAnswers.ts @@ -0,0 +1,9 @@ +import { getQuestionAnswers } from '@/apis/myPage'; +import { useCoreQuery } from '@/hooks/customQuery'; + +export default function useGetQuestionAnswers(questionId: number | null) { + const { data, isLoading, error } = useCoreQuery(['getQuestionAnswers', questionId], () => getQuestionAnswers(questionId!), { + enabled: !!questionId, + }); + return { data, isLoading, error }; +} diff --git a/src/hooks/useGetQuestionFeedback.ts b/src/hooks/useGetQuestionFeedback.ts new file mode 100644 index 0000000..cc7c243 --- /dev/null +++ b/src/hooks/useGetQuestionFeedback.ts @@ -0,0 +1,9 @@ +import { getQuestionFeedback } from '@/apis/myPage'; +import { useCoreQuery } from '@/hooks/customQuery'; + +export default function useGetQuestionFeedback(questionId: number | null) { + const { data, isLoading, error } = useCoreQuery(['getQuestionFeedback', questionId], () => getQuestionFeedback(questionId!), { + enabled: !!questionId, + }); + return { data, isLoading, error }; +} diff --git a/src/hooks/useGetRandomQuestions.ts b/src/hooks/useGetRandomQuestions.ts new file mode 100644 index 0000000..84e90e1 --- /dev/null +++ b/src/hooks/useGetRandomQuestions.ts @@ -0,0 +1,9 @@ +import { getRandomQuestions } from '@/apis/myPage'; +import { useCoreQuery } from '@/hooks/customQuery'; + +export default function useGetRandomQuestions(questionId: number | null) { + const { data, isLoading, error } = useCoreQuery(['getRandomQuestions', questionId], () => getRandomQuestions(questionId!), { + enabled: !!questionId, + }); + return { data, isLoading, error }; +} diff --git a/src/pages/myInterviews.tsx b/src/pages/myInterviews.tsx index 32d2106..bf16c3d 100644 --- a/src/pages/myInterviews.tsx +++ b/src/pages/myInterviews.tsx @@ -1,8 +1,275 @@ -export default function MyInterview() { +import { useEffect, useRef, useState } from 'react'; +import { useParams } from 'react-router-dom'; + +import useGetInterviewSummary from '@/hooks/useGetInterviewSummary'; +import useGetQuestionAnswers from '@/hooks/useGetQuestionAnswers'; +import useGetQuestionFeedback from '@/hooks/useGetQuestionFeedback'; +import useGetRandomQuestions from '@/hooks/useGetRandomQuestions'; + +import ClockFrog from '@/assets/clockFrog.svg?react'; + +type TabType = 'answer' | 'feedback' | 'random'; + +export default function MyInterviews() { + const { id } = useParams<{ id: string }>(); + const interviewId = id ? parseInt(id) : 0; + + const [selectedQuestionId, setSelectedQuestionId] = useState(null); + const [activeTab, setActiveTab] = useState('answer'); + const [playingAudio, setPlayingAudio] = useState(null); + const audioRef = useRef(null); + + const { data: summaryData, isLoading: summaryLoading } = useGetInterviewSummary(interviewId); + const { data: answersData, isLoading: answersLoading } = useGetQuestionAnswers(activeTab === 'answer' ? selectedQuestionId : null); + const { data: feedbackData, isLoading: feedbackLoading } = useGetQuestionFeedback(activeTab === 'feedback' ? selectedQuestionId : null); + const { data: randomData, isLoading: randomLoading } = useGetRandomQuestions(activeTab === 'random' ? selectedQuestionId : null); + + // ์ฒซ ๋ฒˆ์งธ ์งˆ๋ฌธ ์ž๋™ ์„ ํƒ + useEffect(() => { + if (summaryData?.result.questionCards && summaryData.result.questionCards.length > 0 && !selectedQuestionId) { + setSelectedQuestionId(summaryData.result.questionCards[0].questionId); + } + }, [summaryData, selectedQuestionId]); + + const handleAudioPlay = (url: string) => { + if (playingAudio === url) { + audioRef.current?.pause(); + setPlayingAudio(null); + } else { + if (audioRef.current) { + audioRef.current.pause(); + } + audioRef.current = new Audio(url); + audioRef.current.play(); + setPlayingAudio(url); + + audioRef.current.onended = () => { + setPlayingAudio(null); + }; + } + }; + + if (summaryLoading) { + return ( +
+
+ +

๋ฐ์ดํ„ฐ๋ฅผ ๋ถˆ๋Ÿฌ์˜ค๋Š” ์ค‘...

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

๋ฉด์ ‘ ๋ฐ์ดํ„ฐ๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.

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

interview

-

hi

+
+
+ {/* ํ—ค๋” */} +
+

{title}

+ {timedOutCount > 0 && ( +

+ ์‹œ๊ฐ„ ์ดˆ๊ณผ๋กœ ๋‹ต๋ณ€ํ•˜์ง€ ๋ชปํ•œ ์งˆ๋ฌธ{' '} + {timedOutCount}๊ฐœ +

+ )} +
+ + {/* ์งˆ๋ฌธ ์นด๋“œ ๋ฆฌ์ŠคํŠธ */} +
+

๋ฉด์ ‘ ์งˆ๋ฌธ

+
+ {questionCards.map((card) => ( + + ))} +
+
+ + {/* ํƒญ ๋ฉ”๋‰ด */} +
+
+ + + +
+ + {/* ํƒญ ์ฝ˜ํ…์ธ  */} +
+ {isLoading ? ( +
+ +
+ ) : ( + <> + {/* ๋‹ต๋ณ€ ํ™•์ธ ํƒญ */} + {activeTab === 'answer' && answersData?.result && ( +
+ {answersData.result.map((item) => ( +
+
+ + ์งˆ๋ฌธ {item.order} + +

{item.question}

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

{item.answerText}

+ + + ) : ( +

๋‹ต๋ณ€์ด ์—†์Šต๋‹ˆ๋‹ค.

+ )} +
+ ))} +
+ )} + + {/* ํ”ผ๋“œ๋ฐฑ ํ™•์ธ ํƒญ */} + {activeTab === 'feedback' && feedbackData?.result && ( +
+ {/* AI ํ”ผ๋“œ๋ฐฑ */} + {feedbackData.result.aiFeedback && ( +
+

๐Ÿค– AI ํ”ผ๋“œ๋ฐฑ

+

{feedbackData.result.aiFeedback}

+
+ )} + + {/* ์…€ํ”„ ํ”ผ๋“œ๋ฐฑ */} + {feedbackData.result.selfFeedback && ( +
+

โœ๏ธ ์…€ํ”„ ํ”ผ๋“œ๋ฐฑ

+

{feedbackData.result.selfFeedback}

+
+ )} + + {/* ๋™๋ฃŒ ํ”ผ๋“œ๋ฐฑ */} + {feedbackData.result.peerItems && feedbackData.result.peerItems.length > 0 && ( +
+

๐Ÿ‘ฅ ๋™๋ฃŒ ํ”ผ๋“œ๋ฐฑ

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

ํ”ผ๋“œ๋ฐฑ์ด ์—†์Šต๋‹ˆ๋‹ค.

+ )} +
+ )} + + {/* ๋žœ๋ค ์งˆ๋ฌธ ํƒญ */} + {activeTab === 'random' && randomData?.result && ( +
+ {randomData.result.length > 0 ? ( + randomData.result.map((item, index) => ( +
+
+ + ๋žœ๋ค ์งˆ๋ฌธ {index + 1} + +

{item.question}

+
+ +
+

๋‹ต๋ณ€:

+

{item.answerText}

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

AI ํ”ผ๋“œ๋ฐฑ

+

{item.aiFeedback}

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

์…€ํ”„ ํ”ผ๋“œ๋ฐฑ

+

{item.selfFeedback}

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

๋žœ๋ค ์งˆ๋ฌธ์ด ์—†์Šต๋‹ˆ๋‹ค.

+ )} +
+ )} + + )} +
+
+
); } diff --git a/src/routes/index.tsx b/src/routes/index.tsx index 43f0d0e..68cbb3a 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -32,7 +32,7 @@ export const router = createBrowserRouter([ { path: 'community/write', element: }, { path: 'community/edit/:id', element: }, { path: 'community/detail/:id', element: }, - { path: 'myInterview/:id', element: }, + { path: 'myInterviews/:id', element: }, { path: 'myEvaluate/:id', element: }, { path: 'upload', element: }, // โ† myInterview์—์„œ upload๋กœ ๋ณ€๊ฒฝ { path: 'upload-done', element: }, diff --git a/src/services/randomQuestionApi.ts b/src/services/randomQuestionApi.ts index 5ddff10..587a0b4 100644 --- a/src/services/randomQuestionApi.ts +++ b/src/services/randomQuestionApi.ts @@ -1,91 +1,164 @@ import apiClient from './api'; +// result๋งŒ ๋ฝ‘์•„์ฃผ๋Š” ํ—ฌํผ +const unwrapResult = (data: any): T => { + if (data && typeof data === 'object' && 'result' in data) { + return data.result as T; + } + return data as T; +}; + // ==================== ํƒ€์ž… ์ •์˜ ==================== +// 1) SSE ์•Œ๋ฆผ payload (ํŒ์—… ์•Œ๋ฆผ์šฉ) โ€” ํŒ์—… ์•Œ๋ฆผ์„ ์œ„ํ•œ SSE ๊ตฌ๋…์šฉ API +export interface IRandomNotificationPayload { + jobName: string; // ์ง๋ฌด ์ด๋ฆ„ + interviewName: string; // ์ธํ„ฐ๋ทฐ ์ œ๋ชฉ + questionNumber: number; // ํ•ด๋‹น ์ธํ„ฐ๋ทฐ์˜ ๋ช‡ ๋ฒˆ์งธ ์งˆ๋ฌธ์ธ์ง€ + peerFeedbackId: number; // = peerAnswerId (๋žœ๋ค ์งˆ๋ฌธ ์กฐํšŒ์— ์‚ฌ์šฉ) +} + +// 2) ๋žœ๋ค ํŒ์—… ์งˆ๋ฌธ ์กฐํšŒ ์‘๋‹ต โ€” ๋žœ๋ค ํŒ์—… ์งˆ๋ฌธ ์กฐํšŒ API +export interface IRandomQuestionContext { + questionId: number; + questionText: string; + presignedRecordingGetUrl: string; + sttText: string; +} + export interface IRandomQuestion { - questionId: string; - question: string; - peerAnswerId: string; + question: { + questionId: number; + questionText: string; + }; + context: IRandomQuestionContext; +} + +// 3) presign ์‘๋‹ต โ€” ๋žœ๋ค ํŒ์—… ์งˆ๋ฌธ - ๋…น์Œ ์—…๋กœ๋“œ์šฉ ํ”„๋ฆฌ์‚ฌ์ธ URL ๋ฐœ๊ธ‰ +export interface IPresignUrlResponse { + uploadUrl: string; // S3 PUT presigned URL + key: string; // ์—…๋กœ๋“œ๋  S3 ์˜ค๋ธŒ์ ํŠธ ๊ฒฝ๋กœ + requiredHeaders: Record; // PUT ์‹œ ํ•จ๊ป˜ ๋ณด๋‚ด์•ผ ํ•  ํ—ค๋”๋“ค } +// recordingKey ์š”์ฒญ์šฉ ํƒ€์ž…์€ ๋” ์ด์ƒ ์‚ฌ์šฉ ์•ˆ ํ•˜์ง€๋งŒ, ๋‚จ๊ฒจ๋‘  (ํ˜ธํ™˜์šฉ) export interface IRandomQuestionRecordingRequest { recordingKey: string; } +// 4) ๋…น์Œ ์ €์žฅ ์‘๋‹ต โ€” ๋žœ๋ค ์งˆ๋ฌธ์— ๋Œ€ํ•œ recording ์ €์žฅ ๋ฐ ํ”ผ๋“œ๋ฐฑ ์ƒ์„ฑ API (๋น„๋™๊ธฐ) export interface IFeedbackRecordingResponse { - recordingId: string; - status: 'processing' | 'completed' | 'failed'; + recordingId: number; + status: 'UPLOADED'; // ๋น„๋™๊ธฐ ์ž‘์—…์ด ํ์— ์˜ฌ๋ผ๊ฐ”๋‹ค๋Š” ๋œป } -export interface IFeedbackResultResponse { - status: 'processing' | 'completed' | 'failed'; - feedback?: string; +// 5) ํ”ผ๋“œ๋ฐฑ ์กฐํšŒ ์‘๋‹ต โ€” ๋žœ๋ค ์งˆ๋ฌธ์— ๋Œ€ํ•œ ํ”ผ๋“œ๋ฐฑ ํ™•์ธ API (polling) +export type FeedbackProgressStatus = 'WORKING' | 'READY' | 'FAILED'; + +export interface IFeedbackResult { + questionId: number; + questionText: string; + aiFeedback: string; + selfFeedback: string; + presignedRecordingGetUrl: string; + sttText: string; } -export interface IPresignUrlResponse { - presignedUrl: string; - fileKey: string; +export interface IFeedbackResultResponse { + progressStatus: FeedbackProgressStatus; + result: IFeedbackResult | null; // WORKING/FAILED์ผ ๋•Œ๋Š” null } // ==================== API ํ•จ์ˆ˜๋“ค ==================== /** * 1. ๋žœ๋ค ํŒ์—… ์งˆ๋ฌธ ์กฐํšŒ + * GET /api/random-questions/peer/{peerAnswerId} + * peerAnswerId = SSE ์•Œ๋ฆผ์˜ peerFeedbackId */ -export const getRandomQuestion = async (peerAnswerId: string): Promise => { +export const getRandomQuestion = async ( + peerAnswerId: number | string, +): Promise => { const response = await apiClient.get(`/api/random-questions/peer/${peerAnswerId}`); - return response.data; + return unwrapResult(response.data); }; /** * 2. ๋žœ๋ค ํŒ์—… ์งˆ๋ฌธ - ๋…น์Œ ์—…๋กœ๋“œ์šฉ ํ”„๋ฆฌ์‚ฌ์ธ URL ๋ฐœ๊ธ‰ + * POST /api/presign/recording/feedback-question + * Body: { questionId: Long, contentType: String } */ -export const getFeedbackRecordingPresignUrl = async (fileName: string): Promise => { - const response = await apiClient.post('/api/presign/recording/feedback-question', { fileName }); - return response.data; +export const getFeedbackRecordingPresignUrl = async ( + questionId: number, + contentType: string, +): Promise => { + const response = await apiClient.post('/api/presign/recording/feedback-question', { + questionId, + contentType, + }); + return unwrapResult(response.data); }; /** - * 3. S3์— ํŒŒ์ผ ์—…๋กœ๋“œ + * 3. S3์— ํŒŒ์ผ ์—…๋กœ๋“œ (ํ”„๋ฆฌ์‚ฌ์ธ URL ์‚ฌ์šฉ) */ -export const uploadToS3 = async (presignedUrl: string, file: Blob): Promise => { +export const uploadToS3 = async ( + presignedUrl: string, + file: Blob, + extraHeaders: Record = {}, +): Promise => { await fetch(presignedUrl, { method: 'PUT', body: file, headers: { 'Content-Type': file.type || 'audio/webm', + ...extraHeaders, }, }); }; /** * 4. ๋žœ๋ค ์งˆ๋ฌธ์— ๋Œ€ํ•œ recording ์ €์žฅ ๋ฐ ํ”ผ๋“œ๋ฐฑ ์ƒ์„ฑ (๋น„๋™๊ธฐ) + * POST /api/random-questions/peer/questions/{questionId} + * Body ์—†์Œ, path param์œผ๋กœ questionId๋งŒ ๋„˜๊น€ */ -export const saveFeedbackRecording = async (questionId: string, data: IRandomQuestionRecordingRequest): Promise => { - const response = await apiClient.get(`/api/random-questions/peer/questions/${questionId}`, { - params: data, - }); - return response.data; +export const saveFeedbackRecording = async ( + questionId: number, +): Promise => { + const response = await apiClient.post( + `/api/random-questions/peer/questions/${questionId}`, + ); + return unwrapResult(response.data); }; /** * 5. ๋žœ๋ค ์งˆ๋ฌธ์— ๋Œ€ํ•œ ํ”ผ๋“œ๋ฐฑ ํ™•์ธ (polling) + * GET /api/random-questions/peer/recordings/{recordingId}/feedbacks */ -export const getFeedbackResult = async (recordingId: string): Promise => { - const response = await apiClient.get(`/api/random-questions/peer/recordings/${recordingId}/feedbacks`); - return response.data; +export const getFeedbackResult = async ( + recordingId: number, +): Promise => { + const response = await apiClient.get( + `/api/random-questions/peer/recordings/${recordingId}/feedbacks`, + ); + return unwrapResult(response.data); }; /** * 6. Polling ํ—ฌํผ ํ•จ์ˆ˜ + * progressStatus ๊ฐ€ READY / FAILED ๊ฐ€ ๋  ๋•Œ๊นŒ์ง€ ์กฐํšŒ */ -export const pollFeedbackResult = async (recordingId: string, maxAttempts: number = 60, interval: number = 5000): Promise => { +export const pollFeedbackResult = async ( + recordingId: number, + maxAttempts: number = 60, + interval: number = 5000, +): Promise => { let attempts = 0; while (attempts < maxAttempts) { const result = await getFeedbackResult(recordingId); - if (result.status === 'completed' || result.status === 'failed') { + if (result.progressStatus === 'READY' || result.progressStatus === 'FAILED') { return result; } @@ -98,9 +171,14 @@ export const pollFeedbackResult = async (recordingId: string, maxAttempts: numbe /** * 7. SSE ๊ตฌ๋… (Server-Sent Events) + * GET /api/subscribe */ -export const subscribeToNotifications = (onMessage: (event: MessageEvent) => void, onError?: (error: Event) => void): EventSource => { - const eventSource = new EventSource(`${apiClient.defaults.baseURL}/api/subscribe`); +export const subscribeToNotifications = ( + onMessage: (event: MessageEvent) => void, + onError?: (error: Event) => void, +): EventSource => { + const baseURL = apiClient.defaults.baseURL ?? ''; + const eventSource = new EventSource(`${baseURL}/api/subscribe`); eventSource.onmessage = onMessage; @@ -115,32 +193,41 @@ export const subscribeToNotifications = (onMessage: (event: MessageEvent) => voi /** * ๋žœ๋ค ์งˆ๋ฌธ ๋…น์Œ ์—…๋กœ๋“œ ๋ฐ ํ”ผ๋“œ๋ฐฑ ๋ฐ›๊ธฐ ์ „์ฒด ํ”Œ๋กœ์šฐ + * + * 1) ํ”„๋ฆฌ์‚ฌ์ธ URL ๋ฐœ๊ธ‰ + * 2) S3 ์—…๋กœ๋“œ + * 3) recording ์ €์žฅ (๋น„๋™๊ธฐ ํ์— ์˜ฌ๋ฆฌ๊ธฐ) + * 4) ํ”ผ๋“œ๋ฐฑ READY ๋  ๋•Œ๊นŒ์ง€ polling + * 5) IFeedbackResult ๋ฆฌํ„ด (aiFeedback, selfFeedback ๋“ฑ ํฌํ•จ) */ -export const uploadFeedbackRecordingAndGetResult = async (questionId: string, audioBlob: Blob): Promise => { +export const uploadFeedbackRecordingAndGetResult = async ( + questionId: number, + audioBlob: Blob, +): Promise => { // 1. ํ”„๋ฆฌ์‚ฌ์ธ URL ๋ฐ›๊ธฐ - const fileName = `feedback-${questionId}-${Date.now()}.webm`; - const { presignedUrl, fileKey } = await getFeedbackRecordingPresignUrl(fileName); + const contentType = audioBlob.type || 'audio/webm'; + const { uploadUrl, requiredHeaders } = await getFeedbackRecordingPresignUrl( + questionId, + contentType, + ); // 2. S3์— ์—…๋กœ๋“œ - await uploadToS3(presignedUrl, audioBlob); + await uploadToS3(uploadUrl, audioBlob, requiredHeaders); - // 3. ํ”ผ๋“œ๋ฐฑ ์ƒ์„ฑ ์‹œ์ž‘ - const { recordingId, status } = await saveFeedbackRecording(questionId, { - recordingKey: fileKey, - }); + // 3. ๋…น์Œ ์ €์žฅ & ๋น„๋™๊ธฐ ํ”ผ๋“œ๋ฐฑ ์ƒ์„ฑ ํŠธ๋ฆฌ๊ฑฐ + const { recordingId, status } = await saveFeedbackRecording(questionId); - // 4. ์ฆ‰์‹œ ์™„๋ฃŒ๋œ ๊ฒฝ์šฐ - if (status === 'completed') { - const result = await getFeedbackResult(recordingId); - return result.feedback || 'ํ”ผ๋“œ๋ฐฑ์„ ๋ฐ›์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค.'; + if (status !== 'UPLOADED') { + throw new Error(`์˜ˆ์ƒ์น˜ ๋ชปํ•œ recording ์ƒํƒœ์ž…๋‹ˆ๋‹ค: ${status}`); } - // 5. ์ฒ˜๋ฆฌ ์ค‘์ด๋ฉด polling + // 4. ํ”ผ๋“œ๋ฐฑ ์ƒ์„ฑ ์ƒํƒœ polling const result = await pollFeedbackResult(recordingId); - if (result.status === 'failed') { + if (result.progressStatus === 'FAILED' || !result.result) { throw new Error('ํ”ผ๋“œ๋ฐฑ ์ƒ์„ฑ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.'); } - return result.feedback || 'ํ”ผ๋“œ๋ฐฑ์„ ๋ฐ›์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค.'; + // 5. ์ตœ์ข… ํ”ผ๋“œ๋ฐฑ ๊ฒฐ๊ณผ ๋ฆฌํ„ด + return result.result; }; diff --git a/src/types/myPage.ts b/src/types/myPage.ts index ab9d01c..56379cc 100644 --- a/src/types/myPage.ts +++ b/src/types/myPage.ts @@ -96,3 +96,73 @@ export const GROWTH_TAG_LABELS: Record = { export const getExperienceHashTags = (tags?: TExperienceTags[]) => (tags ?? []).map((tag) => `# ${EXPERIENCE_TAG_LABELS[tag]}`); export const getGrowthHashTags = (tags?: TGrowthTags[]) => (tags ?? []).map((tag) => `# ${GROWTH_TAG_LABELS[tag]}`); + +// ๋‚˜์˜ ๋ฉด์ ‘ ์ƒ์„ธ - ์ „์ฒด ์š”์•ฝ API +export type TInterviewSummaryRequest = { + interviewId: number; +}; + +export type TQuestionCard = { + order: number; + questionId: number; +}; + +export type TAnswerCheckItem = { + order: number; + questionId: number; + question: string; + answerText: string | null; + recordUrl: string; +}; + +export type TInterviewSummaryResponse = { + errorCode: null | string; + message: string; + result: { + title: string; + timedOutCount: number; + questionCards: TQuestionCard[]; + firstQuestionThread: TAnswerCheckItem[]; + }; +}; + +// ๋‚˜์˜ ๋ฉด์ ‘ ์ƒ์„ธ - ์งˆ๋ฌธ๋ณ„ ๋‹ต๋ณ€ ํ™•์ธ API +export type TQuestionAnswersRequest = { + questionId: number; +}; + +export type TQuestionAnswersResponse = { + errorCode: null | string; + message: string; + result: TAnswerCheckItem[]; +}; + +// ๋‚˜์˜ ๋ฉด์ ‘ ์ƒ์„ธ - ์งˆ๋ฌธ๋ณ„ ํ”ผ๋“œ๋ฐฑ ์กฐํšŒ API +export type TQuestionFeedbackRequest = { + questionId: number; +}; + +export type TQuestionFeedbackResponse = { + errorCode: null | string; + message: string; + result: { + aiFeedback: string; + selfFeedback: string; + peerItems: string[]; + }; +}; + +// ๋‚˜์˜ ๋ฉด์ ‘ ์ƒ์„ธ - ์งˆ๋ฌธ๋ณ„ ๋žœ๋ค ์งˆ๋ฌธ ๋‹ต๋ณ€ ํ™•์ธ API +export type TRandomQuestionItem = { + question: string; + aiFeedback: string; + selfFeedback: string; + answerText: string; + recordingUrl: string; +}; + +export type TRandomQuestionsResponse = { + errorCode: null | string; + message: string; + result: TRandomQuestionItem[]; +}; From 9930eb8e2d665a0c83593845bd8abca348915978 Mon Sep 17 00:00:00 2001 From: Hello-Worldismine Date: Mon, 17 Nov 2025 03:03:00 +0900 Subject: [PATCH 06/18] =?UTF-8?q?api=EC=97=B0=EB=8F=99,=20=EB=A7=88?= =?UTF-8?q?=EC=9D=B4=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=83=81=EC=84=B8=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/myInterviews.tsx | 114 ++++++++++++++++++------------------- 1 file changed, 57 insertions(+), 57 deletions(-) diff --git a/src/pages/myInterviews.tsx b/src/pages/myInterviews.tsx index bf16c3d..37c401a 100644 --- a/src/pages/myInterviews.tsx +++ b/src/pages/myInterviews.tsx @@ -51,10 +51,10 @@ export default function MyInterviews() { if (summaryLoading) { return ( -
-
- -

๋ฐ์ดํ„ฐ๋ฅผ ๋ถˆ๋Ÿฌ์˜ค๋Š” ์ค‘...

+
+
+ +

๋ฐ์ดํ„ฐ๋ฅผ ๋ถˆ๋Ÿฌ์˜ค๋Š” ์ค‘...

); @@ -62,8 +62,8 @@ export default function MyInterviews() { if (!summaryData?.result) { return ( -
-

๋ฉด์ ‘ ๋ฐ์ดํ„ฐ๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.

+
+

๋ฉด์ ‘ ๋ฐ์ดํ„ฐ๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.

); } @@ -72,23 +72,23 @@ export default function MyInterviews() { const isLoading = answersLoading || feedbackLoading || randomLoading; return ( -
-
+
+
{/* ํ—ค๋” */} -
-

{title}

+
+

{title}

{timedOutCount > 0 && ( -

+

์‹œ๊ฐ„ ์ดˆ๊ณผ๋กœ ๋‹ต๋ณ€ํ•˜์ง€ ๋ชปํ•œ ์งˆ๋ฌธ{' '} - {timedOutCount}๊ฐœ + {timedOutCount}๊ฐœ

)}
{/* ์งˆ๋ฌธ ์นด๋“œ ๋ฆฌ์ŠคํŠธ */} -
-

๋ฉด์ ‘ ์งˆ๋ฌธ

-
+
+

๋ฉด์ ‘ ์งˆ๋ฌธ

+
{questionCards.map((card) => ( ))}
{/* ํƒญ ๋ฉ”๋‰ด */} -
-
+
+
{/* ํƒญ ์ฝ˜ํ…์ธ  */} -
+
{isLoading ? ( -
- +
+
) : ( <> {/* ๋‹ต๋ณ€ ํ™•์ธ ํƒญ */} {activeTab === 'answer' && answersData?.result && ( -
+
{answersData.result.map((item) => ( -
-
- +
+
+ ์งˆ๋ฌธ {item.order} -

{item.question}

+

{item.question}

{item.answerText ? ( <> -

{item.answerText}

+

{item.answerText}

) : ( -

๋‹ต๋ณ€์ด ์—†์Šต๋‹ˆ๋‹ค.

+

๋‹ต๋ณ€์ด ์—†์Šต๋‹ˆ๋‹ค.

)}
))} @@ -177,31 +177,31 @@ export default function MyInterviews() { {/* ํ”ผ๋“œ๋ฐฑ ํ™•์ธ ํƒญ */} {activeTab === 'feedback' && feedbackData?.result && ( -
+
{/* AI ํ”ผ๋“œ๋ฐฑ */} {feedbackData.result.aiFeedback && ( -
-

๐Ÿค– AI ํ”ผ๋“œ๋ฐฑ

-

{feedbackData.result.aiFeedback}

+
+

๐Ÿค– AI ํ”ผ๋“œ๋ฐฑ

+

{feedbackData.result.aiFeedback}

)} {/* ์…€ํ”„ ํ”ผ๋“œ๋ฐฑ */} {feedbackData.result.selfFeedback && ( -
-

โœ๏ธ ์…€ํ”„ ํ”ผ๋“œ๋ฐฑ

-

{feedbackData.result.selfFeedback}

+
+

โœ๏ธ ์…€ํ”„ ํ”ผ๋“œ๋ฐฑ

+

{feedbackData.result.selfFeedback}

)} {/* ๋™๋ฃŒ ํ”ผ๋“œ๋ฐฑ */} {feedbackData.result.peerItems && feedbackData.result.peerItems.length > 0 && ( -
-

๐Ÿ‘ฅ ๋™๋ฃŒ ํ”ผ๋“œ๋ฐฑ

-
    +
    +

    ๐Ÿ‘ฅ ๋™๋ฃŒ ํ”ผ๋“œ๋ฐฑ

    +
      {feedbackData.result.peerItems.map((item, index) => ( -
    • - โ€ข +
    • + โ€ข {item}
    • ))} @@ -210,40 +210,40 @@ export default function MyInterviews() { )} {!feedbackData.result.aiFeedback && !feedbackData.result.selfFeedback && feedbackData.result.peerItems.length === 0 && ( -

      ํ”ผ๋“œ๋ฐฑ์ด ์—†์Šต๋‹ˆ๋‹ค.

      +

      ํ”ผ๋“œ๋ฐฑ์ด ์—†์Šต๋‹ˆ๋‹ค.

      )}
    )} {/* ๋žœ๋ค ์งˆ๋ฌธ ํƒญ */} {activeTab === 'random' && randomData?.result && ( -
    +
    {randomData.result.length > 0 ? ( randomData.result.map((item, index) => ( -
    -
    - +
    +
    + ๋žœ๋ค ์งˆ๋ฌธ {index + 1} -

    {item.question}

    +

    {item.question}

    -
    -

    ๋‹ต๋ณ€:

    -

    {item.answerText}

    +
    +

    ๋‹ต๋ณ€:

    +

    {item.answerText}

    -
    +
    {item.aiFeedback && ( -
    -

    AI ํ”ผ๋“œ๋ฐฑ

    -

    {item.aiFeedback}

    +
    +

    AI ํ”ผ๋“œ๋ฐฑ

    +

    {item.aiFeedback}

    )} {item.selfFeedback && ( -
    -

    ์…€ํ”„ ํ”ผ๋“œ๋ฐฑ

    -

    {item.selfFeedback}

    +
    +

    ์…€ํ”„ ํ”ผ๋“œ๋ฐฑ

    +

    {item.selfFeedback}

    )}
    @@ -261,7 +261,7 @@ export default function MyInterviews() {
    )) ) : ( -

    ๋žœ๋ค ์งˆ๋ฌธ์ด ์—†์Šต๋‹ˆ๋‹ค.

    +

    ๋žœ๋ค ์งˆ๋ฌธ์ด ์—†์Šต๋‹ˆ๋‹ค.

    )}
    )} From e2f575e5d36bb00f52d225defd67544b02a3f8db Mon Sep 17 00:00:00 2001 From: Hello-Worldismine Date: Mon, 17 Nov 2025 03:17:13 +0900 Subject: [PATCH 07/18] =?UTF-8?q?401=EC=97=90=EB=9F=AC=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 6 ++++++ package.json | 1 + src/services/randomQuestionApi.ts | 5 ++++- 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/package-lock.json b/package-lock.json index 7c0c646..a46262d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "eslint-plugin-prettier": "^5.5.4", "eslint-plugin-react": "^7.37.5", "eslint-plugin-simple-import-sort": "^12.1.1", + "event-source-polyfill": "^1.0.31", "lucide-react": "^0.545.0", "prettier": "^3.6.2", "react": "^19.0.0", @@ -4061,6 +4062,11 @@ "node": ">= 0.6" } }, + "node_modules/event-source-polyfill": { + "version": "1.0.31", + "resolved": "https://registry.npmjs.org/event-source-polyfill/-/event-source-polyfill-1.0.31.tgz", + "integrity": "sha512-4IJSItgS/41IxN5UVAVuAyczwZF7ZIEsM1XAoUzIHA6A+xzusEZUutdXz2Nr+MQPLxfTiCvqE79/C8HT8fKFvA==" + }, "node_modules/eventemitter2": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/eventemitter2/-/eventemitter2-5.0.1.tgz", diff --git a/package.json b/package.json index 03f3bac..b3db69e 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "eslint-plugin-prettier": "^5.5.4", "eslint-plugin-react": "^7.37.5", "eslint-plugin-simple-import-sort": "^12.1.1", + "event-source-polyfill": "^1.0.31", "lucide-react": "^0.545.0", "prettier": "^3.6.2", "react": "^19.0.0", diff --git a/src/services/randomQuestionApi.ts b/src/services/randomQuestionApi.ts index 587a0b4..e4ef732 100644 --- a/src/services/randomQuestionApi.ts +++ b/src/services/randomQuestionApi.ts @@ -1,4 +1,5 @@ import apiClient from './api'; +import { EventSourcePolyfill } from 'event-source-polyfill'; // result๋งŒ ๋ฝ‘์•„์ฃผ๋Š” ํ—ฌํผ const unwrapResult = (data: any): T => { @@ -178,7 +179,9 @@ export const subscribeToNotifications = ( onError?: (error: Event) => void, ): EventSource => { const baseURL = apiClient.defaults.baseURL ?? ''; - const eventSource = new EventSource(`${baseURL}/api/subscribe`); + const eventSource = new EventSourcePolyfill(`${baseURL}/api/subscribe`, { + withCredentials: true, + }) as EventSource; eventSource.onmessage = onMessage; From e78bd41651435efbb41aa004a8cc34a51467acdd Mon Sep 17 00:00:00 2001 From: Hello-Worldismine Date: Mon, 17 Nov 2025 03:33:49 +0900 Subject: [PATCH 08/18] =?UTF-8?q?=EB=9E=9C=EB=8D=A4=EC=A7=88=EB=AC=B8=20?= =?UTF-8?q?=ED=99=95=EC=9D=B8=20=ED=91=9C=EC=8B=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/myPage/interviewCard.tsx | 131 ++++++++----------- src/components/myPage/randomQuestionCard.tsx | 45 +++++++ src/pages/myPage.tsx | 11 +- src/types/event-source-polyfill.d.ts | 18 +++ src/types/myPage.ts | 1 + 5 files changed, 130 insertions(+), 76 deletions(-) create mode 100644 src/components/myPage/randomQuestionCard.tsx create mode 100644 src/types/event-source-polyfill.d.ts diff --git a/src/components/myPage/interviewCard.tsx b/src/components/myPage/interviewCard.tsx index 1f66c97..c04a78e 100644 --- a/src/components/myPage/interviewCard.tsx +++ b/src/components/myPage/interviewCard.tsx @@ -1,106 +1,91 @@ import React, { useEffect, useState } from 'react'; import useDeleteInterview from '@/hooks/useDeleteInterview'; -import usePatchInterview from '@/hooks/usePatchInterview'; +import { getInterviewSummary, getRandomQuestions } from '@/apis/myPage'; import Delete from '@/assets/delete.svg?react'; -import Edit from '@/assets/edit.svg?react'; import Frog from '@/assets/frog.svg?react'; -import Check from '@/assets/o.svg?react'; type TCardProps = { id: number; title: string; + createdAt?: string; onClick?: () => void; }; -export default function InterviewCard({ id, title, onClick }: TCardProps) { - const { mutate: updateTitle } = usePatchInterview(); +export default function InterviewCard({ id, title, createdAt, onClick }: TCardProps) { const { mutate: deleteInterview } = useDeleteInterview(); - const [isEditing, setIsEditing] = useState(false); - const [localTitle, setLocalTitle] = useState(title); + const [hasRandomQuestions, setHasRandomQuestions] = useState(false); + // ๋žœ๋ค ์งˆ๋ฌธ ์—ฌ๋ถ€ ํ™•์ธ useEffect(() => { - setLocalTitle(title); - }, [title]); + const checkRandomQuestions = async () => { + try { + // 1. ๋ฉด์ ‘ ์ƒ์„ธ ์ •๋ณด ์กฐํšŒ + const summary = await getInterviewSummary(id); + + if (summary.result.questionCards && summary.result.questionCards.length > 0) { + // 2. ์ฒซ ๋ฒˆ์งธ ์งˆ๋ฌธ์˜ ๋žœ๋ค ์งˆ๋ฌธ ์กฐํšŒ + const firstQuestionId = summary.result.questionCards[0].questionId; + const randomQuestionsData = await getRandomQuestions(firstQuestionId); + + // 3. ๋žœ๋ค ์งˆ๋ฌธ์ด ์žˆ์œผ๋ฉด true + if (randomQuestionsData.result && randomQuestionsData.result.length > 0) { + setHasRandomQuestions(true); + } + } + } catch (error) { + // ์—๋Ÿฌ ๋ฐœ์ƒ ์‹œ ๋ฌด์‹œ (๋žœ๋ค ์งˆ๋ฌธ ์—†์Œ์œผ๋กœ ์ฒ˜๋ฆฌ) + console.log('๋žœ๋ค ์งˆ๋ฌธ ํ™•์ธ ์ค‘ ์˜ค๋ฅ˜:', error); + } + }; - const handleEditClick = (e: React.MouseEvent) => { - e.stopPropagation(); - setIsEditing(true); - }; - - const confirmEdit = () => { - setIsEditing(false); - if (localTitle === title) { - console.log('์ œ๋ชฉ ์•ˆ ๋ฐ”๋€œ. PATCH ์•ˆ ๋ณด๋ƒ„'); - return; - } - - console.log('PATCH ๋ณด๋ƒ„', { id, localTitle }); - updateTitle({ interviewId: id, title: localTitle }); - }; - - const handleInputKeyDown = (e: React.KeyboardEvent) => { - if (e.key === 'Enter') { - e.preventDefault(); - confirmEdit(); - } - if (e.key === 'Escape') { - e.preventDefault(); - setLocalTitle(title); - setIsEditing(false); - } - }; + void checkRandomQuestions(); + }, [id]); const handleDeleteClick = (e: React.MouseEvent) => { e.stopPropagation(); deleteInterview({ sessionId: id }); }; + // ๋‚ ์งœ ํฌ๋งทํŒ… + const formatDate = (dateString?: string) => { + if (!dateString) return null; + const date = new Date(dateString); + const year = date.getFullYear(); + const month = date.getMonth() + 1; + const day = date.getDate(); + return `${year}๋…„ ${month}์›” ${day}์ผ`; + }; + + const formattedDate = formatDate(createdAt); + return (
    -
    - -
    - {isEditing ? ( -
    - setLocalTitle(e.target.value)} - onBlur={() => setIsEditing(false)} - onKeyDown={handleInputKeyDown} - onClick={(e) => e.stopPropagation()} - /> - -
    - ) : ( - <> -

    {title}

    - - - )} -
    -
    - + {/* ์‚ญ์ œ ๋ฒ„ํŠผ */} + + {/* ๋‚ ์งœ, ๋žœ๋ค ์งˆ๋ฌธ ๋ฑƒ์ง€ */} +
    + {formattedDate && {formattedDate}} + {hasRandomQuestions && ( + ๋žœ๋ค์งˆ๋ฌธ + )} +
    + + {/* ๋ฉ”์ธ ์ฝ˜ํ…์ธ  */} +
    + +
    + {/* ํฌ๋ง์ง๊ตฐ (์ˆ˜์ • ๋ถˆ๊ฐ€) */} +

    {title}

    +
    +
    ); } diff --git a/src/components/myPage/randomQuestionCard.tsx b/src/components/myPage/randomQuestionCard.tsx new file mode 100644 index 0000000..df34f1d --- /dev/null +++ b/src/components/myPage/randomQuestionCard.tsx @@ -0,0 +1,45 @@ +import React from 'react'; + +import OrangeFrog from '@/assets/orangeFrog.svg?react'; + +type TRandomQuestionCardProps = { + questionText: string; + createdAt: string; + jobRole?: string; + onClick?: () => void; +}; + +export default function RandomQuestionCard({ questionText, createdAt, jobRole, onClick }: TRandomQuestionCardProps) { + // ๋‚ ์งœ ํฌ๋งทํŒ… + const formatDate = (dateString: string) => { + const date = new Date(dateString); + const year = date.getFullYear(); + const month = date.getMonth() + 1; + const day = date.getDate(); + return `${year}๋…„ ${month}์›” ${day}์ผ`; + }; + + const formattedDate = formatDate(createdAt); + + return ( +
    + {/* ๋žœ๋ค ์งˆ๋ฌธ ๋ฑƒ์ง€ */} +
    + ๋žœ๋ค์งˆ๋ฌธ +
    + + {/* ๋ฉ”์ธ ์ฝ˜ํ…์ธ  */} +
    + +
    +

    {formattedDate}

    + {jobRole &&

    {jobRole}

    } +

    {questionText}

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

    {title}

    {description}

    - + window.location.replace(title === '๋‚˜์˜ ๋ฉด์ ‘,' ? route.myInterviews : route.evaluate)} /> {title === '๋‚˜์˜ ๋ฉด์ ‘,' - ? myInterviewData?.result.items.map(({ interviewId, jobRole }) => ( + ? myInterviewData?.result.items.map(({ interviewId, jobRole, createdAt }) => ( - movePage(routes, interviewId)} /> + movePage(routes, interviewId)} + /> )) : myFeedbackData?.result.items.map(({ peerFeedbackId, title: jobTitle }) => ( diff --git a/src/types/event-source-polyfill.d.ts b/src/types/event-source-polyfill.d.ts new file mode 100644 index 0000000..f51b082 --- /dev/null +++ b/src/types/event-source-polyfill.d.ts @@ -0,0 +1,18 @@ +// src/types/event-source-polyfill.d.ts +declare module 'event-source-polyfill' { + // ๊ธฐ๋ณธ EventSource ์˜ต์…˜์— ํ—ค๋” ๋“ฑ ๋ช‡ ๊ฐ€์ง€ ํ™•์žฅ ์˜ต์…˜์„ ์ถ”๊ฐ€ + export interface ExtendedEventSourceInit extends EventSourceInit { + headers?: Record; + heartbeatTimeout?: number; + withCredentials?: boolean; + } + + // ๋ธŒ๋ผ์šฐ์ € EventSource๋ฅผ ์ƒ์†ํ•˜๋Š” Polyfill ํด๋ž˜์Šค + export class EventSourcePolyfill extends EventSource { + constructor(url: string, eventSourceInitDict?: ExtendedEventSourceInit); + } + + // ์›๋ž˜์˜ ๋„ค์ดํ‹ฐ๋ธŒ EventSource๋ฅผ ํ•จ๊ป˜ export + export const NativeEventSource: typeof EventSource; + } + \ No newline at end of file diff --git a/src/types/myPage.ts b/src/types/myPage.ts index 56379cc..b76c986 100644 --- a/src/types/myPage.ts +++ b/src/types/myPage.ts @@ -23,6 +23,7 @@ export type TMyInterviewResponse = { items: { interviewId: number; jobRole: string; + createdAt?: string; // ์ƒ์„ฑ ๋‚ ์งœ }[]; nextCursor: number | null; hasNext: boolean; From aedc147be3907208fb2e8f4e532aeffe7d3fd011 Mon Sep 17 00:00:00 2001 From: Hello-Worldismine Date: Mon, 17 Nov 2025 03:39:14 +0900 Subject: [PATCH 09/18] =?UTF-8?q?=EB=A7=88=EC=9D=B4=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=20=ED=94=BC=EB=93=9C=EB=B0=B1=20=EC=A1=B0=ED=9A=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/myInterviews.tsx | 145 +++++++++++++++++++++++++++---------- 1 file changed, 108 insertions(+), 37 deletions(-) diff --git a/src/pages/myInterviews.tsx b/src/pages/myInterviews.tsx index 37c401a..cd196d5 100644 --- a/src/pages/myInterviews.tsx +++ b/src/pages/myInterviews.tsx @@ -3,13 +3,23 @@ import { useParams } from 'react-router-dom'; import useGetInterviewSummary from '@/hooks/useGetInterviewSummary'; import useGetQuestionAnswers from '@/hooks/useGetQuestionAnswers'; -import useGetQuestionFeedback from '@/hooks/useGetQuestionFeedback'; import useGetRandomQuestions from '@/hooks/useGetRandomQuestions'; +import { getQuestionFeedback } from '@/apis/myPage'; +import type { TQuestionFeedbackResponse } from '@/types/myPage'; import ClockFrog from '@/assets/clockFrog.svg?react'; type TabType = 'answer' | 'feedback' | 'random'; +type FeedbackItem = { + order: number; + questionId: number; + question: string; + aiFeedback: string; + selfFeedback: string; + peerItems: string[]; +}; + export default function MyInterviews() { const { id } = useParams<{ id: string }>(); const interviewId = id ? parseInt(id) : 0; @@ -17,11 +27,12 @@ export default function MyInterviews() { const [selectedQuestionId, setSelectedQuestionId] = useState(null); const [activeTab, setActiveTab] = useState('answer'); const [playingAudio, setPlayingAudio] = useState(null); + const [feedbackList, setFeedbackList] = useState([]); + const [loadingFeedback, setLoadingFeedback] = useState(false); const audioRef = useRef(null); const { data: summaryData, isLoading: summaryLoading } = useGetInterviewSummary(interviewId); - const { data: answersData, isLoading: answersLoading } = useGetQuestionAnswers(activeTab === 'answer' ? selectedQuestionId : null); - const { data: feedbackData, isLoading: feedbackLoading } = useGetQuestionFeedback(activeTab === 'feedback' ? selectedQuestionId : null); + const { data: answersData, isLoading: answersLoading } = useGetQuestionAnswers(activeTab === 'answer' || activeTab === 'feedback' ? selectedQuestionId : null); const { data: randomData, isLoading: randomLoading } = useGetRandomQuestions(activeTab === 'random' ? selectedQuestionId : null); // ์ฒซ ๋ฒˆ์งธ ์งˆ๋ฌธ ์ž๋™ ์„ ํƒ @@ -31,6 +42,49 @@ export default function MyInterviews() { } }, [summaryData, selectedQuestionId]); + // ํ”ผ๋“œ๋ฐฑ ํƒญ์ผ ๋•Œ ๋ชจ๋“  ์งˆ๋ฌธ์˜ ํ”ผ๋“œ๋ฐฑ ์กฐํšŒ + useEffect(() => { + if (activeTab === 'feedback' && answersData?.result) { + const fetchAllFeedback = async () => { + setLoadingFeedback(true); + try { + const feedbackPromises = answersData.result.map(async (answer) => { + try { + const feedback = await getQuestionFeedback(answer.questionId); + return { + order: answer.order, + questionId: answer.questionId, + question: answer.question, + aiFeedback: feedback.result.aiFeedback, + selfFeedback: feedback.result.selfFeedback, + peerItems: feedback.result.peerItems, + }; + } catch (error) { + console.error(`์งˆ๋ฌธ ${answer.questionId} ํ”ผ๋“œ๋ฐฑ ์กฐํšŒ ์‹คํŒจ:`, error); + return { + order: answer.order, + questionId: answer.questionId, + question: answer.question, + aiFeedback: '', + selfFeedback: '', + peerItems: [], + }; + } + }); + + const allFeedback = await Promise.all(feedbackPromises); + setFeedbackList(allFeedback); + } catch (error) { + console.error('ํ”ผ๋“œ๋ฐฑ ์กฐํšŒ ์ค‘ ์˜ค๋ฅ˜:', error); + } finally { + setLoadingFeedback(false); + } + }; + + void fetchAllFeedback(); + } + }, [activeTab, answersData]); + const handleAudioPlay = (url: string) => { if (playingAudio === url) { audioRef.current?.pause(); @@ -69,7 +123,7 @@ export default function MyInterviews() { } const { title, timedOutCount, questionCards } = summaryData.result; - const isLoading = answersLoading || feedbackLoading || randomLoading; + const isLoading = answersLoading || loadingFeedback || randomLoading; return (
    @@ -176,45 +230,62 @@ export default function MyInterviews() { )} {/* ํ”ผ๋“œ๋ฐฑ ํ™•์ธ ํƒญ */} - {activeTab === 'feedback' && feedbackData?.result && ( -
    - {/* AI ํ”ผ๋“œ๋ฐฑ */} - {feedbackData.result.aiFeedback && ( -
    -

    ๐Ÿค– AI ํ”ผ๋“œ๋ฐฑ

    -

    {feedbackData.result.aiFeedback}

    -
    - )} + {activeTab === 'feedback' && feedbackList.length > 0 && ( +
    + {feedbackList.map((item) => ( +
    +
    + + ์งˆ๋ฌธ {item.order} + +

    {item.question}

    +
    - {/* ์…€ํ”„ ํ”ผ๋“œ๋ฐฑ */} - {feedbackData.result.selfFeedback && ( -
    -

    โœ๏ธ ์…€ํ”„ ํ”ผ๋“œ๋ฐฑ

    -

    {feedbackData.result.selfFeedback}

    -
    - )} +
    + {/* AI ํ”ผ๋“œ๋ฐฑ */} + {item.aiFeedback && ( +
    +

    ๐Ÿค– AI ํ”ผ๋“œ๋ฐฑ

    +

    {item.aiFeedback}

    +
    + )} - {/* ๋™๋ฃŒ ํ”ผ๋“œ๋ฐฑ */} - {feedbackData.result.peerItems && feedbackData.result.peerItems.length > 0 && ( -
    -

    ๐Ÿ‘ฅ ๋™๋ฃŒ ํ”ผ๋“œ๋ฐฑ

    -
      - {feedbackData.result.peerItems.map((item, index) => ( -
    • - โ€ข - {item} -
    • - ))} -
    -
    - )} + {/* ์…€ํ”„ ํ”ผ๋“œ๋ฐฑ */} + {item.selfFeedback && ( +
    +

    โœ๏ธ ์…€ํ”„ ํ”ผ๋“œ๋ฐฑ

    +

    {item.selfFeedback}

    +
    + )} - {!feedbackData.result.aiFeedback && !feedbackData.result.selfFeedback && feedbackData.result.peerItems.length === 0 && ( -

    ํ”ผ๋“œ๋ฐฑ์ด ์—†์Šต๋‹ˆ๋‹ค.

    - )} + {/* ๋™๋ฃŒ ํ”ผ๋“œ๋ฐฑ */} + {item.peerItems && item.peerItems.length > 0 && ( +
    +

    ๐Ÿ‘ฅ ๋™๋ฃŒ ํ”ผ๋“œ๋ฐฑ

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

    ์ด ์งˆ๋ฌธ์— ๋Œ€ํ•œ ํ”ผ๋“œ๋ฐฑ์ด ์—†์Šต๋‹ˆ๋‹ค.

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

    ํ”ผ๋“œ๋ฐฑ์ด ์—†์Šต๋‹ˆ๋‹ค.

    + )} + {/* ๋žœ๋ค ์งˆ๋ฌธ ํƒญ */} {activeTab === 'random' && randomData?.result && (
    From 1169ad83f0196241b5fdd9c0afd2333f54f8fbfd Mon Sep 17 00:00:00 2001 From: Hello-Worldismine Date: Mon, 17 Nov 2025 03:42:37 +0900 Subject: [PATCH 10/18] =?UTF-8?q?=EB=A7=88=EC=9D=B4=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=20=ED=94=BC=EB=93=9C=EB=B0=B1=20=EC=A1=B0=ED=9A=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/myInterviews.tsx | 273 +++++++++++++++++++------------------ 1 file changed, 143 insertions(+), 130 deletions(-) diff --git a/src/pages/myInterviews.tsx b/src/pages/myInterviews.tsx index cd196d5..bf75fd5 100644 --- a/src/pages/myInterviews.tsx +++ b/src/pages/myInterviews.tsx @@ -1,3 +1,4 @@ +// src/pages/myInterviews.tsx import { useEffect, useRef, useState } from 'react'; import { useParams } from 'react-router-dom'; @@ -5,7 +6,7 @@ import useGetInterviewSummary from '@/hooks/useGetInterviewSummary'; import useGetQuestionAnswers from '@/hooks/useGetQuestionAnswers'; import useGetRandomQuestions from '@/hooks/useGetRandomQuestions'; import { getQuestionFeedback } from '@/apis/myPage'; -import type { TQuestionFeedbackResponse } from '@/types/myPage'; +// import type { TQuestionFeedbackResponse } from '@/types/myPage'; // ๋ฏธ์‚ฌ์šฉ์ด๋ฉด ์ฃผ์„ import ClockFrog from '@/assets/clockFrog.svg?react'; @@ -22,7 +23,7 @@ type FeedbackItem = { export default function MyInterviews() { const { id } = useParams<{ id: string }>(); - const interviewId = id ? parseInt(id) : 0; + const interviewId = Number(id) || 0; const [selectedQuestionId, setSelectedQuestionId] = useState(null); const [activeTab, setActiveTab] = useState('answer'); @@ -32,77 +33,98 @@ export default function MyInterviews() { const audioRef = useRef(null); const { data: summaryData, isLoading: summaryLoading } = useGetInterviewSummary(interviewId); - const { data: answersData, isLoading: answersLoading } = useGetQuestionAnswers(activeTab === 'answer' || activeTab === 'feedback' ? selectedQuestionId : null); + + // ํ˜„์žฌ ํƒญ์— ๋”ฐ๋ผ ์งˆ๋ฌธ/๋žœ๋ค์งˆ๋ฌธ ๋ฐ์ดํ„ฐ + const { data: answersData, isLoading: answersLoading } = useGetQuestionAnswers( + activeTab === 'answer' || activeTab === 'feedback' ? selectedQuestionId : null, + ); const { data: randomData, isLoading: randomLoading } = useGetRandomQuestions(activeTab === 'random' ? selectedQuestionId : null); - // ์ฒซ ๋ฒˆ์งธ ์งˆ๋ฌธ ์ž๋™ ์„ ํƒ + // ์ฒซ ๋ฒˆ์งธ ์งˆ๋ฌธ ์ž๋™ ์„ ํƒ (๋„ ๊ฐ€๋“œ + ๊ธฐ๋ณธ๊ฐ’) useEffect(() => { - if (summaryData?.result.questionCards && summaryData.result.questionCards.length > 0 && !selectedQuestionId) { - setSelectedQuestionId(summaryData.result.questionCards[0].questionId); + const questionCards = summaryData?.result?.questionCards ?? []; + if (questionCards.length > 0 && !selectedQuestionId) { + setSelectedQuestionId(questionCards[0].questionId); } }, [summaryData, selectedQuestionId]); // ํ”ผ๋“œ๋ฐฑ ํƒญ์ผ ๋•Œ ๋ชจ๋“  ์งˆ๋ฌธ์˜ ํ”ผ๋“œ๋ฐฑ ์กฐํšŒ useEffect(() => { - if (activeTab === 'feedback' && answersData?.result) { - const fetchAllFeedback = async () => { - setLoadingFeedback(true); - try { - const feedbackPromises = answersData.result.map(async (answer) => { - try { - const feedback = await getQuestionFeedback(answer.questionId); - return { - order: answer.order, - questionId: answer.questionId, - question: answer.question, - aiFeedback: feedback.result.aiFeedback, - selfFeedback: feedback.result.selfFeedback, - peerItems: feedback.result.peerItems, - }; - } catch (error) { - console.error(`์งˆ๋ฌธ ${answer.questionId} ํ”ผ๋“œ๋ฐฑ ์กฐํšŒ ์‹คํŒจ:`, error); - return { - order: answer.order, - questionId: answer.questionId, - question: answer.question, - aiFeedback: '', - selfFeedback: '', - peerItems: [], - }; - } - }); - - const allFeedback = await Promise.all(feedbackPromises); - setFeedbackList(allFeedback); - } catch (error) { - console.error('ํ”ผ๋“œ๋ฐฑ ์กฐํšŒ ์ค‘ ์˜ค๋ฅ˜:', error); - } finally { - setLoadingFeedback(false); - } - }; - - void fetchAllFeedback(); + if (activeTab !== 'feedback') return; + const answers = answersData?.result ?? []; + if (answers.length === 0) { + setFeedbackList([]); + return; } + + const fetchAllFeedback = async () => { + setLoadingFeedback(true); + try { + const feedbackPromises = answers.map(async (answer) => { + try { + const feedback = await getQuestionFeedback(answer.questionId); + return { + order: answer.order, + questionId: answer.questionId, + question: answer.question, + aiFeedback: feedback.result?.aiFeedback ?? '', + selfFeedback: feedback.result?.selfFeedback ?? '', + peerItems: feedback.result?.peerItems ?? [], + } as FeedbackItem; + } catch (error) { + console.error(`์งˆ๋ฌธ ${answer.questionId} ํ”ผ๋“œ๋ฐฑ ์กฐํšŒ ์‹คํŒจ:`, error); + return { + order: answer.order, + questionId: answer.questionId, + question: answer.question, + aiFeedback: '', + selfFeedback: '', + peerItems: [], + } as FeedbackItem; + } + }); + + const allFeedback = await Promise.all(feedbackPromises); + setFeedbackList(allFeedback); + } catch (error) { + console.error('ํ”ผ๋“œ๋ฐฑ ์กฐํšŒ ์ค‘ ์˜ค๋ฅ˜:', error); + setFeedbackList([]); + } finally { + setLoadingFeedback(false); + } + }; + + void fetchAllFeedback(); }, [activeTab, answersData]); + // ์˜ค๋””์˜ค ์žฌ์ƒ/์ •์ง€ const handleAudioPlay = (url: string) => { if (playingAudio === url) { audioRef.current?.pause(); setPlayingAudio(null); - } else { + return; + } + if (audioRef.current) { + audioRef.current.pause(); + } + const audio = new Audio(url); + audioRef.current = audio; + audio.play().catch(() => setPlayingAudio(null)); + setPlayingAudio(url); + audio.onended = () => setPlayingAudio(null); + }; + + // ์ •๋ฆฌ: ์–ธ๋งˆ์šดํŠธ ์‹œ ์˜ค๋””์˜ค ์ •์ง€ + useEffect(() => { + return () => { if (audioRef.current) { audioRef.current.pause(); + audioRef.current = null; } - audioRef.current = new Audio(url); - audioRef.current.play(); - setPlayingAudio(url); - - audioRef.current.onended = () => { - setPlayingAudio(null); - }; - } - }; + }; + }, []); + // ๋กœ๋”ฉ/์—๋Ÿฌ ์ฒ˜๋ฆฌ if (summaryLoading) { return (
    @@ -122,7 +144,10 @@ export default function MyInterviews() { ); } - const { title, timedOutCount, questionCards } = summaryData.result; + const title = summaryData.result.title ?? '๋ฉด์ ‘'; + const timedOutCount = summaryData.result.timedOutCount ?? 0; + const questionCards = summaryData.result.questionCards ?? []; // โ† ํ•ต์‹ฌ ๊ฐ€๋“œ + const isLoading = answersLoading || loadingFeedback || randomLoading; return ( @@ -142,21 +167,24 @@ export default function MyInterviews() { {/* ์งˆ๋ฌธ ์นด๋“œ ๋ฆฌ์ŠคํŠธ */}

    ๋ฉด์ ‘ ์งˆ๋ฌธ

    -
    - {questionCards.map((card) => ( - - ))} -
    + + {questionCards.length === 0 ? ( +

    ์งˆ๋ฌธ ์นด๋“œ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.

    + ) : ( +
    + {questionCards.map((card: any) => ( + + ))} +
    + )}
    {/* ํƒญ ๋ฉ”๋‰ด */} @@ -197,14 +225,12 @@ export default function MyInterviews() { ) : ( <> {/* ๋‹ต๋ณ€ ํ™•์ธ ํƒญ */} - {activeTab === 'answer' && answersData?.result && ( + {activeTab === 'answer' && (answersData?.result ?? []).length > 0 && (
    - {answersData.result.map((item) => ( + {(answersData?.result ?? []).map((item: any) => (
    - - ์งˆ๋ฌธ {item.order} - + ์งˆ๋ฌธ {item.order}

    {item.question}

    {item.answerText ? ( @@ -213,9 +239,7 @@ export default function MyInterviews() { + {(randomData?.result ?? []).map((item: any, index: number) => ( +
    +
    + ๋žœ๋ค ์งˆ๋ฌธ {index + 1} +

    {item.question}

    +
    + +
    +

    ๋‹ต๋ณ€:

    +

    {item.answerText}

    - )) - ) : ( -

    ๋žœ๋ค ์งˆ๋ฌธ์ด ์—†์Šต๋‹ˆ๋‹ค.

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

    AI ํ”ผ๋“œ๋ฐฑ

    +

    {item.aiFeedback}

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

    ์…€ํ”„ ํ”ผ๋“œ๋ฐฑ

    +

    {item.selfFeedback}

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

    ๋žœ๋ค ์งˆ๋ฌธ์ด ์—†์Šต๋‹ˆ๋‹ค.

    } )}
    From 036a41e5c9858e6cdef8ffc55d7fdef50d605b1c Mon Sep 17 00:00:00 2001 From: Hello-Worldismine Date: Mon, 17 Nov 2025 03:47:14 +0900 Subject: [PATCH 11/18] =?UTF-8?q?=EC=97=85=EB=A1=9C=EB=93=9C=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=EC=BA=90=EB=A6=AD=ED=84=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/Interview/upload.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/pages/Interview/upload.tsx b/src/pages/Interview/upload.tsx index 6f9baa5..ad8fefb 100644 --- a/src/pages/Interview/upload.tsx +++ b/src/pages/Interview/upload.tsx @@ -4,6 +4,7 @@ import { Upload } from 'lucide-react'; import InterviewLayout from '@/layouts/InterviewLayout'; import { uploadResume } from '@/services/interviewApi'; +import orangeFrog from '@/assets/orangeFrog.svg'; const ALLOWED_EXTENSIONS = ['.pdf', '.docx']; const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB @@ -148,7 +149,7 @@ export default function MyInterview() {
    - ๋ฆฌ๋ทฐ์บ๋ฆญํ„ฐ + ๋ฆฌ๋ทฐ์บ๋ฆญํ„ฐ
    + ); } From 8a2de5c1d9ce257f8e7c88ecec384e89478e7602 Mon Sep 17 00:00:00 2001 From: Hello-Worldismine Date: Mon, 17 Nov 2025 04:42:02 +0900 Subject: [PATCH 15/18] =?UTF-8?q?=EC=8B=9C=EA=B0=84=ED=9D=90=EB=A6=84=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/services/interviewApi.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/services/interviewApi.ts b/src/services/interviewApi.ts index 92ff194..b2c66af 100644 --- a/src/services/interviewApi.ts +++ b/src/services/interviewApi.ts @@ -139,7 +139,7 @@ export const createInterviewSession = async ( console.log('- interviewType:', data.interviewType); const resumeId = extractResumeId(data.resumeKey); - const mode: InterviewMode = data.interviewType === 'pressure' ? 'HARD' : 'NORMAL'; + const mode: TInterviewMode = data.interviewType === 'pressure' ? 'HARD' : 'NORMAL'; const payload: ICreateInterviewSessionPayload = { mode, From 00365b08228694117a70380c8c836a773e970cf1 Mon Sep 17 00:00:00 2001 From: Hello-Worldismine Date: Mon, 17 Nov 2025 05:44:59 +0900 Subject: [PATCH 16/18] =?UTF-8?q?=EB=B2=84=EA=B7=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/RandomQuestion.tsx | 214 ++++--- src/pages/Interview/main_answer.tsx | 16 +- src/pages/myInterviews.tsx | 846 ++++++++++------------------ src/services/interviewApi.ts | 290 ++++------ src/services/randomQuestionApi.ts | 191 +++---- 5 files changed, 619 insertions(+), 938 deletions(-) diff --git a/src/components/RandomQuestion.tsx b/src/components/RandomQuestion.tsx index c6341f5..d8666ba 100644 --- a/src/components/RandomQuestion.tsx +++ b/src/components/RandomQuestion.tsx @@ -1,3 +1,4 @@ +// RandomQuestion.tsx (SSE ์•ˆ์ •ํ™” ๋ฒ„์ „) import { useEffect, useRef, useState } from 'react'; import { getRandomQuestion, @@ -43,46 +44,133 @@ export default function RandomQuestion() { // ์ œ์ถœ ์ค‘ ์ƒํƒœ const [isSubmitting, setIsSubmitting] = useState(false); - // ===== SSE๋กœ ๋žœ๋ค ํŒ์—… ์•Œ๋ฆผ ๊ตฌ๋… ===== + // ===== SSE ์•ˆ์ •ํ™”: ์žฌ์—ฐ๊ฒฐ/์ค‘๋ณต๋ฐฉ์ง€/๊ฐ€์‹œ์„ฑ ๋Œ€์‘ ===== + const esRef = useRef(null); + const reconnectTimerRef = useRef(null); + const attemptsRef = useRef(0); + const lastPeerIdRef = useRef(null); // ์ค‘๋ณต ๋ฐฉ์ง€ + const lastPingAtRef = useRef(Date.now()); + const pingWatchRef = useRef(null); + + const clearReconnectTimer = () => { + if (reconnectTimerRef.current) { + clearTimeout(reconnectTimerRef.current); + reconnectTimerRef.current = null; + } + }; + + const scheduleReconnect = (why: string) => { + if (reconnectTimerRef.current) return; + const wait = Math.min(30000, 1000 * Math.pow(2, attemptsRef.current)); // 1s,2s,4s..์ตœ๋Œ€ 30s + attemptsRef.current += 1; + // eslint-disable-next-line no-console + console.warn(`[SSE] reconnect in ${wait}ms (${why})`); + reconnectTimerRef.current = window.setTimeout(() => { + reconnectTimerRef.current = null; + openSSE(); + }, wait); + }; + + const closeSSE = () => { + clearReconnectTimer(); + if (esRef.current) { + try { + esRef.current.close(); + } catch { + /* noop */ + } + esRef.current = null; + } + }; + + const handleMessage = async (event: MessageEvent) => { + try { + const data = JSON.parse(event.data) as TNotification; + // ์ค‘๋ณต ์ฐจ๋‹จ + if (lastPeerIdRef.current === data.peerFeedbackId) return; + lastPeerIdRef.current = data.peerFeedbackId; + + // ์ƒˆ ํŒ์—… ์ดˆ๊ธฐํ™” + setNotification(data); + setShowPopup(true); + setErrorMessage(null); + setQuestionDetail(null); + setRecordingTime(0); + setRemainingTime(MAX_TIME); + + setRecordedAudio((prev) => { + if (prev) URL.revokeObjectURL(prev); + return null; + }); + latestAudioBlobRef.current = null; + + setLoadingQuestion(true); + const q = await getRandomQuestion(data.peerFeedbackId); + setQuestionDetail(q); + } catch (err) { + console.error('๋žœ๋ค ํŒ์—… ์งˆ๋ฌธ ์ฒ˜๋ฆฌ ์ค‘ ์˜ค๋ฅ˜:', err); + setErrorMessage('ํŒ์—… ์งˆ๋ฌธ์„ ๋ถˆ๋Ÿฌ์˜ค์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค.'); + } finally { + setLoadingQuestion(false); + } + }; + + const openSSE = () => { + closeSSE(); // ์ค‘๋ณต ๋ฐฉ์ง€ + attemptsRef.current = 0; // ์„ฑ๊ณต ์‹œ ์ดˆ๊ธฐํ™” ์˜ˆ์ • + + const es = subscribeToNotifications(handleMessage, (errorEvt) => { + console.error('SSE ์—ฐ๊ฒฐ ์˜ค๋ฅ˜:', errorEvt); + scheduleReconnect('onerror'); + }); + // ์„ ํƒ: ์„œ๋ฒ„๊ฐ€ event: ping ์„ ๋ณด๋‚ธ๋‹ค๋ฉด ํ•˜ํŠธ๋น„ํŠธ ๊ฐฑ์‹  + try { + (es as any).addEventListener?.('ping', () => { + lastPingAtRef.current = Date.now(); + }); + } catch { + /* noop */ + } + + // ์—ฐ๊ฒฐ ์„ฑ๊ณต ๊ฐ์ง€ + (es as any).onopen = () => { + attemptsRef.current = 0; + lastPingAtRef.current = Date.now(); + // eslint-disable-next-line no-console + console.log('[SSE] opened'); + }; + + esRef.current = es; + + // ํด๋ผ์ด์–ธํŠธ ์ธก ํ•˜ํŠธ๋น„ํŠธ ๊ฐ์‹œ(์„œ๋ฒ„ ping ๋ฏธ์ˆ˜์‹  ์‹œ ์žฌ์—ฐ๊ฒฐ) + if (pingWatchRef.current) clearInterval(pingWatchRef.current); + pingWatchRef.current = window.setInterval(() => { + const diff = Date.now() - lastPingAtRef.current; + // 90์ดˆ ์ด์ƒ ํ™œ๋™ ์—†์œผ๋ฉด ์žฌ์—ฐ๊ฒฐ ์‹œ๋„ + if (diff > 90000) { + scheduleReconnect('no-activity'); + } + }, 15000); + }; + + // ์ตœ์ดˆ ๊ตฌ๋… + ํƒญ ๊ฐ€์‹œ์„ฑ ๋Œ€์‘ useEffect(() => { - const eventSource = subscribeToNotifications( - async (event) => { - try { - const data = JSON.parse(event.data) as TNotification; - - // ์ƒˆ ํŒ์—… ๋„์ฐฉ: ์ƒํƒœ ์ดˆ๊ธฐํ™” - setNotification(data); - setShowPopup(true); - setErrorMessage(null); - setQuestionDetail(null); - setRecordingTime(0); - setRemainingTime(MAX_TIME); - - // ์ด์ „ ๋…น์Œ URL ์ œ๊ฑฐ - setRecordedAudio((prev) => { - if (prev) URL.revokeObjectURL(prev); - return null; - }); - latestAudioBlobRef.current = null; - - setLoadingQuestion(true); - const q = await getRandomQuestion(data.peerFeedbackId); - setQuestionDetail(q); - } catch (err) { - console.error('๋žœ๋ค ํŒ์—… ์งˆ๋ฌธ ์ฒ˜๋ฆฌ ์ค‘ ์˜ค๋ฅ˜:', err); - setErrorMessage('ํŒ์—… ์งˆ๋ฌธ์„ ๋ถˆ๋Ÿฌ์˜ค์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค.'); - } finally { - setLoadingQuestion(false); - } - }, - (error) => { - console.error('SSE ์—ฐ๊ฒฐ ์˜ค๋ฅ˜:', error); - }, - ); + const onVis = () => { + if (document.visibilityState === 'visible') { + openSSE(); + } else { + closeSSE(); + } + }; + openSSE(); + document.addEventListener('visibilitychange', onVis); return () => { - eventSource.close(); + document.removeEventListener('visibilitychange', onVis); + closeSSE(); + if (pingWatchRef.current) clearInterval(pingWatchRef.current); }; + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // ===== ์œ ํ‹ธ ===== @@ -93,7 +181,7 @@ export default function RandomQuestion() { return `${m}:${r.toString().padStart(2, '0')}`; }; - // ===== ํŒ์—… ์ „์ฒด ์ œํ•œ์‹œ๊ฐ„ ํƒ€์ด๋จธ (ํŒ์—…์ด ๋œจ๋Š” ์ˆœ๊ฐ„๋ถ€ํ„ฐ ๊ฐ์†Œ) ===== + // ===== ํŒ์—… ์ „์ฒด ์ œํ•œ์‹œ๊ฐ„ ํƒ€์ด๋จธ ===== useEffect(() => { if (!showPopup) { if (countdownTimerRef.current) { @@ -102,7 +190,6 @@ export default function RandomQuestion() { } return; } - countdownTimerRef.current = window.setInterval(() => { setRemainingTime((prev) => { if (prev <= 1) { @@ -124,12 +211,9 @@ export default function RandomQuestion() { }; }, [showPopup]); - // ์‹œ๊ฐ„ ์ข…๋ฃŒ ์‹œ ๋ถ€๊ฐ€ ์ฒ˜๋ฆฌ (๋…น์Œ ์ค‘์ด๋ฉด ์ •์ง€ ๋“ฑ) + // ์‹œ๊ฐ„ ์ข…๋ฃŒ ์‹œ ๋ถ€๊ฐ€ ์ฒ˜๋ฆฌ useEffect(() => { - if (!showPopup) return; - if (remainingTime > 0) return; - - // ์‹œ๊ฐ„ ๋๋‚ฌ์œผ๋ฉด ๋…น์Œ/์žฌ์ƒ ์ •์ง€ + if (!showPopup || remainingTime > 0) return; if (isRecording && mediaRecorderRef.current) { try { mediaRecorderRef.current.stop(); @@ -139,14 +223,12 @@ export default function RandomQuestion() { setIsRecording(false); setIsPausedRec(false); } - if (audioRef.current) { - audioRef.current.pause(); - } + if (audioRef.current) audioRef.current.pause(); }, [remainingTime, showPopup, isRecording]); const isTimeOver = remainingTime <= 0; - // ===== ๋…น์Œ ํƒ€์ด๋จธ (๋…น์Œ ์ค‘์ผ ๋•Œ๋งŒ ์ฆ๊ฐ€) ===== + // ===== ๋…น์Œ ํƒ€์ด๋จธ ===== useEffect(() => { if (isRecording && !isPausedRec) { recordTimerRef.current = window.setInterval(() => setRecordingTime((t) => t + 1), 1000); @@ -168,7 +250,6 @@ export default function RandomQuestion() { alert('์‹œ๊ฐ„์ด ์ข…๋ฃŒ๋˜์–ด ๋” ์ด์ƒ ๋…น์Œํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.'); return; } - try { if (recordedAudio) { URL.revokeObjectURL(recordedAudio); @@ -189,19 +270,14 @@ export default function RandomQuestion() { mediaRecorder.ondataavailable = (e) => { if (e.data.size > 0) audioChunksRef.current.push(e.data); }; - mediaRecorder.onstop = () => { const mime = mediaRecorder.mimeType || 'audio/webm'; const blob = new Blob(audioChunksRef.current, { type: mime }); latestAudioBlobRef.current = blob; - const url = URL.createObjectURL(blob); setRecordedAudio(url); - - // ์ŠคํŠธ๋ฆผ ์ข…๋ฃŒ stream.getTracks().forEach((t) => t.stop()); streamRef.current = null; - setIsRecording(false); setIsPausedRec(false); }; @@ -243,7 +319,6 @@ export default function RandomQuestion() { alert('์‹œ๊ฐ„์ด ์ข…๋ฃŒ๋˜์–ด ๋‹ค์‹œ ๋…น์Œํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.'); return; } - if (audioRef.current) { audioRef.current.pause(); audioRef.current.currentTime = 0; @@ -251,7 +326,6 @@ export default function RandomQuestion() { setIsPlaying(false); setPlaybackTime(0); setPlaybackDuration(0); - if (recordedAudio) { URL.revokeObjectURL(recordedAudio); setRecordedAudio(null); @@ -279,7 +353,6 @@ export default function RandomQuestion() { useEffect(() => { const el = audioRef.current; if (!el) return; - const onLoadedMeta = () => { setPlaybackDuration(Math.floor(isFinite(el.duration) ? el.duration : 0)); setPlaybackTime(Math.floor(el.currentTime || 0)); @@ -291,15 +364,12 @@ export default function RandomQuestion() { setIsPlaying(false); setPlaybackTime(0); }; - el.addEventListener('loadedmetadata', onLoadedMeta); el.addEventListener('timeupdate', onTimeUpdate); el.addEventListener('play', onPlay); el.addEventListener('pause', onPause); el.addEventListener('ended', onEnded); - if (el.readyState >= 1) onLoadedMeta(); - return () => { el.removeEventListener('loadedmetadata', onLoadedMeta); el.removeEventListener('timeupdate', onTimeUpdate); @@ -316,7 +386,7 @@ export default function RandomQuestion() { setShowPopup(false); }; - // ===== ๋‹ต๋ณ€ ์ œ์ถœ (๋…น์Œ ์—…๋กœ๋“œ + ํ”ผ๋“œ๋ฐฑ ์ƒ์„ฑ) ===== + // ===== ๋‹ต๋ณ€ ์ œ์ถœ ===== const handleSubmit = async () => { if (isTimeOver) { alert('์‹œ๊ฐ„์ด ์ข…๋ฃŒ๋˜์–ด ๋‹ต๋ณ€์„ ์ œ์ถœํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.'); @@ -334,7 +404,6 @@ export default function RandomQuestion() { try { setIsSubmitting(true); const feedback = await uploadFeedbackRecordingAndGetResult(questionDetail.question.questionId, latestAudioBlobRef.current); - alert(`AI ํ”ผ๋“œ๋ฐฑ์ด ๋„์ฐฉํ–ˆ์–ด์š”.\n\n${feedback.aiFeedback}`); setShowPopup(false); } catch (err) { @@ -364,9 +433,7 @@ export default function RandomQuestion() { if (!showPopup) return null; const playbackPercent = playbackDuration > 0 ? Math.min(100, Math.max(0, (playbackTime / playbackDuration) * 100)) : 0; - - // ์ง„ํ–‰๋ฐ”๋Š” ํ•œ ๊ฐœ ์งˆ๋ฌธ์ด๋ผ 100%๋กœ ๊ณ ์ •(๋””์ž์ธ ์œ ์ง€์šฉ) - const progress = 100; + const progress = 100; // ํ•œ ๊ฐœ ์งˆ๋ฌธ์ด๋ผ 100% return (
    @@ -391,31 +458,25 @@ export default function RandomQuestion() {

    {errorMessage}

    ) : questionDetail ? ( <> - {/* ๋งฅ๋ฝ์ด ๋˜๋Š” ์งˆ๋ฌธ + STT */}

    ๋งฅ๋ฝ์ด ๋˜๋Š” ์งˆ๋ฌธ

    {questionDetail.context.questionText}

    {questionDetail.context.sttText &&

    {questionDetail.context.sttText}

    }
    - - {/* ์‹ค์ œ ๋‹ต๋ณ€ํ•ด์•ผ ํ•  ์งˆ๋ฌธ */}

    {questionDetail.question.questionText}

    ) : (

    ์งˆ๋ฌธ ์ •๋ณด๋ฅผ ๋ถˆ๋Ÿฌ์˜ค์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค.

    )} - {/* ์ด๋ฏธ์ง€ (import ์‚ฌ์šฉ) */}
    ๋ฉด์ ‘๊ด€
    - {/* ํŒ์—… ์ œํ•œ์‹œ๊ฐ„ ํ‘œ์‹œ */}

    {remainingTime > 0 ? `๋‹ต๋ณ€ ๊ฐ€๋Šฅ ์‹œ๊ฐ„์ด ${remainingTime}์ดˆ ๋‚จ์•˜์Šต๋‹ˆ๋‹ค.` : '์‹œ๊ฐ„์ด ์ข…๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.'}

    - {/* ์งˆ๋ฌธ ์ง„ํ–‰๋ฐ” (๋””์ž์ธ ์œ ์ง€์šฉ) */}
    @@ -423,13 +484,10 @@ export default function RandomQuestion() {

    ๋žœ๋ค ํŒ์—… ์งˆ๋ฌธ

    - {/* ๋…น์Œ / ์žฌ์ƒ ์˜์—ญ */}
    {!recordedAudio ? ( - // === ๋…น์Œ UI ===
    {!isRecording ? ( - // ์‹œ์ž‘ ๋ฒ„ํŠผ (๋งˆ์ดํฌ ์•„์ด์ฝ˜) - - {/* ๋…น์Œ ์‹œ๊ฐ„ */} {formatTime(recordingTime)} - - {/* ์ •์ง€ */} -
    - {formatTime(playbackTime)} / {formatTime(playbackDuration)} -
    )}
    - {/* ๋ฒ„ํŠผ ๊ทธ๋ฃน */}
    + ))} +
    - {/* ์งˆ๋ฌธ ํƒญ(ํ‘œ์‹œ์šฉ: order) */} -
    - {ordersSeen.map((o) => ( + {/* ํƒญ ๋ฉ”๋‰ด */} +
    +
    + + - ))} -
    - - {/* ์งˆ๋ฌธ ์นด๋“œ */} -
    -

    {currentQuestion ? `์งˆ๋ฌธ${currentQuestion.order}` : '์งˆ๋ฌธ'}

    -
    - -

    - {currentQuestion?.mainQuestion} - {currentQuestion?.subQuestion ? ` โ€” ${currentQuestion.subQuestion}` : ''} -

    - - {/* ์บ๋ฆญํ„ฐ ์ด๋ฏธ์ง€ */} -
    - ๋ฉด์ ‘๊ด€
    - {/* ํƒ€์ด๋จธ & ๋…น์Œ ์ปจํŠธ๋กค */} -
    - {/* ํƒ€์ด๋จธ */} -
    -
    -
    + {/* ํƒญ ์ฝ˜ํ…์ธ  */} +
    + {isLoading ? ( +
    +
    -

    - {remainingTime > 0 ? `๋‹ต๋ณ€ ๊ฐ€๋Šฅ ์‹œ๊ฐ„์ด ${remainingTime}์ดˆ ๋‚จ์•˜์Šต๋‹ˆ๋‹ค ...` : '์‹œ๊ฐ„์ด ์ข…๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.'} -

    -
    - - {isWaitingNext &&

    ๋‹ค์Œ ์งˆ๋ฌธ์„ ์ค€๋น„ํ•˜๊ณ  ์žˆ์–ด์š”โ€ฆ

    } - - {/* ๋…น์Œ ์ปจํŠธ๋กค */} - {!recordedAudioUrl ? ( -
    -
    - {!isRecording ? ( - - ) : ( - <> - + ) : ( - - - +

    ๋‹ต๋ณ€์ด ์—†์Šต๋‹ˆ๋‹ค.

    )} - - - {formatTime(recordingTime)} - - - - )} -
    -
    - ) : ( - // ์žฌ์ƒ UI -
    -
    -
    + ))} +
    + )} + + {/* ํ”ผ๋“œ๋ฐฑ ํ™•์ธ ํƒญ */} + {activeTab === 'feedback' && (feedbackList ?? []).length > 0 && ( +
    + {(feedbackList ?? []).map((item) => ( +
    +
    + ์งˆ๋ฌธ {item.order} +

    {item.question}

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

    ๐Ÿค– AI ํ”ผ๋“œ๋ฐฑ

    +

    {item.aiFeedback}

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

    โœ๏ธ ์…€ํ”„ ํ”ผ๋“œ๋ฐฑ

    +

    {item.selfFeedback}

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

    ๐Ÿ‘ฅ ๋™๋ฃŒ ํ”ผ๋“œ๋ฐฑ

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

    ์ด ์งˆ๋ฌธ์— ๋Œ€ํ•œ ํ”ผ๋“œ๋ฐฑ์ด ์—†์Šต๋‹ˆ๋‹ค.

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

    ํ”ผ๋“œ๋ฐฑ์ด ์—†์Šต๋‹ˆ๋‹ค.

    + )} + + {/* ๋žœ๋ค ์งˆ๋ฌธ ํƒญ */} + {activeTab === 'random' && ( +
    + {(randomItems ?? []).length > 0 ? ( + (randomItems ?? []).map((item, index) => ( +
    +
    + ๋žœ๋ค ์งˆ๋ฌธ {index + 1} +

    {item.question}

    +
    + +
    +

    ๋‹ต๋ณ€:

    +

    {item.answerText}

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

    AI ํ”ผ๋“œ๋ฐฑ

    +

    {item.aiFeedback}

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

    ์…€ํ”„ ํ”ผ๋“œ๋ฐฑ

    +

    {item.selfFeedback}

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

    ๋žœ๋ค ์งˆ๋ฌธ์ด ์—†์Šต๋‹ˆ๋‹ค.

    )} - - -
    -
    -
    -
    - - - {formatTime(playbackTime)} / {formatTime(playbackDuration)} - -
    -
    - )} - - {/* ๋‹ค์‹œ ๋…น์Œํ•˜๊ธฐ */} - {recordedAudioUrl && ( -
    - -
    + )} + )}
    - - {/* ๋‹ค์Œ ๋ฒ„ํŠผ */} -
    - -
    - - {/* ์™„๋ฃŒ ๋ชจ๋‹ฌ */} - {showCompleteModal && ( -
    -
    -
    - ์™„๋ฃŒ -
    -

    ๋ชจ๋“  ์งˆ๋ฌธ์— ์™„๋ฒฝํžˆ ๋‹ตํ–ˆ์–ด์š”!

    - -
    -
    - )} - - - +
    ); } diff --git a/src/services/interviewApi.ts b/src/services/interviewApi.ts index b2c66af..cb8332d 100644 --- a/src/services/interviewApi.ts +++ b/src/services/interviewApi.ts @@ -1,8 +1,9 @@ +// src/services/interviewApi.ts import apiClient from './api'; -// ===================================================== -// ๊ณตํ†ต: BE ์‘๋‹ต(result ๋ž˜ํ•‘ ์œ ๋ฌด ๋ชจ๋‘ ๋Œ€์‘) -// ===================================================== +/* ==================================================== + ๊ณตํ†ต: BE ์‘๋‹ต(result ๋ž˜ํ•‘ ์œ ๋ฌด ๋ชจ๋‘ ๋Œ€์‘) +==================================================== */ const unwrapResult = (data: any): T => { if (data && typeof data === 'object' && 'result' in data) { return (data as { result: T }).result; @@ -10,9 +11,9 @@ const unwrapResult = (data: any): T => { return data as T; }; -// ===================================================== -// 1. ์ž์†Œ์„œ ์—…๋กœ๋“œ์šฉ Presigned URL & ์—…๋กœ๋“œ -// ===================================================== +/* ==================================================== + 1) ์ž์†Œ์„œ ์—…๋กœ๋“œ์šฉ Presigned URL & ์—…๋กœ๋“œ +==================================================== */ /** ์ž์†Œ์„œ ์—…๋กœ๋“œ์šฉ Presigned URL ์‘๋‹ต (POST /api/presign/resume) */ export interface IResumePresignResponse { @@ -23,98 +24,76 @@ export interface IResumePresignResponse { /** ์ž์†Œ์„œ ์—…๋กœ๋“œ์šฉ ํ”„๋ฆฌ์‚ฌ์ธ URL ๋ฐœ๊ธ‰ */ export const getResumePresignUrl = async (fileName: string): Promise => { - const response = await apiClient.post('/api/presign/resume', null, { - params: { fileName }, - }); - + const response = await apiClient.post('/api/presign/resume', null, { params: { fileName } }); return unwrapResult(response.data); }; -/** S3 ๊ณตํ†ต ์—…๋กœ๋“œ ํ—ฌํผ (resume / recording ๋‘˜ ๋‹ค ์‚ฌ์šฉ) */ -export const uploadToS3 = async (presignedUrl: string, file: File | Blob, extraHeaders: Record = {}): Promise => { +/** S3 ๊ณตํ†ต ์—…๋กœ๋“œ ํ—ฌํผ (resume / recording ๊ณต์šฉ) */ +export const uploadToS3 = async ( + presignedUrl: string, + file: File | Blob, + extraHeaders: Record = {}, +): Promise => { const baseHeaders: Record = {}; - - // Blob์— type์ด ์žˆ์œผ๋ฉด ๊ธฐ๋ณธ Content-Type์œผ๋กœ ์‚ฌ์šฉ - if ((file as any).type) { - baseHeaders['Content-Type'] = (file as any).type; - } + if ((file as any).type) baseHeaders['Content-Type'] = (file as any).type; const uploadResponse = await fetch(presignedUrl, { method: 'PUT', body: file, - headers: { - ...baseHeaders, - ...extraHeaders, // presign์—์„œ ๋‚ด๋ ค์ค€ ํ—ค๋”๊ฐ€ ์šฐ์„  - }, + headers: { ...baseHeaders, ...extraHeaders }, }); - - if (!uploadResponse.ok) { - throw new Error(`S3 ์—…๋กœ๋“œ ์‹คํŒจ: ${uploadResponse.status}`); - } + if (!uploadResponse.ok) throw new Error(`S3 ์—…๋กœ๋“œ ์‹คํŒจ: ${uploadResponse.status}`); }; /** S3 key์—์„œ resumeId ์ถ”์ถœ (์˜ˆ: resume/123/aaa-bbb.docx โ†’ aaa-bbb) */ export const extractResumeId = (resumeKey: string): string => { const parts = resumeKey.split('/'); const fileName = parts[parts.length - 1]; - const nameWithoutExt = fileName.split('.').slice(0, -1).join('.'); - return nameWithoutExt; + return fileName.split('.').slice(0, -1).join('.'); }; /** ์ž์†Œ์„œ ์—…๋กœ๋“œ ์ „์ฒด ํ”Œ๋กœ์šฐ: key ๋ฐ˜ํ™˜ */ export const uploadResume = async (file: File): Promise => { try { - const extension = file.name.split('.').pop()?.toLowerCase(); - if (!extension || !['pdf', 'docx'].includes(extension)) { - throw new Error('PDF ๋˜๋Š” DOCX ํŒŒ์ผ๋งŒ ์—…๋กœ๋“œ ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค.'); - } - + const ext = file.name.split('.').pop()?.toLowerCase(); + if (!ext || !['pdf', 'docx'].includes(ext)) throw new Error('PDF ๋˜๋Š” DOCX ํŒŒ์ผ๋งŒ ์—…๋กœ๋“œ ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค.'); const { uploadUrl, key, requiredHeaders } = await getResumePresignUrl(file.name); - await uploadToS3(uploadUrl, file, requiredHeaders); - return key; - } catch (error) { - console.error('โŒ ์ž์†Œ์„œ ์—…๋กœ๋“œ ์‹คํŒจ:', error); - throw error; + } catch (e) { + console.error('โŒ ์ž์†Œ์„œ ์—…๋กœ๋“œ ์‹คํŒจ:', e); + throw e; } }; -// ===================================================== -// 2. ์ธํ„ฐ๋ทฐ ์„ธ์…˜ ์ƒ์„ฑ (์ž์†Œ์„œ ๊ธฐ๋ฐ˜ ์ฒซ ์งˆ๋ฌธ ์ƒ์„ฑ) -// ===================================================== +/* ==================================================== + 2) ์ธํ„ฐ๋ทฐ ์„ธ์…˜ ์ƒ์„ฑ (์ž์†Œ์„œ ๊ธฐ๋ฐ˜ ์ฒซ ์งˆ๋ฌธ ์ƒ์„ฑ) +==================================================== */ -/** ํ”„๋ก ํŠธ์—์„œ ์“ฐ๋Š” ์ธํ„ฐ๋ทฐ ํƒ€์ž… */ export type TInterviewType = 'normal' | 'pressure'; - -/** BE์—์„œ ์‚ฌ์šฉํ•˜๋Š” ๋ฉด์ ‘ ๋ชจ๋“œ */ export type TInterviewMode = 'NORMAL' | 'HARD'; -/** ํ”„๋ก ํŠธ์—์„œ ์“ฐ๋Š” ์š”์ฒญ ํƒ€์ž… */ export interface ICreateInterviewSessionRequest { - /** S3 objectKey (์˜ˆ: resume/123/abcd-efgh.docx) */ - resumeKey: string; - /** ์ง๋ฌด ์ด๋ฆ„ */ - jobTitle: string; - /** 'normal' | 'pressure' โ†’ NORMAL | HARD ๋กœ ๋งคํ•‘ */ - interviewType: TInterviewType; + resumeKey: string; // S3 objectKey + jobTitle: string; // ์ง๋ฌด๋ช… + interviewType: TInterviewType; // 'normal' | 'pressure' } -/** BE์— ์‹ค์ œ๋กœ ๋ณด๋‚ด๋Š” payload (mode / jobRole / resumeId) */ +/** BE์— ๋ณด๋‚ด๋Š” ์‹ค์ œ payload */ interface ICreateInterviewSessionPayload { mode: TInterviewMode; jobRole: string; resumeId: string; } -/** BE ์‘๋‹ต ์›๋ณธ ํƒ€์ž… (result ๋‚ด๋ถ€) */ +/** BE ์›๋ณธ ์‘๋‹ต(result ๋‚ด๋ถ€) */ interface ICreateInterviewSessionApiResponse { sessionId: number; firstQuestionId: number; firstQuestionText: string; } -/** ํ”„๋ก ํŠธ์—์„œ ์“ฐ๊ธฐ ์ข‹์€ ํ˜•ํƒœ */ +/** ํ”„๋ก ํŠธ ์งˆ๋ฌธ ํƒ€์ž… */ export interface IQuestion { questionId: string; mainQuestion: string; @@ -122,22 +101,15 @@ export interface IQuestion { order: number; } -/** ํ”„๋ก ํŠธ์—์„œ ์ตœ์ข…์œผ๋กœ ๋ฐ›๋Š” ์‘๋‹ต ํƒ€์ž… */ export interface ICreateInterviewSessionResponse { sessionId: string; firstQuestion: IQuestion; } -/** ์ž์†Œ์„œ ๊ธฐ๋ฐ˜ ์งˆ๋ฌธ ์ƒ์„ฑ ๋ฐ ์ฒซ๋ฒˆ์งธ ์งˆ๋ฌธ ์กฐํšŒ */ export const createInterviewSession = async ( data: ICreateInterviewSessionRequest, ): Promise => { try { - console.log('๐ŸŽฌ API ํ˜ธ์ถœ: /api/interview-sessions'); - console.log('- resumeKey:', data.resumeKey); - console.log('- jobTitle:', data.jobTitle); - console.log('- interviewType:', data.interviewType); - const resumeId = extractResumeId(data.resumeKey); const mode: TInterviewMode = data.interviewType === 'pressure' ? 'HARD' : 'NORMAL'; @@ -147,92 +119,66 @@ export const createInterviewSession = async ( resumeId, }; - console.log('๐Ÿ“ค ์š”์ฒญ payload:', payload); - const response = await apiClient.post('/api/interview-sessions', payload); - - console.log('๐Ÿ“ฅ ์›๋ณธ ์‘๋‹ต:', response.data); - const apiResult = unwrapResult(response.data); - console.log('โœ… unwrap ํ›„ ๊ฒฐ๊ณผ:', apiResult); - - const firstQuestion: IQuestion = { - questionId: String(apiResult.firstQuestionId), - mainQuestion: apiResult.firstQuestionText, - subQuestion: '', - order: 1, - }; - - const result = { + return { sessionId: String(apiResult.sessionId), - firstQuestion, + firstQuestion: { + questionId: String(apiResult.firstQuestionId), + mainQuestion: apiResult.firstQuestionText, + subQuestion: '', + order: 1, + }, }; - - console.log('โœ… ์ตœ์ข… ๋ฐ˜ํ™˜๊ฐ’:', result); - - return result; } catch (error: any) { - console.error('โŒ createInterviewSession ์—๋Ÿฌ:'); - console.error('- ์—๋Ÿฌ ๊ฐ์ฒด:', error); - console.error('- ์—๋Ÿฌ ๋ฉ”์‹œ์ง€:', error.message); - console.error('- ์‘๋‹ต ๋ฐ์ดํ„ฐ:', error.response?.data); - console.error('- ์‘๋‹ต ์ƒํƒœ:', error.response?.status); + console.error('โŒ createInterviewSession ์—๋Ÿฌ:', { + message: error?.message, + data: error?.response?.data, + status: error?.response?.status, + }); throw error; } }; -// ===================================================== -// 3. ๋…น์Œ ์—…๋กœ๋“œ Presign URL (POST /api/presign/recording) -// ===================================================== - -/** ๋…น์Œ Presign ์š”์ฒญ */ +/* ==================================================== + 3) ๋…น์Œ ์—…๋กœ๋“œ Presign URL +==================================================== */ interface IRecordingPresignRequest { questionId: number; contentType: string; } - -/** ๋…น์Œ Presign ์‘๋‹ต */ export interface IRecordingPresignResponse { uploadUrl: string; key: string; requiredHeaders: Record; } -/** ๋…น์Œ ์—…๋กœ๋“œ์šฉ ํ”„๋ฆฌ์‚ฌ์ธ URL ๋ฐœ๊ธ‰ */ -export const getRecordingPresignUrl = async (questionId: number, contentType: string): Promise => { +export const getRecordingPresignUrl = async ( + questionId: number, + contentType: string, +): Promise => { const payload: IRecordingPresignRequest = { questionId, contentType }; - const response = await apiClient.post('/api/presign/recording', payload); return unwrapResult(response.data); }; -// ===================================================== -// 4. recording ์ €์žฅ (๋น„๋™๊ธฐ ํŠธ๋ฆฌ๊ฑฐ) & Polling ํƒ€์ž…๋“ค -// ===================================================== - -/** ๋…น์Œ ์ œ์ถœ API ์‘๋‹ต status (ํ˜„์žฌ ์ŠคํŽ™์ƒ UPLOADED ๊ณ ์ •) */ +/* ==================================================== + 4) recording ์ €์žฅ & Polling ํƒ€์ž… +==================================================== */ export type TRecordingEnqueueStatus = 'UPLOADED'; - -/** ๋…น์Œ ์ €์žฅ ์‘๋‹ต (POST /api/questions/{questionId}/recordings) */ export interface ISaveRecordingResponse { recordingId: number; status: TRecordingEnqueueStatus; } - -/** ๋…น์Œ ์ €์žฅ ๋ฐ ๊ผฌ๋ฆฌ์งˆ๋ฌธ ์ƒ์„ฑ API (๋น„๋™๊ธฐ, ๋ฐ”๋กœ ์‘๋‹ต) */ export const saveRecording = async (questionId: number): Promise => { const response = await apiClient.post(`/api/questions/${questionId}/recordings`); return unwrapResult(response.data); }; -/** Polling API status */ export type TRecordingResultStatus = 'WORKING' | 'READY' | 'FAILED'; - -/** next.type */ export type TNextQuestionType = 'FOLLOW_UP' | 'ROOT' | 'NONE'; -/** Polling/Timeout ๊ณตํ†ต next ๊ฐ์ฒด ํƒ€์ž… */ export interface IRecordingResultNext { type: TNextQuestionType; nextQuestionId: number | null; @@ -242,143 +188,102 @@ export interface IRecordingResultNext { rootIndex: number | null; } -/** Polling API ์‘๋‹ต ํƒ€์ž… (GET /api/recordings/{recordingId}/results) */ export interface IRecordingResultResponse { sessionId: number; status: TRecordingResultStatus; next: IRecordingResultNext | null; } -/** Polling API - ํ•œ ๋ฒˆ ์กฐํšŒ */ export const getRecordingResult = async (recordingId: number): Promise => { const response = await apiClient.get(`/api/recordings/${recordingId}/results`); return unwrapResult(response.data); }; -/** next ๊ฐ์ฒด โ†’ ํ”„๋ก ํŠธ์—์„œ ์“ฐ๋Š” IQuestion ๋กœ ๋งคํ•‘ */ +/** next ๊ฐ์ฒด โ†’ IQuestion ๋งคํ•‘ */ const mapNextToQuestion = (next: IRecordingResultNext | null): IQuestion | null => { - if (!next || next.type === 'NONE' || next.nextQuestionId == null) { - return null; - } - + if (!next || next.type === 'NONE' || next.nextQuestionId == null) return null; const isFollowUp = next.type === 'FOLLOW_UP'; - return { questionId: String(next.nextQuestionId), - mainQuestion: isFollowUp - ? (next.rootText ?? '') // ๊ผฌ๋ฆฌ์งˆ๋ฌธ์ด๋ฉด rootText๋ฅผ ๋ฉ”์ธ ์งˆ๋ฌธ์œผ๋กœ - : (next.nextQuestionText ?? ''), // ๋ฃจํŠธ ์งˆ๋ฌธ์ด๋ฉด ๊ทธ๋Œ€๋กœ - subQuestion: isFollowUp - ? (next.nextQuestionText ?? '') // ๊ผฌ๋ฆฌ์งˆ๋ฌธ ํ…์ŠคํŠธ - : '', + mainQuestion: isFollowUp ? (next.rootText ?? '') : (next.nextQuestionText ?? ''), + subQuestion: isFollowUp ? (next.nextQuestionText ?? '') : '', order: next.rootIndex ?? 0, }; }; -/** Polling ํ—ฌํผ: READY/FAILED ๋  ๋•Œ๊นŒ์ง€ ๋ฐ˜๋ณต ์กฐํšŒ */ -export const pollRecordingResult = async (recordingId: number, maxAttempts: number = 60, intervalMs: number = 3000): Promise => { +/** Polling: READY/FAILED๊นŒ์ง€ ๋ฐ˜๋ณต ์กฐํšŒ */ +export const pollRecordingResult = async ( + recordingId: number, + maxAttempts = 60, + intervalMs = 3000, +): Promise => { let attempts = 0; - while (attempts < maxAttempts) { const result = await getRecordingResult(recordingId); - - if (result.status === 'READY' || result.status === 'FAILED') { - return result; - } - - await new Promise((resolve) => setTimeout(resolve, intervalMs)); + if (result.status === 'READY' || result.status === 'FAILED') return result; + await new Promise((r) => setTimeout(r, intervalMs)); attempts += 1; } - throw new Error('Polling timeout - ๋…น์Œ ์ฒ˜๋ฆฌ ๋Œ€๊ธฐ ์‹œ๊ฐ„์ด ๋„ˆ๋ฌด ๊น๋‹ˆ๋‹ค.'); }; -// ===================================================== -// 5. ๋…น์Œ ํŒŒ์ผ ์—…๋กœ๋“œ & ๋‹ค์Œ ์งˆ๋ฌธ ๋ฐ›๊ธฐ ์ „์ฒด ํ”Œ๋กœ์šฐ -// ===================================================== - -/** - * ๋…น์Œ ํŒŒ์ผ ์—…๋กœ๋“œ + recording ์ €์žฅ + polling ํ›„ - * ๋‹ค์Œ ์งˆ๋ฌธ(IQuestion) ํ˜น์€ null(์„ธ์…˜ ์ข…๋ฃŒ) ๋ฐ˜ํ™˜ - */ -export const uploadRecordingAndGetNext = async (questionId: string | number, audioBlob: Blob): Promise => { - const numericQuestionId = typeof questionId === 'string' ? Number(questionId) : questionId; - - if (Number.isNaN(numericQuestionId)) { - throw new Error('์œ ํšจํ•˜์ง€ ์•Š์€ questionId ์ž…๋‹ˆ๋‹ค.'); - } +/* ==================================================== + 5) ๋…น์Œ ํŒŒ์ผ ์—…๋กœ๋“œ & ๋‹ค์Œ ์งˆ๋ฌธ ๋ฐ›๊ธฐ (ํ†ตํ•ฉ ํ”Œ๋กœ์šฐ) +==================================================== */ +export const uploadRecordingAndGetNext = async ( + questionId: string | number, + audioBlob: Blob, +): Promise => { + const numericId = typeof questionId === 'string' ? Number(questionId) : questionId; + if (Number.isNaN(numericId)) throw new Error('์œ ํšจํ•˜์ง€ ์•Š์€ questionId ์ž…๋‹ˆ๋‹ค.'); const contentType = (audioBlob as any).type || 'audio/webm'; - - // 1) Presign ๋ฐœ๊ธ‰ - const { uploadUrl, requiredHeaders } = await getRecordingPresignUrl(numericQuestionId, contentType); - - // 2) S3 ์—…๋กœ๋“œ + const { uploadUrl, requiredHeaders } = await getRecordingPresignUrl(numericId, contentType); await uploadToS3(uploadUrl, audioBlob, requiredHeaders); - // 3) ๋…น์Œ ์ €์žฅ ์š”์ฒญ (๋น„๋™๊ธฐ ์ž‘์—… ํŠธ๋ฆฌ๊ฑฐ) - const { recordingId } = await saveRecording(numericQuestionId); - - // 4) Polling ์œผ๋กœ ๊ผฌ๋ฆฌ์งˆ๋ฌธ/๋‹ค์Œ ๋ฃจํŠธ์งˆ๋ฌธ ์ƒ์„ฑ ์™„๋ฃŒ๊นŒ์ง€ ๋Œ€๊ธฐ + const { recordingId } = await saveRecording(numericId); const result = await pollRecordingResult(recordingId); + if (result.status === 'FAILED') throw new Error('๋…น์Œ ์ฒ˜๋ฆฌ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.'); - if (result.status === 'FAILED') { - throw new Error('๋…น์Œ ์ฒ˜๋ฆฌ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.'); - } - - // 5) next ๊ฐ์ฒด โ†’ IQuestion ์œผ๋กœ ๋ณ€ํ™˜ (์—†์œผ๋ฉด null) return mapNextToQuestion(result.next); }; -// ===================================================== -// 6. ์‹œ๊ฐ„์ดˆ๊ณผ ์‹œ Timeout API -// ===================================================== - -/** Timeout API ์‘๋‹ต (status๋Š” ํ•ญ์ƒ READY) */ +/* ==================================================== + 6) ์‹œ๊ฐ„์ดˆ๊ณผ Timeout +==================================================== */ +/** BE Timeout ์›๋ณธ ์‘๋‹ต */ export interface ITimeoutResponse { sessionId: number; status: 'READY'; next: IRecordingResultNext | null; } -/** ์‚ฌ์šฉ์ž๊ฐ€ ์‹œ๊ฐ„์ดˆ๊ณผ๋กœ ๋‹ต๋ณ€ํ•˜์ง€ ๋ชปํ•œ ๊ฒฝ์šฐ - Timeout API ํ˜ธ์ถœ */ -export const sendTimeout = async (questionId: string | number): Promise => { - const response = await apiClient.post(`/api/questions/${questionId}/timeout`); - return unwrapResult(response.data); -}; - /** - * Timeout ์ฒ˜๋ฆฌ ํ›„ ๋ฐ”๋กœ ๋‹ค์Œ ์งˆ๋ฌธ(IQuestion) ํ˜น์€ null(์„ธ์…˜ ์ข…๋ฃŒ) ๋ฐ˜ํ™˜ํ•˜๋Š” ํ—ฌํผ - * - next.type === ROOT โ†’ ๋‹ค์Œ ๋ฃจํŠธ ์งˆ๋ฌธ - * - next.type === NONE โ†’ ๋” ์ด์ƒ ์งˆ๋ฌธ ์—†์Œ (์ตœ์ข… ํ”ผ๋“œ๋ฐฑ ์กฐํšŒ) + * ์‹œ๊ฐ„์ดˆ๊ณผ ์ฒ˜๋ฆฌ: ๋ฐ”๋กœ ๋‹ค์Œ ์งˆ๋ฌธ(IQuestion) ๋˜๋Š” null(์„ธ์…˜ ์ข…๋ฃŒ) ๋ฐ˜ํ™˜ + * - ์„œ๋ฒ„๊ฐ€ next.type === ROOT / FOLLOW_UP / NONE ํ˜•ํƒœ๋กœ ๋‚ด๋ ค์คŒ */ -export const timeoutAndGetNextQuestion = async (questionId: string | number): Promise => { - const result = await sendTimeout(questionId); - - if (!result.next || result.next.type === 'NONE') { - return null; - } - - return mapNextToQuestion(result.next); +export const sendTimeout = async (questionId: string | number): Promise => { + const response = await apiClient.post(`/api/questions/${questionId}/timeout`); + const data = unwrapResult(response.data); + return mapNextToQuestion(data.next); // โ† ํ•ต์‹ฌ: ๋‹ค์Œ ์งˆ๋ฌธ ๋งคํ•‘ }; -// ===================================================== -// 7. ์ตœ์ข… ํ”ผ๋“œ๋ฐฑ ์กฐํšŒ API -// ===================================================== +/** ๊ธฐ์กด ํ—ฌํผ์™€์˜ ํ˜ธํ™˜ (๋ณ„์นญ) */ +export const timeoutAndGetNextQuestion = async ( + questionId: string | number, +): Promise => sendTimeout(questionId); -/** ํ”ผ๋“œ๋ฐฑ ์ƒ์„ฑ ์ง„ํ–‰ ์ƒํƒœ */ +/* ==================================================== + 7) ์ตœ์ข… ํ”ผ๋“œ๋ฐฑ ์กฐํšŒ +==================================================== */ export type TFeedbackProgressStatus = 'WORKING' | 'READY' | 'FAILED'; - -/** QnA ํ„ด ํƒ€์ž… */ export type TFeedbackTurnType = 'QUESTION' | 'ANSWER'; -/** ํ•œ ํ„ด (์งˆ๋ฌธ / ๋‹ต๋ณ€) */ export interface IQnaTurn { turn: TFeedbackTurnType; content: string; } -/** ํ•œ ๋ฃจํŠธ ์งˆ๋ฌธ์— ๋Œ€ํ•œ ์š”์•ฝ ์ •๋ณด */ export interface IQuestionSummary { questionNumber: number; rootQuestion: string; @@ -387,17 +292,15 @@ export interface IQuestionSummary { qnaTurns: IQnaTurn[]; } -/** ์ธํ„ฐ๋ทฐ ์ „์ฒด ์š”์•ฝ */ export interface IInterviewSummary { interviewTitle: string; timeoutQuestionNumber: number; questionSummaries: IQuestionSummary[]; } -/** ์ตœ์ข… ํ”ผ๋“œ๋ฐฑ ์กฐํšŒ ์‘๋‹ต */ export interface IFinalFeedbackResponse { feedbackProgressStatus: TFeedbackProgressStatus; - interviewSummary: IInterviewSummary | null; // WORKING์ผ ๋•Œ๋Š” null + interviewSummary: IInterviewSummary | null; feedbacks: { feedbackType: 'positive' | 'improvement'; answer: string; @@ -410,7 +313,6 @@ export interface IFinalFeedbackResponse { timeoutCount: number; } -/** ์ตœ์ข… ํ”ผ๋“œ๋ฐฑ ์กฐํšŒ (GET /api/interview-sessions/{sessionId}) */ export const getFinalFeedback = async ( sessionId: string | number, ): Promise => { diff --git a/src/services/randomQuestionApi.ts b/src/services/randomQuestionApi.ts index e4ef732..41ca5d3 100644 --- a/src/services/randomQuestionApi.ts +++ b/src/services/randomQuestionApi.ts @@ -1,61 +1,60 @@ +// src/services/randomQuestionApi.ts import apiClient from './api'; import { EventSourcePolyfill } from 'event-source-polyfill'; -// result๋งŒ ๋ฝ‘์•„์ฃผ๋Š” ํ—ฌํผ +// ---------------------- ๊ณตํ†ต ์œ ํ‹ธ ---------------------- const unwrapResult = (data: any): T => { if (data && typeof data === 'object' && 'result' in data) { - return data.result as T; + return (data as { result: T }).result; } return data as T; }; -// ==================== ํƒ€์ž… ์ •์˜ ==================== +const joinUrl = (base = '', path = '') => + `${base.replace(/\/+$/, '')}/${path.replace(/^\/+/, '')}`; -// 1) SSE ์•Œ๋ฆผ payload (ํŒ์—… ์•Œ๋ฆผ์šฉ) โ€” ํŒ์—… ์•Œ๋ฆผ์„ ์œ„ํ•œ SSE ๊ตฌ๋…์šฉ API +// ---------------------- ํƒ€์ž… ์ •์˜ ---------------------- + +// 1) SSE ์•Œ๋ฆผ payload export interface IRandomNotificationPayload { - jobName: string; // ์ง๋ฌด ์ด๋ฆ„ - interviewName: string; // ์ธํ„ฐ๋ทฐ ์ œ๋ชฉ - questionNumber: number; // ํ•ด๋‹น ์ธํ„ฐ๋ทฐ์˜ ๋ช‡ ๋ฒˆ์งธ ์งˆ๋ฌธ์ธ์ง€ - peerFeedbackId: number; // = peerAnswerId (๋žœ๋ค ์งˆ๋ฌธ ์กฐํšŒ์— ์‚ฌ์šฉ) + jobName: string; + interviewName: string; + questionNumber: number; + peerFeedbackId: number; // = peerAnswerId } -// 2) ๋žœ๋ค ํŒ์—… ์งˆ๋ฌธ ์กฐํšŒ ์‘๋‹ต โ€” ๋žœ๋ค ํŒ์—… ์งˆ๋ฌธ ์กฐํšŒ API +// 2) ๋žœ๋ค ํŒ์—… ์งˆ๋ฌธ ์กฐํšŒ ์‘๋‹ต export interface IRandomQuestionContext { questionId: number; questionText: string; presignedRecordingGetUrl: string; sttText: string; } - export interface IRandomQuestion { - question: { - questionId: number; - questionText: string; - }; + question: { questionId: number; questionText: string }; context: IRandomQuestionContext; } -// 3) presign ์‘๋‹ต โ€” ๋žœ๋ค ํŒ์—… ์งˆ๋ฌธ - ๋…น์Œ ์—…๋กœ๋“œ์šฉ ํ”„๋ฆฌ์‚ฌ์ธ URL ๋ฐœ๊ธ‰ +// 3) presign ์‘๋‹ต export interface IPresignUrlResponse { - uploadUrl: string; // S3 PUT presigned URL - key: string; // ์—…๋กœ๋“œ๋  S3 ์˜ค๋ธŒ์ ํŠธ ๊ฒฝ๋กœ - requiredHeaders: Record; // PUT ์‹œ ํ•จ๊ป˜ ๋ณด๋‚ด์•ผ ํ•  ํ—ค๋”๋“ค + uploadUrl: string; + key: string; + requiredHeaders: Record; } -// recordingKey ์š”์ฒญ์šฉ ํƒ€์ž…์€ ๋” ์ด์ƒ ์‚ฌ์šฉ ์•ˆ ํ•˜์ง€๋งŒ, ๋‚จ๊ฒจ๋‘  (ํ˜ธํ™˜์šฉ) +// (ํ˜ธํ™˜์šฉ, ํ˜„์žฌ ๋ฏธ์‚ฌ์šฉ) export interface IRandomQuestionRecordingRequest { recordingKey: string; } -// 4) ๋…น์Œ ์ €์žฅ ์‘๋‹ต โ€” ๋žœ๋ค ์งˆ๋ฌธ์— ๋Œ€ํ•œ recording ์ €์žฅ ๋ฐ ํ”ผ๋“œ๋ฐฑ ์ƒ์„ฑ API (๋น„๋™๊ธฐ) +// 4) ๋…น์Œ ์ €์žฅ ์‘๋‹ต export interface IFeedbackRecordingResponse { recordingId: number; - status: 'UPLOADED'; // ๋น„๋™๊ธฐ ์ž‘์—…์ด ํ์— ์˜ฌ๋ผ๊ฐ”๋‹ค๋Š” ๋œป + status: 'UPLOADED'; } -// 5) ํ”ผ๋“œ๋ฐฑ ์กฐํšŒ ์‘๋‹ต โ€” ๋žœ๋ค ์งˆ๋ฌธ์— ๋Œ€ํ•œ ํ”ผ๋“œ๋ฐฑ ํ™•์ธ API (polling) -export type FeedbackProgressStatus = 'WORKING' | 'READY' | 'FAILED'; - +// 5) ํ”ผ๋“œ๋ฐฑ ์กฐํšŒ ์‘๋‹ต +export type TFeedbackProgressStatus = 'WORKING' | 'READY' | 'FAILED'; export interface IFeedbackResult { questionId: number; questionText: string; @@ -64,173 +63,143 @@ export interface IFeedbackResult { presignedRecordingGetUrl: string; sttText: string; } - export interface IFeedbackResultResponse { - progressStatus: FeedbackProgressStatus; - result: IFeedbackResult | null; // WORKING/FAILED์ผ ๋•Œ๋Š” null + progressStatus: TFeedbackProgressStatus; + result: IFeedbackResult | null; } -// ==================== API ํ•จ์ˆ˜๋“ค ==================== +// ---------------------- API ํ•จ์ˆ˜๋“ค ---------------------- -/** - * 1. ๋žœ๋ค ํŒ์—… ์งˆ๋ฌธ ์กฐํšŒ - * GET /api/random-questions/peer/{peerAnswerId} - * peerAnswerId = SSE ์•Œ๋ฆผ์˜ peerFeedbackId - */ +/** 1. ๋žœ๋ค ํŒ์—… ์งˆ๋ฌธ ์กฐํšŒ (GET /api/random-questions/peer/{peerAnswerId}) */ export const getRandomQuestion = async ( peerAnswerId: number | string, ): Promise => { - const response = await apiClient.get(`/api/random-questions/peer/${peerAnswerId}`); - return unwrapResult(response.data); + const resp = await apiClient.get(`/api/random-questions/peer/${peerAnswerId}`); + return unwrapResult(resp.data); }; -/** - * 2. ๋žœ๋ค ํŒ์—… ์งˆ๋ฌธ - ๋…น์Œ ์—…๋กœ๋“œ์šฉ ํ”„๋ฆฌ์‚ฌ์ธ URL ๋ฐœ๊ธ‰ - * POST /api/presign/recording/feedback-question - * Body: { questionId: Long, contentType: String } - */ +/** 2. ๋…น์Œ ์—…๋กœ๋“œ์šฉ ํ”„๋ฆฌ์‚ฌ์ธ URL (POST /api/presign/recording/feedback-question) */ export const getFeedbackRecordingPresignUrl = async ( questionId: number, contentType: string, ): Promise => { - const response = await apiClient.post('/api/presign/recording/feedback-question', { + const resp = await apiClient.post('/api/presign/recording/feedback-question', { questionId, contentType, }); - return unwrapResult(response.data); + return unwrapResult(resp.data); }; -/** - * 3. S3์— ํŒŒ์ผ ์—…๋กœ๋“œ (ํ”„๋ฆฌ์‚ฌ์ธ URL ์‚ฌ์šฉ) - */ +/** 3. S3 ์—…๋กœ๋“œ (PUT presigned URL) */ export const uploadToS3 = async ( presignedUrl: string, file: Blob, extraHeaders: Record = {}, ): Promise => { - await fetch(presignedUrl, { + const r = await fetch(presignedUrl, { method: 'PUT', body: file, headers: { - 'Content-Type': file.type || 'audio/webm', + 'Content-Type': (file as any).type || 'audio/webm', ...extraHeaders, }, }); + if (!r.ok) throw new Error(`S3 ์—…๋กœ๋“œ ์‹คํŒจ: ${r.status}`); }; -/** - * 4. ๋žœ๋ค ์งˆ๋ฌธ์— ๋Œ€ํ•œ recording ์ €์žฅ ๋ฐ ํ”ผ๋“œ๋ฐฑ ์ƒ์„ฑ (๋น„๋™๊ธฐ) - * POST /api/random-questions/peer/questions/{questionId} - * Body ์—†์Œ, path param์œผ๋กœ questionId๋งŒ ๋„˜๊น€ - */ +/** 4. ๋…น์Œ ์ €์žฅ & ํ”ผ๋“œ๋ฐฑ ์ƒ์„ฑ ํŠธ๋ฆฌ๊ฑฐ (POST /api/random-questions/peer/questions/{questionId}) */ export const saveFeedbackRecording = async ( questionId: number, ): Promise => { - const response = await apiClient.post( - `/api/random-questions/peer/questions/${questionId}`, - ); - return unwrapResult(response.data); + const resp = await apiClient.post(`/api/random-questions/peer/questions/${questionId}`); + return unwrapResult(resp.data); }; -/** - * 5. ๋žœ๋ค ์งˆ๋ฌธ์— ๋Œ€ํ•œ ํ”ผ๋“œ๋ฐฑ ํ™•์ธ (polling) - * GET /api/random-questions/peer/recordings/{recordingId}/feedbacks - */ +/** 5. ํ”ผ๋“œ๋ฐฑ ์กฐํšŒ (GET /api/random-questions/peer/recordings/{recordingId}/feedbacks) */ export const getFeedbackResult = async ( recordingId: number, ): Promise => { - const response = await apiClient.get( + const resp = await apiClient.get( `/api/random-questions/peer/recordings/${recordingId}/feedbacks`, ); - return unwrapResult(response.data); + return unwrapResult(resp.data); }; -/** - * 6. Polling ํ—ฌํผ ํ•จ์ˆ˜ - * progressStatus ๊ฐ€ READY / FAILED ๊ฐ€ ๋  ๋•Œ๊นŒ์ง€ ์กฐํšŒ - */ +/** 6. Polling ํ—ฌํผ (READY/FAILED ๋  ๋•Œ๊นŒ์ง€) */ export const pollFeedbackResult = async ( recordingId: number, - maxAttempts: number = 60, - interval: number = 5000, + maxAttempts = 60, + intervalMs = 5000, ): Promise => { let attempts = 0; - while (attempts < maxAttempts) { const result = await getFeedbackResult(recordingId); - if (result.progressStatus === 'READY' || result.progressStatus === 'FAILED') { return result; } - - await new Promise((resolve) => setTimeout(resolve, interval)); - attempts++; + await new Promise((res) => setTimeout(res, intervalMs)); + attempts += 1; } - throw new Error('Polling timeout - ํ”ผ๋“œ๋ฐฑ ์ƒ์„ฑ ์‹œ๊ฐ„์ด ๋„ˆ๋ฌด ์˜ค๋ž˜ ๊ฑธ๋ฆฝ๋‹ˆ๋‹ค.'); }; +// ---------------------- SSE ๊ตฌ๋… ---------------------- + /** - * 7. SSE ๊ตฌ๋… (Server-Sent Events) - * GET /api/subscribe + * 7. SSE ๊ตฌ๋… + * - ๊ธฐ๋ณธ ๊ฒฝ๋กœ: /api/subscribe (env ๋กœ ์˜ค๋ฒ„๋ผ์ด๋“œ ๊ฐ€๋Šฅ: VITE_SSE_PATH) + * - Authorization ํ•„์š” ์‹œ ํ—ค๋”์™€ ์ฟผ๋ฆฌ ๋ชจ๋‘ ์ง€์› */ export const subscribeToNotifications = ( - onMessage: (event: MessageEvent) => void, - onError?: (error: Event) => void, + onMessage: (event: MessageEvent) => void, + onError?: (error: unknown) => void, ): EventSource => { - const baseURL = apiClient.defaults.baseURL ?? ''; - const eventSource = new EventSourcePolyfill(`${baseURL}/api/subscribe`, { - withCredentials: true, - }) as EventSource; - - eventSource.onmessage = onMessage; + const base = apiClient.defaults.baseURL ?? ''; // ์˜ˆ: '/api' + const ssePath = import.meta.env.VITE_SSE_PATH ?? '/api/subscribe'; + const url = joinUrl(base, ssePath); + + const token = localStorage.getItem('accessToken') ?? ''; + + // ํ—ค๋”๊ฐ€ ํ•„์š”ํ•œ ๊ฒฝ์šฐ ํด๋ฆฌํ•„ ์‚ฌ์šฉ (์ฟผ๋ฆฌ ํŒŒ๋ผ๋ฏธํ„ฐ๋กœ๋„ ํ•จ๊ป˜ ์ „๋‹ฌ) + const es = new EventSourcePolyfill( + token ? `${url}?token=${encodeURIComponent(token)}` : url, + { + withCredentials: true, + heartbeatTimeout: 120_000, // ์„œ๋ฒ„ keep-alive ๊ฐ€ ๋œธํ•ด๋„ ๋ฒ„ํผ๋ง ์—ฌ์œ  + headers: token ? { Authorization: `Bearer ${token}` } : undefined, + }, + ) as EventSource; - if (onError) { - eventSource.onerror = onError; - } + es.onmessage = onMessage as any; + if (onError) es.onerror = onError as any; - return eventSource; + return es; // ํ˜ธ์ถœ๋ถ€์—์„œ .close()๋กœ ์ •๋ฆฌ }; -// ==================== ์ „์ฒด ํ”Œ๋กœ์šฐ ํ—ฌํผ ํ•จ์ˆ˜ ==================== +// ---------------------- ์ „์ฒด ํ”Œ๋กœ์šฐ ---------------------- /** - * ๋žœ๋ค ์งˆ๋ฌธ ๋…น์Œ ์—…๋กœ๋“œ ๋ฐ ํ”ผ๋“œ๋ฐฑ ๋ฐ›๊ธฐ ์ „์ฒด ํ”Œ๋กœ์šฐ - * - * 1) ํ”„๋ฆฌ์‚ฌ์ธ URL ๋ฐœ๊ธ‰ - * 2) S3 ์—…๋กœ๋“œ - * 3) recording ์ €์žฅ (๋น„๋™๊ธฐ ํ์— ์˜ฌ๋ฆฌ๊ธฐ) - * 4) ํ”ผ๋“œ๋ฐฑ READY ๋  ๋•Œ๊นŒ์ง€ polling - * 5) IFeedbackResult ๋ฆฌํ„ด (aiFeedback, selfFeedback ๋“ฑ ํฌํ•จ) + * 8. ์—…๋กœ๋“œ โ†’ ์ €์žฅ ํŠธ๋ฆฌ๊ฑฐ โ†’ Polling โ†’ ์ตœ์ข… ํ”ผ๋“œ๋ฐฑ ๋ฐ˜ํ™˜ */ export const uploadFeedbackRecordingAndGetResult = async ( questionId: number, audioBlob: Blob, ): Promise => { - // 1. ํ”„๋ฆฌ์‚ฌ์ธ URL ๋ฐ›๊ธฐ - const contentType = audioBlob.type || 'audio/webm'; + const contentType = (audioBlob as any).type || 'audio/webm'; + const { uploadUrl, requiredHeaders } = await getFeedbackRecordingPresignUrl( questionId, contentType, ); - // 2. S3์— ์—…๋กœ๋“œ await uploadToS3(uploadUrl, audioBlob, requiredHeaders); - // 3. ๋…น์Œ ์ €์žฅ & ๋น„๋™๊ธฐ ํ”ผ๋“œ๋ฐฑ ์ƒ์„ฑ ํŠธ๋ฆฌ๊ฑฐ const { recordingId, status } = await saveFeedbackRecording(questionId); + if (status !== 'UPLOADED') throw new Error(`์˜ˆ์ƒ์น˜ ๋ชปํ•œ recording ์ƒํƒœ: ${status}`); - if (status !== 'UPLOADED') { - throw new Error(`์˜ˆ์ƒ์น˜ ๋ชปํ•œ recording ์ƒํƒœ์ž…๋‹ˆ๋‹ค: ${status}`); - } - - // 4. ํ”ผ๋“œ๋ฐฑ ์ƒ์„ฑ ์ƒํƒœ polling - const result = await pollFeedbackResult(recordingId); - - if (result.progressStatus === 'FAILED' || !result.result) { + const polled = await pollFeedbackResult(recordingId); + if (polled.progressStatus !== 'READY' || !polled.result) { throw new Error('ํ”ผ๋“œ๋ฐฑ ์ƒ์„ฑ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.'); } - - // 5. ์ตœ์ข… ํ”ผ๋“œ๋ฐฑ ๊ฒฐ๊ณผ ๋ฆฌํ„ด - return result.result; + return polled.result; }; From ff9ff4d4fde973a6355270e36197fde7d93db5e0 Mon Sep 17 00:00:00 2001 From: Hello-Worldismine Date: Mon, 17 Nov 2025 05:47:18 +0900 Subject: [PATCH 17/18] =?UTF-8?q?=EB=B2=84=EA=B7=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/RandomQuestion.tsx | 130 +++++++++++++----------------- src/services/randomQuestionApi.ts | 89 ++++++++++++-------- 2 files changed, 113 insertions(+), 106 deletions(-) diff --git a/src/components/RandomQuestion.tsx b/src/components/RandomQuestion.tsx index d8666ba..e4878ef 100644 --- a/src/components/RandomQuestion.tsx +++ b/src/components/RandomQuestion.tsx @@ -1,4 +1,3 @@ -// RandomQuestion.tsx (SSE ์•ˆ์ •ํ™” ๋ฒ„์ „) import { useEffect, useRef, useState } from 'react'; import { getRandomQuestion, @@ -44,13 +43,11 @@ export default function RandomQuestion() { // ์ œ์ถœ ์ค‘ ์ƒํƒœ const [isSubmitting, setIsSubmitting] = useState(false); - // ===== SSE ์•ˆ์ •ํ™”: ์žฌ์—ฐ๊ฒฐ/์ค‘๋ณต๋ฐฉ์ง€/๊ฐ€์‹œ์„ฑ ๋Œ€์‘ ===== + // ===== SSE & ์š”์ฒญ ์ทจ์†Œ ์ปจํŠธ๋กค ===== const esRef = useRef(null); const reconnectTimerRef = useRef(null); const attemptsRef = useRef(0); - const lastPeerIdRef = useRef(null); // ์ค‘๋ณต ๋ฐฉ์ง€ - const lastPingAtRef = useRef(Date.now()); - const pingWatchRef = useRef(null); + const fetchAbortRef = useRef(null); // โœ… ์ด์ „ ์š”์ฒญ ์ทจ์†Œ์šฉ const clearReconnectTimer = () => { if (reconnectTimerRef.current) { @@ -61,9 +58,8 @@ export default function RandomQuestion() { const scheduleReconnect = (why: string) => { if (reconnectTimerRef.current) return; - const wait = Math.min(30000, 1000 * Math.pow(2, attemptsRef.current)); // 1s,2s,4s..์ตœ๋Œ€ 30s + const wait = Math.min(30000, 1000 * Math.pow(2, attemptsRef.current)); attemptsRef.current += 1; - // eslint-disable-next-line no-console console.warn(`[SSE] reconnect in ${wait}ms (${why})`); reconnectTimerRef.current = window.setTimeout(() => { reconnectTimerRef.current = null; @@ -76,84 +72,83 @@ export default function RandomQuestion() { if (esRef.current) { try { esRef.current.close(); - } catch { - /* noop */ - } + } catch {} esRef.current = null; } }; + const fetchRandomQuestion = async (peerFeedbackId: number) => { + // ์ด์ „ ์š”์ฒญ ์ทจ์†Œ + fetchAbortRef.current?.abort(); + fetchAbortRef.current = new AbortController(); + + setLoadingQuestion(true); + setErrorMessage(null); + + try { + console.log('[RQ] fetch start', { peerFeedbackId }); + const q = await getRandomQuestion(peerFeedbackId, { + noCache: true, // โœ… ์บ์‹œ ์šฐํšŒ + signal: fetchAbortRef.current.signal, // โœ… ์ด์ „ ์š”์ฒญ ์ทจ์†Œ ๋Œ€์‘ + }); + console.log('[RQ] fetch ok', q); + setQuestionDetail(q); + } catch (err: any) { + if (err?.name === 'CanceledError' || err?.name === 'AbortError') { + console.log('[RQ] fetch aborted'); + } else { + console.error('[RQ] fetch error', err); + setErrorMessage('ํŒ์—… ์งˆ๋ฌธ์„ ๋ถˆ๋Ÿฌ์˜ค์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค.'); + } + } finally { + setLoadingQuestion(false); + } + }; + const handleMessage = async (event: MessageEvent) => { + // ์„œ๋ฒ„๊ฐ€ ๋ณด๋‚ด๋Š” ping/keepalive ๋“ฑ ๋น„ JSON์€ ๋ฌด์‹œ try { - const data = JSON.parse(event.data) as TNotification; - // ์ค‘๋ณต ์ฐจ๋‹จ - if (lastPeerIdRef.current === data.peerFeedbackId) return; - lastPeerIdRef.current = data.peerFeedbackId; + const parsed = JSON.parse(event.data); + if (!parsed || typeof parsed !== 'object' || parsed.peerFeedbackId == null) { + return; + } + const data = parsed as TNotification; // ์ƒˆ ํŒ์—… ์ดˆ๊ธฐํ™” setNotification(data); setShowPopup(true); - setErrorMessage(null); setQuestionDetail(null); - setRecordingTime(0); - setRemainingTime(MAX_TIME); - setRecordedAudio((prev) => { if (prev) URL.revokeObjectURL(prev); return null; }); latestAudioBlobRef.current = null; + setRecordingTime(0); + setRemainingTime(MAX_TIME); - setLoadingQuestion(true); - const q = await getRandomQuestion(data.peerFeedbackId); - setQuestionDetail(q); - } catch (err) { - console.error('๋žœ๋ค ํŒ์—… ์งˆ๋ฌธ ์ฒ˜๋ฆฌ ์ค‘ ์˜ค๋ฅ˜:', err); - setErrorMessage('ํŒ์—… ์งˆ๋ฌธ์„ ๋ถˆ๋Ÿฌ์˜ค์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค.'); - } finally { - setLoadingQuestion(false); + await fetchRandomQuestion(Number(data.peerFeedbackId)); + } catch { + // ๋ฌด์‹œ(keepalive) } }; const openSSE = () => { - closeSSE(); // ์ค‘๋ณต ๋ฐฉ์ง€ - attemptsRef.current = 0; // ์„ฑ๊ณต ์‹œ ์ดˆ๊ธฐํ™” ์˜ˆ์ • + closeSSE(); + attemptsRef.current = 0; const es = subscribeToNotifications(handleMessage, (errorEvt) => { console.error('SSE ์—ฐ๊ฒฐ ์˜ค๋ฅ˜:', errorEvt); scheduleReconnect('onerror'); }); - // ์„ ํƒ: ์„œ๋ฒ„๊ฐ€ event: ping ์„ ๋ณด๋‚ธ๋‹ค๋ฉด ํ•˜ํŠธ๋น„ํŠธ ๊ฐฑ์‹  - try { - (es as any).addEventListener?.('ping', () => { - lastPingAtRef.current = Date.now(); - }); - } catch { - /* noop */ - } - // ์—ฐ๊ฒฐ ์„ฑ๊ณต ๊ฐ์ง€ (es as any).onopen = () => { attemptsRef.current = 0; - lastPingAtRef.current = Date.now(); - // eslint-disable-next-line no-console console.log('[SSE] opened'); }; esRef.current = es; - - // ํด๋ผ์ด์–ธํŠธ ์ธก ํ•˜ํŠธ๋น„ํŠธ ๊ฐ์‹œ(์„œ๋ฒ„ ping ๋ฏธ์ˆ˜์‹  ์‹œ ์žฌ์—ฐ๊ฒฐ) - if (pingWatchRef.current) clearInterval(pingWatchRef.current); - pingWatchRef.current = window.setInterval(() => { - const diff = Date.now() - lastPingAtRef.current; - // 90์ดˆ ์ด์ƒ ํ™œ๋™ ์—†์œผ๋ฉด ์žฌ์—ฐ๊ฒฐ ์‹œ๋„ - if (diff > 90000) { - scheduleReconnect('no-activity'); - } - }, 15000); }; - // ์ตœ์ดˆ ๊ตฌ๋… + ํƒญ ๊ฐ€์‹œ์„ฑ ๋Œ€์‘ useEffect(() => { const onVis = () => { if (document.visibilityState === 'visible') { @@ -164,11 +159,20 @@ export default function RandomQuestion() { }; openSSE(); document.addEventListener('visibilitychange', onVis); - return () => { document.removeEventListener('visibilitychange', onVis); closeSSE(); - if (pingWatchRef.current) clearInterval(pingWatchRef.current); + fetchAbortRef.current?.abort(); + if (recordedAudio) URL.revokeObjectURL(recordedAudio); + if (recordTimerRef.current) clearInterval(recordTimerRef.current); + if (streamRef.current) { + streamRef.current.getTracks().forEach((t) => t.stop()); + streamRef.current = null; + } + if (countdownTimerRef.current) { + clearInterval(countdownTimerRef.current); + countdownTimerRef.current = null; + } }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -217,9 +221,7 @@ export default function RandomQuestion() { if (isRecording && mediaRecorderRef.current) { try { mediaRecorderRef.current.stop(); - } catch { - /* noop */ - } + } catch {} setIsRecording(false); setIsPausedRec(false); } @@ -414,26 +416,10 @@ export default function RandomQuestion() { } }; - // ์ •๋ฆฌ - useEffect(() => { - return () => { - if (recordedAudio) URL.revokeObjectURL(recordedAudio); - if (recordTimerRef.current) clearInterval(recordTimerRef.current); - if (streamRef.current) { - streamRef.current.getTracks().forEach((t) => t.stop()); - streamRef.current = null; - } - if (countdownTimerRef.current) { - clearInterval(countdownTimerRef.current); - countdownTimerRef.current = null; - } - }; - }, [recordedAudio]); - if (!showPopup) return null; const playbackPercent = playbackDuration > 0 ? Math.min(100, Math.max(0, (playbackTime / playbackDuration) * 100)) : 0; - const progress = 100; // ํ•œ ๊ฐœ ์งˆ๋ฌธ์ด๋ผ 100% + const progress = 100; return (
    diff --git a/src/services/randomQuestionApi.ts b/src/services/randomQuestionApi.ts index 41ca5d3..6e3eaea 100644 --- a/src/services/randomQuestionApi.ts +++ b/src/services/randomQuestionApi.ts @@ -1,4 +1,3 @@ -// src/services/randomQuestionApi.ts import apiClient from './api'; import { EventSourcePolyfill } from 'event-source-polyfill'; @@ -14,8 +13,6 @@ const joinUrl = (base = '', path = '') => `${base.replace(/\/+$/, '')}/${path.replace(/^\/+/, '')}`; // ---------------------- ํƒ€์ž… ์ •์˜ ---------------------- - -// 1) SSE ์•Œ๋ฆผ payload export interface IRandomNotificationPayload { jobName: string; interviewName: string; @@ -23,7 +20,6 @@ export interface IRandomNotificationPayload { peerFeedbackId: number; // = peerAnswerId } -// 2) ๋žœ๋ค ํŒ์—… ์งˆ๋ฌธ ์กฐํšŒ ์‘๋‹ต export interface IRandomQuestionContext { questionId: number; questionText: string; @@ -35,25 +31,17 @@ export interface IRandomQuestion { context: IRandomQuestionContext; } -// 3) presign ์‘๋‹ต export interface IPresignUrlResponse { uploadUrl: string; key: string; requiredHeaders: Record; } -// (ํ˜ธํ™˜์šฉ, ํ˜„์žฌ ๋ฏธ์‚ฌ์šฉ) -export interface IRandomQuestionRecordingRequest { - recordingKey: string; -} - -// 4) ๋…น์Œ ์ €์žฅ ์‘๋‹ต export interface IFeedbackRecordingResponse { recordingId: number; status: 'UPLOADED'; } -// 5) ํ”ผ๋“œ๋ฐฑ ์กฐํšŒ ์‘๋‹ต export type TFeedbackProgressStatus = 'WORKING' | 'READY' | 'FAILED'; export interface IFeedbackResult { questionId: number; @@ -73,12 +61,27 @@ export interface IFeedbackResultResponse { /** 1. ๋žœ๋ค ํŒ์—… ์งˆ๋ฌธ ์กฐํšŒ (GET /api/random-questions/peer/{peerAnswerId}) */ export const getRandomQuestion = async ( peerAnswerId: number | string, + opts?: { noCache?: boolean; signal?: AbortSignal }, ): Promise => { - const resp = await apiClient.get(`/api/random-questions/peer/${peerAnswerId}`); + const params = opts?.noCache ? { _ts: Date.now() } : undefined; // ์บ์‹œ ์šฐํšŒ + // ๋””๋ฒ„๊น… ๋กœ๊ทธ + // eslint-disable-next-line no-console + console.log('[RQ] GET random question', { peerAnswerId, params }); + + const resp = await apiClient.get(`/api/random-questions/peer/${peerAnswerId}`, { + params, + signal: opts?.signal as any, + headers: opts?.noCache + ? { + 'Cache-Control': 'no-cache', + Pragma: 'no-cache', + } + : undefined, + }); return unwrapResult(resp.data); }; -/** 2. ๋…น์Œ ์—…๋กœ๋“œ์šฉ ํ”„๋ฆฌ์‚ฌ์ธ URL (POST /api/presign/recording/feedback-question) */ +/** 2. ๋…น์Œ ์—…๋กœ๋“œ์šฉ ํ”„๋ฆฌ์‚ฌ์ธ URL */ export const getFeedbackRecordingPresignUrl = async ( questionId: number, contentType: string, @@ -90,7 +93,7 @@ export const getFeedbackRecordingPresignUrl = async ( return unwrapResult(resp.data); }; -/** 3. S3 ์—…๋กœ๋“œ (PUT presigned URL) */ +/** 3. S3 ์—…๋กœ๋“œ */ export const uploadToS3 = async ( presignedUrl: string, file: Blob, @@ -107,7 +110,7 @@ export const uploadToS3 = async ( if (!r.ok) throw new Error(`S3 ์—…๋กœ๋“œ ์‹คํŒจ: ${r.status}`); }; -/** 4. ๋…น์Œ ์ €์žฅ & ํ”ผ๋“œ๋ฐฑ ์ƒ์„ฑ ํŠธ๋ฆฌ๊ฑฐ (POST /api/random-questions/peer/questions/{questionId}) */ +/** 4. ๋…น์Œ ์ €์žฅ & ํ”ผ๋“œ๋ฐฑ ์ƒ์„ฑ ํŠธ๋ฆฌ๊ฑฐ */ export const saveFeedbackRecording = async ( questionId: number, ): Promise => { @@ -115,7 +118,7 @@ export const saveFeedbackRecording = async ( return unwrapResult(resp.data); }; -/** 5. ํ”ผ๋“œ๋ฐฑ ์กฐํšŒ (GET /api/random-questions/peer/recordings/{recordingId}/feedbacks) */ +/** 5. ํ”ผ๋“œ๋ฐฑ ์กฐํšŒ */ export const getFeedbackResult = async ( recordingId: number, ): Promise => { @@ -125,7 +128,7 @@ export const getFeedbackResult = async ( return unwrapResult(resp.data); }; -/** 6. Polling ํ—ฌํผ (READY/FAILED ๋  ๋•Œ๊นŒ์ง€) */ +/** 6. Polling ํ—ฌํผ */ export const pollFeedbackResult = async ( recordingId: number, maxAttempts = 60, @@ -147,51 +150,69 @@ export const pollFeedbackResult = async ( /** * 7. SSE ๊ตฌ๋… - * - ๊ธฐ๋ณธ ๊ฒฝ๋กœ: /api/subscribe (env ๋กœ ์˜ค๋ฒ„๋ผ์ด๋“œ ๊ฐ€๋Šฅ: VITE_SSE_PATH) - * - Authorization ํ•„์š” ์‹œ ํ—ค๋”์™€ ์ฟผ๋ฆฌ ๋ชจ๋‘ ์ง€์› + * - ๊ธฐ๋ณธ ๊ฒฝ๋กœ๋ฅผ 'subscribe'๋กœ ๋‘๊ณ , baseURL์ด '/api'๋ฉด ์ตœ์ข… '/api/subscribe' + * - ์ ˆ๋Œ€์ฃผ์†Œ(https://...)๊ฐ€ ์˜ค๋ฉด ๊ทธ๋Œ€๋กœ ์‚ฌ์šฉ */ export const subscribeToNotifications = ( onMessage: (event: MessageEvent) => void, onError?: (error: unknown) => void, ): EventSource => { - const base = apiClient.defaults.baseURL ?? ''; // ์˜ˆ: '/api' - const ssePath = import.meta.env.VITE_SSE_PATH ?? '/api/subscribe'; - const url = joinUrl(base, ssePath); + const base = apiClient.defaults.baseURL ?? ''; // ex) 'https://api.re-view-me.shop' ๋˜๋Š” '/api' + // ๊ธฐ๋ณธ ๊ฒฝ๋กœ๋ฅผ '/api/subscribe'๋กœ ๊ณ ์ •(ENV๋กœ ๋ฎ์–ด์“ธ ์ˆ˜ ์žˆ์Œ) + const rawPath = import.meta.env.VITE_SSE_PATH ?? '/api/subscribe'; + + const buildSseUrl = (baseUrl: string, p: string) => { + // ์ ˆ๋Œ€ URL์ด๋ฉด ๊ทธ๋Œ€๋กœ ์‚ฌ์šฉ + if (/^https?:\/\//.test(p)) return p; + + // base๊ฐ€ .../api, path๊ฐ€ /api/... ์ธ ๊ฒฝ์šฐ ์ค‘๋ณต api ์ œ๊ฑฐ + const baseHasApi = /\/api\/?$/.test(baseUrl); + const pathHasApi = /^\/?api\//.test(p); + let path = p; + if (baseHasApi && pathHasApi) { + path = p.replace(/^\/?api\//, ''); // ์„ ๋‘ api/ ์ œ๊ฑฐ + } + // join + const normBase = baseUrl.replace(/\/+$/, ''); + const normPath = path.replace(/^\/+/, ''); + return `${normBase}/${normPath}`; + }; + + const url = buildSseUrl(base, rawPath); const token = localStorage.getItem('accessToken') ?? ''; - // ํ—ค๋”๊ฐ€ ํ•„์š”ํ•œ ๊ฒฝ์šฐ ํด๋ฆฌํ•„ ์‚ฌ์šฉ (์ฟผ๋ฆฌ ํŒŒ๋ผ๋ฏธํ„ฐ๋กœ๋„ ํ•จ๊ป˜ ์ „๋‹ฌ) - const es = new EventSourcePolyfill( + // ๋””๋ฒ„๊น… ๋กœ๊ทธ + // eslint-disable-next-line no-console + console.log('[SSE] connect', { url, base, rawPath, hasToken: !!token }); + + const es = new (EventSourcePolyfill as any)( token ? `${url}?token=${encodeURIComponent(token)}` : url, { withCredentials: true, - heartbeatTimeout: 120_000, // ์„œ๋ฒ„ keep-alive ๊ฐ€ ๋œธํ•ด๋„ ๋ฒ„ํผ๋ง ์—ฌ์œ  + heartbeatTimeout: 120_000, headers: token ? { Authorization: `Bearer ${token}` } : undefined, }, ) as EventSource; - es.onmessage = onMessage as any; - if (onError) es.onerror = onError as any; + (es as any).onmessage = onMessage; + if (onError) (es as any).onerror = onError; - return es; // ํ˜ธ์ถœ๋ถ€์—์„œ .close()๋กœ ์ •๋ฆฌ + return es; }; + // ---------------------- ์ „์ฒด ํ”Œ๋กœ์šฐ ---------------------- -/** - * 8. ์—…๋กœ๋“œ โ†’ ์ €์žฅ ํŠธ๋ฆฌ๊ฑฐ โ†’ Polling โ†’ ์ตœ์ข… ํ”ผ๋“œ๋ฐฑ ๋ฐ˜ํ™˜ - */ export const uploadFeedbackRecordingAndGetResult = async ( questionId: number, audioBlob: Blob, ): Promise => { const contentType = (audioBlob as any).type || 'audio/webm'; - const { uploadUrl, requiredHeaders } = await getFeedbackRecordingPresignUrl( questionId, contentType, ); - await uploadToS3(uploadUrl, audioBlob, requiredHeaders); const { recordingId, status } = await saveFeedbackRecording(questionId); From 8bbe1868406a0c8c7f9d358d86203f8818187658 Mon Sep 17 00:00:00 2001 From: Hello-Worldismine Date: Mon, 17 Nov 2025 05:53:45 +0900 Subject: [PATCH 18/18] =?UTF-8?q?=EB=B2=84=EA=B7=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/myPage/randomQuestionCard.tsx | 2 - src/services/randomQuestionApi.ts | 101 +++++++++++-------- 2 files changed, 58 insertions(+), 45 deletions(-) diff --git a/src/components/myPage/randomQuestionCard.tsx b/src/components/myPage/randomQuestionCard.tsx index df34f1d..1fae218 100644 --- a/src/components/myPage/randomQuestionCard.tsx +++ b/src/components/myPage/randomQuestionCard.tsx @@ -1,5 +1,3 @@ -import React from 'react'; - import OrangeFrog from '@/assets/orangeFrog.svg?react'; type TRandomQuestionCardProps = { diff --git a/src/services/randomQuestionApi.ts b/src/services/randomQuestionApi.ts index 6e3eaea..d2a573d 100644 --- a/src/services/randomQuestionApi.ts +++ b/src/services/randomQuestionApi.ts @@ -1,3 +1,4 @@ +// src/services/randomQuestionApi.ts import apiClient from './api'; import { EventSourcePolyfill } from 'event-source-polyfill'; @@ -9,10 +10,9 @@ const unwrapResult = (data: any): T => { return data as T; }; -const joinUrl = (base = '', path = '') => - `${base.replace(/\/+$/, '')}/${path.replace(/^\/+/, '')}`; - // ---------------------- ํƒ€์ž… ์ •์˜ ---------------------- + +// 1) SSE ์•Œ๋ฆผ payload export interface IRandomNotificationPayload { jobName: string; interviewName: string; @@ -20,6 +20,7 @@ export interface IRandomNotificationPayload { peerFeedbackId: number; // = peerAnswerId } +// 2) ๋žœ๋ค ํŒ์—… ์งˆ๋ฌธ ์กฐํšŒ ์‘๋‹ต export interface IRandomQuestionContext { questionId: number; questionText: string; @@ -31,17 +32,20 @@ export interface IRandomQuestion { context: IRandomQuestionContext; } +// 3) presign ์‘๋‹ต export interface IPresignUrlResponse { uploadUrl: string; key: string; requiredHeaders: Record; } +// 4) ๋…น์Œ ์ €์žฅ ์‘๋‹ต export interface IFeedbackRecordingResponse { recordingId: number; status: 'UPLOADED'; } +// 5) ํ”ผ๋“œ๋ฐฑ ์กฐํšŒ ์‘๋‹ต export type TFeedbackProgressStatus = 'WORKING' | 'READY' | 'FAILED'; export interface IFeedbackResult { questionId: number; @@ -63,19 +67,17 @@ export const getRandomQuestion = async ( peerAnswerId: number | string, opts?: { noCache?: boolean; signal?: AbortSignal }, ): Promise => { - const params = opts?.noCache ? { _ts: Date.now() } : undefined; // ์บ์‹œ ์šฐํšŒ - // ๋””๋ฒ„๊น… ๋กœ๊ทธ + const params = opts?.noCache ? { _ts: Date.now() } : undefined; + + // ๋””๋ฒ„๊น… ์ฐธ๊ณ  ๋กœ๊ทธ // eslint-disable-next-line no-console - console.log('[RQ] GET random question', { peerAnswerId, params }); + console.log('[RQ] GET /api/random-questions/peer/:id', { peerAnswerId, params }); const resp = await apiClient.get(`/api/random-questions/peer/${peerAnswerId}`, { params, signal: opts?.signal as any, headers: opts?.noCache - ? { - 'Cache-Control': 'no-cache', - Pragma: 'no-cache', - } + ? { 'Cache-Control': 'no-cache', Pragma: 'no-cache' } : undefined, }); return unwrapResult(resp.data); @@ -150,49 +152,46 @@ export const pollFeedbackResult = async ( /** * 7. SSE ๊ตฌ๋… - * - ๊ธฐ๋ณธ ๊ฒฝ๋กœ๋ฅผ 'subscribe'๋กœ ๋‘๊ณ , baseURL์ด '/api'๋ฉด ์ตœ์ข… '/api/subscribe' - * - ์ ˆ๋Œ€์ฃผ์†Œ(https://...)๊ฐ€ ์˜ค๋ฉด ๊ทธ๋Œ€๋กœ ์‚ฌ์šฉ + * - ๊ธฐ๋ณธ ๊ฒฝ๋กœ: /api/subscribe (VITE_SSE_PATH๋กœ ์˜ค๋ฒ„๋ผ์ด๋“œ ๊ฐ€๋Šฅ) + * - baseURL ๋ฐ path๊ฐ€ ๋ชจ๋‘ /api๋ฅผ ํฌํ•จํ•ด๋„ ์ค‘๋ณต๋˜์ง€ ์•Š๋„๋ก ์ •๊ทœํ™” + * - ์ ˆ๋Œ€ URL์ด ์˜ค๋ฉด ๊ทธ๋Œ€๋กœ ์‚ฌ์šฉ */ export const subscribeToNotifications = ( onMessage: (event: MessageEvent) => void, onError?: (error: unknown) => void, ): EventSource => { - const base = apiClient.defaults.baseURL ?? ''; // ex) 'https://api.re-view-me.shop' ๋˜๋Š” '/api' - // ๊ธฐ๋ณธ ๊ฒฝ๋กœ๋ฅผ '/api/subscribe'๋กœ ๊ณ ์ •(ENV๋กœ ๋ฎ์–ด์“ธ ์ˆ˜ ์žˆ์Œ) - const rawPath = import.meta.env.VITE_SSE_PATH ?? '/api/subscribe'; - - const buildSseUrl = (baseUrl: string, p: string) => { - // ์ ˆ๋Œ€ URL์ด๋ฉด ๊ทธ๋Œ€๋กœ ์‚ฌ์šฉ - if (/^https?:\/\//.test(p)) return p; - - // base๊ฐ€ .../api, path๊ฐ€ /api/... ์ธ ๊ฒฝ์šฐ ์ค‘๋ณต api ์ œ๊ฑฐ - const baseHasApi = /\/api\/?$/.test(baseUrl); - const pathHasApi = /^\/?api\//.test(p); - let path = p; - if (baseHasApi && pathHasApi) { - path = p.replace(/^\/?api\//, ''); // ์„ ๋‘ api/ ์ œ๊ฑฐ - } - // join - const normBase = baseUrl.replace(/\/+$/, ''); - const normPath = path.replace(/^\/+/, ''); - return `${normBase}/${normPath}`; - }; + const base = apiClient.defaults.baseURL ?? ''; // ์˜ˆ: '', '/api', 'https://api.domain.com', 'https://api.domain.com/api' + let path = import.meta.env.VITE_SSE_PATH ?? '/api/subscribe'; + + // ์ ˆ๋Œ€ ๊ฒฝ๋กœ๋ฉด ๊ทธ๋Œ€๋กœ ์‚ฌ์šฉ + if (/^https?:\/\//.test(path)) { + // eslint-disable-next-line no-console + console.log('[SSE] connect (absolute)', { url: path, hasToken: !!localStorage.getItem('accessToken') }); + return new (EventSourcePolyfill as any)( + appendToken(path), + esOptions(), + ) as EventSource; + } - const url = buildSseUrl(base, rawPath); + // base์™€ path ๋ชจ๋‘ ์ƒ๋Œ€๋ผ๋ฉด ์•ˆ์ „ํ•˜๊ฒŒ ํ•ฉ์น˜๊ธฐ + const normBase = (base || '').replace(/\/+$/, ''); // ๋ ์Šฌ๋ž˜์‹œ ์ œ๊ฑฐ + let normPath = path.replace(/^\/+/, '/'); // ์•ž ์Šฌ๋ž˜์‹œ๋Š” ํ•˜๋‚˜๋งŒ ์œ ์ง€ - const token = localStorage.getItem('accessToken') ?? ''; + // base๊ฐ€ /api ๋กœ ๋๋‚˜๊ณ , path๊ฐ€ /api/...๋กœ ์‹œ์ž‘ํ•˜๋ฉด path์˜ ์„ ๋‘ /api ์ œ๊ฑฐ + if (/\/api$/.test(normBase) && /^\/api\//.test(normPath)) { + normPath = normPath.replace(/^\/api/, ''); + } - // ๋””๋ฒ„๊น… ๋กœ๊ทธ + // ์ตœ์ข… URL + const finalUrl = `${normBase}${normPath.startsWith('/') ? '' : '/'}${normPath || ''}` || '/api/subscribe'; + + // ๋””๋ฒ„๊น… // eslint-disable-next-line no-console - console.log('[SSE] connect', { url, base, rawPath, hasToken: !!token }); + console.log('[SSE] connect', { base: normBase || '(relative)', rawPath: path, url: finalUrl, hasToken: !!localStorage.getItem('accessToken') }); const es = new (EventSourcePolyfill as any)( - token ? `${url}?token=${encodeURIComponent(token)}` : url, - { - withCredentials: true, - heartbeatTimeout: 120_000, - headers: token ? { Authorization: `Bearer ${token}` } : undefined, - }, + appendToken(finalUrl), + esOptions(), ) as EventSource; (es as any).onmessage = onMessage; @@ -201,8 +200,24 @@ export const subscribeToNotifications = ( return es; }; +// ํ† ํฐ์„ ์ฟผ๋ฆฌ์ŠคํŠธ๋ง์œผ๋กœ ์ถ”๊ฐ€(๋ฐฑ์—”๋“œ๊ฐ€ ํ—ˆ์šฉํ•˜๋Š” ๊ฒฝ์šฐ) +function appendToken(url: string) { + const token = localStorage.getItem('accessToken') ?? ''; + if (!token) return url; + const sep = url.includes('?') ? '&' : '?'; + return `${url}${sep}token=${encodeURIComponent(token)}`; +} + +function esOptions() { + const token = localStorage.getItem('accessToken') ?? ''; + return { + withCredentials: true, + heartbeatTimeout: 120_000, + headers: token ? { Authorization: `Bearer ${token}` } : undefined, + }; +} -// ---------------------- ์ „์ฒด ํ”Œ๋กœ์šฐ ---------------------- +// ---------------------- ์—…๋กœ๋“œ โ†’ ํ”ผ๋“œ๋ฐฑ ์ „์ฒด ํ”Œ๋กœ์šฐ ---------------------- export const uploadFeedbackRecordingAndGetResult = async ( questionId: number,