From d02cfaa5fcb807854fe215b3a4b71723d7973459 Mon Sep 17 00:00:00 2001 From: merrycoral Date: Thu, 30 Oct 2025 09:52:55 +0900 Subject: [PATCH 1/2] =?UTF-8?q?eventId=20=EC=83=9D=EC=84=B1=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=EC=9D=84=20=ED=94=84=EB=A1=A0=ED=8A=B8=EC=97=94?= =?UTF-8?q?=EB=93=9C=EB=A1=9C=20=EC=9D=B4=EB=8F=99=20=EB=B0=8F=20=EC=BF=A0?= =?UTF-8?q?=ED=82=A4=20=EC=A0=80=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ObjectiveStep에서 eventId 생성 및 쿠키 저장 - page.tsx에서 eventId를 context로 전달 - RecommendationStep에서 이벤트 생성 API 호출 제거 - eventId를 props로 받아 바로 AI 추천 요청 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/app/(main)/events/create/page.tsx | 6 ++-- .../events/create/steps/ObjectiveStep.tsx | 23 ++++++++++-- .../create/steps/RecommendationStep.tsx | 36 +++++-------------- 3 files changed, 33 insertions(+), 32 deletions(-) diff --git a/src/app/(main)/events/create/page.tsx b/src/app/(main)/events/create/page.tsx index 1fa7006..d4a46c7 100644 --- a/src/app/(main)/events/create/page.tsx +++ b/src/app/(main)/events/create/page.tsx @@ -16,6 +16,7 @@ export type EventMethod = 'online' | 'offline'; export interface EventData { eventDraftId?: number; + eventId?: string; objective?: EventObjective; recommendation?: { recommendation: { @@ -95,14 +96,15 @@ export default function EventCreatePage() { ( { - history.push('recommendation', { objective }); + onNext={({ objective, eventId }) => { + history.push('recommendation', { objective, eventId }); }} /> )} recommendation={({ context, history }) => ( { history.push('channel', { ...context, recommendation }); }} diff --git a/src/app/(main)/events/create/steps/ObjectiveStep.tsx b/src/app/(main)/events/create/steps/ObjectiveStep.tsx index 284cb3f..497da3b 100644 --- a/src/app/(main)/events/create/steps/ObjectiveStep.tsx +++ b/src/app/(main)/events/create/steps/ObjectiveStep.tsx @@ -67,15 +67,34 @@ const objectives: ObjectiveOption[] = [ ]; interface ObjectiveStepProps { - onNext: (objective: EventObjective) => void; + onNext: (data: { objective: EventObjective; eventId: string }) => void; } +// eventId 생성 함수 +const generateEventId = () => { + return `evt_${Date.now()}_${Math.random().toString(36).substring(7)}`; +}; + +// 쿠키 저장 함수 +const setCookie = (name: string, value: string, days: number = 1) => { + const expires = new Date(); + expires.setTime(expires.getTime() + days * 24 * 60 * 60 * 1000); + document.cookie = `${name}=${value};expires=${expires.toUTCString()};path=/`; +}; + export default function ObjectiveStep({ onNext }: ObjectiveStepProps) { const [selected, setSelected] = useState(null); const handleNext = () => { if (selected) { - onNext(selected); + // eventId 생성 + const eventId = generateEventId(); + + // 쿠키에 저장 + setCookie('eventId', eventId, 1); // 1일 동안 유지 + + // objective와 eventId를 함께 전달 + onNext({ objective: selected, eventId }); } }; diff --git a/src/app/(main)/events/create/steps/RecommendationStep.tsx b/src/app/(main)/events/create/steps/RecommendationStep.tsx index e3472b8..2a765f0 100644 --- a/src/app/(main)/events/create/steps/RecommendationStep.tsx +++ b/src/app/(main)/events/create/steps/RecommendationStep.tsx @@ -68,34 +68,14 @@ export default function RecommendationStep({ // 컴포넌트 마운트 시 AI 추천 요청 useEffect(() => { - if (!eventId && objective) { - // Step 1: 이벤트 생성 - createEventAndRequestAI(); - } else if (eventId) { - // 이미 eventId가 있으면 AI 추천 요청 - requestAIRecommendations(eventId); + if (initialEventId) { + // eventId가 있으면 바로 AI 추천 요청 + requestAIRecommendations(initialEventId); + } else { + setError('이벤트 ID가 없습니다'); } }, []); - const createEventAndRequestAI = async () => { - try { - setLoading(true); - setError(null); - - // Step 1: 이벤트 목적 선택 및 생성 - const eventResponse = await eventApi.selectObjective(objective || '신규 고객 유치'); - const newEventId = eventResponse.eventId; - setEventId(newEventId); - - // Step 2: AI 추천 요청 - await requestAIRecommendations(newEventId); - } catch (err: any) { - console.error('이벤트 생성 실패:', err); - setError(err.response?.data?.message || '이벤트 생성에 실패했습니다'); - setLoading(false); - } - }; - const requestAIRecommendations = async (evtId: string) => { try { setLoading(true); @@ -289,10 +269,10 @@ export default function RecommendationStep({ size="large" onClick={() => { setError(null); - if (eventId) { - requestAIRecommendations(eventId); + if (initialEventId) { + requestAIRecommendations(initialEventId); } else { - createEventAndRequestAI(); + setError('이벤트 ID가 없습니다. 이전 단계로 돌아가서 다시 시도하세요.'); } }} sx={{ From b09fac2396db3b95807268d1df75ff34c5fb1b4f Mon Sep 17 00:00:00 2001 From: merrycoral Date: Thu, 30 Oct 2025 15:58:56 +0900 Subject: [PATCH 2/2] =?UTF-8?q?=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20=ED=94=8C=EB=A1=9C=EC=9A=B0=20=EA=B0=9C=EC=84=A0=20?= =?UTF-8?q?=EB=B0=8F=20API=20=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RecommendationStep 컴포넌트 확장 및 기능 개선 - 이벤트 API 및 AI API 연동 강화 - 인증 관련 훅 기능 확장 - 타입 정의 및 에러 처리 개선 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/app/(main)/events/create/page.tsx | 2 +- .../events/create/steps/ObjectiveStep.tsx | 12 +- .../create/steps/RecommendationStep.tsx | 157 +++++++++++++++--- src/entities/event/api/eventApi.ts | 5 +- src/features/auth/model/useAuth.ts | 23 ++- src/shared/api/aiApi.ts | 4 +- src/shared/api/eventApi.ts | 41 ++++- 7 files changed, 206 insertions(+), 38 deletions(-) diff --git a/src/app/(main)/events/create/page.tsx b/src/app/(main)/events/create/page.tsx index d4a46c7..6e0c7e3 100644 --- a/src/app/(main)/events/create/page.tsx +++ b/src/app/(main)/events/create/page.tsx @@ -103,8 +103,8 @@ export default function EventCreatePage() { )} recommendation={({ context, history }) => ( { history.push('channel', { ...context, recommendation }); }} diff --git a/src/app/(main)/events/create/steps/ObjectiveStep.tsx b/src/app/(main)/events/create/steps/ObjectiveStep.tsx index 497da3b..23c6513 100644 --- a/src/app/(main)/events/create/steps/ObjectiveStep.tsx +++ b/src/app/(main)/events/create/steps/ObjectiveStep.tsx @@ -82,13 +82,23 @@ const setCookie = (name: string, value: string, days: number = 1) => { document.cookie = `${name}=${value};expires=${expires.toUTCString()};path=/`; }; +// 쿠키 삭제 함수 +const deleteCookie = (name: string) => { + document.cookie = `${name}=;expires=Thu, 01 Jan 1970 00:00:00 UTC;path=/`; +}; + export default function ObjectiveStep({ onNext }: ObjectiveStepProps) { const [selected, setSelected] = useState(null); const handleNext = () => { if (selected) { - // eventId 생성 + // 이전 쿠키 삭제 (깨끗한 상태에서 시작) + deleteCookie('eventId'); + deleteCookie('jobId'); + + // 새로운 eventId 생성 const eventId = generateEventId(); + console.log('✅ 새로운 eventId 생성:', eventId); // 쿠키에 저장 setCookie('eventId', eventId, 1); // 1일 동안 유지 diff --git a/src/app/(main)/events/create/steps/RecommendationStep.tsx b/src/app/(main)/events/create/steps/RecommendationStep.tsx index 2a765f0..d2c0477 100644 --- a/src/app/(main)/events/create/steps/RecommendationStep.tsx +++ b/src/app/(main)/events/create/steps/RecommendationStep.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef } from 'react'; import { Box, Container, @@ -19,7 +19,6 @@ import { Alert, } from '@mui/material'; import { ArrowBack, Edit, Insights } from '@mui/icons-material'; -import { EventObjective, BudgetLevel, EventMethod } from '../page'; import { aiApi, eventApi, AIRecommendationResult, EventRecommendation } from '@/shared/api'; // 디자인 시스템 색상 @@ -41,8 +40,8 @@ const colors = { }; interface RecommendationStepProps { - objective?: EventObjective; eventId?: string; // 이전 단계에서 생성된 eventId + objective?: string; // 이전 단계에서 선택된 objective onNext: (data: { recommendation: EventRecommendation; eventId: string; @@ -50,9 +49,30 @@ interface RecommendationStepProps { onBack: () => void; } +// 쿠키에서 값 가져오기 +const getCookie = (name: string): string | null => { + if (typeof document === 'undefined') return null; + + const value = `; ${document.cookie}`; + const parts = value.split(`; ${name}=`); + if (parts.length === 2) { + return parts.pop()?.split(';').shift() || null; + } + return null; +}; + +// 쿠키 저장 함수 +const setCookie = (name: string, value: string, days: number = 1) => { + if (typeof document === 'undefined') return; + + const expires = new Date(); + expires.setTime(expires.getTime() + days * 24 * 60 * 60 * 1000); + document.cookie = `${name}=${value};expires=${expires.toUTCString()};path=/`; +}; + export default function RecommendationStep({ - objective, eventId: initialEventId, + objective, onNext, onBack }: RecommendationStepProps) { @@ -66,36 +86,94 @@ export default function RecommendationStep({ const [selected, setSelected] = useState(null); const [editedData, setEditedData] = useState>({}); + // 중복 호출 방지를 위한 ref + const requestedEventIdRef = useRef(null); + // 컴포넌트 마운트 시 AI 추천 요청 useEffect(() => { + // props에서만 eventId를 받음 (쿠키 사용 안 함) if (initialEventId) { + // 이미 요청한 eventId면 중복 요청하지 않음 + if (requestedEventIdRef.current === initialEventId) { + console.log('⚠️ 이미 요청한 eventId입니다. 중복 요청 방지:', initialEventId); + return; + } + + requestedEventIdRef.current = initialEventId; + setEventId(initialEventId); + console.log('✅ RecommendationStep - eventId 설정:', initialEventId); // eventId가 있으면 바로 AI 추천 요청 requestAIRecommendations(initialEventId); } else { - setError('이벤트 ID가 없습니다'); + console.error('❌ eventId가 없습니다. ObjectiveStep으로 돌아가세요.'); + setError('이벤트 ID가 없습니다. 이전 단계로 돌아가서 다시 시도하세요.'); } - }, []); + }, [initialEventId]); const requestAIRecommendations = async (evtId: string) => { try { setLoading(true); setError(null); - // 사용자 정보에서 매장 정보 가져오기 - const userProfile = JSON.parse(localStorage.getItem('userProfile') || '{}'); - const storeInfo = { - storeId: userProfile.storeId || '1', - storeName: userProfile.storeName || '내 매장', - category: userProfile.industry || '음식점', - description: userProfile.businessHours || '', + // 로그인한 사용자 정보에서 매장 정보 가져오기 + const user = JSON.parse(localStorage.getItem('user') || '{}'); + console.log('📋 localStorage user:', user); + + // UUID v4 생성 함수 (테스트용) + const generateUUID = () => { + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { + const r = Math.random() * 16 | 0; + const v = c === 'x' ? r : (r & 0x3 | 0x8); + return v.toString(16); + }); }; - // AI 추천 요청 - const jobResponse = await eventApi.requestAiRecommendations(evtId, storeInfo); - setJobId(jobResponse.jobId); + // storeId: 로그인한 경우 user.storeId 사용, 아니면 테스트용 UUID 생성 + const storeInfo = { + storeId: user.storeId ? String(user.storeId) : generateUUID(), + storeName: user.storeName || '테스트 매장', + category: user.industry || '음식점', + description: user.businessHours || '테스트 설명', + }; - // Job 폴링 시작 - pollJobStatus(jobResponse.jobId, evtId); + console.log('📤 전송할 storeInfo:', storeInfo); + console.log('🎯 eventId:', evtId); + console.log('🎯 objective:', objective || '테스트 목적'); + + // AI 추천 요청 + const jobResponse = await eventApi.requestAiRecommendations( + evtId, + objective || '테스트 목적', + storeInfo + ); + + console.log('📦 백엔드 응답 (전체):', JSON.stringify(jobResponse, null, 2)); + + // 백엔드 응답 구조 확인: { success, data: { jobId, status, message }, timestamp } + const actualJobId = (jobResponse as any).data?.jobId || jobResponse.jobId; + + console.log('📦 jobResponse.data?.jobId:', (jobResponse as any).data?.jobId); + console.log('📦 jobResponse.jobId:', jobResponse.jobId); + console.log('📦 실제 사용할 jobId:', actualJobId); + + if (!actualJobId) { + console.error('❌ 백엔드에서 jobId를 반환하지 않았습니다!'); + console.error('📦 응답 구조:', JSON.stringify(jobResponse, null, 2)); + setError('백엔드에서 Job ID를 받지 못했습니다. 백엔드 응답을 확인해주세요.'); + setLoading(false); + return; + } + + // jobId를 쿠키에 저장 + setCookie('jobId', actualJobId, 1); + setJobId(actualJobId); + console.log('✅ AI 추천 Job 생성 완료, jobId:', actualJobId); + console.log('🍪 jobId를 쿠키에 저장:', actualJobId); + + // Job 폴링 시작 (2초 후 시작하여 백엔드에서 Job 저장 시간 확보) + setTimeout(() => { + pollJobStatus(actualJobId, evtId); + }, 2000); } catch (err: any) { console.error('AI 추천 요청 실패:', err); setError(err.response?.data?.message || 'AI 추천 요청에 실패했습니다'); @@ -110,8 +188,22 @@ export default function RecommendationStep({ const poll = async () => { try { - const status = await eventApi.getJobStatus(jId); - console.log('Job 상태:', status); + // jobId 확인: 파라미터 우선, 없으면 쿠키에서 읽기 + const currentJobId = jId || getCookie('jobId'); + + if (!currentJobId) { + console.error('❌ jobId를 찾을 수 없습니다 (파라미터와 쿠키 모두 없음)'); + setError('jobId를 찾을 수 없습니다'); + setLoading(false); + setPolling(false); + return; + } + + console.log(`🔄 Job 상태 조회 시도 (${attempts + 1}/${maxAttempts}), jobId: ${currentJobId}`); + console.log(`🍪 jobId 출처: ${jId ? '파라미터' : '쿠키'}`); + + const status = await eventApi.getJobStatus(currentJobId); + console.log('✅ Job 상태:', status); if (status.status === 'COMPLETED') { // AI 추천 결과 조회 @@ -137,7 +229,23 @@ export default function RecommendationStep({ setPolling(false); } } catch (err: any) { - console.error('Job 상태 조회 실패:', err); + console.error('❌ Job 상태 조회 실패:', err); + + // Job을 찾을 수 없는 경우 (404 또는 JOB_001) - 초기 몇 번은 재시도 + if (err.response?.data?.errorCode === 'JOB_001' || err.response?.status === 404) { + attempts++; + if (attempts < 5) { // 처음 5번 시도는 Job 생성 대기 + console.log(`⏳ Job이 아직 준비되지 않음. ${attempts}/5 재시도 예정...`); + setTimeout(poll, 3000); // 3초 후 재시도 + return; + } else if (attempts < maxAttempts) { + console.log(`⏳ Job 폴링 계속... ${attempts}/${maxAttempts}`); + setTimeout(poll, 5000); // 5초 후 재시도 + return; + } + } + + // 다른 에러이거나 재시도 횟수 초과 setError(err.response?.data?.message || 'Job 상태 조회에 실패했습니다'); setLoading(false); setPolling(false); @@ -269,8 +377,11 @@ export default function RecommendationStep({ size="large" onClick={() => { setError(null); - if (initialEventId) { - requestAIRecommendations(initialEventId); + // props에서 eventId가 없으면 쿠키에서 읽어오기 + const evtId = initialEventId || getCookie('eventId'); + + if (evtId) { + requestAIRecommendations(evtId); } else { setError('이벤트 ID가 없습니다. 이전 단계로 돌아가서 다시 시도하세요.'); } diff --git a/src/entities/event/api/eventApi.ts b/src/entities/event/api/eventApi.ts index 6820d7a..048a792 100644 --- a/src/entities/event/api/eventApi.ts +++ b/src/entities/event/api/eventApi.ts @@ -32,12 +32,15 @@ const EVENT_HOST = process.env.NEXT_PUBLIC_EVENT_HOST || 'http://localhost:8080' */ import axios from 'axios'; +const API_VERSION = process.env.NEXT_PUBLIC_API_VERSION || 'v1'; + const eventApiClient = axios.create({ - baseURL: EVENT_HOST, + baseURL: `${EVENT_HOST}/api/${API_VERSION}`, timeout: 30000, headers: { 'Content-Type': 'application/json', }, + withCredentials: false, // CORS 설정 }); // Request interceptor - JWT 토큰 추가 diff --git a/src/features/auth/model/useAuth.ts b/src/features/auth/model/useAuth.ts index 5714b3f..3329ecf 100644 --- a/src/features/auth/model/useAuth.ts +++ b/src/features/auth/model/useAuth.ts @@ -57,14 +57,25 @@ export const useAuth = () => { try { const response = await userApi.login(credentials); + // 토큰을 먼저 저장 (프로필 조회에 필요) + localStorage.setItem(TOKEN_KEY, response.token); + + // 프로필 조회하여 storeId 포함한 전체 정보 가져오기 + const profile = await userApi.getProfile(); + const user: User = { - userId: response.userId, - userName: response.userName, - email: response.email, - role: response.role, + userId: profile.userId, + userName: profile.userName, + email: profile.email, + role: profile.role, + phoneNumber: profile.phoneNumber, + storeId: profile.storeId, + storeName: profile.storeName, + industry: profile.industry, + address: profile.address, + businessHours: profile.businessHours, }; - localStorage.setItem(TOKEN_KEY, response.token); localStorage.setItem(USER_KEY, JSON.stringify(user)); setAuthState({ @@ -76,6 +87,8 @@ export const useAuth = () => { return { success: true, user }; } catch (error) { + // 로그인 실패 시 저장된 토큰 삭제 + localStorage.removeItem(TOKEN_KEY); return { success: false, error: error instanceof Error ? error.message : '로그인에 실패했습니다.', diff --git a/src/shared/api/aiApi.ts b/src/shared/api/aiApi.ts index c541eb8..024f5f3 100644 --- a/src/shared/api/aiApi.ts +++ b/src/shared/api/aiApi.ts @@ -2,13 +2,15 @@ import axios, { AxiosInstance } from 'axios'; // AI Service API 클라이언트 const AI_API_BASE_URL = process.env.NEXT_PUBLIC_AI_HOST || 'http://localhost:8083'; +const API_VERSION = process.env.NEXT_PUBLIC_API_VERSION || 'v1'; export const aiApiClient: AxiosInstance = axios.create({ - baseURL: AI_API_BASE_URL, + baseURL: `${AI_API_BASE_URL}/api/${API_VERSION}`, timeout: 300000, // AI 생성은 최대 5분 headers: { 'Content-Type': 'application/json', }, + withCredentials: false, // CORS 설정 }); // Request interceptor diff --git a/src/shared/api/eventApi.ts b/src/shared/api/eventApi.ts index 4e9465f..6171431 100644 --- a/src/shared/api/eventApi.ts +++ b/src/shared/api/eventApi.ts @@ -2,14 +2,15 @@ import axios, { AxiosInstance } from 'axios'; // Event Service API 클라이언트 const EVENT_API_BASE_URL = process.env.NEXT_PUBLIC_EVENT_HOST || 'http://localhost:8080'; -const API_VERSION = process.env.NEXT_PUBLIC_API_VERSION || 'api'; +const API_VERSION = process.env.NEXT_PUBLIC_API_VERSION || 'v1'; export const eventApiClient: AxiosInstance = axios.create({ - baseURL: `${EVENT_API_BASE_URL}/${API_VERSION}`, + baseURL: `${EVENT_API_BASE_URL}/api/${API_VERSION}`, timeout: 30000, // Job 폴링 고려 headers: { 'Content-Type': 'application/json', }, + withCredentials: false, // CORS 설정 }); // Request interceptor @@ -19,9 +20,15 @@ eventApiClient.interceptors.request.use( method: config.method?.toUpperCase(), url: config.url, baseURL: config.baseURL, - data: config.data, + fullURL: `${config.baseURL}${config.url}`, }); + // POST/PUT 요청일 경우 payload를 JSON 형태로 출력 + if (config.data) { + console.log('📦 Request Payload (JSON):', JSON.stringify(config.data, null, 2)); + console.log('📦 Request Payload (Object):', config.data); + } + const token = localStorage.getItem('accessToken'); if (token && config.headers) { config.headers.Authorization = `Bearer ${token}`; @@ -49,8 +56,18 @@ eventApiClient.interceptors.response.use( message: error.message, status: error.response?.status, url: error.config?.url, - data: error.response?.data, + requestData: error.config?.data, + responseData: error.response?.data, }); + + // 400 에러일 경우 더 상세한 정보 출력 + if (error.response?.status === 400) { + console.error('🚨 400 Bad Request 상세 정보:'); + console.error(' 요청 URL:', `${error.config?.baseURL}${error.config?.url}`); + console.error(' 요청 본문:', JSON.stringify(error.config?.data, null, 2)); + console.error(' 응답 본문:', JSON.stringify(error.response?.data, null, 2)); + } + return Promise.reject(error); } ); @@ -68,6 +85,7 @@ export interface EventCreatedResponse { } export interface AiRecommendationRequest { + objective: string; storeInfo: { storeId: string; storeName: string; @@ -218,18 +236,29 @@ export const eventApi = { // Step 2: AI 추천 요청 requestAiRecommendations: async ( eventId: string, + objective: string, storeInfo: AiRecommendationRequest['storeInfo'] ): Promise => { const response = await eventApiClient.post( `/events/${eventId}/ai-recommendations`, - { storeInfo } + { objective, storeInfo } ); return response.data; }, // Job 상태 폴링 getJobStatus: async (jobId: string): Promise => { - const response = await eventApiClient.get(`/jobs/${jobId}`); + const response = await eventApiClient.get(`/jobs/${jobId}`); + // 백엔드 응답 구조: { success, data: { jobId, jobType, status, ... }, timestamp } + console.log('📦 getJobStatus 원본 응답:', response.data); + + // data 안에 실제 job 정보가 있는지 확인 + if (response.data.data) { + console.log('✅ response.data.data 사용'); + return response.data.data; + } + + console.log('✅ response.data 직접 사용'); return response.data; },