diff --git a/src/app/(main)/events/create/page.tsx b/src/app/(main)/events/create/page.tsx index 1fa7006..6e0c7e3 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,13 +96,14 @@ 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..23c6513 100644 --- a/src/app/(main)/events/create/steps/ObjectiveStep.tsx +++ b/src/app/(main)/events/create/steps/ObjectiveStep.tsx @@ -67,15 +67,44 @@ 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=/`; +}; + +// 쿠키 삭제 함수 +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) { - onNext(selected); + // 이전 쿠키 삭제 (깨끗한 상태에서 시작) + deleteCookie('eventId'); + deleteCookie('jobId'); + + // 새로운 eventId 생성 + const eventId = generateEventId(); + console.log('✅ 새로운 eventId 생성:', eventId); + + // 쿠키에 저장 + 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 2635a38..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,56 +86,94 @@ export default function RecommendationStep({ const [selected, setSelected] = useState(null); const [editedData, setEditedData] = useState>({}); + // 중복 호출 방지를 위한 ref + const requestedEventIdRef = useRef(null); + // 컴포넌트 마운트 시 AI 추천 요청 useEffect(() => { - if (!eventId && objective) { - // Step 1: 이벤트 생성 - createEventAndRequestAI(); - } else if (eventId) { - // 이미 eventId가 있으면 AI 추천 요청 - requestAIRecommendations(eventId); + // 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 { + console.error('❌ eventId가 없습니다. ObjectiveStep으로 돌아가세요.'); + 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 || err.message || '이벤트 생성에 실패했습니다'); - setLoading(false); - } - }; + }, [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 추천 요청에 실패했습니다'); @@ -130,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 추천 결과 조회 @@ -157,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); @@ -289,10 +377,13 @@ export default function RecommendationStep({ size="large" onClick={() => { setError(null); - if (eventId) { - requestAIRecommendations(eventId); + // props에서 eventId가 없으면 쿠키에서 읽어오기 + const evtId = initialEventId || getCookie('eventId'); + + if (evtId) { + requestAIRecommendations(evtId); } else { - createEventAndRequestAI(); + setError('이벤트 ID가 없습니다. 이전 단계로 돌아가서 다시 시도하세요.'); } }} sx={{ diff --git a/src/entities/event/api/eventApi.ts b/src/entities/event/api/eventApi.ts index 0717ec0..048a792 100644 --- a/src/entities/event/api/eventApi.ts +++ b/src/entities/event/api/eventApi.ts @@ -23,17 +23,24 @@ import type { * * 현재는 apiClient를 사용하되, baseURL을 오버라이드합니다. */ +const EVENT_API_BASE = '/api/v1/events'; +const EVENT_HOST = process.env.NEXT_PUBLIC_EVENT_HOST || 'http://localhost:8080'; + +/** + * Event Service용 API 클라이언트 + * Event Service는 별도 포트(8080)에서 실행되므로 별도 클라이언트 생성 + */ import axios from 'axios'; -const EVENT_API_BASE = '/api/v1/events'; -const API_BASE_URL = 'http://kt-event-marketing-api.20.214.196.128.nip.io'; +const API_VERSION = process.env.NEXT_PUBLIC_API_VERSION || 'v1'; const eventApiClient = axios.create({ - baseURL: API_BASE_URL, + 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 4d1f4c2..024f5f3 100644 --- a/src/shared/api/aiApi.ts +++ b/src/shared/api/aiApi.ts @@ -1,14 +1,16 @@ import axios, { AxiosInstance } from 'axios'; // AI Service API 클라이언트 -const AI_API_BASE_URL = 'http://kt-event-marketing-api.20.214.196.128.nip.io'; +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 03ad0fa..6171431 100644 --- a/src/shared/api/eventApi.ts +++ b/src/shared/api/eventApi.ts @@ -1,16 +1,16 @@ import axios, { AxiosInstance } from 'axios'; // Event Service API 클라이언트 -const EVENT_API_BASE_URL = 'http://kt-event-marketing-api.20.214.196.128.nip.io'; -const API_VERSION = 'v1'; -const BASE_URL = `${EVENT_API_BASE_URL}/api/${API_VERSION}`; +const EVENT_API_BASE_URL = process.env.NEXT_PUBLIC_EVENT_HOST || 'http://localhost:8080'; +const API_VERSION = process.env.NEXT_PUBLIC_API_VERSION || 'v1'; export const eventApiClient: AxiosInstance = axios.create({ - baseURL: BASE_URL, + baseURL: `${EVENT_API_BASE_URL}/api/${API_VERSION}`, timeout: 30000, // Job 폴링 고려 headers: { 'Content-Type': 'application/json', }, + withCredentials: false, // CORS 설정 }); // Request interceptor @@ -20,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}`; @@ -50,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); } ); @@ -69,6 +85,7 @@ export interface EventCreatedResponse { } export interface AiRecommendationRequest { + objective: string; storeInfo: { storeId: string; storeName: string; @@ -219,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; },