feature/event 브랜치를 develop에 병합

- 최신 변경사항으로 충돌 해결
- RecommendationStep, eventApi, aiApi, eventApi 업데이트

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
merrycoral 2025-10-30 16:00:56 +09:00
commit e50cc86ece
7 changed files with 242 additions and 70 deletions

View File

@ -16,6 +16,7 @@ export type EventMethod = 'online' | 'offline';
export interface EventData { export interface EventData {
eventDraftId?: number; eventDraftId?: number;
eventId?: string;
objective?: EventObjective; objective?: EventObjective;
recommendation?: { recommendation?: {
recommendation: { recommendation: {
@ -95,13 +96,14 @@ export default function EventCreatePage() {
<funnel.Render <funnel.Render
objective={({ history }) => ( objective={({ history }) => (
<ObjectiveStep <ObjectiveStep
onNext={(objective) => { onNext={({ objective, eventId }) => {
history.push('recommendation', { objective }); history.push('recommendation', { objective, eventId });
}} }}
/> />
)} )}
recommendation={({ context, history }) => ( recommendation={({ context, history }) => (
<RecommendationStep <RecommendationStep
eventId={context.eventId}
objective={context.objective} objective={context.objective}
onNext={(recommendation) => { onNext={(recommendation) => {
history.push('channel', { ...context, recommendation }); history.push('channel', { ...context, recommendation });

View File

@ -67,15 +67,44 @@ const objectives: ObjectiveOption[] = [
]; ];
interface ObjectiveStepProps { 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) { export default function ObjectiveStep({ onNext }: ObjectiveStepProps) {
const [selected, setSelected] = useState<EventObjective | null>(null); const [selected, setSelected] = useState<EventObjective | null>(null);
const handleNext = () => { const handleNext = () => {
if (selected) { 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 });
} }
}; };

View File

@ -1,6 +1,6 @@
'use client'; 'use client';
import { useState, useEffect } from 'react'; import { useState, useEffect, useRef } from 'react';
import { import {
Box, Box,
Container, Container,
@ -19,7 +19,6 @@ import {
Alert, Alert,
} from '@mui/material'; } from '@mui/material';
import { ArrowBack, Edit, Insights } from '@mui/icons-material'; import { ArrowBack, Edit, Insights } from '@mui/icons-material';
import { EventObjective, BudgetLevel, EventMethod } from '../page';
import { aiApi, eventApi, AIRecommendationResult, EventRecommendation } from '@/shared/api'; import { aiApi, eventApi, AIRecommendationResult, EventRecommendation } from '@/shared/api';
// 디자인 시스템 색상 // 디자인 시스템 색상
@ -41,8 +40,8 @@ const colors = {
}; };
interface RecommendationStepProps { interface RecommendationStepProps {
objective?: EventObjective;
eventId?: string; // 이전 단계에서 생성된 eventId eventId?: string; // 이전 단계에서 생성된 eventId
objective?: string; // 이전 단계에서 선택된 objective
onNext: (data: { onNext: (data: {
recommendation: EventRecommendation; recommendation: EventRecommendation;
eventId: string; eventId: string;
@ -50,9 +49,30 @@ interface RecommendationStepProps {
onBack: () => void; 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({ export default function RecommendationStep({
objective,
eventId: initialEventId, eventId: initialEventId,
objective,
onNext, onNext,
onBack onBack
}: RecommendationStepProps) { }: RecommendationStepProps) {
@ -66,56 +86,94 @@ export default function RecommendationStep({
const [selected, setSelected] = useState<number | null>(null); const [selected, setSelected] = useState<number | null>(null);
const [editedData, setEditedData] = useState<Record<number, { title: string; description: string }>>({}); const [editedData, setEditedData] = useState<Record<number, { title: string; description: string }>>({});
// 중복 호출 방지를 위한 ref
const requestedEventIdRef = useRef<string | null>(null);
// 컴포넌트 마운트 시 AI 추천 요청 // 컴포넌트 마운트 시 AI 추천 요청
useEffect(() => { useEffect(() => {
if (!eventId && objective) { // props에서만 eventId를 받음 (쿠키 사용 안 함)
// Step 1: 이벤트 생성 if (initialEventId) {
createEventAndRequestAI(); // 이미 요청한 eventId면 중복 요청하지 않음
} else if (eventId) { if (requestedEventIdRef.current === initialEventId) {
// 이미 eventId가 있으면 AI 추천 요청 console.log('⚠️ 이미 요청한 eventId입니다. 중복 요청 방지:', initialEventId);
requestAIRecommendations(eventId); return;
}
requestedEventIdRef.current = initialEventId;
setEventId(initialEventId);
console.log('✅ RecommendationStep - eventId 설정:', initialEventId);
// eventId가 있으면 바로 AI 추천 요청
requestAIRecommendations(initialEventId);
} else {
console.error('❌ eventId가 없습니다. ObjectiveStep으로 돌아가세요.');
setError('이벤트 ID가 없습니다. 이전 단계로 돌아가서 다시 시도하세요.');
} }
}, []); }, [initialEventId]);
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);
}
};
const requestAIRecommendations = async (evtId: string) => { const requestAIRecommendations = async (evtId: string) => {
try { try {
setLoading(true); setLoading(true);
setError(null); setError(null);
// 사용자 정보에서 매장 정보 가져오기 // 로그인한 사용자 정보에서 매장 정보 가져오기
const userProfile = JSON.parse(localStorage.getItem('userProfile') || '{}'); const user = JSON.parse(localStorage.getItem('user') || '{}');
const storeInfo = { console.log('📋 localStorage user:', user);
storeId: userProfile.storeId || '1',
storeName: userProfile.storeName || '내 매장', // UUID v4 생성 함수 (테스트용)
category: userProfile.industry || '음식점', const generateUUID = () => {
description: userProfile.businessHours || '', 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 추천 요청 // storeId: 로그인한 경우 user.storeId 사용, 아니면 테스트용 UUID 생성
const jobResponse = await eventApi.requestAiRecommendations(evtId, storeInfo); const storeInfo = {
setJobId(jobResponse.jobId); storeId: user.storeId ? String(user.storeId) : generateUUID(),
storeName: user.storeName || '테스트 매장',
category: user.industry || '음식점',
description: user.businessHours || '테스트 설명',
};
// Job 폴링 시작 console.log('📤 전송할 storeInfo:', storeInfo);
pollJobStatus(jobResponse.jobId, evtId); 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) { } catch (err: any) {
console.error('AI 추천 요청 실패:', err); console.error('AI 추천 요청 실패:', err);
setError(err.response?.data?.message || 'AI 추천 요청에 실패했습니다'); setError(err.response?.data?.message || 'AI 추천 요청에 실패했습니다');
@ -130,8 +188,22 @@ export default function RecommendationStep({
const poll = async () => { const poll = async () => {
try { try {
const status = await eventApi.getJobStatus(jId); // jobId 확인: 파라미터 우선, 없으면 쿠키에서 읽기
console.log('Job 상태:', status); 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') { if (status.status === 'COMPLETED') {
// AI 추천 결과 조회 // AI 추천 결과 조회
@ -157,7 +229,23 @@ export default function RecommendationStep({
setPolling(false); setPolling(false);
} }
} catch (err: any) { } 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 상태 조회에 실패했습니다'); setError(err.response?.data?.message || 'Job 상태 조회에 실패했습니다');
setLoading(false); setLoading(false);
setPolling(false); setPolling(false);
@ -289,10 +377,13 @@ export default function RecommendationStep({
size="large" size="large"
onClick={() => { onClick={() => {
setError(null); setError(null);
if (eventId) { // props에서 eventId가 없으면 쿠키에서 읽어오기
requestAIRecommendations(eventId); const evtId = initialEventId || getCookie('eventId');
if (evtId) {
requestAIRecommendations(evtId);
} else { } else {
createEventAndRequestAI(); setError('이벤트 ID가 없습니다. 이전 단계로 돌아가서 다시 시도하세요.');
} }
}} }}
sx={{ sx={{

View File

@ -23,17 +23,24 @@ import type {
* *
* apiClient를 , baseURL을 . * 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'; import axios from 'axios';
const EVENT_API_BASE = '/api/v1/events'; const API_VERSION = process.env.NEXT_PUBLIC_API_VERSION || 'v1';
const API_BASE_URL = 'http://kt-event-marketing-api.20.214.196.128.nip.io';
const eventApiClient = axios.create({ const eventApiClient = axios.create({
baseURL: API_BASE_URL, baseURL: `${EVENT_HOST}/api/${API_VERSION}`,
timeout: 30000, timeout: 30000,
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
withCredentials: false, // CORS 설정
}); });
// Request interceptor - JWT 토큰 추가 // Request interceptor - JWT 토큰 추가

View File

@ -57,14 +57,25 @@ export const useAuth = () => {
try { try {
const response = await userApi.login(credentials); const response = await userApi.login(credentials);
// 토큰을 먼저 저장 (프로필 조회에 필요)
localStorage.setItem(TOKEN_KEY, response.token);
// 프로필 조회하여 storeId 포함한 전체 정보 가져오기
const profile = await userApi.getProfile();
const user: User = { const user: User = {
userId: response.userId, userId: profile.userId,
userName: response.userName, userName: profile.userName,
email: response.email, email: profile.email,
role: response.role, 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)); localStorage.setItem(USER_KEY, JSON.stringify(user));
setAuthState({ setAuthState({
@ -76,6 +87,8 @@ export const useAuth = () => {
return { success: true, user }; return { success: true, user };
} catch (error) { } catch (error) {
// 로그인 실패 시 저장된 토큰 삭제
localStorage.removeItem(TOKEN_KEY);
return { return {
success: false, success: false,
error: error instanceof Error ? error.message : '로그인에 실패했습니다.', error: error instanceof Error ? error.message : '로그인에 실패했습니다.',

View File

@ -1,14 +1,16 @@
import axios, { AxiosInstance } from 'axios'; import axios, { AxiosInstance } from 'axios';
// AI Service API 클라이언트 // 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({ export const aiApiClient: AxiosInstance = axios.create({
baseURL: AI_API_BASE_URL, baseURL: `${AI_API_BASE_URL}/api/${API_VERSION}`,
timeout: 300000, // AI 생성은 최대 5분 timeout: 300000, // AI 생성은 최대 5분
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
withCredentials: false, // CORS 설정
}); });
// Request interceptor // Request interceptor

View File

@ -1,16 +1,16 @@
import axios, { AxiosInstance } from 'axios'; import axios, { AxiosInstance } from 'axios';
// Event Service API 클라이언트 // Event Service API 클라이언트
const EVENT_API_BASE_URL = 'http://kt-event-marketing-api.20.214.196.128.nip.io'; const EVENT_API_BASE_URL = process.env.NEXT_PUBLIC_EVENT_HOST || 'http://localhost:8080';
const API_VERSION = 'v1'; const API_VERSION = process.env.NEXT_PUBLIC_API_VERSION || 'v1';
const BASE_URL = `${EVENT_API_BASE_URL}/api/${API_VERSION}`;
export const eventApiClient: AxiosInstance = axios.create({ export const eventApiClient: AxiosInstance = axios.create({
baseURL: BASE_URL, baseURL: `${EVENT_API_BASE_URL}/api/${API_VERSION}`,
timeout: 30000, // Job 폴링 고려 timeout: 30000, // Job 폴링 고려
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
withCredentials: false, // CORS 설정
}); });
// Request interceptor // Request interceptor
@ -20,9 +20,15 @@ eventApiClient.interceptors.request.use(
method: config.method?.toUpperCase(), method: config.method?.toUpperCase(),
url: config.url, url: config.url,
baseURL: config.baseURL, 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'); const token = localStorage.getItem('accessToken');
if (token && config.headers) { if (token && config.headers) {
config.headers.Authorization = `Bearer ${token}`; config.headers.Authorization = `Bearer ${token}`;
@ -50,8 +56,18 @@ eventApiClient.interceptors.response.use(
message: error.message, message: error.message,
status: error.response?.status, status: error.response?.status,
url: error.config?.url, 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); return Promise.reject(error);
} }
); );
@ -69,6 +85,7 @@ export interface EventCreatedResponse {
} }
export interface AiRecommendationRequest { export interface AiRecommendationRequest {
objective: string;
storeInfo: { storeInfo: {
storeId: string; storeId: string;
storeName: string; storeName: string;
@ -219,18 +236,29 @@ export const eventApi = {
// Step 2: AI 추천 요청 // Step 2: AI 추천 요청
requestAiRecommendations: async ( requestAiRecommendations: async (
eventId: string, eventId: string,
objective: string,
storeInfo: AiRecommendationRequest['storeInfo'] storeInfo: AiRecommendationRequest['storeInfo']
): Promise<JobAcceptedResponse> => { ): Promise<JobAcceptedResponse> => {
const response = await eventApiClient.post<JobAcceptedResponse>( const response = await eventApiClient.post<JobAcceptedResponse>(
`/events/${eventId}/ai-recommendations`, `/events/${eventId}/ai-recommendations`,
{ storeInfo } { objective, storeInfo }
); );
return response.data; return response.data;
}, },
// Job 상태 폴링 // Job 상태 폴링
getJobStatus: async (jobId: string): Promise<EventJobStatusResponse> => { getJobStatus: async (jobId: string): Promise<EventJobStatusResponse> => {
const response = await eventApiClient.get<EventJobStatusResponse>(`/jobs/${jobId}`); const response = await eventApiClient.get<any>(`/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; return response.data;
}, },