박세원 86ae038a31 Event Service API 통합 및 AI 추천 조회 로직 개선
- eventApi에 getAiRecommendations 메서드 추가
- Job COMPLETED 시 Event Service의 공개 API로 추천 결과 조회
- AI Service Internal API 대신 Event Service API 사용
- 타입 정의 통합 및 중복 제거
- 환경변수 포트 설정 수정 (AI_HOST: 8083)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-30 20:03:42 +09:00

662 lines
24 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client';
import { useState, useEffect, useRef } from 'react';
import {
Box,
Container,
Typography,
Card,
CardContent,
Button,
Grid,
Chip,
TextField,
Radio,
RadioGroup,
FormControlLabel,
IconButton,
CircularProgress,
Alert,
} from '@mui/material';
import { ArrowBack, Edit, Insights } from '@mui/icons-material';
import { eventApi } from '@/shared/api';
import type { AiRecommendationResult, EventRecommendation } from '@/shared/api/eventApi';
// 디자인 시스템 색상
const colors = {
pink: '#F472B6',
purple: '#C084FC',
purpleLight: '#E9D5FF',
blue: '#60A5FA',
mint: '#34D399',
orange: '#FB923C',
yellow: '#FBBF24',
gray: {
900: '#1A1A1A',
700: '#4A4A4A',
500: '#9E9E9E',
300: '#D9D9D9',
100: '#F5F5F5',
},
};
interface RecommendationStepProps {
eventId?: string; // 이전 단계에서 생성된 eventId
objective?: string; // 이전 단계에서 선택된 objective
onNext: (data: {
recommendation: EventRecommendation;
eventId: string;
}) => 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({
eventId: initialEventId,
objective,
onNext,
onBack
}: RecommendationStepProps) {
const [eventId, setEventId] = useState<string | null>(initialEventId || null);
const [jobId, setJobId] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [polling, setPolling] = useState(false);
const [error, setError] = useState<string | null>(null);
const [aiResult, setAiResult] = useState<AiRecommendationResult | null>(null);
const [selected, setSelected] = useState<number | null>(null);
const [editedData, setEditedData] = useState<Record<number, { title: string; description: string }>>({});
// 중복 호출 방지를 위한 ref
const requestedEventIdRef = useRef<string | null>(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 {
console.error('❌ eventId가 없습니다. ObjectiveStep으로 돌아가세요.');
setError('이벤트 ID가 없습니다. 이전 단계로 돌아가서 다시 시도하세요.');
}
}, [initialEventId]);
const requestAIRecommendations = async (evtId: string) => {
try {
setLoading(true);
setError(null);
// 로그인한 사용자 정보에서 매장 정보 가져오기
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);
});
};
// storeId: 로그인한 경우 user.storeId 사용, 아니면 테스트용 UUID 생성
const storeInfo = {
storeId: user.storeId ? String(user.storeId) : generateUUID(),
storeName: user.storeName || '테스트 매장',
category: user.industry || '음식점',
description: user.businessHours || '테스트 설명',
};
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 추천 요청에 실패했습니다');
setLoading(false);
}
};
const pollJobStatus = async (jId: string, evtId: string) => {
setPolling(true);
const maxAttempts = 60; // 최대 5분 (5초 간격)
let attempts = 0;
const poll = async () => {
try {
// 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 추천 결과 조회 (Event Service API 사용)
const recommendations = await eventApi.getAiRecommendations(evtId);
setAiResult(recommendations);
setLoading(false);
setPolling(false);
return;
} else if (status.status === 'FAILED') {
setError(status.errorMessage || 'AI 추천 생성에 실패했습니다');
setLoading(false);
setPolling(false);
return;
}
// 계속 폴링
attempts++;
if (attempts < maxAttempts) {
setTimeout(poll, 5000); // 5초 후 재시도
} else {
setError('AI 추천 생성 시간이 초과되었습니다');
setLoading(false);
setPolling(false);
}
} catch (err: any) {
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);
}
};
poll();
};
const handleNext = async () => {
if (selected === null || !aiResult || !eventId) return;
const selectedRec = aiResult.recommendations[selected - 1];
const edited = editedData[selected];
try {
setLoading(true);
// AI 추천 선택 API 호출
await eventApi.selectRecommendation(eventId, {
recommendationId: `${eventId}-opt${selected}`,
customizations: {
eventName: edited?.title || selectedRec.title,
description: edited?.description || selectedRec.description,
},
});
// 다음 단계로 이동
onNext({
recommendation: {
...selectedRec,
title: edited?.title || selectedRec.title,
description: edited?.description || selectedRec.description,
},
eventId,
});
} catch (err: any) {
console.error('추천 선택 실패:', err);
setError(err.response?.data?.message || '추천 선택에 실패했습니다');
} finally {
setLoading(false);
}
};
const handleEditTitle = (optionNumber: number, title: string) => {
setEditedData((prev) => ({
...prev,
[optionNumber]: {
...prev[optionNumber],
title
},
}));
};
const handleEditDescription = (optionNumber: number, description: string) => {
setEditedData((prev) => ({
...prev,
[optionNumber]: {
...prev[optionNumber],
description
},
}));
};
// 로딩 상태 표시
if (loading || polling) {
return (
<Box sx={{ minHeight: '100vh', bgcolor: 'background.default', pb: 20 }}>
<Container maxWidth="lg" sx={{ pt: 8, pb: 8, px: { xs: 6, sm: 6, md: 8 } }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 3, mb: 8 }}>
<IconButton onClick={onBack} sx={{ width: 48, height: 48 }}>
<ArrowBack />
</IconButton>
<Typography variant="h5" sx={{ fontWeight: 700, fontSize: '1.5rem' }}>
AI
</Typography>
</Box>
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 4, py: 12 }}>
<CircularProgress size={60} sx={{ color: colors.purple }} />
<Typography variant="h6" sx={{ fontWeight: 600, fontSize: '1.25rem' }}>
AI가 ...
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ fontSize: '1rem' }}>
, ,
</Typography>
</Box>
</Container>
</Box>
);
}
// 에러 상태 표시
if (error) {
return (
<Box sx={{ minHeight: '100vh', bgcolor: 'background.default', pb: 20 }}>
<Container maxWidth="lg" sx={{ pt: 8, pb: 8, px: { xs: 6, sm: 6, md: 8 } }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 3, mb: 8 }}>
<IconButton onClick={onBack} sx={{ width: 48, height: 48 }}>
<ArrowBack />
</IconButton>
<Typography variant="h5" sx={{ fontWeight: 700, fontSize: '1.5rem' }}>
AI
</Typography>
</Box>
<Alert severity="error" sx={{ mb: 4 }}>
{error}
</Alert>
<Box sx={{ display: 'flex', gap: 4 }}>
<Button
fullWidth
variant="outlined"
size="large"
onClick={onBack}
sx={{
py: 3,
borderRadius: 3,
fontSize: '1rem',
fontWeight: 600,
}}
>
</Button>
<Button
fullWidth
variant="contained"
size="large"
onClick={() => {
setError(null);
// props에서 eventId가 없으면 쿠키에서 읽어오기
const evtId = initialEventId || getCookie('eventId');
if (evtId) {
requestAIRecommendations(evtId);
} else {
setError('이벤트 ID가 없습니다. 이전 단계로 돌아가서 다시 시도하세요.');
}
}}
sx={{
py: 3,
borderRadius: 3,
fontSize: '1rem',
fontWeight: 700,
background: `linear-gradient(135deg, ${colors.purple} 0%, ${colors.pink} 100%)`,
}}
>
</Button>
</Box>
</Container>
</Box>
);
}
// AI 결과가 없으면 로딩 표시
if (!aiResult) {
return (
<Box sx={{ minHeight: '100vh', bgcolor: 'background.default', pb: 20 }}>
<Container maxWidth="lg" sx={{ pt: 8, pb: 8, px: { xs: 6, sm: 6, md: 8 } }}>
<CircularProgress />
</Container>
</Box>
);
}
return (
<Box sx={{ minHeight: '100vh', bgcolor: 'background.default', pb: 20 }}>
<Container maxWidth="lg" sx={{ pt: 8, pb: 8, px: { xs: 6, sm: 6, md: 8 } }}>
{/* Header */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 3, mb: 8 }}>
<IconButton onClick={onBack} sx={{ width: 48, height: 48 }}>
<ArrowBack />
</IconButton>
<Typography variant="h5" sx={{ fontWeight: 700, fontSize: '1.5rem' }}>
AI
</Typography>
</Box>
{/* Trends Analysis */}
<Card
elevation={0}
sx={{
mb: 10,
borderRadius: 4,
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)',
}}
>
<CardContent sx={{ p: 8 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 6 }}>
<Insights sx={{ fontSize: 32, color: colors.purple }} />
<Typography variant="h6" sx={{ fontWeight: 700, fontSize: '1.25rem' }}>
AI
</Typography>
</Box>
<Grid container spacing={6}>
<Grid item xs={12} md={4}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 2, fontSize: '1rem' }}>
📍
</Typography>
{aiResult.trendAnalysis.industryTrends.slice(0, 3).map((trend, idx) => (
<Typography key={idx} variant="body2" color="text.secondary" sx={{ fontSize: '0.95rem', mb: 1 }}>
{trend.description}
</Typography>
))}
</Grid>
<Grid item xs={12} md={4}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 2, fontSize: '1rem' }}>
🗺
</Typography>
{aiResult.trendAnalysis.regionalTrends.slice(0, 3).map((trend, idx) => (
<Typography key={idx} variant="body2" color="text.secondary" sx={{ fontSize: '0.95rem', mb: 1 }}>
{trend.description}
</Typography>
))}
</Grid>
<Grid item xs={12} md={4}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 2, fontSize: '1rem' }}>
</Typography>
{aiResult.trendAnalysis.seasonalTrends.slice(0, 3).map((trend, idx) => (
<Typography key={idx} variant="body2" color="text.secondary" sx={{ fontSize: '0.95rem', mb: 1 }}>
{trend.description}
</Typography>
))}
</Grid>
</Grid>
</CardContent>
</Card>
{/* AI Recommendations */}
<Box sx={{ mb: 8 }}>
<Typography variant="h6" sx={{ fontWeight: 700, mb: 4, fontSize: '1.25rem' }}>
AI ({aiResult.recommendations.length} )
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 6, fontSize: '1rem' }}>
. .
</Typography>
</Box>
{/* Recommendations */}
<RadioGroup value={selected} onChange={(e) => setSelected(Number(e.target.value))}>
<Grid container spacing={6} sx={{ mb: 10 }}>
{aiResult.recommendations.map((rec) => (
<Grid item xs={12} key={rec.optionNumber}>
<Card
elevation={0}
sx={{
cursor: 'pointer',
borderRadius: 4,
border: selected === rec.optionNumber ? 2 : 1,
borderColor: selected === rec.optionNumber ? colors.purple : 'divider',
bgcolor: selected === rec.optionNumber ? `${colors.purpleLight}40` : 'background.paper',
transition: 'all 0.2s',
boxShadow: selected === rec.optionNumber ? '0 4px 12px rgba(0, 0, 0, 0.15)' : '0 2px 8px rgba(0, 0, 0, 0.08)',
'&:hover': {
borderColor: colors.purple,
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
transform: 'translateY(-2px)',
},
}}
onClick={() => setSelected(rec.optionNumber)}
>
<CardContent sx={{ p: 6 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 4 }}>
<Box sx={{ display: 'flex', gap: 2 }}>
<Chip
label={`옵션 ${rec.optionNumber}`}
color="primary"
size="medium"
sx={{ fontSize: '0.875rem', py: 2 }}
/>
<Chip
label={rec.concept}
variant="outlined"
size="medium"
sx={{ fontSize: '0.875rem', py: 2 }}
/>
</Box>
<FormControlLabel
value={rec.optionNumber}
control={<Radio />}
label=""
sx={{ m: 0 }}
/>
</Box>
<TextField
fullWidth
variant="outlined"
value={editedData[rec.optionNumber]?.title || rec.title}
onChange={(e) => handleEditTitle(rec.optionNumber, e.target.value)}
onClick={(e) => e.stopPropagation()}
sx={{ mb: 4 }}
InputProps={{
endAdornment: <Edit fontSize="small" color="action" />,
sx: { fontSize: '1.1rem', fontWeight: 600, py: 2 },
}}
/>
<TextField
fullWidth
multiline
rows={2}
variant="outlined"
value={editedData[rec.optionNumber]?.description || rec.description}
onChange={(e) => handleEditDescription(rec.optionNumber, e.target.value)}
onClick={(e) => e.stopPropagation()}
sx={{ mb: 4 }}
InputProps={{
sx: { fontSize: '1rem' },
}}
/>
<Grid container spacing={4} sx={{ mt: 2 }}>
<Grid item xs={6} md={3}>
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.875rem' }}>
</Typography>
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: '1rem', mt: 1 }}>
{rec.targetAudience}
</Typography>
</Grid>
<Grid item xs={6} md={3}>
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.875rem' }}>
</Typography>
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: '1rem', mt: 1 }}>
{(rec.estimatedCost.min / 10000).toFixed(0)}~{(rec.estimatedCost.max / 10000).toFixed(0)}
</Typography>
</Grid>
<Grid item xs={6} md={3}>
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.875rem' }}>
</Typography>
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: '1rem', mt: 1 }}>
{rec.expectedMetrics.newCustomers.min}~{rec.expectedMetrics.newCustomers.max}
</Typography>
</Grid>
<Grid item xs={6} md={3}>
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.875rem' }}>
ROI
</Typography>
<Typography variant="body2" sx={{ fontWeight: 600, color: 'error.main', fontSize: '1rem', mt: 1 }}>
{rec.expectedMetrics.roi.min}~{rec.expectedMetrics.roi.max}%
</Typography>
</Grid>
<Grid item xs={12}>
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.875rem' }}>
</Typography>
<Typography variant="body2" sx={{ fontSize: '0.95rem', mt: 1 }}>
{rec.differentiator}
</Typography>
</Grid>
</Grid>
</CardContent>
</Card>
</Grid>
))}
</Grid>
</RadioGroup>
{/* Action Buttons */}
<Box sx={{ display: 'flex', gap: 4 }}>
<Button
fullWidth
variant="outlined"
size="large"
onClick={onBack}
sx={{
py: 3,
borderRadius: 3,
fontSize: '1rem',
fontWeight: 600,
borderWidth: 2,
'&:hover': {
borderWidth: 2,
},
}}
>
</Button>
<Button
fullWidth
variant="contained"
size="large"
disabled={selected === null || loading}
onClick={handleNext}
sx={{
py: 3,
borderRadius: 3,
fontSize: '1rem',
fontWeight: 700,
background: `linear-gradient(135deg, ${colors.purple} 0%, ${colors.pink} 100%)`,
'&:hover': {
background: `linear-gradient(135deg, ${colors.purple} 0%, ${colors.pink} 100%)`,
opacity: 0.9,
},
'&:disabled': {
background: colors.gray[300],
},
}}
>
{loading ? <CircularProgress size={24} sx={{ color: 'white' }} /> : '다음'}
</Button>
</Box>
</Container>
</Box>
);
}