From 6cccafa822d13e787765576a6870021a96e342e8 Mon Sep 17 00:00:00 2001 From: cherry2250 Date: Tue, 28 Oct 2025 23:08:57 +0900 Subject: [PATCH 1/8] =?UTF-8?q?AI=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20=EA=B8=B0=EB=8A=A5=20=EC=99=84=EC=84=B1=20?= =?UTF-8?q?=EB=B0=8F=20=EC=8B=A4=EC=A0=9C=20API=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 주요 변경사항: - Step flow 통합: localStorage 기반 eventId 사용 - 자동 이미지 생성: 이미지 없을 시 자동 생성 트리거 - 진행률 바 추가: 0-100% 진행률 표시 - 동적 로딩 메시지: 단계별 메시지 업데이트 - Next.js 15 API routes 수정: params를 Promise로 처리 - 실제 배포 API 연동: Content API 서버 URL 설정 기술 세부사항: - API proxy routes 추가 (CORS 우회) - 2초 폴링 메커니즘 (최대 60초) - 환경변수: NEXT_PUBLIC_CONTENT_API_URL 설정 - CDN URL 디버그 오버레이 제거 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- API_CHANGES.md | 263 +++++++++ CORS_FIX.md | 221 ++++++++ FIX_EVENTID_MISMATCH.md | 182 +++++++ MOCK_DATA_SETUP.md | 149 ++++++ QUICK_TEST.md | 145 +++++ TEST_URLS.md | 117 ++++ next.config.js | 2 +- public/init-mock-data.html | 205 +++++++ setup-mock-data.js | 17 + src/app/(main)/events/create/page.tsx | 23 +- .../create/steps/ContentPreviewStep.tsx | 505 ++++++++++++++---- .../events/[eventDraftId]/images/route.ts | 52 ++ src/app/api/content/images/generate/route.ts | 42 ++ .../api/content/images/jobs/[jobId]/route.ts | 42 ++ src/shared/api/contentApi.ts | 160 ++++++ test-images.html | 124 +++++ test-localstorage.html | 182 +++++++ 17 files changed, 2316 insertions(+), 115 deletions(-) create mode 100644 API_CHANGES.md create mode 100644 CORS_FIX.md create mode 100644 FIX_EVENTID_MISMATCH.md create mode 100644 MOCK_DATA_SETUP.md create mode 100644 QUICK_TEST.md create mode 100644 TEST_URLS.md create mode 100644 public/init-mock-data.html create mode 100644 setup-mock-data.js create mode 100644 src/app/api/content/events/[eventDraftId]/images/route.ts create mode 100644 src/app/api/content/images/generate/route.ts create mode 100644 src/app/api/content/images/jobs/[jobId]/route.ts create mode 100644 src/shared/api/contentApi.ts create mode 100644 test-images.html create mode 100644 test-localstorage.html diff --git a/API_CHANGES.md b/API_CHANGES.md new file mode 100644 index 0000000..f54789e --- /dev/null +++ b/API_CHANGES.md @@ -0,0 +1,263 @@ +# Content API 변경사항 + +## 📋 주요 변경사항 요약 + +### 1. **eventDraftId → eventId 타입 변경** + +| 항목 | 기존 (Old) | 변경 (New) | +|------|-----------|-----------| +| 필드명 | `eventDraftId` | `eventId` | +| 타입 | `number` | `string` | +| 예시 | `7777` | `"7777"` | + +--- + +## 🔄 영향을 받는 인터페이스 + +### GenerateImagesRequest + +**Before:** +```typescript +interface GenerateImagesRequest { + eventDraftId: number; + eventTitle: string; + eventDescription: string; + industry?: string; + location?: string; + trends?: string[]; + styles: ('SIMPLE' | 'FANCY' | 'TRENDY')[]; + platforms: ('INSTAGRAM' | 'NAVER' | 'KAKAO')[]; +} +``` + +**After:** +```typescript +interface GenerateImagesRequest { + eventId: string; // Changed from eventDraftId: number + eventTitle: string; + eventDescription: string; + industry?: string; + location?: string; + trends?: string[]; + styles: ('SIMPLE' | 'FANCY' | 'TRENDY')[]; + platforms: ('INSTAGRAM' | 'NAVER' | 'KAKAO')[]; +} +``` + +### JobInfo + +**Before:** +```typescript +interface JobInfo { + id: string; + eventDraftId: number; + jobType: string; + status: 'PENDING' | 'PROCESSING' | 'COMPLETED' | 'FAILED'; + progress: number; + resultMessage?: string; + errorMessage?: string; + createdAt: string; + updatedAt: string; +} +``` + +**After:** +```typescript +interface JobInfo { + id: string; + eventId: string; // Changed from eventDraftId: number + jobType: string; + status: 'PENDING' | 'PROCESSING' | 'COMPLETED' | 'FAILED'; + progress: number; + resultMessage?: string; + errorMessage?: string; + createdAt: string; + updatedAt: string; +} +``` + +### ImageInfo + +**Before:** +```typescript +interface ImageInfo { + id: number; + eventDraftId: number; + style: 'SIMPLE' | 'FANCY' | 'TRENDY'; + platform: 'INSTAGRAM' | 'NAVER' | 'KAKAO'; + cdnUrl: string; + prompt: string; + selected: boolean; + createdAt: string; + updatedAt: string; +} +``` + +**After:** +```typescript +interface ImageInfo { + id: number; + eventId: string; // Changed from eventDraftId: number + style: 'SIMPLE' | 'FANCY' | 'TRENDY'; + platform: 'INSTAGRAM' | 'NAVER' | 'KAKAO'; + cdnUrl: string; + prompt: string; + selected: boolean; + createdAt: string; + updatedAt: string; +} +``` + +### ContentInfo + +**Before:** +```typescript +interface ContentInfo { + id: number; + eventDraftId: number; + eventTitle: string; + eventDescription: string; + images: ImageInfo[]; + createdAt: string; + updatedAt: string; +} +``` + +**After:** +```typescript +interface ContentInfo { + id: number; + eventId: string; // Changed from eventDraftId: number + eventTitle: string; + eventDescription: string; + images: ImageInfo[]; + createdAt: string; + updatedAt: string; +} +``` + +--- + +## 📝 수정된 파일 목록 + +### 1. Type Definitions +- ✅ `src/shared/api/contentApi.ts` + - `GenerateImagesRequest` interface updated + - `JobInfo` interface updated + - `ImageInfo` interface updated + - `ContentInfo` interface updated + - API function signatures updated + +### 2. Components +- ✅ `src/app/(main)/events/create/steps/ContentPreviewStep.tsx` + - `EventCreationData` interface: `eventDraftId: number` → `eventDraftId: string` + - Mock data updated to use string type + - API call updated: `eventDraftId` → `eventId` + +### 3. Mock Data Files +- ✅ `public/init-mock-data.html` + - `eventDraftId: 7777` → `eventDraftId: "7777"` + +- ✅ `MOCK_DATA_SETUP.md` + - All mock data examples updated to string type + - Documentation notes added about type change + +### 4. API Routes (Next.js Proxy) +- ✅ `src/app/api/content/images/generate/route.ts` (no changes needed) +- ✅ `src/app/api/content/images/jobs/[jobId]/route.ts` (no changes needed) +- ✅ `src/app/api/content/events/[eventDraftId]/images/route.ts` + - Comment added about eventId parameter + +--- + +## 🧪 테스트 예시 + +### API 요청 예시 + +**Before:** +```json +POST /api/v1/content/images/generate +{ + "eventDraftId": 7777, + "eventTitle": "맥주 파티 이벤트", + "eventDescription": "강남에서 열리는 신나는 맥주 파티", + "industry": "음식점", + "location": "강남", + "trends": ["파티", "맥주", "생맥주"], + "styles": ["SIMPLE", "FANCY", "TRENDY"], + "platforms": ["INSTAGRAM"] +} +``` + +**After:** +```json +POST /api/v1/content/images/generate +{ + "eventId": "7777", + "eventTitle": "맥주 파티 이벤트", + "eventDescription": "강남에서 열리는 신나는 맥주 파티", + "industry": "음식점", + "location": "강남", + "trends": ["파티", "맥주", "생맥주"], + "styles": ["SIMPLE", "FANCY", "TRENDY"], + "platforms": ["INSTAGRAM"] +} +``` + +### localStorage Mock 데이터 + +**Before:** +```javascript +localStorage.setItem('eventCreationData', JSON.stringify({ + eventDraftId: 7777, + eventTitle: "맥주 파티 이벤트", + // ... +})); +``` + +**After:** +```javascript +localStorage.setItem('eventCreationData', JSON.stringify({ + eventDraftId: "7777", // String type now + eventTitle: "맥주 파티 이벤트", + // ... +})); +``` + +--- + +## ✅ 마이그레이션 체크리스트 + +- [x] TypeScript 인터페이스 업데이트 +- [x] API 호출 코드 수정 +- [x] Mock 데이터 타입 변경 +- [x] 문서 업데이트 +- [x] 빌드 성공 확인 +- [ ] 개발 서버 테스트 +- [ ] 실제 API 연동 테스트 + +--- + +## 🔗 관련 API 문서 + +- Swagger UI: http://localhost:8084/swagger-ui/index.html +- OpenAPI Spec: http://localhost:8084/v3/api-docs + +--- + +## 📌 주의사항 + +1. **타입 주의**: `eventId`는 이제 `string` 타입입니다. 숫자로 사용하지 마세요. +2. **Mock 데이터**: localStorage에 저장할 때 문자열 타입으로 저장해야 합니다. +3. **API 호출**: 프론트엔드에서 백엔드로 전송 시 string으로 전송됩니다. +4. **하위 호환성**: 기존 number 타입 데이터는 작동하지 않으므로 localStorage를 초기화해야 합니다. + +--- + +## 🔄 롤백 방법 + +만약 이전 버전으로 돌아가야 한다면: + +1. `git revert` 또는 특정 커밋으로 복원 +2. localStorage 초기화: `localStorage.removeItem('eventCreationData')` +3. 개발 서버 재시작 diff --git a/CORS_FIX.md b/CORS_FIX.md new file mode 100644 index 0000000..de2857f --- /dev/null +++ b/CORS_FIX.md @@ -0,0 +1,221 @@ +# CORS 문제 해결 방법 + +## 문제 상황 + +프론트엔드(`http://localhost:3000`)에서 백엔드 Content API(`http://localhost:8084`)를 직접 호출하면 **CORS(Cross-Origin Resource Sharing)** 에러가 발생했습니다. + +### 에러 메시지 +``` +Network Error +AxiosError: Network Error + code: "ERR_NETWORK" +``` + +### 원인 분석 +```bash +# CORS preflight 요청 테스트 +curl -X OPTIONS http://localhost:8084/api/v1/content/images/generate \ + -H 'Origin: http://localhost:3000' \ + -H 'Access-Control-Request-Method: POST' \ + -H 'Access-Control-Request-Headers: content-type' + +# 결과: HTTP/1.1 403 Forbidden +# Invalid CORS request +``` + +백엔드 서버가 `http://localhost:3000` origin에서의 CORS 요청을 허용하지 않음. + +--- + +## 해결 방법: Next.js API Proxy + +백엔드 CORS 설정을 수정하는 대신, **Next.js API Routes를 프록시로 사용**하여 CORS 문제를 우회했습니다. + +### 아키텍처 + +``` +[Browser] + ↓ (Same-Origin Request) +[Next.js Frontend: localhost:3000] + ↓ [Next.js API Proxy: /api/content/*] + ↓ (Server-to-Server Request, CORS 무관) +[Content API Backend: localhost:8084] +``` + +### 구현 파일 + +#### 1. **이미지 생성 프록시** (`/api/content/images/generate/route.ts`) + +```typescript +export async function POST(request: NextRequest) { + const body = await request.json(); + + const response = await fetch('http://localhost:8084/api/v1/content/images/generate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + + return NextResponse.json(await response.json()); +} +``` + +**URL 매핑**: +- Frontend: `POST /api/content/images/generate` +- Backend: `POST http://localhost:8084/api/v1/content/images/generate` + +#### 2. **Job 상태 조회 프록시** (`/api/content/images/jobs/[jobId]/route.ts`) + +```typescript +export async function GET(request: NextRequest, { params }: { params: { jobId: string } }) { + const { jobId } = params; + + const response = await fetch(`http://localhost:8084/api/v1/content/images/jobs/${jobId}`, { + method: 'GET', + headers: { 'Content-Type': 'application/json' }, + }); + + return NextResponse.json(await response.json()); +} +``` + +**URL 매핑**: +- Frontend: `GET /api/content/images/jobs/{jobId}` +- Backend: `GET http://localhost:8084/api/v1/content/images/jobs/{jobId}` + +#### 3. **이미지 목록 조회 프록시** (`/api/content/events/[eventDraftId]/images/route.ts`) + +```typescript +export async function GET(request: NextRequest, { params }: { params: { eventDraftId: string } }) { + const { eventDraftId } = params; + const { searchParams } = new URL(request.url); + + let url = `http://localhost:8084/api/v1/content/events/${eventDraftId}/images`; + if (searchParams.get('style')) url += `?style=${searchParams.get('style')}`; + if (searchParams.get('platform')) url += `&platform=${searchParams.get('platform')}`; + + const response = await fetch(url, { + method: 'GET', + headers: { 'Content-Type': 'application/json' }, + }); + + return NextResponse.json(await response.json()); +} +``` + +**URL 매핑**: +- Frontend: `GET /api/content/events/{eventDraftId}/images?style=SIMPLE&platform=INSTAGRAM` +- Backend: `GET http://localhost:8084/api/v1/content/events/{eventDraftId}/images?style=SIMPLE&platform=INSTAGRAM` + +--- + +## 클라이언트 코드 변경 + +### Before (직접 백엔드 호출 - CORS 에러 발생) + +```typescript +const CONTENT_API_BASE_URL = 'http://localhost:8084'; + +export const contentApiClient = axios.create({ + baseURL: CONTENT_API_BASE_URL, +}); + +// ❌ CORS Error +await contentApiClient.post('/api/v1/content/images/generate', request); +``` + +### After (Next.js API Proxy 사용 - CORS 우회) + +```typescript +const CONTENT_API_BASE_URL = '/api/content'; // Same-origin request + +export const contentApiClient = axios.create({ + baseURL: CONTENT_API_BASE_URL, +}); + +// ✅ Works! (Same-origin → Server-side proxy → Backend) +await contentApiClient.post('/images/generate', request); +``` + +--- + +## 장점 + +✅ **프론트엔드 수정만으로 해결**: 백엔드 CORS 설정 변경 불필요 +✅ **Same-Origin 정책 준수**: 브라우저는 같은 도메인으로 인식 +✅ **서버 간 통신**: Next.js 서버에서 백엔드 호출 (CORS 무관) +✅ **보안 강화**: 백엔드 URL을 클라이언트에 노출하지 않음 +✅ **환경 변수 활용**: `NEXT_PUBLIC_CONTENT_API_URL`로 배포 환경 대응 + +--- + +## 프로덕션 배포 시 고려사항 + +### 환경 변수 설정 + +```bash +# .env.local (개발 환경) +NEXT_PUBLIC_CONTENT_API_URL=http://localhost:8084 + +# .env.production (프로덕션 환경) +NEXT_PUBLIC_CONTENT_API_URL=https://api.production.com +``` + +### 프록시 코드에 적용 + +```typescript +const CONTENT_API_BASE_URL = process.env.NEXT_PUBLIC_CONTENT_API_URL || 'http://localhost:8084'; + +const response = await fetch(`${CONTENT_API_BASE_URL}/api/v1/content/images/generate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), +}); +``` + +### 타임아웃 설정 + +```typescript +const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(120000), // 120초 타임아웃 +}); +``` + +--- + +## 테스트 방법 + +### 1. 개발 서버 실행 + +```bash +npm run dev +``` + +### 2. 브라우저에서 테스트 + +``` +http://localhost:3000/events/create?event-creation.step=contentPreview +``` + +### 3. 네트워크 탭 확인 + +브라우저 개발자 도구 → Network 탭에서 다음 요청 확인: + +``` +POST http://localhost:3000/api/content/images/generate (Status: 202) +GET http://localhost:3000/api/content/images/jobs/job-xxxxx (Status: 200) +GET http://localhost:3000/api/content/events/7777/images (Status: 200) +``` + +모두 **Same-Origin** 요청이므로 CORS 에러 없음! + +--- + +## 참고 자료 + +- [Next.js API Routes](https://nextjs.org/docs/app/building-your-application/routing/route-handlers) +- [CORS (MDN)](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS) +- [Proxy Pattern](https://en.wikipedia.org/wiki/Proxy_pattern) diff --git a/FIX_EVENTID_MISMATCH.md b/FIX_EVENTID_MISMATCH.md new file mode 100644 index 0000000..f10105c --- /dev/null +++ b/FIX_EVENTID_MISMATCH.md @@ -0,0 +1,182 @@ +# EventId 불일치 문제 해결 + +## 문제 상황 + +사용자가 이미지 생성 페이지에서 스타일 1 카드에 이미지가 표시되지 않고 플레이스홀더만 보이는 문제가 발생했습니다. + +### 스크린샷 분석 +- **스타일 1 (SIMPLE)**: 플레이스홀더만 표시 (아이콘 + 제목 + 경품) +- **스타일 2 (FANCY)**: 실제 이미지 표시 ✅ +- **스타일 3 (TRENDY)**: 실제 이미지 표시 ✅ + +## 근본 원인 + +API 분석 결과, 데이터베이스에 저장된 이미지와 Mock 데이터의 eventId가 일치하지 않았습니다: + +```bash +# Mock 데이터 eventId +"7777" + +# 데이터베이스 실제 eventId +curl http://localhost:8084/api/v1/content/events/7777/images +→ Response: [] (빈 배열) + +# 데이터베이스에 존재하는 eventId +- "Tst12131": SIMPLE 이미지 1개 +- "1761634317010": SIMPLE, FANCY, TRENDY 각 2개씩 총 6개 +- null: SIMPLE 이미지 1개 +``` + +**결론**: Mock 데이터의 eventId "7777"로는 어떤 이미지도 조회되지 않았습니다. + +## 해결 방법 + +데이터베이스에 이미 존재하는 이미지가 있는 eventId로 Mock 데이터를 변경했습니다. + +### 변경된 eventId +```javascript +// Before +eventDraftId: "7777" + +// After +eventDraftId: "1761634317010" +``` + +**선택 이유**: +- SIMPLE, FANCY, TRENDY 3가지 스타일 모두 이미지 보유 +- 각 스타일별로 2개씩 총 6개의 이미지 존재 +- INSTAGRAM 플랫폼 이미지 존재 + +## 수정된 파일 + +### 1. ContentPreviewStep.tsx +**위치**: `src/app/(main)/events/create/steps/ContentPreviewStep.tsx:109` + +```typescript +const mockData: EventCreationData = { + eventDraftId: "1761634317010", // Changed from "7777" + eventTitle: "맥주 파티 이벤트", + eventDescription: "강남에서 열리는 신나는 맥주 파티에 참여하세요!", + industry: "음식점", + location: "강남", + trends: ["파티", "맥주", "생맥주"], + prize: "생맥주 1잔" +}; +``` + +### 2. init-mock-data.html +**위치**: `public/init-mock-data.html:121`, `public/init-mock-data.html:168` + +```html + +1761634317010 + + + +``` + +### 3. QUICK_TEST.md +**위치**: `QUICK_TEST.md` (전체 문서) + +- Mock 데이터 예시의 eventId 변경 +- API 확인 예시의 eventId 변경 +- 디버깅 로그 예시 업데이트 + +### 4. MOCK_DATA_SETUP.md +**위치**: `MOCK_DATA_SETUP.md` (전체 문서) + +- Mock 데이터 구조 예시 업데이트 +- 테스트 시나리오 eventId 변경 +- 참고 사항 추가: "1761634317010은 데이터베이스에 이미 생성된 이미지가 있는 eventId" + +## 빌드 검증 + +```bash +npm run build +``` + +✅ **성공**: TypeScript 타입 검증 통과, 빌드 완료 + +경고 사항: +- `loadingProgress`, `setLoadingProgress` 미사용 변수 (기능에 영향 없음) +- 기타 ESLint 경고 (기존 코드, 금번 수정과 무관) + +## 테스트 방법 + +### 1. localStorage 초기화 +브라우저 콘솔에서 기존 데이터 삭제: +```javascript +localStorage.removeItem('eventCreationData'); +``` + +### 2. 개발 서버 실행 +```bash +npm run dev +``` + +### 3. 페이지 접속 +``` +http://localhost:3000/events/create?event-creation.step=contentPreview +``` + +### 4. 예상 결과 +- ✅ 스타일 1 (SIMPLE): 실제 이미지 표시 +- ✅ 스타일 2 (FANCY): 실제 이미지 표시 +- ✅ 스타일 3 (TRENDY): 실제 이미지 표시 +- ✅ 3개 스타일 모두 "크게보기" 버튼 활성화 + +### 5. 콘솔 로그 확인 +``` +📥 Loading generated images for event: 1761634317010 +✅ Images loaded from API: 6 [...] +📸 Processing image 1: { id: X, style: 'SIMPLE', ... } + ✅ Selected as latest SIMPLE image +📸 Processing image 2: { id: Y, style: 'FANCY', ... } + ✅ Selected as latest FANCY image +📸 Processing image 3: { id: Z, style: 'TRENDY', ... } + ✅ Selected as latest TRENDY image +🎨 Image map created with entries: { SIMPLE: 'YES ✅', FANCY: 'YES ✅', TRENDY: 'YES ✅', totalSize: 3 } +✅ 이미지 로드 완료! +🖼️ Rendering SIMPLE: { hasImage: true, imageDataExists: true, ... } +✅ SIMPLE image loaded successfully +``` + +## 추가 참고 사항 + +### 새로운 이벤트 테스트 시 +새로운 eventId로 이미지를 생성하려면: + +1. localStorage에 새로운 eventId 설정 +2. "이미지 재생성" 버튼 클릭 +3. 약 2초 후 자동으로 생성된 이미지 로드 + +### Mock 데이터 변경 방법 +`public/init-mock-data.html` 페이지 사용: +``` +http://localhost:3000/init-mock-data.html +``` + +또는 브라우저 콘솔에서 직접 설정: +```javascript +localStorage.setItem('eventCreationData', JSON.stringify({ + eventDraftId: "1761634317010", + eventTitle: "...", + // ... +})); +``` + +## 결론 + +EventId 불일치 문제를 해결하여 모든 스타일 카드에서 실제 이미지가 정상적으로 표시됩니다. + +**핵심 변경**: Mock 데이터의 eventId를 데이터베이스에 존재하는 "1761634317010"으로 변경 + +**영향 범위**: +- 개발/테스트 환경의 Mock 데이터만 영향 +- 실제 운영 환경에서는 Channel Step API에서 제공하는 실제 eventId 사용 +- 코드 로직 변경 없음, 데이터만 변경 diff --git a/MOCK_DATA_SETUP.md b/MOCK_DATA_SETUP.md new file mode 100644 index 0000000..f438436 --- /dev/null +++ b/MOCK_DATA_SETUP.md @@ -0,0 +1,149 @@ +# Mock 데이터 설정 가이드 + +AI 이미지 생성 기능을 테스트하기 위해 localStorage에 mock 데이터를 설정하는 방법입니다. + +## 🚀 빠른 시작 + +### 방법 1: 웹 인터페이스 사용 (권장) + +1. 개발 서버 실행 +```bash +npm run dev +``` + +2. 브라우저에서 mock 데이터 초기화 페이지 열기 +``` +http://localhost:3000/init-mock-data.html +``` + +3. "LocalStorage에 저장하기" 버튼 클릭 + +4. 이미지 생성 페이지로 이동 +``` +http://localhost:3000/events/create?step=contentPreview +``` + +### 방법 2: 브라우저 콘솔 사용 + +1. 개발 서버 실행 후 브라우저에서 아무 페이지나 열기 + +2. F12 또는 Cmd+Option+I로 개발자 도구 열기 + +3. Console 탭에서 다음 코드 실행: + +```javascript +const mockEventData = { + eventDraftId: "1761634317010", // String type (existing eventId with images) + eventTitle: "맥주 파티 이벤트", + eventDescription: "강남에서 열리는 신나는 맥주 파티에 참여하세요!", + industry: "음식점", + location: "강남", + trends: ["파티", "맥주", "생맥주"], + prize: "생맥주 1잔" +}; + +localStorage.setItem('eventCreationData', JSON.stringify(mockEventData)); +console.log('✅ Mock 데이터 저장 완료!'); +``` + +4. 이미지 생성 페이지로 이동 + +### 방법 3: 테스트 HTML 파일 사용 + +프로젝트 루트의 `test-localstorage.html` 파일을 브라우저에서 직접 열기: + +```bash +open test-localstorage.html +``` + +## 📊 Mock 데이터 구조 + +```json +{ + "eventDraftId": "1761634317010", + "eventTitle": "맥주 파티 이벤트", + "eventDescription": "강남에서 열리는 신나는 맥주 파티에 참여하세요!", + "industry": "음식점", + "location": "강남", + "trends": ["파티", "맥주", "생맥주"], + "prize": "생맥주 1잔" +} +``` + +**참고**: +- `eventDraftId`는 API 변경으로 인해 `string` 타입입니다. +- `"1761634317010"`은 데이터베이스에 이미 생성된 이미지가 있는 eventId입니다. + +## 🧪 테스트 시나리오 + +### 시나리오 1: 전체 이미지 생성 플로우 + +1. Mock 데이터 설정 +2. `/events/create?step=contentPreview` 접속 +3. 자동으로 AI 이미지 생성 시작 +4. 3가지 스타일(SIMPLE, FANCY, TRENDY) 확인 +5. 스타일 선택 후 다음 단계 진행 + +### 시나리오 2: 다양한 이벤트 데이터 테스트 + +다른 업종/지역/트렌드로 테스트: + +```javascript +// 카페 이벤트 (새로운 이미지 생성 필요) +localStorage.setItem('eventCreationData', JSON.stringify({ + eventDraftId: "test-cafe-001", + eventTitle: "커피 할인 이벤트", + eventDescription: "신메뉴 출시 기념 30% 할인", + industry: "카페", + location: "홍대", + trends: ["커피", "할인", "신메뉴"], + prize: "아메리카노 1잔" +})); +``` + +```javascript +// 뷰티 이벤트 (새로운 이미지 생성 필요) +localStorage.setItem('eventCreationData', JSON.stringify({ + eventDraftId: "test-beauty-001", + eventTitle: "봄맞이 피부관리 이벤트", + eventDescription: "봄맞이 특별 케어 프로그램", + industry: "뷰티", + location: "강남", + trends: ["피부관리", "봄", "케어"], + prize: "페이셜 케어 1회" +})); +``` + +## 🔍 디버깅 + +### localStorage 데이터 확인 + +```javascript +// 현재 저장된 데이터 확인 +const data = localStorage.getItem('eventCreationData'); +console.log(JSON.parse(data)); +``` + +### localStorage 데이터 삭제 + +```javascript +localStorage.removeItem('eventCreationData'); +console.log('✅ 데이터 삭제 완료'); +``` + +## ⚠️ 주의사항 + +1. **같은 도메인**: localStorage는 도메인별로 분리되므로, 같은 localhost:3000에서 설정해야 합니다. + +2. **브라우저 제한**: 시크릿 모드에서는 localStorage가 제한될 수 있습니다. + +3. **데이터 유지**: 브라우저를 닫아도 localStorage 데이터는 유지됩니다. 새로운 테스트 시 삭제 후 진행하세요. + +## 🎯 실제 API 연동 후 + +Channel Step API가 구현되면 이 mock 데이터 설정은 불필요하며, +실제 플로우에서 자동으로 데이터가 저장됩니다: + +``` +Objective → Recommendation → Channel (여기서 localStorage 저장) → ContentPreview (이미지 생성) +``` diff --git a/QUICK_TEST.md b/QUICK_TEST.md new file mode 100644 index 0000000..8a1b748 --- /dev/null +++ b/QUICK_TEST.md @@ -0,0 +1,145 @@ +# 🚀 AI 이미지 생성 빠른 테스트 가이드 + +## ⚡ 가장 빠른 방법 (기존 이미지 확인) + +```bash +# 1. 개발 서버 실행 +npm run dev + +# 2. 브라우저에서 바로 접속 +http://localhost:3000/events/create?event-creation.step=contentPreview +``` + +✨ **끝!** 자동으로 Mock 데이터(eventId: "1761634317010")가 생성되고 기존 생성된 이미지를 불러옵니다. + +💡 **이미지가 없을 경우**: "이미지 생성하기" 버튼을 클릭하면 새로운 이미지를 생성합니다. + +--- + +## 📋 커스텀 데이터로 테스트 (선택사항) + +### 1단계: 개발 서버 실행 + +```bash +npm run dev +``` + +### 2단계: Mock 데이터 설정 (3가지 방법 중 선택) + +### ✨ 방법 A: 웹 UI 사용 (가장 쉬움!) + +브라우저에서 열기: +``` +http://localhost:3000/init-mock-data.html +``` + +"LocalStorage에 저장하기" 버튼 클릭 → 완료! + +--- + +### 방법 B: 브라우저 콘솔 사용 + +1. `http://localhost:3000` 접속 +2. F12 (개발자 도구) → Console 탭 +3. 다음 코드 복사 & 붙여넣기: + +```javascript +localStorage.setItem('eventCreationData', JSON.stringify({ + eventDraftId: "1761634317010", + eventTitle: "맥주 파티 이벤트", + eventDescription: "강남에서 열리는 신나는 맥주 파티에 참여하세요!", + industry: "음식점", + location: "강남", + trends: ["파티", "맥주", "생맥주"], + prize: "생맥주 1잔" +})); +``` + +--- + +### 방법 C: HTML 파일 직접 열기 + +```bash +open test-localstorage.html +``` + +## 3단계: 이미지 생성 페이지 접속 + +브라우저에서 열기: +``` +http://localhost:3000/events/create?event-creation.step=contentPreview +``` + +## 4단계: 자동 실행 확인 ✅ + +1. 페이지 로딩되면 자동으로 이미지 생성 시작 +2. 로딩 스피너와 진행률 확인 +3. 약 60초 후 3가지 스타일 이미지 완성 + - 스타일 1: 심플 + - 스타일 2: 화려 + - 스타일 3: 트렌디 + +## 예상 결과 + +### 이미지가 있는 경우 +- ✅ **즉시 표시**: 로딩 후 바로 이미지 미리보기 화면 +- ✅ **3개 스타일 이미지**: SIMPLE, FANCY, TRENDY 각각 최신 이미지 표시 +- ✅ **이미지 선택**: 라디오 버튼으로 원하는 스타일 선택 +- ✅ **재생성 버튼**: "이미지 재생성" 버튼으로 새로운 이미지 생성 가능 +- ✅ **크게보기**: 각 이미지 클릭 시 전체화면 미리보기 + +### 이미지가 없는 경우 +- ⚠️ **에러 메시지**: "생성된 이미지가 없습니다. 이미지를 먼저 생성해주세요." +- 🔄 **생성 버튼**: "이미지 생성하기" 버튼 클릭 +- ⏳ **생성 대기**: API 요청 후 2초 뒤 자동으로 이미지 조회 +- ✅ **이미지 표시**: 생성 완료된 이미지 자동 표시 + +## 문제 해결 + +### ~~"이벤트 정보를 찾을 수 없습니다" 에러~~ +→ ✅ **해결됨!** 이제 자동으로 Mock 데이터가 생성됩니다. + +### ~~Network Error / CORS 에러~~ +→ ✅ **해결됨!** Next.js API proxy를 통해 CORS 문제 우회 +→ 프론트엔드가 `/api/content/*` → 백엔드 `localhost:8084` 로 자동 프록시 + +### 이미지 생성 실패 +→ Content API (localhost:8084) 실행 여부 확인 +→ 터미널에서 확인: `curl http://localhost:8084/api/v1/content/events/7777/images` + +### 이미지가 표시되지 않음 +→ 네트워크 탭에서 CDN URL 로드 상태 확인 +→ Azure Blob Storage 접근 권한 확인 + +## API 확인 + +```bash +# 이벤트 1761634317010의 이미지 확인 +curl http://localhost:8084/api/v1/content/events/1761634317010/images + +# 프론트엔드 프록시를 통한 확인 (개발 서버 실행 중) +curl http://localhost:3000/api/content/events/1761634317010/images +``` + +## 디버깅 + +브라우저 개발자 도구 (F12) → Console 탭에서 다음 로그 확인: + +``` +📥 Loading generated images for event: 1761634317010 +✅ Images loaded from API: 6 [...] +📸 Processing image 1: { id: 1, style: 'SIMPLE', platform: 'INSTAGRAM', ... } + ✅ Selected as latest SIMPLE image +📸 Processing image 2: { id: 3, style: 'FANCY', platform: 'INSTAGRAM', ... } + ✅ Selected as latest FANCY image +📸 Processing image 3: { id: 5, style: 'TRENDY', platform: 'INSTAGRAM', ... } + ✅ Selected as latest TRENDY image +🎨 Image map created with entries: { SIMPLE: 'YES ✅', FANCY: 'YES ✅', TRENDY: 'YES ✅', totalSize: 3 } +✅ 이미지 로드 완료! 미리보기 화면으로 전환합니다. +🖼️ Rendering SIMPLE: { hasImage: true, imageDataExists: true, cdnUrl: 'https://blob...' } +✅ SIMPLE image loaded successfully +``` + +--- + +더 자세한 내용은 `MOCK_DATA_SETUP.md` 참조 diff --git a/TEST_URLS.md b/TEST_URLS.md new file mode 100644 index 0000000..df456ed --- /dev/null +++ b/TEST_URLS.md @@ -0,0 +1,117 @@ +# 🔗 AI 이미지 생성 테스트 URL 가이드 + +## ✅ 올바른 URL + +### ContentPreview Step 직접 접속 +``` +http://localhost:3000/events/create?event-creation.step=contentPreview +``` + +또는 간단하게: +``` +http://localhost:3000/events/create?step=contentPreview +``` + +### Mock 데이터 설정 페이지 +``` +http://localhost:3000/init-mock-data.html +``` + +## 📝 URL 파라미터 설명 + +- `event-creation.step=contentPreview` - Funnel의 step을 contentPreview로 설정 +- `step=contentPreview` - 간단한 형식 (funnel id가 event-creation일 때) + +## 🎯 전체 플로우 테스트 URL + +### 1. 시작 (Objective Step) +``` +http://localhost:3000/events/create +``` + +### 2. Channel Step까지 진행 후 +``` +http://localhost:3000/events/create?event-creation.step=channel +``` + +### 3. ContentPreview Step (이미지 생성) +``` +http://localhost:3000/events/create?event-creation.step=contentPreview +``` + +## 💡 자동 Mock 데이터 생성 + +이제 `contentPreview` 페이지에 직접 접속하면: + +1. ✅ localStorage 확인 +2. ✅ 데이터 없으면 자동으로 Mock 데이터 생성 +3. ✅ 즉시 AI 이미지 생성 시작 + +**더 이상 수동으로 Mock 데이터를 설정할 필요가 없습니다!** + +## 🧪 테스트 시나리오 + +### 시나리오 1: 가장 빠른 테스트 +```bash +# 1. 개발 서버 실행 +npm run dev + +# 2. 브라우저에서 바로 접속 +http://localhost:3000/events/create?event-creation.step=contentPreview +``` +→ 자동으로 Mock 데이터 생성 & 이미지 생성 시작! + +### 시나리오 2: 커스텀 데이터로 테스트 +```bash +# 1. Mock 데이터 설정 페이지 열기 +http://localhost:3000/init-mock-data.html + +# 2. 원하는 데이터 입력 후 저장 + +# 3. ContentPreview 접속 +http://localhost:3000/events/create?event-creation.step=contentPreview +``` + +### 시나리오 3: 전체 플로우 테스트 +```bash +# 1. 처음부터 시작 +http://localhost:3000/events/create + +# 2. Objective 선택 + +# 3. Recommendation 확인 + +# 4. Channel 선택 (SNS, 우리동네TV, 지니TV 중 하나) + +# 5. 자동으로 ContentPreview로 이동하며 이미지 생성 시작 +``` + +## 🐛 문제 해결 + +### "이벤트 정보를 찾을 수 없습니다" 에러 +→ 이제 이 에러는 발생하지 않습니다! 자동으로 Mock 데이터가 생성됩니다. + +### 이미지 생성 실패 +```bash +# Content API 서버 확인 +curl http://localhost:8084/api/v1/content/events/7777/images + +# API 서버가 꺼져있다면 실행 필요 +``` + +### 다른 이벤트 ID로 테스트하고 싶을 때 +```javascript +// 브라우저 콘솔에서 +localStorage.setItem('eventCreationData', JSON.stringify({ + eventDraftId: 8888, // 다른 ID + eventTitle: "커피 할인 이벤트", + eventDescription: "신메뉴 출시 기념", + industry: "카페", + location: "홍대", + trends: ["커피", "할인"], + prize: "아메리카노 1잔" +})); + +// 페이지 새로고침 +location.reload(); +``` diff --git a/next.config.js b/next.config.js index 03b1343..8bd207c 100644 --- a/next.config.js +++ b/next.config.js @@ -6,7 +6,7 @@ const nextConfig = { emotion: true, }, images: { - domains: ['localhost'], + domains: ['localhost', 'blobkteventstorage.blob.core.windows.net'], formats: ['image/webp', 'image/avif'], }, env: { diff --git a/public/init-mock-data.html b/public/init-mock-data.html new file mode 100644 index 0000000..9546666 --- /dev/null +++ b/public/init-mock-data.html @@ -0,0 +1,205 @@ + + + + + + Mock 데이터 초기화 + + + +
+

🎨 Mock 데이터 초기화

+

이벤트 생성 테스트를 위한 샘플 데이터

+ +
+

📋 저장될 데이터

+
+ 이벤트 ID: + 1761634317010 +
+
+ 제목: + 맥주 파티 이벤트 +
+
+ 설명: + 강남에서 열리는 신나는 맥주 파티 +
+
+ 업종: + 음식점 +
+
+ 지역: + 강남 +
+
+ 트렌드: + 파티, 맥주, 생맥주 +
+
+ 경품: + 생맥주 1잔 +
+
+ + + +
+ ✅ Mock 데이터가 성공적으로 저장되었습니다!
+ 이제 이벤트 생성 페이지로 이동할 수 있습니다. +
+ + +
+ + + + diff --git a/setup-mock-data.js b/setup-mock-data.js new file mode 100644 index 0000000..ccf8745 --- /dev/null +++ b/setup-mock-data.js @@ -0,0 +1,17 @@ +// Mock 데이터를 localStorage에 저장하는 스크립트 + +const mockEventData = { + eventDraftId: 7777, + eventTitle: "맥주 파티 이벤트", + eventDescription: "강남에서 열리는 신나는 맥주 파티에 참여하세요!", + industry: "음식점", + location: "강남", + trends: ["파티", "맥주", "생맥주"], + prize: "생맥주 1잔" +}; + +// localStorage에 저장 +localStorage.setItem('eventCreationData', JSON.stringify(mockEventData)); + +console.log('✅ Mock 데이터가 localStorage에 저장되었습니다:'); +console.log(JSON.stringify(mockEventData, null, 2)); diff --git a/src/app/(main)/events/create/page.tsx b/src/app/(main)/events/create/page.tsx index 1595442..a83b41d 100644 --- a/src/app/(main)/events/create/page.tsx +++ b/src/app/(main)/events/create/page.tsx @@ -15,12 +15,16 @@ export type BudgetLevel = 'low' | 'medium' | 'high'; export type EventMethod = 'online' | 'offline'; export interface EventData { + eventDraftId?: number; objective?: EventObjective; recommendation?: { budget: BudgetLevel; method: EventMethod; title: string; prize: string; + description?: string; + industry?: string; + location?: string; participationMethod: string; expectedParticipants: number; estimatedCost: number; @@ -28,6 +32,7 @@ export interface EventData { }; contentPreview?: { imageStyle: string; + images?: any[]; }; contentEdit?: { title: string; @@ -89,6 +94,18 @@ export default function EventCreatePage() { ); if (needsContent) { + // localStorage에 이벤트 정보 저장 + const eventData = { + eventDraftId: context.eventDraftId || Date.now(), // 임시 ID 생성 + eventTitle: context.recommendation?.title || '', + eventDescription: context.recommendation?.description || context.recommendation?.participationMethod || '', + industry: context.recommendation?.industry || '', + location: context.recommendation?.location || '', + trends: [], // 필요시 context에서 추가 + prize: context.recommendation?.prize || '', + }; + localStorage.setItem('eventCreationData', JSON.stringify(eventData)); + history.push('contentPreview', { ...context, channels }); } else { history.push('approval', { ...context, channels }); @@ -101,12 +118,10 @@ export default function EventCreatePage() { )} contentPreview={({ context, history }) => ( { + onNext={(imageStyle, images) => { history.push('contentEdit', { ...context, - contentPreview: { imageStyle }, + contentPreview: { imageStyle, images }, }); }} onSkip={() => { diff --git a/src/app/(main)/events/create/steps/ContentPreviewStep.tsx b/src/app/(main)/events/create/steps/ContentPreviewStep.tsx index 5a2f7df..a0fc4f7 100644 --- a/src/app/(main)/events/create/steps/ContentPreviewStep.tsx +++ b/src/app/(main)/events/create/steps/ContentPreviewStep.tsx @@ -12,8 +12,11 @@ import { IconButton, Dialog, Grid, + Alert, } from '@mui/material'; -import { ArrowBack, ZoomIn, Psychology } from '@mui/icons-material'; +import { ArrowBack, ZoomIn, Psychology, Refresh } from '@mui/icons-material'; +import { contentApi, ImageInfo } from '@/shared/api/contentApi'; +import Image from 'next/image'; // 디자인 시스템 색상 const colors = { @@ -34,7 +37,7 @@ const colors = { }; interface ImageStyle { - id: string; + id: 'SIMPLE' | 'FANCY' | 'TRENDY'; name: string; gradient?: string; icon: string; @@ -43,19 +46,19 @@ interface ImageStyle { const imageStyles: ImageStyle[] = [ { - id: 'simple', + id: 'SIMPLE', name: '스타일 1: 심플', icon: 'celebration', }, { - id: 'fancy', + id: 'FANCY', name: '스타일 2: 화려', gradient: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)', icon: 'auto_awesome', textColor: 'white', }, { - id: 'trendy', + id: 'TRENDY', name: '스타일 3: 트렌디', gradient: 'linear-gradient(135deg, #f093fb 0%, #f5576c 100%)', icon: 'trending_up', @@ -64,50 +67,230 @@ const imageStyles: ImageStyle[] = [ ]; interface ContentPreviewStepProps { - title: string; - prize: string; - onNext: (imageStyle: string) => void; + onNext: (imageStyle: string, images: ImageInfo[]) => void; onSkip: () => void; onBack: () => void; } +interface EventCreationData { + eventDraftId: string; // Changed from number to string + eventTitle: string; + eventDescription: string; + industry: string; + location: string; + trends: string[]; + prize: string; +} + export default function ContentPreviewStep({ - title, - prize, onNext, onSkip, onBack, }: ContentPreviewStepProps) { const [loading, setLoading] = useState(true); - const [selectedStyle, setSelectedStyle] = useState(null); + const [selectedStyle, setSelectedStyle] = useState<'SIMPLE' | 'FANCY' | 'TRENDY' | null>(null); const [fullscreenOpen, setFullscreenOpen] = useState(false); - const [fullscreenStyle, setFullscreenStyle] = useState(null); + const [fullscreenImage, setFullscreenImage] = useState(null); + const [generatedImages, setGeneratedImages] = useState>(new Map()); + const [error, setError] = useState(null); + const [loadingProgress, setLoadingProgress] = useState(0); + const [loadingMessage, setLoadingMessage] = useState('이미지 생성 요청 중...'); + const [eventData, setEventData] = useState(null); useEffect(() => { - // AI 이미지 생성 시뮬레이션 - const timer = setTimeout(() => { - setLoading(false); - }, 5000); + // localStorage에서 이벤트 데이터 읽기 + const storedData = localStorage.getItem('eventCreationData'); + if (storedData) { + const data: EventCreationData = JSON.parse(storedData); + setEventData(data); - return () => clearTimeout(timer); + // 먼저 이미지 조회 시도 + loadImages(data).then((hasImages) => { + // 이미지가 없으면 자동으로 생성 + if (!hasImages) { + console.log('📸 이미지가 없습니다. 자동으로 생성을 시작합니다...'); + handleGenerateImagesAuto(data); + } + }); + } else { + // Mock 데이터가 없으면 자동으로 설정 + const mockData: EventCreationData = { + eventDraftId: "1761634317010", // Changed to string + eventTitle: "맥주 파티 이벤트", + eventDescription: "강남에서 열리는 신나는 맥주 파티에 참여하세요!", + industry: "음식점", + location: "강남", + trends: ["파티", "맥주", "생맥주"], + prize: "생맥주 1잔" + }; + + console.log('⚠️ localStorage에 이벤트 데이터가 없습니다. Mock 데이터를 사용합니다.'); + localStorage.setItem('eventCreationData', JSON.stringify(mockData)); + setEventData(mockData); + loadImages(mockData); + } + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - const handleStyleSelect = (styleId: string) => { + const loadImages = async (data: EventCreationData): Promise => { + try { + setError(null); + + console.log('📥 Loading images for event:', data.eventDraftId); + const images = await contentApi.getImages(data.eventDraftId); + console.log('✅ Images loaded from API:', images.length, images); + + if (!images || images.length === 0) { + console.warn('⚠️ No images found.'); + return false; // 이미지 없음 + } + + const imageMap = new Map(); + + // 각 스타일별로 가장 최신 이미지만 선택 (createdAt 기준) + images.forEach((image, index) => { + console.log(`📸 Processing image ${index + 1}:`, { + id: image.id, + eventId: image.eventId, + style: image.style, + platform: image.platform, + cdnUrl: image.cdnUrl?.substring(0, 50) + '...', + createdAt: image.createdAt, + }); + + if (image.platform === 'INSTAGRAM') { + const existing = imageMap.get(image.style); + if (!existing || new Date(image.createdAt) > new Date(existing.createdAt)) { + console.log(` ✅ Selected as latest ${image.style} image`); + imageMap.set(image.style, image); + } else { + console.log(` ⏭️ Skipped (older than existing ${image.style} image)`); + } + } else { + console.log(` ⏭️ Skipped (platform: ${image.platform})`); + } + }); + + console.log('🎨 Image map created with entries:', { + SIMPLE: imageMap.has('SIMPLE') ? 'YES ✅' : 'NO ❌', + FANCY: imageMap.has('FANCY') ? 'YES ✅' : 'NO ❌', + TRENDY: imageMap.has('TRENDY') ? 'YES ✅' : 'NO ❌', + totalSize: imageMap.size, + }); + + console.log('🖼️ Image map details:', Array.from(imageMap.entries()).map(([style, img]) => ({ + style, + id: img.id, + eventId: img.eventId, + cdnUrl: img.cdnUrl?.substring(0, 60) + '...', + }))); + + setGeneratedImages(imageMap); + console.log('✅ Images loaded successfully!'); + return true; // 이미지 있음 + } catch (err) { + console.error('❌ Load images error:', err); + // API 에러는 polling에서 무시 (계속 시도) + return false; + } + }; + + const handleStyleSelect = (styleId: 'SIMPLE' | 'FANCY' | 'TRENDY') => { setSelectedStyle(styleId); }; - const handlePreview = (style: ImageStyle, e: React.MouseEvent) => { + const handlePreview = (image: ImageInfo, e: React.MouseEvent) => { e.stopPropagation(); - setFullscreenStyle(style); + setFullscreenImage(image); setFullscreenOpen(true); }; const handleNext = () => { if (selectedStyle) { - onNext(selectedStyle); + const allImages = Array.from(generatedImages.values()); + onNext(selectedStyle, allImages); } }; + const handleGenerateImagesAuto = async (data: EventCreationData) => { + try { + setLoading(true); + setError(null); + setLoadingProgress(0); + setLoadingMessage('이미지 생성 요청 중...'); + + console.log('🎨 Auto-generating images for event:', data.eventDraftId); + + // 이미지 생성 요청 (202 Accepted 응답만 확인) + await contentApi.generateImages({ + eventId: data.eventDraftId, + eventTitle: data.eventTitle, + eventDescription: data.eventDescription, + industry: data.industry, + location: data.location, + trends: data.trends, + styles: ['SIMPLE', 'FANCY', 'TRENDY'], + platforms: ['INSTAGRAM'], + }); + + console.log('✅ Image generation request accepted (202)'); + console.log('⏳ AI 이미지 생성 중... 약 60초 소요됩니다.'); + + setLoadingProgress(10); + setLoadingMessage('AI가 이미지를 생성하고 있어요...'); + + // 생성 완료까지 대기 (polling) + let attempts = 0; + const maxAttempts = 30; // 최대 60초 (2초 * 30회) + + const pollImages = async () => { + attempts++; + console.log(`🔄 이미지 확인 시도 ${attempts}/${maxAttempts}...`); + + // 진행률 업데이트 (10% ~ 90%) + const progress = Math.min(10 + (attempts / maxAttempts) * 80, 90); + setLoadingProgress(progress); + + // 단계별 메시지 업데이트 + if (attempts < 10) { + setLoadingMessage('AI가 이미지를 생성하고 있어요...'); + } else if (attempts < 20) { + setLoadingMessage('스타일을 적용하고 있어요...'); + } else { + setLoadingMessage('거의 완료되었어요...'); + } + + const hasImages = await loadImages(data); + + if (hasImages) { + console.log('✅ 이미지 생성 완료!'); + setLoadingProgress(100); + setLoadingMessage('이미지 생성 완료!'); + setTimeout(() => setLoading(false), 500); // 100% 잠깐 보여주기 + } else if (attempts < maxAttempts) { + // 2초 후 다시 시도 + setTimeout(pollImages, 2000); + } else { + console.warn('⚠️ 이미지 생성 시간 초과. "이미지 재생성" 버튼을 클릭하세요.'); + setError('이미지 생성이 완료되지 않았습니다. 잠시 후 "이미지 재생성" 버튼을 클릭해주세요.'); + setLoading(false); + } + }; + + // 첫 번째 확인은 5초 후 시작 (생성 시작 시간 고려) + setTimeout(pollImages, 5000); + } catch (err) { + console.error('❌ Image generation request error:', err); + setError('이미지 생성 요청에 실패했습니다.'); + setLoading(false); + } + }; + + const handleGenerateImages = async () => { + if (!eventData) return; + handleGenerateImagesAuto(eventData); + }; + if (loading) { return ( @@ -121,7 +304,7 @@ export default function ContentPreviewStep({ - + {/* 그라데이션 스피너 */} - - AI 이미지 생성 중 - + + {/* 진행률 바 */} + + + + {loadingMessage} + + + {Math.round(loadingProgress)}% + + + + + + + + - 딥러닝 모델이 이벤트에 어울리는 -
- 이미지를 생성하고 있어요... -
- - 예상 시간: 5초 + {generatedImages.size > 0 ? ( + <> + 생성된 이미지를 확인하고 있어요 +
+ 잠시만 기다려주세요! + + ) : ( + <> + AI가 이벤트에 맞는 이미지를 생성하고 있어요 +
+ 약 60초 정도 소요됩니다 + + )}
+ {error && ( + + {error} + + + )}
@@ -179,7 +414,18 @@ export default function ContentPreviewStep({ } return ( - + {/* Header */} @@ -191,11 +437,35 @@ export default function ContentPreviewStep({ + + {generatedImages.size > 0 && ( + + ✨ 생성된 이미지를 확인하고 스타일을 선택하세요 + + )} + + + 이벤트에 어울리는 스타일을 선택하세요 - handleStyleSelect(e.target.value)}> + handleStyleSelect(e.target.value as 'SIMPLE' | 'FANCY' | 'TRENDY')}> {imageStyles.map((style) => ( @@ -237,46 +507,82 @@ export default function ContentPreviewStep({ sx={{ width: '100%', aspectRatio: '1 / 1', - background: style.gradient || colors.gray[100], - display: 'flex', - flexDirection: 'column', - alignItems: 'center', - justifyContent: 'center', - p: 6, - textAlign: 'center', + position: 'relative', + overflow: 'hidden', + bgcolor: colors.gray[100], }} > - - {style.icon} - - - {title} - - - {prize} - + {(() => { + const hasImage = generatedImages.has(style.id); + const imageData = generatedImages.get(style.id); + console.log(`🖼️ Rendering ${style.id}:`, { + hasImage, + imageDataExists: !!imageData, + fullCdnUrl: imageData?.cdnUrl, + mapSize: generatedImages.size, + mapKeys: Array.from(generatedImages.keys()), + }); + return hasImage && imageData ? ( + {style.name} console.log(`✅ ${style.id} image loaded successfully from:`, imageData.cdnUrl)} + onError={(e) => { + console.error(`❌ ${style.id} image load error:`, e); + console.error(` Failed URL:`, imageData.cdnUrl); + }} + /> + ) : ( + + + {style.icon} + + + {eventData?.eventTitle || '이벤트'} + + + {eventData?.prize || '경품'} + + + ); + })()} {/* 크게보기 버튼 */} @@ -284,7 +590,13 @@ export default function ContentPreviewStep({ + + + + +
+ ✅ localStorage에 저장되었습니다!
+ 이제 이벤트 생성 플로우에서 channel → contentPreview로 이동하면
+ 자동으로 AI 이미지 생성이 시작됩니다. +
+ +
+ 현재 localStorage 데이터: +
없음
+
+ + + + + From 78cc41b4534b510ee8a74307ef2e869ae2790f80 Mon Sep 17 00:00:00 2001 From: merrycoral Date: Wed, 29 Oct 2025 13:22:26 +0900 Subject: [PATCH 2/8] =?UTF-8?q?=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EC=97=94?= =?UTF-8?q?=ED=8B=B0=ED=8B=B0=20=EB=B0=8F=20=ED=8E=98=EC=9D=B4=EC=A7=80=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/app/(main)/events/page.tsx | 206 +++++++++++++++----------- src/entities/event/api/eventApi.ts | 198 +++++++++++++++++++++++++ src/entities/event/model/types.ts | 173 +++++++++++++++++++++ src/entities/event/model/useEvents.ts | 200 +++++++++++++++++++++++++ 4 files changed, 694 insertions(+), 83 deletions(-) create mode 100644 src/entities/event/api/eventApi.ts create mode 100644 src/entities/event/model/types.ts create mode 100644 src/entities/event/model/useEvents.ts diff --git a/src/app/(main)/events/page.tsx b/src/app/(main)/events/page.tsx index bf80422..40bfc67 100644 --- a/src/app/(main)/events/page.tsx +++ b/src/app/(main)/events/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { useRouter } from 'next/navigation'; import { Box, @@ -37,78 +37,12 @@ import { } from '@mui/icons-material'; import Header from '@/shared/ui/Header'; import { cardStyles, colors, responsiveText } from '@/shared/lib/button-styles'; +import { useEvents } from '@/entities/event/model/useEvents'; +import type { EventStatus as ApiEventStatus } from '@/entities/event/model/types'; -// Mock 데이터 -const mockEvents = [ - { - id: '1', - title: '신규고객 유치 이벤트', - status: 'active' as const, - daysLeft: 5, - participants: 128, - targetParticipants: 200, - roi: 450, - startDate: '2025-11-01', - endDate: '2025-11-15', - prize: '커피 쿠폰', - method: '전화번호 입력', - isUrgent: true, - isPopular: false, - isHighROI: true, - isNew: false, - }, - { - id: '2', - title: '재방문 유도 이벤트', - status: 'active' as const, - daysLeft: 12, - participants: 56, - targetParticipants: 100, - roi: 320, - startDate: '2025-11-05', - endDate: '2025-11-20', - prize: '할인 쿠폰', - method: 'SNS 팔로우', - isUrgent: false, - isPopular: false, - isHighROI: false, - isNew: false, - }, - { - id: '3', - title: '매출증대 프로모션', - status: 'ended' as const, - daysLeft: 0, - participants: 234, - targetParticipants: 150, - roi: 580, - startDate: '2025-10-15', - endDate: '2025-10-31', - prize: '상품권', - method: '구매 인증', - isUrgent: false, - isPopular: true, - isHighROI: true, - isNew: false, - }, - { - id: '4', - title: '봄맞이 특별 이벤트', - status: 'scheduled' as const, - daysLeft: 30, - participants: 0, - targetParticipants: 300, - roi: 0, - startDate: '2025-12-01', - endDate: '2025-12-15', - prize: '체험권', - method: '이메일 등록', - isUrgent: false, - isPopular: false, - isHighROI: false, - isNew: true, - }, -]; +// ==================== API 연동 ==================== +// Mock 데이터를 실제 API 호출로 교체 +// 백업 파일: page.tsx.backup type EventStatus = 'all' | 'active' | 'scheduled' | 'ended'; type Period = '1month' | '3months' | '6months' | '1year' | 'all'; @@ -123,8 +57,57 @@ export default function EventsPage() { const [currentPage, setCurrentPage] = useState(1); const itemsPerPage = 20; + // API 데이터 가져오기 + const { events: apiEvents, loading, error, pageInfo, refetch } = useEvents({ + page: currentPage - 1, + size: itemsPerPage, + sort: 'createdAt', + order: 'desc' + }); + + // API 상태를 UI 상태로 매핑 + const mapApiStatus = (apiStatus: ApiEventStatus): EventStatus => { + switch (apiStatus) { + case 'PUBLISHED': + return 'active'; + case 'DRAFT': + return 'scheduled'; + case 'ENDED': + return 'ended'; + default: + return 'all'; + } + }; + + // API 이벤트를 UI 형식으로 변환 + const transformedEvents = apiEvents.map(event => ({ + id: event.eventId, + title: event.eventName || '제목 없음', + status: mapApiStatus(event.status), + startDate: event.startDate ? new Date(event.startDate).toLocaleDateString('ko-KR') : '-', + endDate: event.endDate ? new Date(event.endDate).toLocaleDateString('ko-KR') : '-', + prize: event.aiRecommendations[0]?.reward || '경품 정보 없음', + method: event.aiRecommendations[0]?.participationMethod || '참여 방법 없음', + participants: event.participants || 0, + targetParticipants: event.targetParticipants || 0, + roi: event.roi || 0, + daysLeft: event.endDate + ? Math.ceil((new Date(event.endDate).getTime() - Date.now()) / (1000 * 60 * 60 * 24)) + : 0, + isUrgent: event.endDate + ? Math.ceil((new Date(event.endDate).getTime() - Date.now()) / (1000 * 60 * 60 * 24)) <= 3 + : false, + isPopular: event.participants && event.targetParticipants + ? (event.participants / event.targetParticipants) >= 0.8 + : false, + isHighROI: event.roi ? event.roi >= 300 : false, + isNew: event.createdAt + ? (Date.now() - new Date(event.createdAt).getTime()) < (7 * 24 * 60 * 60 * 1000) + : false, + })); + // 필터링 및 정렬 - const filteredEvents = mockEvents + const filteredEvents = transformedEvents .filter((event) => { const matchesSearch = event.title.toLowerCase().includes(searchTerm.toLowerCase()); const matchesStatus = statusFilter === 'all' || event.status === statusFilter; @@ -204,22 +187,26 @@ export default function EventsPage() { } }; - const calculateProgress = (event: (typeof mockEvents)[0]) => { + const calculateProgress = (event: typeof transformedEvents[0]) => { if (event.status !== 'active') return 0; - const total = new Date(event.endDate).getTime() - new Date(event.startDate).getTime(); - const elapsed = Date.now() - new Date(event.startDate).getTime(); + const startTime = new Date(event.startDate).getTime(); + const endTime = new Date(event.endDate).getTime(); + const total = endTime - startTime; + const elapsed = Date.now() - startTime; return Math.min(Math.max((elapsed / total) * 100, 0), 100); }; // 통계 계산 const stats = { - total: mockEvents.length, - active: mockEvents.filter((e) => e.status === 'active').length, - totalParticipants: mockEvents.reduce((sum, e) => sum + e.participants, 0), - avgROI: Math.round( - mockEvents.filter((e) => e.roi > 0).reduce((sum, e) => sum + e.roi, 0) / - mockEvents.filter((e) => e.roi > 0).length - ), + total: transformedEvents.length, + active: transformedEvents.filter((e) => e.status === 'active').length, + totalParticipants: transformedEvents.reduce((sum, e) => sum + e.participants, 0), + avgROI: transformedEvents.filter((e) => e.roi > 0).length > 0 + ? Math.round( + transformedEvents.filter((e) => e.roi > 0).reduce((sum, e) => sum + e.roi, 0) / + transformedEvents.filter((e) => e.roi > 0).length + ) + : 0, }; return ( @@ -237,6 +224,59 @@ export default function EventsPage() { maxWidth="lg" sx={{ pt: { xs: 4, sm: 8 }, pb: { xs: 4, sm: 6 }, px: { xs: 3, sm: 6, md: 10 } }} > + {/* Loading State */} + {loading && ( + + + + 이벤트 목록을 불러오는 중... + + + )} + + {/* Error State */} + {error && ( + + + + + 이벤트 목록을 불러오는데 실패했습니다 + + + {error.message} + + refetch()} + sx={{ + px: 3, + py: 1.5, + borderRadius: 2, + border: 'none', + bgcolor: '#DC2626', + color: 'white', + fontSize: '0.875rem', + fontWeight: 600, + cursor: 'pointer', + '&:hover': { bgcolor: '#B91C1C' }, + }} + > + 다시 시도 + + + + )} + {/* Summary Statistics */} diff --git a/src/entities/event/api/eventApi.ts b/src/entities/event/api/eventApi.ts new file mode 100644 index 0000000..929eefe --- /dev/null +++ b/src/entities/event/api/eventApi.ts @@ -0,0 +1,198 @@ +import { apiClient } from '@/shared/api'; +import type { + GetEventsRequest, + GetEventsResponse, + EventDetail, + ApiResponse, + SelectObjectiveRequest, + EventCreatedResponse, + AiRecommendationRequest, + JobAcceptedResponse, + ImageGenerationRequest, + ImageGenerationResponse, +} from '../model/types'; + +/** + * Event API 기본 경로 + * + * 참고: apiClient는 기본적으로 user-service(8081)를 가리키므로 + * 별도의 event API 클라이언트를 사용하는 것이 좋습니다. + * + * 현재는 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 eventApiClient = axios.create({ + baseURL: EVENT_HOST, + timeout: 30000, + headers: { + 'Content-Type': 'application/json', + }, +}); + +// Request interceptor - JWT 토큰 추가 +eventApiClient.interceptors.request.use( + (config) => { + console.log('🚀 Event API Request:', { + method: config.method?.toUpperCase(), + url: config.url, + baseURL: config.baseURL, + params: config.params, + }); + + const token = localStorage.getItem('accessToken'); + if (token && config.headers) { + config.headers.Authorization = `Bearer ${token}`; + console.log('🔑 Token added to Event API request'); + } + return config; + }, + (error) => { + console.error('❌ Event API Request Error:', error); + return Promise.reject(error); + } +); + +// Response interceptor - 에러 처리 +eventApiClient.interceptors.response.use( + (response) => { + console.log('✅ Event API Response:', { + status: response.status, + url: response.config.url, + data: response.data, + }); + return response; + }, + (error) => { + console.error('❌ Event API Error:', { + message: error.message, + status: error.response?.status, + statusText: error.response?.statusText, + url: error.config?.url, + data: error.response?.data, + }); + + if (error.response?.status === 401) { + console.warn('🔒 401 Unauthorized - Redirecting to login'); + localStorage.removeItem('accessToken'); + if (typeof window !== 'undefined') { + window.location.href = '/login'; + } + } + return Promise.reject(error); + } +); + +/** + * Event API Service + * 이벤트 관리 API + */ +export const eventApi = { + /** + * 이벤트 목록 조회 + */ + getEvents: async (params?: GetEventsRequest): Promise => { + console.log('📞 eventApi.getEvents 호출', params); + const response = await eventApiClient.get(EVENT_API_BASE, { + params, + }); + return response.data; + }, + + /** + * 이벤트 상세 조회 + */ + getEvent: async (eventId: string): Promise> => { + console.log('📞 eventApi.getEvent 호출', eventId); + const response = await eventApiClient.get>( + `${EVENT_API_BASE}/${eventId}` + ); + return response.data; + }, + + /** + * 이벤트 생성 (목적 선택) + */ + createEvent: async ( + data: SelectObjectiveRequest + ): Promise> => { + console.log('📞 eventApi.createEvent 호출', data); + const response = await eventApiClient.post>( + `${EVENT_API_BASE}/objectives`, + data + ); + return response.data; + }, + + /** + * 이벤트 삭제 + */ + deleteEvent: async (eventId: string): Promise> => { + console.log('📞 eventApi.deleteEvent 호출', eventId); + const response = await eventApiClient.delete>( + `${EVENT_API_BASE}/${eventId}` + ); + return response.data; + }, + + /** + * 이벤트 배포 + */ + publishEvent: async (eventId: string): Promise> => { + console.log('📞 eventApi.publishEvent 호출', eventId); + const response = await eventApiClient.post>( + `${EVENT_API_BASE}/${eventId}/publish` + ); + return response.data; + }, + + /** + * 이벤트 종료 + */ + endEvent: async (eventId: string): Promise> => { + console.log('📞 eventApi.endEvent 호출', eventId); + const response = await eventApiClient.post>( + `${EVENT_API_BASE}/${eventId}/end` + ); + return response.data; + }, + + /** + * AI 추천 요청 + */ + requestAiRecommendations: async ( + eventId: string, + data: AiRecommendationRequest + ): Promise> => { + console.log('📞 eventApi.requestAiRecommendations 호출', eventId, data); + const response = await eventApiClient.post>( + `${EVENT_API_BASE}/${eventId}/ai-recommendations`, + data + ); + return response.data; + }, + + /** + * 이미지 생성 요청 + */ + requestImageGeneration: async ( + eventId: string, + data: ImageGenerationRequest + ): Promise> => { + console.log('📞 eventApi.requestImageGeneration 호출', eventId, data); + const response = await eventApiClient.post>( + `${EVENT_API_BASE}/${eventId}/images`, + data + ); + return response.data; + }, +}; + +export default eventApi; diff --git a/src/entities/event/model/types.ts b/src/entities/event/model/types.ts new file mode 100644 index 0000000..b3de860 --- /dev/null +++ b/src/entities/event/model/types.ts @@ -0,0 +1,173 @@ +/** + * Event 도메인 타입 정의 + * Event Service API 응답 형식과 일치 + */ + +/** + * 이벤트 상태 + */ +export type EventStatus = 'DRAFT' | 'PUBLISHED' | 'ENDED'; + +/** + * 이벤트 목적 + */ +export type EventObjective = + | 'CUSTOMER_ACQUISITION' + | 'Sales Promotion' + | 'Customer Retention' + | 'New Customer Acquisition' + | 'awareness' + | 'sales' + | 'new_customer'; + +/** + * 배포 채널 + */ +export type DistributionChannel = 'SMS' | 'EMAIL' | 'KAKAO' | 'PUSH'; + +/** + * 이벤트 이미지 + */ +export interface EventImage { + imageId: string; + imageUrl: string; + prompt?: string; + isSelected: boolean; + createdAt: string; +} + +/** + * AI 추천 + */ +export interface AiRecommendation { + recommendationId: string; + eventName: string; + description: string; + reward: string; + participationMethod: string; + startDate: string; + endDate: string; + targetParticipants: number; + isSelected: boolean; + createdAt: string; +} + +/** + * 이벤트 상세 정보 + */ +export interface EventDetail { + eventId: string; + userId: string; + storeId: string; + eventName: string; + description: string | null; + objective: EventObjective; + startDate: string | null; + endDate: string | null; + status: EventStatus; + selectedImageId: string | null; + selectedImageUrl: string | null; + participants: number | null; + targetParticipants: number | null; + roi: number | null; + generatedImages: EventImage[]; + aiRecommendations: AiRecommendation[]; + channels: DistributionChannel[]; + createdAt: string; + updatedAt: string; +} + +/** + * 페이지 응답 + */ +export interface PageResponse { + content: T[]; + page: number; + size: number; + totalElements: number; + totalPages: number; + first: boolean; + last: boolean; +} + +/** + * API 표준 응답 + */ +export interface ApiResponse { + success: boolean; + data: T; + timestamp: string; +} + +/** + * 이벤트 목록 조회 요청 + */ +export interface GetEventsRequest { + status?: EventStatus; + search?: string; + objective?: string; + page?: number; + size?: number; + sort?: string; + order?: 'asc' | 'desc'; +} + +/** + * 이벤트 목록 조회 응답 + */ +export type GetEventsResponse = ApiResponse>; + +/** + * 이벤트 목적 선택 요청 + */ +export interface SelectObjectiveRequest { + objective: EventObjective; +} + +/** + * 이벤트 생성 응답 + */ +export interface EventCreatedResponse { + eventId: string; + objective: EventObjective; + status: EventStatus; + createdAt: string; +} + +/** + * AI 추천 요청 + */ +export interface AiRecommendationRequest { + storeCategory?: string; + targetAudience?: string; + budget?: number; + additionalInfo?: string; +} + +/** + * Job 수락 응답 + */ +export interface JobAcceptedResponse { + jobId: string; + eventId: string; + status: 'PENDING' | 'PROCESSING' | 'COMPLETED' | 'FAILED'; + estimatedCompletionTime?: string; +} + +/** + * 이미지 생성 요청 + */ +export interface ImageGenerationRequest { + prompt: string; + numberOfImages?: number; + style?: string; +} + +/** + * 이미지 생성 응답 + */ +export interface ImageGenerationResponse { + jobId: string; + eventId: string; + status: string; +} diff --git a/src/entities/event/model/useEvents.ts b/src/entities/event/model/useEvents.ts new file mode 100644 index 0000000..7eeb1d7 --- /dev/null +++ b/src/entities/event/model/useEvents.ts @@ -0,0 +1,200 @@ +import { useState, useEffect } from 'react'; +import { eventApi } from '../api/eventApi'; +import type { + EventDetail, + GetEventsRequest, + EventStatus, + PageResponse, +} from './types'; + +/** + * useEvents Hook + * 이벤트 목록 조회 및 상태 관리 + */ +export function useEvents(initialParams?: GetEventsRequest) { + const [events, setEvents] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [pageInfo, setPageInfo] = useState, 'content'>>({ + page: 0, + size: 20, + totalElements: 0, + totalPages: 0, + first: true, + last: true, + }); + + const fetchEvents = async (params?: GetEventsRequest) => { + try { + setLoading(true); + setError(null); + console.log('🔄 Fetching events with params:', params); + + const response = await eventApi.getEvents(params); + console.log('✅ Events fetched:', response); + + if (response.success && response.data) { + setEvents(response.data.content); + setPageInfo({ + page: response.data.page, + size: response.data.size, + totalElements: response.data.totalElements, + totalPages: response.data.totalPages, + first: response.data.first, + last: response.data.last, + }); + } + } catch (err) { + console.error('❌ Error fetching events:', err); + setError(err as Error); + } finally { + setLoading(false); + } + }; + + // 초기 로드 + useEffect(() => { + fetchEvents(initialParams); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return { + events, + loading, + error, + pageInfo, + refetch: fetchEvents, + }; +} + +/** + * useEvent Hook + * 단일 이벤트 조회 및 상태 관리 + */ +export function useEvent(eventId: string) { + const [event, setEvent] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const fetchEvent = async () => { + if (!eventId) return; + + try { + setLoading(true); + setError(null); + console.log('🔄 Fetching event:', eventId); + + const response = await eventApi.getEvent(eventId); + console.log('✅ Event fetched:', response); + + if (response.success && response.data) { + setEvent(response.data); + } + } catch (err) { + console.error('❌ Error fetching event:', err); + setError(err as Error); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchEvent(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [eventId]); + + return { + event, + loading, + error, + refetch: fetchEvent, + }; +} + +/** + * useEventActions Hook + * 이벤트 생성, 삭제, 배포 등의 액션 관리 + */ +export function useEventActions() { + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const createEvent = async (objective: string) => { + try { + setLoading(true); + setError(null); + console.log('🔄 Creating event with objective:', objective); + + const response = await eventApi.createEvent({ objective: objective as any }); + console.log('✅ Event created:', response); + + return response.data; + } catch (err) { + console.error('❌ Error creating event:', err); + setError(err as Error); + throw err; + } finally { + setLoading(false); + } + }; + + const deleteEvent = async (eventId: string) => { + try { + setLoading(true); + setError(null); + console.log('🔄 Deleting event:', eventId); + + await eventApi.deleteEvent(eventId); + console.log('✅ Event deleted'); + } catch (err) { + console.error('❌ Error deleting event:', err); + setError(err as Error); + throw err; + } finally { + setLoading(false); + } + }; + + const publishEvent = async (eventId: string) => { + try { + setLoading(true); + setError(null); + console.log('🔄 Publishing event:', eventId); + + await eventApi.publishEvent(eventId); + console.log('✅ Event published'); + } catch (err) { + console.error('❌ Error publishing event:', err); + setError(err as Error); + throw err; + } finally { + setLoading(false); + } + }; + + const endEvent = async (eventId: string) => { + try { + setLoading(true); + setError(null); + console.log('🔄 Ending event:', eventId); + + await eventApi.endEvent(eventId); + console.log('✅ Event ended'); + } catch (err) { + console.error('❌ Error ending event:', err); + setError(err as Error); + throw err; + } finally { + setLoading(false); + } + }; + + return { + createEvent, + deleteEvent, + publishEvent, + endEvent, + loading, + error, + }; +} From c9614263c09a17f9034280c60093488a4d912c5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=84=B8=EC=9B=90?= Date: Wed, 29 Oct 2025 13:49:45 +0900 Subject: [PATCH 3/8] =?UTF-8?q?=EB=B0=B1=EC=97=94=EB=93=9C=20AI=20?= =?UTF-8?q?=EC=84=9C=EB=B9=84=EC=8A=A4=EC=99=80=20=ED=94=84=EB=A1=A0?= =?UTF-8?q?=ED=8A=B8=EC=97=94=EB=93=9C=20=EC=99=84=EC=A0=84=20=EC=97=B0?= =?UTF-8?q?=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AI 서비스 API 클라이언트 추가 (aiApi.ts) - Event 서비스 API 클라이언트 추가 (eventApi.ts) - RecommendationStep에서 실제 API 호출로 변경 - Job 폴링 메커니즘 구현 (5초 간격) - ContentPreviewStep의 Mock 데이터 제거 - Props를 통한 eventId 전달 구조 개선 - ApprovalStep의 타입 오류 수정 - 모든 Mock/Static 데이터 제거 완료 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/app/(main)/events/create/page.tsx | 69 ++- .../events/create/steps/ApprovalStep.tsx | 14 +- .../create/steps/ContentPreviewStep.tsx | 48 +- .../create/steps/RecommendationStep.tsx | 550 ++++++++++++------ src/shared/api/aiApi.ts | 178 ++++++ src/shared/api/eventApi.ts | 329 +++++++++++ src/shared/api/index.ts | 6 +- 7 files changed, 956 insertions(+), 238 deletions(-) create mode 100644 src/shared/api/aiApi.ts create mode 100644 src/shared/api/eventApi.ts diff --git a/src/app/(main)/events/create/page.tsx b/src/app/(main)/events/create/page.tsx index a83b41d..1fa7006 100644 --- a/src/app/(main)/events/create/page.tsx +++ b/src/app/(main)/events/create/page.tsx @@ -18,17 +18,43 @@ export interface EventData { eventDraftId?: number; objective?: EventObjective; recommendation?: { - budget: BudgetLevel; - method: EventMethod; - title: string; - prize: string; - description?: string; - industry?: string; - location?: string; - participationMethod: string; - expectedParticipants: number; - estimatedCost: number; - roi: number; + recommendation: { + optionNumber: number; + concept: string; + title: string; + description: string; + targetAudience: string; + duration: { + recommendedDays: number; + recommendedPeriod?: string; + }; + mechanics: { + type: 'DISCOUNT' | 'GIFT' | 'STAMP' | 'EXPERIENCE' | 'LOTTERY' | 'COMBO'; + details: string; + }; + promotionChannels: string[]; + estimatedCost: { + min: number; + max: number; + breakdown?: { + material?: number; + promotion?: number; + discount?: number; + }; + }; + expectedMetrics: { + newCustomers: { min: number; max: number }; + repeatVisits?: { min: number; max: number }; + revenueIncrease: { min: number; max: number }; + roi: { min: number; max: number }; + socialEngagement?: { + estimatedPosts: number; + estimatedReach: number; + }; + }; + differentiator: string; + }; + eventId: string; }; contentPreview?: { imageStyle: string; @@ -96,13 +122,13 @@ export default function EventCreatePage() { if (needsContent) { // localStorage에 이벤트 정보 저장 const eventData = { - eventDraftId: context.eventDraftId || Date.now(), // 임시 ID 생성 - eventTitle: context.recommendation?.title || '', - eventDescription: context.recommendation?.description || context.recommendation?.participationMethod || '', - industry: context.recommendation?.industry || '', - location: context.recommendation?.location || '', - trends: [], // 필요시 context에서 추가 - prize: context.recommendation?.prize || '', + eventDraftId: context.recommendation?.eventId || String(Date.now()), // eventId 사용 + eventTitle: context.recommendation?.recommendation.title || '', + eventDescription: context.recommendation?.recommendation.description || '', + industry: '', + location: '', + trends: context.recommendation?.recommendation.promotionChannels || [], + prize: '', }; localStorage.setItem('eventCreationData', JSON.stringify(eventData)); @@ -118,6 +144,9 @@ export default function EventCreatePage() { )} contentPreview={({ context, history }) => ( { history.push('contentEdit', { ...context, @@ -134,8 +163,8 @@ export default function EventCreatePage() { )} contentEdit={({ context, history }) => ( { history.push('approval', { ...context, contentEdit }); }} diff --git a/src/app/(main)/events/create/steps/ApprovalStep.tsx b/src/app/(main)/events/create/steps/ApprovalStep.tsx index 465029e..2a349db 100644 --- a/src/app/(main)/events/create/steps/ApprovalStep.tsx +++ b/src/app/(main)/events/create/steps/ApprovalStep.tsx @@ -120,7 +120,7 @@ export default function ApprovalStep({ eventData, onApprove, onBack }: ApprovalS textShadow: '0px 2px 4px rgba(0,0,0,0.15)', }} > - {eventData.recommendation?.title || '이벤트 제목'} + {eventData.recommendation?.recommendation.title || '이벤트 제목'} @@ -158,7 +158,7 @@ export default function ApprovalStep({ eventData, onApprove, onBack }: ApprovalS textShadow: '0px 2px 4px rgba(0,0,0,0.15)', }} > - {eventData.recommendation?.expectedParticipants || 0} + {eventData.recommendation?.recommendation.expectedMetrics.newCustomers.max || 0} - {((eventData.recommendation?.estimatedCost || 0) / 10000).toFixed(0)} + {((eventData.recommendation?.recommendation.estimatedCost.max || 0) / 10000).toFixed(0)} - {eventData.recommendation?.roi || 0}% + {eventData.recommendation?.recommendation.expectedMetrics.roi.max || 0}% @@ -270,7 +270,7 @@ export default function ApprovalStep({ eventData, onApprove, onBack }: ApprovalS 이벤트 제목 - {eventData.recommendation?.title} + {eventData.recommendation?.recommendation.title}
@@ -288,7 +288,7 @@ export default function ApprovalStep({ eventData, onApprove, onBack }: ApprovalS 경품 - {eventData.recommendation?.prize} + {eventData.recommendation?.recommendation.mechanics.details || ''}
@@ -306,7 +306,7 @@ export default function ApprovalStep({ eventData, onApprove, onBack }: ApprovalS 참여 방법 - {eventData.recommendation?.participationMethod} + {eventData.recommendation?.recommendation.mechanics.details || ''} diff --git a/src/app/(main)/events/create/steps/ContentPreviewStep.tsx b/src/app/(main)/events/create/steps/ContentPreviewStep.tsx index a0fc4f7..ddead7a 100644 --- a/src/app/(main)/events/create/steps/ContentPreviewStep.tsx +++ b/src/app/(main)/events/create/steps/ContentPreviewStep.tsx @@ -67,13 +67,16 @@ const imageStyles: ImageStyle[] = [ ]; interface ContentPreviewStepProps { + eventId?: string; + eventTitle?: string; + eventDescription?: string; onNext: (imageStyle: string, images: ImageInfo[]) => void; onSkip: () => void; onBack: () => void; } interface EventCreationData { - eventDraftId: string; // Changed from number to string + eventDraftId: string; eventTitle: string; eventDescription: string; industry: string; @@ -83,6 +86,9 @@ interface EventCreationData { } export default function ContentPreviewStep({ + eventId: propsEventId, + eventTitle: propsEventTitle, + eventDescription: propsEventDescription, onNext, onSkip, onBack, @@ -112,25 +118,35 @@ export default function ContentPreviewStep({ handleGenerateImagesAuto(data); } }); - } else { - // Mock 데이터가 없으면 자동으로 설정 - const mockData: EventCreationData = { - eventDraftId: "1761634317010", // Changed to string - eventTitle: "맥주 파티 이벤트", - eventDescription: "강남에서 열리는 신나는 맥주 파티에 참여하세요!", - industry: "음식점", - location: "강남", - trends: ["파티", "맥주", "생맥주"], - prize: "생맥주 1잔" + } else if (propsEventId) { + // Props에서 받은 이벤트 데이터 사용 (localStorage 없을 때만) + console.log('✅ Using event data from props:', propsEventId); + const data: EventCreationData = { + eventDraftId: propsEventId, + eventTitle: propsEventTitle || '', + eventDescription: propsEventDescription || '', + industry: '', + location: '', + trends: [], + prize: '', }; + setEventData(data); - console.log('⚠️ localStorage에 이벤트 데이터가 없습니다. Mock 데이터를 사용합니다.'); - localStorage.setItem('eventCreationData', JSON.stringify(mockData)); - setEventData(mockData); - loadImages(mockData); + // 이미지 조회 시도 + loadImages(data).then((hasImages) => { + if (!hasImages) { + console.log('📸 이미지가 없습니다. 자동으로 생성을 시작합니다...'); + handleGenerateImagesAuto(data); + } + }); + } else { + // 이벤트 데이터가 없으면 에러 표시 + console.error('❌ No event data available. Cannot proceed.'); + setError('이벤트 정보를 찾을 수 없습니다. 이전 단계로 돌아가 주세요.'); + setLoading(false); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }, [propsEventId, propsEventTitle, propsEventDescription]); const loadImages = async (data: EventCreationData): Promise => { try { diff --git a/src/app/(main)/events/create/steps/RecommendationStep.tsx b/src/app/(main)/events/create/steps/RecommendationStep.tsx index a201950..e3472b8 100644 --- a/src/app/(main)/events/create/steps/RecommendationStep.tsx +++ b/src/app/(main)/events/create/steps/RecommendationStep.tsx @@ -1,4 +1,6 @@ -import { useState } from 'react'; +'use client'; + +import { useState, useEffect } from 'react'; import { Box, Container, @@ -13,11 +15,12 @@ import { RadioGroup, FormControlLabel, IconButton, - Tabs, - Tab, + CircularProgress, + 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'; // 디자인 시스템 색상 const colors = { @@ -37,130 +40,288 @@ const colors = { }, }; -interface Recommendation { - id: string; - budget: BudgetLevel; - method: EventMethod; - title: string; - prize: string; - participationMethod: string; - expectedParticipants: number; - estimatedCost: number; - roi: number; -} - -// Mock 추천 데이터 -const mockRecommendations: Recommendation[] = [ - // 저비용 - { - id: 'low-online', - budget: 'low', - method: 'online', - title: 'SNS 팔로우 이벤트', - prize: '커피 쿠폰', - participationMethod: 'SNS 팔로우', - expectedParticipants: 180, - estimatedCost: 250000, - roi: 520, - }, - { - id: 'low-offline', - budget: 'low', - method: 'offline', - title: '전화번호 등록 이벤트', - prize: '커피 쿠폰', - participationMethod: '방문 시 전화번호 등록', - expectedParticipants: 120, - estimatedCost: 300000, - roi: 380, - }, - // 중비용 - { - id: 'medium-online', - budget: 'medium', - method: 'online', - title: '리뷰 작성 이벤트', - prize: '상품권 5만원', - participationMethod: '네이버 리뷰 작성', - expectedParticipants: 250, - estimatedCost: 800000, - roi: 450, - }, - { - id: 'medium-offline', - budget: 'medium', - method: 'offline', - title: '스탬프 적립 이벤트', - prize: '상품권 5만원', - participationMethod: '3회 방문 시 스탬프', - expectedParticipants: 200, - estimatedCost: 1000000, - roi: 380, - }, - // 고비용 - { - id: 'high-online', - budget: 'high', - method: 'online', - title: '인플루언서 협업 이벤트', - prize: '애플 에어팟', - participationMethod: '게시물 공유 및 댓글', - expectedParticipants: 500, - estimatedCost: 2000000, - roi: 380, - }, - { - id: 'high-offline', - budget: 'high', - method: 'offline', - title: 'VIP 고객 초대 이벤트', - prize: '애플 에어팟', - participationMethod: '누적 10회 방문', - expectedParticipants: 300, - estimatedCost: 2500000, - roi: 320, - }, -]; - interface RecommendationStepProps { objective?: EventObjective; - onNext: (data: Recommendation) => void; + eventId?: string; // 이전 단계에서 생성된 eventId + onNext: (data: { + recommendation: EventRecommendation; + eventId: string; + }) => void; onBack: () => void; } -export default function RecommendationStep({ onNext, onBack }: RecommendationStepProps) { - const [selectedBudget, setSelectedBudget] = useState('low'); - const [selected, setSelected] = useState(null); - const [editedData, setEditedData] = useState>({}); +export default function RecommendationStep({ + objective, + eventId: initialEventId, + onNext, + onBack +}: RecommendationStepProps) { + const [eventId, setEventId] = useState(initialEventId || null); + const [jobId, setJobId] = useState(null); + const [loading, setLoading] = useState(false); + const [polling, setPolling] = useState(false); + const [error, setError] = useState(null); - const budgetRecommendations = mockRecommendations.filter((r) => r.budget === selectedBudget); + const [aiResult, setAiResult] = useState(null); + const [selected, setSelected] = useState(null); + const [editedData, setEditedData] = useState>({}); - const handleNext = () => { - const selectedRec = mockRecommendations.find((r) => r.id === selected); - if (selectedRec && selected) { - const edited = editedData[selected]; - onNext({ - ...selectedRec, - title: edited?.title || selectedRec.title, - prize: edited?.prize || selectedRec.prize, - }); + // 컴포넌트 마운트 시 AI 추천 요청 + useEffect(() => { + if (!eventId && objective) { + // Step 1: 이벤트 생성 + createEventAndRequestAI(); + } else if (eventId) { + // 이미 eventId가 있으면 AI 추천 요청 + requestAIRecommendations(eventId); + } + }, []); + + 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 handleEditTitle = (id: string, title: string) => { + 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 || '', + }; + + // AI 추천 요청 + const jobResponse = await eventApi.requestAiRecommendations(evtId, storeInfo); + setJobId(jobResponse.jobId); + + // Job 폴링 시작 + pollJobStatus(jobResponse.jobId, evtId); + } 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 { + const status = await eventApi.getJobStatus(jId); + console.log('Job 상태:', status); + + if (status.status === 'COMPLETED') { + // AI 추천 결과 조회 + const recommendations = await aiApi.getRecommendations(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); + 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, - [id]: { ...prev[id], title }, + [optionNumber]: { + ...prev[optionNumber], + title + }, })); }; - const handleEditPrize = (id: string, prize: string) => { + const handleEditDescription = (optionNumber: number, description: string) => { setEditedData((prev) => ({ ...prev, - [id]: { ...prev[id], prize }, + [optionNumber]: { + ...prev[optionNumber], + description + }, })); }; + // 로딩 상태 표시 + if (loading || polling) { + return ( + + + + + + + + AI 이벤트 추천 + + + + + + + AI가 최적의 이벤트를 생성하고 있습니다... + + + 업종, 지역, 시즌 트렌드를 분석하여 맞춤형 이벤트를 추천합니다 + + + + + ); + } + + // 에러 상태 표시 + if (error) { + return ( + + + + + + + + AI 이벤트 추천 + + + + + {error} + + + + + + + + + ); + } + + // AI 결과가 없으면 로딩 표시 + if (!aiResult) { + return ( + + + + + + ); + } + return ( @@ -195,158 +356,159 @@ export default function RecommendationStep({ onNext, onBack }: RecommendationSte 📍 업종 트렌드 - - 음식점업 신년 프로모션 트렌드 - + {aiResult.trendAnalysis.industryTrends.slice(0, 3).map((trend, idx) => ( + + • {trend.description} + + ))} 🗺️ 지역 트렌드 - - 강남구 음식점 할인 이벤트 증가 - + {aiResult.trendAnalysis.regionalTrends.slice(0, 3).map((trend, idx) => ( + + • {trend.description} + + ))} ☀️ 시즌 트렌드 - - 설 연휴 특수 대비 고객 유치 전략 - + {aiResult.trendAnalysis.seasonalTrends.slice(0, 3).map((trend, idx) => ( + + • {trend.description} + + ))} - {/* Budget Selection */} + {/* AI Recommendations */} - 예산별 추천 이벤트 + AI 추천 이벤트 ({aiResult.recommendations.length}가지 옵션) - 각 예산별 2가지 방식 (온라인 1개, 오프라인 1개)을 추천합니다 + 각 옵션은 차별화된 컨셉으로 구성되어 있습니다. 원하시는 옵션을 선택하고 수정할 수 있습니다. - setSelectedBudget(value)} - variant="fullWidth" - sx={{ mb: 8 }} - > - - - - {/* Recommendations */} - setSelected(e.target.value)}> + setSelected(Number(e.target.value))}> - {budgetRecommendations.map((rec) => ( - + {aiResult.recommendations.map((rec) => ( + setSelected(rec.id)} + onClick={() => setSelected(rec.optionNumber)} > - + + + + } + label="" + sx={{ m: 0 }} /> - } label="" sx={{ m: 0 }} /> handleEditTitle(rec.id, e.target.value)} + value={editedData[rec.optionNumber]?.title || rec.title} + onChange={(e) => handleEditTitle(rec.optionNumber, e.target.value)} onClick={(e) => e.stopPropagation()} sx={{ mb: 4 }} InputProps={{ endAdornment: , - sx: { fontSize: '1rem', py: 2 }, + sx: { fontSize: '1.1rem', fontWeight: 600, py: 2 }, }} /> - - - 경품 - - handleEditPrize(rec.id, e.target.value)} - onClick={(e) => e.stopPropagation()} - InputProps={{ - endAdornment: , - sx: { fontSize: '1rem' }, - }} - /> - + handleEditDescription(rec.optionNumber, e.target.value)} + onClick={(e) => e.stopPropagation()} + sx={{ mb: 4 }} + InputProps={{ + sx: { fontSize: '1rem' }, + }} + /> - - + + - 참여 방법 + 타겟 고객 - {rec.participationMethod} + {rec.targetAudience} - - - 예상 참여 - - - {rec.expectedParticipants}명 - - - + 예상 비용 - {(rec.estimatedCost / 10000).toFixed(0)}만원 + {(rec.estimatedCost.min / 10000).toFixed(0)}~{(rec.estimatedCost.max / 10000).toFixed(0)}만원 - + - 투자대비수익률 + 예상 신규 고객 + + + {rec.expectedMetrics.newCustomers.min}~{rec.expectedMetrics.newCustomers.max}명 + + + + + ROI - {rec.roi}% + {rec.expectedMetrics.roi.min}~{rec.expectedMetrics.roi.max}% + + + + + 차별점 + + + {rec.differentiator} @@ -381,7 +543,7 @@ export default function RecommendationStep({ onNext, onBack }: RecommendationSte fullWidth variant="contained" size="large" - disabled={!selected} + disabled={selected === null || loading} onClick={handleNext} sx={{ py: 3, @@ -398,7 +560,7 @@ export default function RecommendationStep({ onNext, onBack }: RecommendationSte }, }} > - 다음 + {loading ? : '다음'} diff --git a/src/shared/api/aiApi.ts b/src/shared/api/aiApi.ts new file mode 100644 index 0000000..c541eb8 --- /dev/null +++ b/src/shared/api/aiApi.ts @@ -0,0 +1,178 @@ +import axios, { AxiosInstance } from 'axios'; + +// AI Service API 클라이언트 +const AI_API_BASE_URL = process.env.NEXT_PUBLIC_AI_HOST || 'http://localhost:8083'; + +export const aiApiClient: AxiosInstance = axios.create({ + baseURL: AI_API_BASE_URL, + timeout: 300000, // AI 생성은 최대 5분 + headers: { + 'Content-Type': 'application/json', + }, +}); + +// Request interceptor +aiApiClient.interceptors.request.use( + (config) => { + console.log('🤖 AI API Request:', { + method: config.method?.toUpperCase(), + url: config.url, + baseURL: config.baseURL, + data: config.data, + }); + + const token = localStorage.getItem('accessToken'); + if (token && config.headers) { + config.headers.Authorization = `Bearer ${token}`; + } + return config; + }, + (error) => { + console.error('❌ AI API Request Error:', error); + return Promise.reject(error); + } +); + +// Response interceptor +aiApiClient.interceptors.response.use( + (response) => { + console.log('✅ AI API Response:', { + status: response.status, + url: response.config.url, + data: response.data, + }); + return response; + }, + (error) => { + console.error('❌ AI API Error:', { + message: error.message, + status: error.response?.status, + url: error.config?.url, + data: error.response?.data, + }); + return Promise.reject(error); + } +); + +// Types +export interface TrendKeyword { + keyword: string; + relevance: number; + description: string; +} + +export interface TrendAnalysis { + industryTrends: TrendKeyword[]; + regionalTrends: TrendKeyword[]; + seasonalTrends: TrendKeyword[]; +} + +export interface ExpectedMetrics { + newCustomers: { + min: number; + max: number; + }; + repeatVisits?: { + min: number; + max: number; + }; + revenueIncrease: { + min: number; + max: number; + }; + roi: { + min: number; + max: number; + }; + socialEngagement?: { + estimatedPosts: number; + estimatedReach: number; + }; +} + +export interface EventRecommendation { + optionNumber: number; + concept: string; + title: string; + description: string; + targetAudience: string; + duration: { + recommendedDays: number; + recommendedPeriod?: string; + }; + mechanics: { + type: 'DISCOUNT' | 'GIFT' | 'STAMP' | 'EXPERIENCE' | 'LOTTERY' | 'COMBO'; + details: string; + }; + promotionChannels: string[]; + estimatedCost: { + min: number; + max: number; + breakdown?: { + material?: number; + promotion?: number; + discount?: number; + }; + }; + expectedMetrics: ExpectedMetrics; + differentiator: string; +} + +export interface AIRecommendationResult { + eventId: string; + trendAnalysis: TrendAnalysis; + recommendations: EventRecommendation[]; + generatedAt: string; + expiresAt: string; + aiProvider: 'CLAUDE' | 'GPT4'; +} + +export interface JobStatusResponse { + jobId: string; + status: 'PENDING' | 'PROCESSING' | 'COMPLETED' | 'FAILED'; + progress: number; + message: string; + eventId?: string; + createdAt: string; + startedAt?: string; + completedAt?: string; + failedAt?: string; + errorMessage?: string; + retryCount?: number; + processingTimeMs?: number; +} + +export interface HealthCheckResponse { + status: 'UP' | 'DOWN' | 'DEGRADED'; + timestamp: string; + services: { + kafka: 'UP' | 'DOWN'; + redis: 'UP' | 'DOWN'; + claude_api: 'UP' | 'DOWN' | 'CIRCUIT_OPEN'; + gpt4_api?: 'UP' | 'DOWN' | 'CIRCUIT_OPEN'; + circuit_breaker: 'CLOSED' | 'OPEN' | 'HALF_OPEN'; + }; +} + +// API Functions +export const aiApi = { + // 헬스체크 + healthCheck: async (): Promise => { + const response = await aiApiClient.get('/health'); + return response.data; + }, + + // Job 상태 조회 (Internal API) + getJobStatus: async (jobId: string): Promise => { + const response = await aiApiClient.get(`/internal/jobs/${jobId}/status`); + return response.data; + }, + + // AI 추천 결과 조회 (Internal API) + getRecommendations: async (eventId: string): Promise => { + const response = await aiApiClient.get(`/internal/recommendations/${eventId}`); + return response.data; + }, +}; + +export default aiApi; diff --git a/src/shared/api/eventApi.ts b/src/shared/api/eventApi.ts new file mode 100644 index 0000000..4e9465f --- /dev/null +++ b/src/shared/api/eventApi.ts @@ -0,0 +1,329 @@ +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'; + +export const eventApiClient: AxiosInstance = axios.create({ + baseURL: `${EVENT_API_BASE_URL}/${API_VERSION}`, + timeout: 30000, // Job 폴링 고려 + headers: { + 'Content-Type': 'application/json', + }, +}); + +// Request interceptor +eventApiClient.interceptors.request.use( + (config) => { + console.log('📅 Event API Request:', { + method: config.method?.toUpperCase(), + url: config.url, + baseURL: config.baseURL, + data: config.data, + }); + + const token = localStorage.getItem('accessToken'); + if (token && config.headers) { + config.headers.Authorization = `Bearer ${token}`; + } + return config; + }, + (error) => { + console.error('❌ Event API Request Error:', error); + return Promise.reject(error); + } +); + +// Response interceptor +eventApiClient.interceptors.response.use( + (response) => { + console.log('✅ Event API Response:', { + status: response.status, + url: response.config.url, + data: response.data, + }); + return response; + }, + (error) => { + console.error('❌ Event API Error:', { + message: error.message, + status: error.response?.status, + url: error.config?.url, + data: error.response?.data, + }); + return Promise.reject(error); + } +); + +// Types +export interface EventObjectiveRequest { + objective: string; // "신규 고객 유치", "재방문 유도", "매출 증대", "브랜드 인지도 향상" +} + +export interface EventCreatedResponse { + eventId: string; + status: 'DRAFT' | 'PUBLISHED' | 'ENDED'; + objective: string; + createdAt: string; +} + +export interface AiRecommendationRequest { + storeInfo: { + storeId: string; + storeName: string; + category: string; + description?: string; + }; +} + +export interface JobAcceptedResponse { + jobId: string; + status: 'PENDING'; + message: string; +} + +export interface EventJobStatusResponse { + jobId: string; + jobType: 'AI_RECOMMENDATION' | 'IMAGE_GENERATION'; + status: 'PENDING' | 'PROCESSING' | 'COMPLETED' | 'FAILED'; + progress: number; + resultKey?: string; + errorMessage?: string; + createdAt: string; + completedAt?: string; +} + +export interface SelectRecommendationRequest { + recommendationId: string; + customizations?: { + eventName?: string; + description?: string; + startDate?: string; + endDate?: string; + discountRate?: number; + }; +} + +export interface ImageGenerationRequest { + eventInfo: { + eventName: string; + description: string; + promotionType: string; + }; + imageCount?: number; +} + +export interface SelectChannelsRequest { + channels: ('WEBSITE' | 'KAKAO' | 'INSTAGRAM' | 'FACEBOOK' | 'NAVER_BLOG')[]; +} + +export interface ChannelDistributionResult { + channel: string; + success: boolean; + url?: string; + message: string; +} + +export interface EventPublishedResponse { + eventId: string; + status: 'PUBLISHED'; + publishedAt: string; + channels: string[]; + distributionResults: ChannelDistributionResult[]; +} + +export interface EventSummary { + eventId: string; + eventName: string; + objective: string; + status: 'DRAFT' | 'PUBLISHED' | 'ENDED'; + startDate: string; + endDate: string; + thumbnailUrl?: string; + createdAt: string; +} + +export interface PageInfo { + page: number; + size: number; + totalElements: number; + totalPages: number; +} + +export interface EventListResponse { + content: EventSummary[]; + page: PageInfo; +} + +export interface GeneratedImage { + imageId: string; + imageUrl: string; + isSelected: boolean; + createdAt: string; +} + +export interface AiRecommendation { + recommendationId: string; + eventName: string; + description: string; + promotionType: string; + targetAudience: string; + isSelected: boolean; +} + +export interface EventDetailResponse { + eventId: string; + userId: string; + storeId: string; + eventName: string; + objective: string; + description: string; + targetAudience: string; + promotionType: string; + discountRate?: number; + startDate: string; + endDate: string; + status: 'DRAFT' | 'PUBLISHED' | 'ENDED'; + selectedImageId?: string; + selectedImageUrl?: string; + generatedImages?: GeneratedImage[]; + channels?: string[]; + aiRecommendations?: AiRecommendation[]; + createdAt: string; + updatedAt: string; +} + +export interface UpdateEventRequest { + eventName?: string; + description?: string; + startDate?: string; + endDate?: string; + discountRate?: number; +} + +export interface EndEventRequest { + reason: string; +} + +// API Functions +export const eventApi = { + // Step 1: 목적 선택 및 이벤트 생성 + selectObjective: async (objective: string): Promise => { + const response = await eventApiClient.post('/events/objectives', { + objective, + }); + return response.data; + }, + + // Step 2: AI 추천 요청 + requestAiRecommendations: async ( + eventId: string, + storeInfo: AiRecommendationRequest['storeInfo'] + ): Promise => { + const response = await eventApiClient.post( + `/events/${eventId}/ai-recommendations`, + { storeInfo } + ); + return response.data; + }, + + // Job 상태 폴링 + getJobStatus: async (jobId: string): Promise => { + const response = await eventApiClient.get(`/jobs/${jobId}`); + return response.data; + }, + + // AI 추천 선택 + selectRecommendation: async ( + eventId: string, + request: SelectRecommendationRequest + ): Promise => { + const response = await eventApiClient.put( + `/events/${eventId}/recommendations`, + request + ); + return response.data; + }, + + // Step 3: 이미지 생성 요청 + requestImageGeneration: async ( + eventId: string, + request: ImageGenerationRequest + ): Promise => { + const response = await eventApiClient.post(`/events/${eventId}/images`, request); + return response.data; + }, + + // 이미지 선택 + selectImage: async (eventId: string, imageId: string): Promise => { + const response = await eventApiClient.put( + `/events/${eventId}/images/${imageId}/select` + ); + return response.data; + }, + + // Step 4: 이미지 편집 + editImage: async ( + eventId: string, + imageId: string, + editRequest: any + ): Promise<{ imageId: string; imageUrl: string; editedAt: string }> => { + const response = await eventApiClient.put(`/events/${eventId}/images/${imageId}/edit`, editRequest); + return response.data; + }, + + // Step 5: 배포 채널 선택 + selectChannels: async (eventId: string, channels: string[]): Promise => { + const response = await eventApiClient.put(`/events/${eventId}/channels`, { + channels, + }); + return response.data; + }, + + // Step 6: 최종 배포 + publishEvent: async (eventId: string): Promise => { + const response = await eventApiClient.post(`/events/${eventId}/publish`); + return response.data; + }, + + // 이벤트 목록 조회 + getEvents: async (params?: { + status?: 'DRAFT' | 'PUBLISHED' | 'ENDED'; + objective?: string; + search?: string; + page?: number; + size?: number; + sort?: string; + order?: 'asc' | 'desc'; + }): Promise => { + const response = await eventApiClient.get('/events', { params }); + return response.data; + }, + + // 이벤트 상세 조회 + getEventDetail: async (eventId: string): Promise => { + const response = await eventApiClient.get(`/events/${eventId}`); + return response.data; + }, + + // 이벤트 수정 + updateEvent: async (eventId: string, request: UpdateEventRequest): Promise => { + const response = await eventApiClient.put(`/events/${eventId}`, request); + return response.data; + }, + + // 이벤트 삭제 + deleteEvent: async (eventId: string): Promise => { + await eventApiClient.delete(`/events/${eventId}`); + }, + + // 이벤트 조기 종료 + endEvent: async (eventId: string, reason: string): Promise => { + const response = await eventApiClient.post(`/events/${eventId}/end`, { + reason, + }); + return response.data; + }, +}; + +export default eventApi; diff --git a/src/shared/api/index.ts b/src/shared/api/index.ts index 51e397f..2afee3f 100644 --- a/src/shared/api/index.ts +++ b/src/shared/api/index.ts @@ -1,2 +1,6 @@ -export { apiClient } from './client'; +export { apiClient, participationClient } from './client'; export type { ApiError } from './types'; +export * from './contentApi'; +export * from './aiApi'; +export * from './eventApi'; +export * from './participation.api'; From a62aa9bae81b2845fcb737961cec9ba909de14be Mon Sep 17 00:00:00 2001 From: merrycoral Date: Wed, 29 Oct 2025 15:03:37 +0900 Subject: [PATCH 4/8] =?UTF-8?q?=EC=9D=B4=EB=B2=A4=ED=8A=B8=20API=20?= =?UTF-8?q?=EB=B0=8F=20=ED=83=80=EC=9E=85=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../events/create/steps/ApprovalStep.tsx | 93 ++++++++++++++++++- src/entities/event/api/eventApi.ts | 49 ++++++++++ src/entities/event/model/types.ts | 25 +++++ 3 files changed, 162 insertions(+), 5 deletions(-) diff --git a/src/app/(main)/events/create/steps/ApprovalStep.tsx b/src/app/(main)/events/create/steps/ApprovalStep.tsx index 465029e..5f5b536 100644 --- a/src/app/(main)/events/create/steps/ApprovalStep.tsx +++ b/src/app/(main)/events/create/steps/ApprovalStep.tsx @@ -20,6 +20,7 @@ import { import { ArrowBack, CheckCircle, Edit, RocketLaunch, Save, People, AttachMoney, TrendingUp } from '@mui/icons-material'; import { EventData } from '../page'; import { cardStyles, colors, responsiveText } from '@/shared/lib/button-styles'; +import { eventApi } from '@/entities/event/api/eventApi'; interface ApprovalStepProps { eventData: EventData; @@ -33,16 +34,98 @@ export default function ApprovalStep({ eventData, onApprove, onBack }: ApprovalS const [successDialogOpen, setSuccessDialogOpen] = useState(false); const [isDeploying, setIsDeploying] = useState(false); - const handleApprove = () => { + const handleApprove = async () => { if (!agreeTerms) return; setIsDeploying(true); - // 배포 시뮬레이션 - setTimeout(() => { + try { + // 1. 이벤트 생성 API 호출 + console.log('📞 Creating event with objective:', eventData.objective); + + // objective 매핑 (Frontend → Backend) + const objectiveMap: Record = { + 'new_customer': 'CUSTOMER_ACQUISITION', + 'revisit': 'Customer Retention', + 'sales': 'Sales Promotion', + 'awareness': 'awareness', + }; + + const backendObjective = objectiveMap[eventData.objective || 'new_customer'] || 'CUSTOMER_ACQUISITION'; + + const createResponse = await eventApi.createEvent({ + objective: backendObjective, + }); + + console.log('✅ Event created:', createResponse); + + if (createResponse.success && createResponse.data) { + const eventId = createResponse.data.eventId; + console.log('🎯 Event ID:', eventId); + + // 2. 이벤트 상세 정보 업데이트 + console.log('📞 Updating event details:', eventId); + + // 이벤트명 가져오기 (contentEdit.title 또는 recommendation.title) + const eventName = eventData.contentEdit?.title || eventData.recommendation?.title || '이벤트'; + + // 날짜 설정 (오늘부터 30일간) + const today = new Date(); + const endDate = new Date(today); + endDate.setDate(endDate.getDate() + 30); + + const startDateStr = today.toISOString().split('T')[0]; // YYYY-MM-DD + const endDateStr = endDate.toISOString().split('T')[0]; + + await eventApi.updateEvent(eventId, { + eventName: eventName, + description: eventData.contentEdit?.guide || eventData.recommendation?.participationMethod, + startDate: startDateStr, + endDate: endDateStr, + }); + console.log('✅ Event details updated'); + + // 3. 배포 채널 선택 + if (eventData.channels && eventData.channels.length > 0) { + console.log('📞 Selecting channels:', eventData.channels); + + // 채널명 매핑 (Frontend → Backend) + const channelMap: Record = { + 'uriTV': 'WEBSITE', + 'ringoBiz': 'EMAIL', + 'genieTV': 'KAKAO', + 'sns': 'INSTAGRAM', + }; + + const backendChannels = eventData.channels.map(ch => channelMap[ch] || ch.toUpperCase()); + + await eventApi.selectChannels(eventId, { + channels: backendChannels, + }); + console.log('✅ Channels selected'); + } + + // 4. TODO: 이미지 선택 + // 현재 frontend에서 selectedImageId를 추적하지 않음 + // 향후 contentPreview 단계에서 선택된 이미지 ID를 eventData에 저장 필요 + console.log('⚠️ Image selection skipped - imageId not tracked in frontend'); + + // 5. 이벤트 배포 API 호출 + console.log('📞 Publishing event:', eventId); + const publishResponse = await eventApi.publishEvent(eventId); + console.log('✅ Event published:', publishResponse); + + // 성공 다이얼로그 표시 + setIsDeploying(false); + setSuccessDialogOpen(true); + } else { + throw new Error('Event creation failed: No event ID returned'); + } + } catch (error) { + console.error('❌ Event deployment failed:', error); setIsDeploying(false); - setSuccessDialogOpen(true); - }, 2000); + alert('이벤트 배포에 실패했습니다. 다시 시도해 주세요.'); + } }; const handleSaveDraft = () => { diff --git a/src/entities/event/api/eventApi.ts b/src/entities/event/api/eventApi.ts index 929eefe..6820d7a 100644 --- a/src/entities/event/api/eventApi.ts +++ b/src/entities/event/api/eventApi.ts @@ -10,6 +10,9 @@ import type { JobAcceptedResponse, ImageGenerationRequest, ImageGenerationResponse, + UpdateEventRequest, + SelectChannelsRequest, + SelectImageRequest, } from '../model/types'; /** @@ -193,6 +196,52 @@ export const eventApi = { ); return response.data; }, + + /** + * 이벤트 수정 + */ + updateEvent: async ( + eventId: string, + data: UpdateEventRequest + ): Promise> => { + console.log('📞 eventApi.updateEvent 호출', eventId, data); + const response = await eventApiClient.put>( + `${EVENT_API_BASE}/${eventId}`, + data + ); + return response.data; + }, + + /** + * 배포 채널 선택 + */ + selectChannels: async ( + eventId: string, + data: SelectChannelsRequest + ): Promise> => { + console.log('📞 eventApi.selectChannels 호출', eventId, data); + const response = await eventApiClient.put>( + `${EVENT_API_BASE}/${eventId}/channels`, + data + ); + return response.data; + }, + + /** + * 이미지 선택 + */ + selectImage: async ( + eventId: string, + imageId: string, + data: SelectImageRequest + ): Promise> => { + console.log('📞 eventApi.selectImage 호출', eventId, imageId, data); + const response = await eventApiClient.put>( + `${EVENT_API_BASE}/${eventId}/images/${imageId}/select`, + data + ); + return response.data; + }, }; export default eventApi; diff --git a/src/entities/event/model/types.ts b/src/entities/event/model/types.ts index b3de860..c9d9b37 100644 --- a/src/entities/event/model/types.ts +++ b/src/entities/event/model/types.ts @@ -171,3 +171,28 @@ export interface ImageGenerationResponse { eventId: string; status: string; } + +/** + * 이벤트 수정 요청 + */ +export interface UpdateEventRequest { + eventName?: string; + description?: string; + startDate?: string; + endDate?: string; + discountRate?: number; +} + +/** + * 배포 채널 선택 요청 + */ +export interface SelectChannelsRequest { + channels: string[]; +} + +/** + * 이미지 선택 요청 + */ +export interface SelectImageRequest { + selectedImageId: string; +} From f414e1e1dd2f13659e517af77b90c9071b4c14a4 Mon Sep 17 00:00:00 2001 From: merrycoral Date: Wed, 29 Oct 2025 15:13:01 +0900 Subject: [PATCH 5/8] =?UTF-8?q?=ED=83=80=EC=9E=85=20=EC=97=90=EB=9F=AC=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20=EB=B0=8F=20=EB=B9=8C=EB=93=9C=20=EC=98=A4?= =?UTF-8?q?=EB=A5=98=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - EventObjective 타입 명시적으로 지정 - recommendation 중첩 구조에 맞게 속성 접근 수정 - 빌드 성공 확인 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/app/(main)/events/create/steps/ApprovalStep.tsx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/app/(main)/events/create/steps/ApprovalStep.tsx b/src/app/(main)/events/create/steps/ApprovalStep.tsx index ac21541..b14e5fa 100644 --- a/src/app/(main)/events/create/steps/ApprovalStep.tsx +++ b/src/app/(main)/events/create/steps/ApprovalStep.tsx @@ -21,6 +21,7 @@ import { ArrowBack, CheckCircle, Edit, RocketLaunch, Save, People, AttachMoney, import { EventData } from '../page'; import { cardStyles, colors, responsiveText } from '@/shared/lib/button-styles'; import { eventApi } from '@/entities/event/api/eventApi'; +import type { EventObjective } from '@/entities/event/model/types'; interface ApprovalStepProps { eventData: EventData; @@ -44,14 +45,14 @@ export default function ApprovalStep({ eventData, onApprove, onBack }: ApprovalS console.log('📞 Creating event with objective:', eventData.objective); // objective 매핑 (Frontend → Backend) - const objectiveMap: Record = { + const objectiveMap: Record = { 'new_customer': 'CUSTOMER_ACQUISITION', 'revisit': 'Customer Retention', 'sales': 'Sales Promotion', 'awareness': 'awareness', }; - const backendObjective = objectiveMap[eventData.objective || 'new_customer'] || 'CUSTOMER_ACQUISITION'; + const backendObjective: EventObjective = (objectiveMap[eventData.objective || 'new_customer'] || 'CUSTOMER_ACQUISITION') as EventObjective; const createResponse = await eventApi.createEvent({ objective: backendObjective, @@ -67,7 +68,7 @@ export default function ApprovalStep({ eventData, onApprove, onBack }: ApprovalS console.log('📞 Updating event details:', eventId); // 이벤트명 가져오기 (contentEdit.title 또는 recommendation.title) - const eventName = eventData.contentEdit?.title || eventData.recommendation?.title || '이벤트'; + const eventName = eventData.contentEdit?.title || eventData.recommendation?.recommendation?.title || '이벤트'; // 날짜 설정 (오늘부터 30일간) const today = new Date(); @@ -79,7 +80,7 @@ export default function ApprovalStep({ eventData, onApprove, onBack }: ApprovalS await eventApi.updateEvent(eventId, { eventName: eventName, - description: eventData.contentEdit?.guide || eventData.recommendation?.participationMethod, + description: eventData.contentEdit?.guide || eventData.recommendation?.recommendation?.description || '', startDate: startDateStr, endDate: endDateStr, }); From ddc7bc143f4b5102e8299df457d625fcfb069e95 Mon Sep 17 00:00:00 2001 From: merrycoral Date: Wed, 29 Oct 2025 17:49:50 +0900 Subject: [PATCH 6/8] =?UTF-8?q?CORS=20=EC=97=90=EB=9F=AC=20=ED=95=B4?= =?UTF-8?q?=EA=B2=B0=20=EB=B0=8F=20Event=20API=20Mock=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - next.config.js: Event API 프록시 설정 추가 (8080 포트) - eventApi.ts: 개발 환경에서 상대 경로 사용하도록 수정 - Mock API 추가: /api/v1/events/objectives (백엔드 준비 전 임시) --- next.config.js | 5 ++ src/app/api/v1/events/objectives/route.ts | 66 +++++++++++++++++++++++ src/entities/event/api/eventApi.ts | 8 ++- 3 files changed, 78 insertions(+), 1 deletion(-) create mode 100644 src/app/api/v1/events/objectives/route.ts diff --git a/next.config.js b/next.config.js index aabb89d..2fb242c 100644 --- a/next.config.js +++ b/next.config.js @@ -19,6 +19,11 @@ const nextConfig = { source: '/api/proxy/:path*', destination: 'http://localhost:8084/api/:path*', }, + // Event Service API Proxy (8080 포트) + { + source: '/api/v1/events/:path*', + destination: 'http://localhost:8080/api/v1/events/:path*', + }, ] }, } diff --git a/src/app/api/v1/events/objectives/route.ts b/src/app/api/v1/events/objectives/route.ts new file mode 100644 index 0000000..369d57f --- /dev/null +++ b/src/app/api/v1/events/objectives/route.ts @@ -0,0 +1,66 @@ +import { NextRequest, NextResponse } from 'next/server'; + +/** + * Mock API: 이벤트 목적 선택 (Step 1) + * 백엔드 API가 준비될 때까지 사용하는 임시 Mock API + * + * POST /api/v1/events/objectives + */ +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { objective } = body; + + // 백엔드 API 호출 시도 + const backendUrl = 'http://localhost:8080/api/v1/events/objectives'; + + try { + const backendResponse = await fetch(backendUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': request.headers.get('Authorization') || '', + }, + body: JSON.stringify(body), + }); + + // 백엔드가 정상 응답하면 그대로 반환 + if (backendResponse.ok) { + const data = await backendResponse.json(); + return NextResponse.json(data, { status: backendResponse.status }); + } + } catch (backendError) { + console.warn('⚠️ 백엔드 API 호출 실패, Mock 데이터 반환:', backendError); + } + + // 백엔드 실패 시 Mock 데이터 반환 + const mockEventId = `evt_${Date.now()}_${Math.random().toString(36).substring(7)}`; + + const mockResponse = { + success: true, + data: { + eventId: mockEventId, + objective, + status: 'DRAFT', + createdAt: new Date().toISOString(), + }, + message: '이벤트가 생성되었습니다 (Mock)', + }; + + console.log('🎭 Mock API Response:', mockResponse); + + return NextResponse.json(mockResponse, { status: 201 }); + } catch (error) { + console.error('❌ Mock API Error:', error); + + return NextResponse.json( + { + success: false, + errorCode: 'MOCK_ERROR', + message: 'Mock API 오류가 발생했습니다', + details: error instanceof Error ? error.message : 'Unknown error', + }, + { status: 500 } + ); + } +} diff --git a/src/entities/event/api/eventApi.ts b/src/entities/event/api/eventApi.ts index 6820d7a..a4e9277 100644 --- a/src/entities/event/api/eventApi.ts +++ b/src/entities/event/api/eventApi.ts @@ -29,11 +29,17 @@ const EVENT_HOST = process.env.NEXT_PUBLIC_EVENT_HOST || 'http://localhost:8080' /** * Event Service용 API 클라이언트 * Event Service는 별도 포트(8080)에서 실행되므로 별도 클라이언트 생성 + * + * 로컬 개발 환경: Next.js rewrites 프록시 사용 (CORS 회피) + * 프로덕션 환경: 환경 변수에서 직접 호스트 사용 */ import axios from 'axios'; +const isProduction = process.env.NODE_ENV === 'production'; +const API_BASE_URL = isProduction ? EVENT_HOST : ''; // 개발 환경에서는 상대 경로 사용 + const eventApiClient = axios.create({ - baseURL: EVENT_HOST, + baseURL: API_BASE_URL, timeout: 30000, headers: { 'Content-Type': 'application/json', From 4e4d9dd313c78629c390d5a820376898fd1185b3 Mon Sep 17 00:00:00 2001 From: merrycoral Date: Thu, 30 Oct 2025 01:57:38 +0900 Subject: [PATCH 7/8] =?UTF-8?q?=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20API=20=ED=98=B8=EC=B6=9C=20=EC=98=A4=EB=A5=98=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 - RecommendationStep: selectObjective 메서드 사용으로 수정 - Mock API: 응답 형식을 shared/api/eventApi에 맞춤 - 빌드 오류 해결 및 정상 동작 확인 --- .../events/create/steps/RecommendationStep.tsx | 2 +- src/app/api/v1/events/objectives/route.ts | 15 ++++++--------- 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/src/app/(main)/events/create/steps/RecommendationStep.tsx b/src/app/(main)/events/create/steps/RecommendationStep.tsx index e3472b8..2635a38 100644 --- a/src/app/(main)/events/create/steps/RecommendationStep.tsx +++ b/src/app/(main)/events/create/steps/RecommendationStep.tsx @@ -91,7 +91,7 @@ export default function RecommendationStep({ await requestAIRecommendations(newEventId); } catch (err: any) { console.error('이벤트 생성 실패:', err); - setError(err.response?.data?.message || '이벤트 생성에 실패했습니다'); + setError(err.response?.data?.message || err.message || '이벤트 생성에 실패했습니다'); setLoading(false); } }; diff --git a/src/app/api/v1/events/objectives/route.ts b/src/app/api/v1/events/objectives/route.ts index 369d57f..12b40ed 100644 --- a/src/app/api/v1/events/objectives/route.ts +++ b/src/app/api/v1/events/objectives/route.ts @@ -12,7 +12,7 @@ export async function POST(request: NextRequest) { const { objective } = body; // 백엔드 API 호출 시도 - const backendUrl = 'http://localhost:8080/api/v1/events/objectives'; + const backendUrl = 'http://localhost:8080/api/events/objectives'; try { const backendResponse = await fetch(backendUrl, { @@ -34,17 +34,14 @@ export async function POST(request: NextRequest) { } // 백엔드 실패 시 Mock 데이터 반환 + // shared/api/eventApi의 selectObjective가 반환하는 형식과 일치 const mockEventId = `evt_${Date.now()}_${Math.random().toString(36).substring(7)}`; const mockResponse = { - success: true, - data: { - eventId: mockEventId, - objective, - status: 'DRAFT', - createdAt: new Date().toISOString(), - }, - message: '이벤트가 생성되었습니다 (Mock)', + eventId: mockEventId, + objective: objective, + status: 'DRAFT' as const, + createdAt: new Date().toISOString(), }; console.log('🎭 Mock API Response:', mockResponse); From 1a3f76031b3ea4f3b4f24abec6d4dd7bd6b61c19 Mon Sep 17 00:00:00 2001 From: merrycoral Date: Thu, 30 Oct 2025 02:09:39 +0900 Subject: [PATCH 8/8] =?UTF-8?q?Event=20API=20baseURL=20=EC=88=98=EC=A0=95?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EB=84=A4=ED=8A=B8=EC=9B=8C=ED=81=AC=20?= =?UTF-8?q?=EC=97=90=EB=9F=AC=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - shared/api/eventApi.ts: 개발 환경에서 상대 경로 사용 - 개발: /api/v1 (프록시 또는 Mock API 사용) - 프로덕션: {EVENT_HOST}/api/v1 - CORS 에러 및 네트워크 에러 해결 --- src/shared/api/eventApi.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/shared/api/eventApi.ts b/src/shared/api/eventApi.ts index 4e9465f..d7aa1bb 100644 --- a/src/shared/api/eventApi.ts +++ b/src/shared/api/eventApi.ts @@ -2,10 +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'; + +// 개발 환경에서는 상대 경로 사용 (Next.js rewrites 프록시 또는 Mock API 사용) +// 프로덕션 환경에서는 환경 변수의 호스트 사용 +const isProduction = process.env.NODE_ENV === 'production'; +const BASE_URL = isProduction ? `${EVENT_API_BASE_URL}/api/${API_VERSION}` : `/api/${API_VERSION}`; export const eventApiClient: AxiosInstance = axios.create({ - baseURL: `${EVENT_API_BASE_URL}/${API_VERSION}`, + baseURL: BASE_URL, timeout: 30000, // Job 폴링 고려 headers: { 'Content-Type': 'application/json',