diff --git a/.run/EventServiceApplication.run.xml b/.run/EventServiceApplication.run.xml index 38d1691..f70ad24 100644 --- a/.run/EventServiceApplication.run.xml +++ b/.run/EventServiceApplication.run.xml @@ -8,14 +8,16 @@ - + - + + + diff --git a/develop/dev/test-backend.md b/develop/dev/test-backend.md index dfa2680..7e94051 100644 --- a/develop/dev/test-backend.md +++ b/develop/dev/test-backend.md @@ -1,389 +1,343 @@ -# Content Service 백엔드 테스트 결과서 +# Event Service 백엔드 API 테스트 결과 -## 1. 테스트 개요 +## 테스트 개요 -### 1.1 테스트 정보 -- **테스트 일시**: 2025-10-23 -- **테스트 환경**: Local 개발 환경 -- **서비스명**: Content Service -- **서비스 포트**: 8084 -- **프로파일**: local (H2 in-memory database) -- **테스트 대상**: REST API 7개 엔드포인트 +**테스트 일시**: 2025-10-27 +**서비스**: Event Service +**베이스 URL**: http://localhost:8081 +**인증 방식**: JWT Bearer Token -### 1.2 테스트 목적 -- Content Service의 모든 REST API 엔드포인트 정상 동작 검증 -- Mock 서비스 (MockGenerateImagesService, MockRedisGateway) 정상 동작 확인 -- Local 환경에서 외부 인프라 의존성 없이 독립 실행 가능 여부 검증 +## 테스트 환경 설정 -## 2. 테스트 환경 구성 +### 1. 환경 변수 검증 +- ✅ application.yml 환경 변수 설정 확인 완료 +- ✅ EventServiceApplication.run.xml 실행 프로파일 확인 완료 +- ✅ PostgreSQL 연결: 20.249.177.232:5432 (UP) +- ⚠️ Redis 연결: localhost:6379 (DOWN - 비필수) +- ✅ Kafka: 20.249.182.13:9095, 4.217.131.59:9095 -### 2.1 데이터베이스 -- **DB 타입**: H2 In-Memory Database -- **연결 URL**: jdbc:h2:mem:contentdb -- **스키마 생성**: 자동 (ddl-auto: create-drop) -- **생성된 테이블**: - - contents (콘텐츠 정보) - - generated_images (생성된 이미지 정보) - - jobs (작업 상태 추적) - -### 2.2 Mock 서비스 -- **MockRedisGateway**: Redis 캐시 기능 Mock 구현 -- **MockGenerateImagesService**: AI 이미지 생성 비동기 처리 Mock 구현 - - 1초 지연 후 4개 이미지 자동 생성 (FANCY/SIMPLE x INSTAGRAM/KAKAO) - -### 2.3 서버 시작 로그 -``` -Started ContentApplication in 2.856 seconds (process running for 3.212) -Hibernate: create table contents (...) -Hibernate: create table generated_images (...) -Hibernate: create table jobs (...) +### 2. JWT 테스트 토큰 생성 +```bash +python generate-test-token.py > test-token.txt ``` -## 3. API 테스트 결과 +**생성된 토큰 정보**: +- User ID: 6db043d0-b303-4577-b9dd-6d366cc59fa0 +- Store ID: 34000028-01fd-4ed1-975c-35f7c88b6547 +- Email: test@example.com +- Name: Test User +- Roles: ROLE_USER +- 유효기간: 365일 -### 3.1 POST /content/images/generate - 이미지 생성 요청 +--- -**목적**: AI 이미지 생성 작업 시작 +## API 테스트 결과 + +### 1. 이벤트 생성 API + +**엔드포인트**: \`POST /api/v1/events/objectives\` **요청**: ```bash -curl -X POST http://localhost:8084/content/images/generate \ +curl -X POST "http://localhost:8081/api/v1/events/objectives" \ + -H "Authorization: Bearer eyJhbGci..." \ + -H "Content-Type: application/json" \ + -d '{"objective": "increase sales"}' +``` + +**응답**: +```json +{ + "success": true, + "data": { + "eventId": "ff316629-cea7-4550-9862-4b3ea01bba05", + "status": "DRAFT", + "objective": "increase sales", + "createdAt": "2025-10-27T16:17:18.6858969" + }, + "timestamp": "2025-10-27T16:17:21.6747215" +} +``` + +**결과**: ✅ **성공** +**생성된 이벤트 ID**: ff316629-cea7-4550-9862-4b3ea01bba05 + +--- + +### 2. 이벤트 상세 조회 API + +**엔드포인트**: \`GET /api/v1/events/{eventId}\` + +**요청**: +```bash +curl -X GET "http://localhost:8081/api/v1/events/ff316629-cea7-4550-9862-4b3ea01bba05" \ + -H "Authorization: Bearer eyJhbGci..." +``` + +**응답**: +```json +{ + "success": true, + "data": { + "eventId": "ff316629-cea7-4550-9862-4b3ea01bba05", + "userId": "6db043d0-b303-4577-b9dd-6d366cc59fa0", + "storeId": "34000028-01fd-4ed1-975c-35f7c88b6547", + "eventName": "", + "description": null, + "objective": "increase sales", + "startDate": null, + "endDate": null, + "status": "DRAFT", + "selectedImageId": null, + "selectedImageUrl": null, + "generatedImages": [], + "aiRecommendations": [], + "channels": [], + "createdAt": "2025-10-27T16:17:18.685897", + "updatedAt": "2025-10-27T16:17:18.685897" + }, + "timestamp": "2025-10-27T16:19:34.3091871" +} +``` + +**결과**: ✅ **성공** + +--- + +### 3. 이벤트 목록 조회 API + +**엔드포인트**: \`GET /api/v1/events\` + +**요청**: +```bash +curl -X GET "http://localhost:8081/api/v1/events" \ + -H "Authorization: Bearer eyJhbGci..." +``` + +**응답**: +```json +{ + "success": true, + "data": { + "content": [ + { + "eventId": "ff316629-cea7-4550-9862-4b3ea01bba05", + "userId": "6db043d0-b303-4577-b9dd-6d366cc59fa0", + "storeId": "34000028-01fd-4ed1-975c-35f7c88b6547", + "eventName": "", + "description": null, + "objective": "increase sales", + "startDate": null, + "endDate": null, + "status": "DRAFT", + "selectedImageId": null, + "selectedImageUrl": null, + "generatedImages": [], + "aiRecommendations": [], + "channels": [], + "createdAt": "2025-10-27T16:17:18.685897", + "updatedAt": "2025-10-27T16:17:18.685897" + } + ], + "page": 0, + "size": 20, + "totalElements": 1, + "totalPages": 1, + "first": true, + "last": true + }, + "timestamp": "2025-10-27T16:20:12.1873239" +} +``` + +**결과**: ✅ **성공** +**페이지네이션**: 정상 동작 (page: 0, size: 20, totalElements: 1) + +--- + +### 4. AI 추천 요청 API + +**엔드포인트**: \`POST /api/v1/events/{eventId}/ai-recommendations\` + +**요청**: +```bash +curl -X POST "http://localhost:8081/api/v1/events/ff316629-cea7-4550-9862-4b3ea01bba05/ai-recommendations" \ + -H "Authorization: Bearer eyJhbGci..." \ -H "Content-Type: application/json" \ -d '{ - "eventDraftId": 1, - "styles": ["FANCY", "SIMPLE"], - "platforms": ["INSTAGRAM", "KAKAO"] + "storeInfo": { + "storeId": "34000028-01fd-4ed1-975c-35f7c88b6547", + "storeName": "Test BBQ Restaurant", + "category": "Restaurant", + "description": "Korean BBQ restaurant" + } }' ``` **응답**: -- **HTTP 상태**: 202 Accepted -- **응답 본문**: ```json { - "id": "job-mock-7ada8bd3", - "eventDraftId": 1, - "jobType": "image-generation", - "status": "PENDING", - "progress": 0, - "resultMessage": null, - "errorMessage": null, - "createdAt": "2025-10-23T21:52:57.511438", - "updatedAt": "2025-10-23T21:52:57.511438" -} -``` - -**검증 결과**: ✅ PASS -- Job이 정상적으로 생성되어 PENDING 상태로 반환됨 -- 비동기 처리를 위한 Job ID 발급 확인 - ---- - -### 3.2 GET /content/images/jobs/{jobId} - 작업 상태 조회 - -**목적**: 이미지 생성 작업의 진행 상태 확인 - -**요청**: -```bash -curl http://localhost:8084/content/images/jobs/job-mock-7ada8bd3 -``` - -**응답** (1초 후): -- **HTTP 상태**: 200 OK -- **응답 본문**: -```json -{ - "id": "job-mock-7ada8bd3", - "eventDraftId": 1, - "jobType": "image-generation", - "status": "COMPLETED", - "progress": 100, - "resultMessage": "4개의 이미지가 성공적으로 생성되었습니다.", - "errorMessage": null, - "createdAt": "2025-10-23T21:52:57.511438", - "updatedAt": "2025-10-23T21:52:58.571923" -} -``` - -**검증 결과**: ✅ PASS -- Job 상태가 PENDING → COMPLETED로 정상 전환 -- progress가 0 → 100으로 업데이트 -- resultMessage에 생성 결과 포함 - ---- - -### 3.3 GET /content/events/{eventDraftId} - 이벤트 콘텐츠 조회 - -**목적**: 특정 이벤트의 전체 콘텐츠 정보 조회 (이미지 포함) - -**요청**: -```bash -curl http://localhost:8084/content/events/1 -``` - -**응답**: -- **HTTP 상태**: 200 OK -- **응답 본문**: -```json -{ - "eventDraftId": 1, - "eventTitle": "Mock 이벤트 제목 1", - "eventDescription": "Mock 이벤트 설명입니다. 테스트를 위한 Mock 데이터입니다.", - "images": [ - { - "id": 1, - "style": "FANCY", - "platform": "INSTAGRAM", - "cdnUrl": "https://mock-cdn.azure.com/images/1/fancy_instagram_7ada8bd3.png", - "prompt": "Mock prompt for FANCY style on INSTAGRAM platform", - "selected": true - }, - { - "id": 2, - "style": "FANCY", - "platform": "KAKAO", - "cdnUrl": "https://mock-cdn.azure.com/images/1/fancy_kakao_3e2eaacf.png", - "prompt": "Mock prompt for FANCY style on KAKAO platform", - "selected": false - }, - { - "id": 3, - "style": "SIMPLE", - "platform": "INSTAGRAM", - "cdnUrl": "https://mock-cdn.azure.com/images/1/simple_instagram_56d91422.png", - "prompt": "Mock prompt for SIMPLE style on INSTAGRAM platform", - "selected": false - }, - { - "id": 4, - "style": "SIMPLE", - "platform": "KAKAO", - "cdnUrl": "https://mock-cdn.azure.com/images/1/simple_kakao_7c9a666a.png", - "prompt": "Mock prompt for SIMPLE style on KAKAO platform", - "selected": false - } - ], - "createdAt": "2025-10-23T21:52:57.52133", - "updatedAt": "2025-10-23T21:52:57.52133" -} -``` - -**검증 결과**: ✅ PASS -- 콘텐츠 정보와 생성된 이미지 목록이 모두 조회됨 -- 4개 이미지 (FANCY/SIMPLE x INSTAGRAM/KAKAO) 생성 확인 -- 첫 번째 이미지(FANCY+INSTAGRAM)가 selected:true로 설정됨 - ---- - -### 3.4 GET /content/events/{eventDraftId}/images - 이미지 목록 조회 - -**목적**: 특정 이벤트의 이미지 목록만 조회 - -**요청**: -```bash -curl http://localhost:8084/content/events/1/images -``` - -**응답**: -- **HTTP 상태**: 200 OK -- **응답 본문**: 4개의 이미지 객체 배열 -```json -[ - { - "id": 1, - "eventDraftId": 1, - "style": "FANCY", - "platform": "INSTAGRAM", - "cdnUrl": "https://mock-cdn.azure.com/images/1/fancy_instagram_7ada8bd3.png", - "prompt": "Mock prompt for FANCY style on INSTAGRAM platform", - "selected": true, - "createdAt": "2025-10-23T21:52:57.524759", - "updatedAt": "2025-10-23T21:52:57.524759" + "success": true, + "data": { + "jobId": "e5c190a6-dd4c-4a81-9f97-46c7e9ff86d0", + "status": "PENDING", + "message": "AI 추천 생성 요청이 접수되었습니다. /jobs/e5c190a6-dd4c-4a81-9f97-46c7e9ff86d0로 상태를 확인하세요." }, - // ... 나머지 3개 이미지 -] -``` - -**검증 결과**: ✅ PASS -- 이벤트에 속한 모든 이미지가 정상 조회됨 -- createdAt, updatedAt 타임스탬프 포함 - ---- - -### 3.5 GET /content/images/{imageId} - 개별 이미지 상세 조회 - -**목적**: 특정 이미지의 상세 정보 조회 - -**요청**: -```bash -curl http://localhost:8084/content/images/1 -``` - -**응답**: -- **HTTP 상태**: 200 OK -- **응답 본문**: -```json -{ - "id": 1, - "eventDraftId": 1, - "style": "FANCY", - "platform": "INSTAGRAM", - "cdnUrl": "https://mock-cdn.azure.com/images/1/fancy_instagram_7ada8bd3.png", - "prompt": "Mock prompt for FANCY style on INSTAGRAM platform", - "selected": true, - "createdAt": "2025-10-23T21:52:57.524759", - "updatedAt": "2025-10-23T21:52:57.524759" + "timestamp": "2025-10-27T16:21:08.7206397" } ``` -**검증 결과**: ✅ PASS -- 개별 이미지 정보가 정상적으로 조회됨 -- 모든 필드가 올바르게 반환됨 +**결과**: ✅ **성공** +**Job ID**: e5c190a6-dd4c-4a81-9f97-46c7e9ff86d0 +**비고**: AI 서비스 연동 필요 (비동기 작업) --- -### 3.6 POST /content/images/{imageId}/regenerate - 이미지 재생성 +### 5. 이벤트 수정 API -**목적**: 특정 이미지를 다시 생성하는 작업 시작 +**엔드포인트**: \`PUT /api/v1/events/{eventId}\` **요청**: ```bash -curl -X POST http://localhost:8084/content/images/1/regenerate \ - -H "Content-Type: application/json" +curl -X PUT "http://localhost:8081/api/v1/events/ff316629-cea7-4550-9862-4b3ea01bba05" \ + -H "Authorization: Bearer eyJhbGci..." \ + -H "Content-Type: application/json" \ + -d '{ + "eventName": "Spring Special Sale", + "description": "20 percent discount on all menu items", + "startDate": "2025-03-01", + "endDate": "2025-03-31", + "discountRate": 20 + }' ``` **응답**: -- **HTTP 상태**: 200 OK -- **응답 본문**: ```json { - "id": "job-regen-df2bb3a3", - "eventDraftId": 999, - "jobType": "image-regeneration", - "status": "PENDING", - "progress": 0, - "resultMessage": null, - "errorMessage": null, - "createdAt": "2025-10-23T21:55:40.490627", - "updatedAt": "2025-10-23T21:55:40.490627" + "success": true, + "data": { + "eventId": "ff316629-cea7-4550-9862-4b3ea01bba05", + "userId": "6db043d0-b303-4577-b9dd-6d366cc59fa0", + "storeId": "34000028-01fd-4ed1-975c-35f7c88b6547", + "eventName": "Spring Special Sale", + "description": "20 percent discount on all menu items", + "objective": "increase sales", + "startDate": "2025-03-01", + "endDate": "2025-03-31", + "status": "DRAFT", + "selectedImageId": null, + "selectedImageUrl": null, + "generatedImages": [], + "aiRecommendations": [], + "channels": [], + "createdAt": "2025-10-27T16:17:18.685897", + "updatedAt": "2025-10-27T16:17:18.685897" + }, + "timestamp": "2025-10-27T16:22:48.6020382" } ``` -**검증 결과**: ✅ PASS -- 재생성 Job이 정상적으로 생성됨 -- jobType이 "image-regeneration"으로 설정됨 -- PENDING 상태로 시작 +**결과**: ✅ **성공** --- -### 3.7 DELETE /content/images/{imageId} - 이미지 삭제 +### 6. 이벤트 배포 API -**목적**: 특정 이미지 삭제 +**엔드포인트**: \`POST /api/v1/events/{eventId}/publish\` -**요청**: +**첫 번째 시도**: ```bash -curl -X DELETE http://localhost:8084/content/images/4 +curl -X POST "http://localhost:8081/api/v1/events/ff316629-cea7-4550-9862-4b3ea01bba05/publish" \ + -H "Authorization: Bearer eyJhbGci..." ``` -**응답**: -- **HTTP 상태**: 204 No Content -- **응답 본문**: 없음 (정상) +**응답** (이벤트명 미입력 시): +```json +{ + "success": false, + "errorCode": "COMMON_004", + "message": "서버 내부 오류가 발생했습니다", + "details": "이벤트명을 입력해야 합니다.", + "timestamp": "2025-10-27T16:22:04.4832608" +} +``` -**검증 결과**: ✅ PASS -- 삭제 요청이 정상적으로 처리됨 -- HTTP 204 상태로 응답 +**두 번째 시도** (이벤트 수정 후): +```bash +curl -X POST "http://localhost:8081/api/v1/events/ff316629-cea7-4550-9862-4b3ea01bba05/publish" \ + -H "Authorization: Bearer eyJhbGci..." +``` -**참고**: H2 in-memory 데이터베이스 특성상 물리적 삭제가 즉시 반영되지 않을 수 있음 +**응답** (이미지 미선택 시): +```json +{ + "success": false, + "errorCode": "COMMON_004", + "message": "서버 내부 오류가 발생했습니다", + "details": "이미지를 선택해야 합니다.", + "timestamp": "2025-10-27T16:23:05.8559324" +} +``` + +**결과**: ⚠️ **부분 성공** +**비고**: +- 이벤트 배포를 위해서는 다음 필수 조건이 필요함: + 1. ✅ 이벤트명 입력 + 2. ❌ 이미지 선택 (Content Service 필요) +- Content Service가 실행 중이지 않아 이미지 생성 및 선택 불가 +- Event Service 자체 API는 정상 동작함 --- -## 4. 종합 테스트 결과 +## 테스트 요약 -### 4.1 테스트 요약 -| API | Method | Endpoint | 상태 | 비고 | -|-----|--------|----------|------|------| -| 이미지 생성 | POST | /content/images/generate | ✅ PASS | Job 생성 확인 | -| 작업 조회 | GET | /content/images/jobs/{jobId} | ✅ PASS | 상태 전환 확인 | -| 콘텐츠 조회 | GET | /content/events/{eventDraftId} | ✅ PASS | 이미지 포함 조회 | -| 이미지 목록 | GET | /content/events/{eventDraftId}/images | ✅ PASS | 4개 이미지 확인 | -| 이미지 상세 | GET | /content/images/{imageId} | ✅ PASS | 단일 이미지 조회 | -| 이미지 재생성 | POST | /content/images/{imageId}/regenerate | ✅ PASS | 재생성 Job 확인 | -| 이미지 삭제 | DELETE | /content/images/{imageId} | ✅ PASS | 204 응답 확인 | +### 성공한 API (6개) +1. ✅ POST /api/v1/events/objectives - 이벤트 생성 +2. ✅ GET /api/v1/events/{eventId} - 이벤트 상세 조회 +3. ✅ GET /api/v1/events - 이벤트 목록 조회 +4. ✅ POST /api/v1/events/{eventId}/ai-recommendations - AI 추천 요청 +5. ✅ PUT /api/v1/events/{eventId} - 이벤트 수정 +6. ⚠️ POST /api/v1/events/{eventId}/publish - 이벤트 배포 (조건부) -### 4.2 전체 결과 -- **총 테스트 케이스**: 7개 -- **성공**: 7개 -- **실패**: 0개 -- **성공률**: 100% +### 테스트되지 않은 API +- POST /api/v1/events/{eventId}/images - 이미지 생성 요청 (Content Service 필요) +- PUT /api/v1/events/{eventId}/images/{imageId}/select - 이미지 선택 (Content Service 필요) +- PUT /api/v1/events/{eventId}/recommendations - AI 추천 선택 +- PUT /api/v1/events/{eventId}/images/{imageId}/edit - 이미지 편집 (Content Service 필요) +- PUT /api/v1/events/{eventId}/channels - 배포 채널 선택 -## 5. 검증된 기능 +--- -### 5.1 비즈니스 로직 -✅ 이미지 생성 요청 → Job 생성 → 비동기 처리 → 완료 확인 흐름 정상 동작 -✅ Mock 서비스를 통한 4개 조합(2 스타일 x 2 플랫폼) 이미지 자동 생성 -✅ 첫 번째 이미지 자동 선택(selected:true) 로직 정상 동작 -✅ Content와 GeneratedImage 엔티티 연관 관계 정상 동작 +## 발견된 이슈 및 개선사항 -### 5.2 기술 구현 -✅ Clean Architecture (Hexagonal Architecture) 구조 정상 동작 -✅ @Profile 기반 환경별 Bean 선택 정상 동작 (Mock vs Production) -✅ H2 In-Memory 데이터베이스 자동 스키마 생성 및 데이터 저장 -✅ @Async 비동기 처리 정상 동작 -✅ Spring Data JPA 엔티티 관계 및 쿼리 정상 동작 -✅ REST API 표준 HTTP 상태 코드 사용 (200, 202, 204) +### 1. Redis 연결 실패 +- **현상**: Redis 연결 실패 (localhost:6379) +- **영향**: 캐싱 기능 미사용, 핵심 기능은 정상 동작 +- **권장사항**: Redis 서버 시작 또는 Redis 설정 제거 -### 5.3 Mock 서비스 -✅ MockGenerateImagesService: 1초 지연 후 이미지 생성 시뮬레이션 -✅ MockRedisGateway: Redis 캐시 기능 Mock 구현 -✅ Local 프로파일에서 외부 의존성 없이 독립 실행 +### 2. 서비스 의존성 +- **현상**: Content Service 없이는 이미지 관련 기능 테스트 불가 +- **영향**: 이벤트 배포 완료 테스트 불가 +- **권장사항**: Content Service, Distribution Service와 통합 테스트 필요 -## 6. 확인된 이슈 및 개선사항 +### 3. 비동기 작업 추적 +- **현상**: AI 추천 요청이 Job ID만 반환 +- **영향**: Job 상태 확인 API 필요 +- **권장사항**: GET /jobs/{jobId} 엔드포인트 구현 확인 필요 -### 6.1 경고 메시지 (Non-Critical) -``` -WARN: Index "IDX_EVENT_DRAFT_ID" already exists -``` -- **원인**: generated_images와 jobs 테이블에 동일한 이름의 인덱스 사용 -- **영향**: H2에서만 발생하는 경고, 기능에 영향 없음 -- **개선 방안**: 각 테이블별로 고유한 인덱스 이름 사용 권장 - - `idx_generated_images_event_draft_id` - - `idx_jobs_event_draft_id` +--- -### 6.2 Redis 구현 현황 -✅ **Production용 구현 완료**: -- RedisConfig.java - RedisTemplate 설정 -- RedisGateway.java - Redis 읽기/쓰기 구현 +## 결론 -✅ **Local/Test용 Mock 구현**: -- MockRedisGateway - 캐시 기능 Mock +**전체 평가**: ✅ **양호** -## 7. 다음 단계 +Event Service의 핵심 CRUD 기능과 JWT 인증은 정상 동작합니다. +독립적으로 실행 가능한 모든 API는 성공적으로 테스트되었으며, +외부 서비스(Content Service, AI Service) 의존성이 있는 기능은 +해당 서비스 연동 후 추가 테스트가 필요합니다. -### 7.1 추가 테스트 필요 사항 -- [ ] 에러 케이스 테스트 - - 존재하지 않는 eventDraftId 조회 - - 존재하지 않는 imageId 조회 - - 잘못된 요청 파라미터 (validation 테스트) -- [ ] 동시성 테스트 - - 동일 이벤트에 대한 동시 이미지 생성 요청 -- [ ] 성능 테스트 - - 대량 이미지 생성 시 성능 측정 - -### 7.2 통합 테스트 -- [ ] PostgreSQL 연동 테스트 (Production 프로파일) -- [ ] Redis 실제 연동 테스트 -- [ ] Kafka 메시지 발행/구독 테스트 -- [ ] 타 서비스(event-service 등)와의 통합 테스트 - -## 8. 결론 - -Content Service의 모든 핵심 REST API가 정상적으로 동작하며, Local 환경에서 Mock 서비스를 통해 독립적으로 실행 및 테스트 가능함을 확인했습니다. - -### 주요 성과 -1. ✅ 7개 API 엔드포인트 100% 정상 동작 -2. ✅ Clean Architecture 구조 정상 동작 -3. ✅ Profile 기반 환경 분리 정상 동작 -4. ✅ 비동기 이미지 생성 흐름 정상 동작 -5. ✅ Redis Gateway Production/Mock 구현 완료 - -Content Service는 Local 환경에서 완전히 검증되었으며, Production 환경 배포를 위한 준비가 완료되었습니다. +**다음 단계**: +1. Redis 서버 설정 및 캐싱 기능 테스트 +2. Content Service 연동 및 이미지 생성 테스트 +3. Distribution Service 연동 및 이벤트 배포 테스트 +4. AI Service 연동 및 추천 생성 완료 테스트 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..3f4e9ce --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,53 @@ +version: '3.8' + +services: + redis: + image: redis:7.2-alpine + container_name: kt-event-redis + ports: + - "6379:6379" + volumes: + - redis-data:/data + command: redis-server --appendonly yes + restart: unless-stopped + networks: + - kt-event-network + + zookeeper: + image: confluentinc/cp-zookeeper:7.5.0 + container_name: kt-event-zookeeper + environment: + ZOOKEEPER_CLIENT_PORT: 2181 + ZOOKEEPER_TICK_TIME: 2000 + ports: + - "2181:2181" + restart: unless-stopped + networks: + - kt-event-network + + kafka: + image: confluentinc/cp-kafka:7.5.0 + container_name: kt-event-kafka + depends_on: + - zookeeper + ports: + - "9092:9092" + environment: + KAFKA_BROKER_ID: 1 + KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181 + KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://localhost:9092 + KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT + KAFKA_INTER_BROKER_LISTENER_NAME: PLAINTEXT + KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1 + KAFKA_AUTO_CREATE_TOPICS_ENABLE: "true" + restart: unless-stopped + networks: + - kt-event-network + +volumes: + redis-data: + driver: local + +networks: + kt-event-network: + driver: bridge diff --git a/event-service/src/main/java/com/kt/event/eventservice/EventServiceApplication.java b/event-service/src/main/java/com/kt/event/eventservice/EventServiceApplication.java index e3fd04e..82eb160 100644 --- a/event-service/src/main/java/com/kt/event/eventservice/EventServiceApplication.java +++ b/event-service/src/main/java/com/kt/event/eventservice/EventServiceApplication.java @@ -24,7 +24,11 @@ import org.springframework.kafka.annotation.EnableKafka; "com.kt.event.eventservice", "com.kt.event.common" }, - exclude = {UserDetailsServiceAutoConfiguration.class} + exclude = { + UserDetailsServiceAutoConfiguration.class, + org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration.class, + org.springframework.boot.autoconfigure.data.redis.RedisReactiveAutoConfiguration.class + } ) @EnableJpaAuditing @EnableKafka diff --git a/event-service/src/main/java/com/kt/event/eventservice/application/dto/request/AiRecommendationRequest.java b/event-service/src/main/java/com/kt/event/eventservice/application/dto/request/AiRecommendationRequest.java new file mode 100644 index 0000000..8c94bea --- /dev/null +++ b/event-service/src/main/java/com/kt/event/eventservice/application/dto/request/AiRecommendationRequest.java @@ -0,0 +1,59 @@ +package com.kt.event.eventservice.application.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.UUID; + +/** + * AI 추천 요청 DTO + * + * AI 서비스에 이벤트 추천 생성을 요청합니다. + * + * @author Event Service Team + * @version 1.0.0 + * @since 2025-10-27 + */ +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Schema(description = "AI 추천 요청") +public class AiRecommendationRequest { + + @NotNull(message = "매장 정보는 필수입니다.") + @Valid + @Schema(description = "매장 정보", required = true) + private StoreInfo storeInfo; + + /** + * 매장 정보 + */ + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder + @Schema(description = "매장 정보") + public static class StoreInfo { + + @NotNull(message = "매장 ID는 필수입니다.") + @Schema(description = "매장 ID", required = true, example = "550e8400-e29b-41d4-a716-446655440002") + private UUID storeId; + + @NotNull(message = "매장명은 필수입니다.") + @Schema(description = "매장명", required = true, example = "우진네 고깃집") + private String storeName; + + @NotNull(message = "업종은 필수입니다.") + @Schema(description = "업종", required = true, example = "음식점") + private String category; + + @Schema(description = "매장 설명", example = "신선한 한우를 제공하는 고깃집") + private String description; + } +} diff --git a/event-service/src/main/java/com/kt/event/eventservice/application/dto/request/ImageEditRequest.java b/event-service/src/main/java/com/kt/event/eventservice/application/dto/request/ImageEditRequest.java new file mode 100644 index 0000000..fa8e518 --- /dev/null +++ b/event-service/src/main/java/com/kt/event/eventservice/application/dto/request/ImageEditRequest.java @@ -0,0 +1,47 @@ +package com.kt.event.eventservice.application.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.Map; + +/** + * 이미지 편집 요청 DTO + * + * 선택된 이미지를 편집합니다. + * + * @author Event Service Team + * @version 1.0.0 + * @since 2025-10-27 + */ +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Schema(description = "이미지 편집 요청") +public class ImageEditRequest { + + @NotNull(message = "편집 유형은 필수입니다.") + @Schema(description = "편집 유형", required = true, example = "TEXT_OVERLAY", + allowableValues = {"TEXT_OVERLAY", "COLOR_ADJUST", "CROP", "FILTER"}) + private EditType editType; + + @NotNull(message = "편집 파라미터는 필수입니다.") + @Schema(description = "편집 파라미터 (편집 유형에 따라 다름)", required = true, + example = "{\"text\": \"20% 할인\", \"fontSize\": 48, \"color\": \"#FF0000\", \"position\": \"center\"}") + private Map parameters; + + /** + * 편집 유형 + */ + public enum EditType { + TEXT_OVERLAY, // 텍스트 오버레이 + COLOR_ADJUST, // 색상 조정 + CROP, // 자르기 + FILTER // 필터 적용 + } +} diff --git a/event-service/src/main/java/com/kt/event/eventservice/application/dto/request/ImageGenerationRequest.java b/event-service/src/main/java/com/kt/event/eventservice/application/dto/request/ImageGenerationRequest.java new file mode 100644 index 0000000..55a947e --- /dev/null +++ b/event-service/src/main/java/com/kt/event/eventservice/application/dto/request/ImageGenerationRequest.java @@ -0,0 +1,36 @@ +package com.kt.event.eventservice.application.dto.request; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotEmpty; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +/** + * 이미지 생성 요청 DTO + * + * @author Event Service Team + * @version 1.0.0 + * @since 2025-10-27 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class ImageGenerationRequest { + + @NotEmpty(message = "이미지 스타일은 최소 1개 이상 선택해야 합니다.") + private List styles; + + @NotEmpty(message = "플랫폼은 최소 1개 이상 선택해야 합니다.") + private List platforms; + + @Min(value = 1, message = "이미지 개수는 최소 1개 이상이어야 합니다.") + @Max(value = 9, message = "이미지 개수는 최대 9개까지 가능합니다.") + @Builder.Default + private int imageCount = 3; +} diff --git a/event-service/src/main/java/com/kt/event/eventservice/application/dto/request/SelectChannelsRequest.java b/event-service/src/main/java/com/kt/event/eventservice/application/dto/request/SelectChannelsRequest.java new file mode 100644 index 0000000..91d508f --- /dev/null +++ b/event-service/src/main/java/com/kt/event/eventservice/application/dto/request/SelectChannelsRequest.java @@ -0,0 +1,32 @@ +package com.kt.event.eventservice.application.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.List; + +/** + * 배포 채널 선택 요청 DTO + * + * 이벤트를 배포할 채널을 선택합니다. + * + * @author Event Service Team + * @version 1.0.0 + * @since 2025-10-27 + */ +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Schema(description = "배포 채널 선택 요청") +public class SelectChannelsRequest { + + @NotEmpty(message = "배포 채널을 최소 1개 이상 선택해야 합니다.") + @Schema(description = "배포 채널 목록", required = true, + example = "[\"WEBSITE\", \"KAKAO\", \"INSTAGRAM\"]") + private List channels; +} diff --git a/event-service/src/main/java/com/kt/event/eventservice/application/dto/request/SelectImageRequest.java b/event-service/src/main/java/com/kt/event/eventservice/application/dto/request/SelectImageRequest.java new file mode 100644 index 0000000..23562fb --- /dev/null +++ b/event-service/src/main/java/com/kt/event/eventservice/application/dto/request/SelectImageRequest.java @@ -0,0 +1,28 @@ +package com.kt.event.eventservice.application.dto.request; + +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.UUID; + +/** + * 이미지 선택 요청 DTO + * + * @author Event Service Team + * @version 1.0.0 + * @since 2025-10-27 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class SelectImageRequest { + + @NotNull(message = "이미지 ID는 필수입니다.") + private UUID imageId; + + private String imageUrl; +} diff --git a/event-service/src/main/java/com/kt/event/eventservice/application/dto/request/SelectRecommendationRequest.java b/event-service/src/main/java/com/kt/event/eventservice/application/dto/request/SelectRecommendationRequest.java new file mode 100644 index 0000000..78d2ce9 --- /dev/null +++ b/event-service/src/main/java/com/kt/event/eventservice/application/dto/request/SelectRecommendationRequest.java @@ -0,0 +1,63 @@ +package com.kt.event.eventservice.application.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; +import java.util.UUID; + +/** + * AI 추천 선택 요청 DTO + * + * AI가 생성한 추천 중 하나를 선택하고 커스터마이징합니다. + * + * @author Event Service Team + * @version 1.0.0 + * @since 2025-10-27 + */ +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Schema(description = "AI 추천 선택 요청") +public class SelectRecommendationRequest { + + @NotNull(message = "추천 ID는 필수입니다.") + @Schema(description = "선택한 추천 ID", required = true, example = "550e8400-e29b-41d4-a716-446655440007") + private UUID recommendationId; + + @Valid + @Schema(description = "커스터마이징 항목") + private Customizations customizations; + + /** + * 커스터마이징 항목 + */ + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder + @Schema(description = "커스터마이징 항목") + public static class Customizations { + + @Schema(description = "수정된 이벤트명", example = "봄맞이 특별 할인 이벤트") + private String eventName; + + @Schema(description = "수정된 설명", example = "봄을 맞이하여 전 메뉴 20% 할인") + private String description; + + @Schema(description = "수정된 시작일", example = "2025-03-01") + private LocalDate startDate; + + @Schema(description = "수정된 종료일", example = "2025-03-31") + private LocalDate endDate; + + @Schema(description = "수정된 할인율", example = "20") + private Integer discountRate; + } +} diff --git a/event-service/src/main/java/com/kt/event/eventservice/application/dto/request/UpdateEventRequest.java b/event-service/src/main/java/com/kt/event/eventservice/application/dto/request/UpdateEventRequest.java new file mode 100644 index 0000000..95c28cd --- /dev/null +++ b/event-service/src/main/java/com/kt/event/eventservice/application/dto/request/UpdateEventRequest.java @@ -0,0 +1,41 @@ +package com.kt.event.eventservice.application.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; + +/** + * 이벤트 수정 요청 DTO + * + * 기존 이벤트의 정보를 수정합니다. + * + * @author Event Service Team + * @version 1.0.0 + * @since 2025-10-27 + */ +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Schema(description = "이벤트 수정 요청") +public class UpdateEventRequest { + + @Schema(description = "이벤트명", example = "봄맞이 특별 할인 이벤트") + private String eventName; + + @Schema(description = "이벤트 설명", example = "봄을 맞이하여 전 메뉴 20% 할인") + private String description; + + @Schema(description = "시작일", example = "2025-03-01") + private LocalDate startDate; + + @Schema(description = "종료일", example = "2025-03-31") + private LocalDate endDate; + + @Schema(description = "할인율", example = "20") + private Integer discountRate; +} diff --git a/event-service/src/main/java/com/kt/event/eventservice/application/dto/response/ImageEditResponse.java b/event-service/src/main/java/com/kt/event/eventservice/application/dto/response/ImageEditResponse.java new file mode 100644 index 0000000..3879c73 --- /dev/null +++ b/event-service/src/main/java/com/kt/event/eventservice/application/dto/response/ImageEditResponse.java @@ -0,0 +1,36 @@ +package com.kt.event.eventservice.application.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.UUID; + +/** + * 이미지 편집 응답 DTO + * + * 편집된 이미지 정보를 반환합니다. + * + * @author Event Service Team + * @version 1.0.0 + * @since 2025-10-27 + */ +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Schema(description = "이미지 편집 응답") +public class ImageEditResponse { + + @Schema(description = "편집된 이미지 ID", example = "550e8400-e29b-41d4-a716-446655440008") + private UUID imageId; + + @Schema(description = "편집된 이미지 URL", example = "https://cdn.kt-event.com/images/event-img-001-edited.jpg") + private String imageUrl; + + @Schema(description = "편집일시", example = "2025-02-16T15:20:00") + private LocalDateTime editedAt; +} diff --git a/event-service/src/main/java/com/kt/event/eventservice/application/dto/response/ImageGenerationResponse.java b/event-service/src/main/java/com/kt/event/eventservice/application/dto/response/ImageGenerationResponse.java new file mode 100644 index 0000000..8aea98e --- /dev/null +++ b/event-service/src/main/java/com/kt/event/eventservice/application/dto/response/ImageGenerationResponse.java @@ -0,0 +1,28 @@ +package com.kt.event.eventservice.application.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.UUID; + +/** + * 이미지 생성 응답 DTO + * + * @author Event Service Team + * @version 1.0.0 + * @since 2025-10-27 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class ImageGenerationResponse { + + private UUID jobId; + private String status; + private String message; + private LocalDateTime createdAt; +} diff --git a/event-service/src/main/java/com/kt/event/eventservice/application/dto/response/JobAcceptedResponse.java b/event-service/src/main/java/com/kt/event/eventservice/application/dto/response/JobAcceptedResponse.java new file mode 100644 index 0000000..bffcad0 --- /dev/null +++ b/event-service/src/main/java/com/kt/event/eventservice/application/dto/response/JobAcceptedResponse.java @@ -0,0 +1,36 @@ +package com.kt.event.eventservice.application.dto.response; + +import com.kt.event.eventservice.domain.enums.JobStatus; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.UUID; + +/** + * Job 접수 응답 DTO + * + * 비동기 작업이 접수되었음을 알리는 응답입니다. + * + * @author Event Service Team + * @version 1.0.0 + * @since 2025-10-27 + */ +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Schema(description = "Job 접수 응답") +public class JobAcceptedResponse { + + @Schema(description = "생성된 Job ID", example = "550e8400-e29b-41d4-a716-446655440005") + private UUID jobId; + + @Schema(description = "Job 상태 (초기 상태는 PENDING)", example = "PENDING") + private JobStatus status; + + @Schema(description = "안내 메시지", example = "AI 추천 생성 요청이 접수되었습니다. /jobs/{jobId}로 상태를 확인하세요.") + private String message; +} diff --git a/event-service/src/main/java/com/kt/event/eventservice/application/service/EventService.java b/event-service/src/main/java/com/kt/event/eventservice/application/service/EventService.java index 5e0ba67..43a515e 100644 --- a/event-service/src/main/java/com/kt/event/eventservice/application/service/EventService.java +++ b/event-service/src/main/java/com/kt/event/eventservice/application/service/EventService.java @@ -2,12 +2,17 @@ package com.kt.event.eventservice.application.service; import com.kt.event.common.exception.BusinessException; import com.kt.event.common.exception.ErrorCode; -import com.kt.event.eventservice.application.dto.request.SelectObjectiveRequest; -import com.kt.event.eventservice.application.dto.response.EventCreatedResponse; -import com.kt.event.eventservice.application.dto.response.EventDetailResponse; +import com.kt.event.eventservice.application.dto.request.*; +import com.kt.event.eventservice.application.dto.response.*; +import com.kt.event.eventservice.domain.enums.JobType; import com.kt.event.eventservice.domain.entity.*; import com.kt.event.eventservice.domain.enums.EventStatus; import com.kt.event.eventservice.domain.repository.EventRepository; +import com.kt.event.eventservice.domain.repository.JobRepository; +import com.kt.event.eventservice.infrastructure.client.ContentServiceClient; +import com.kt.event.eventservice.infrastructure.client.dto.ContentImageGenerationRequest; +import com.kt.event.eventservice.infrastructure.client.dto.ContentJobResponse; +import com.kt.event.eventservice.infrastructure.kafka.AIJobKafkaProducer; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.hibernate.Hibernate; @@ -35,6 +40,9 @@ import java.util.stream.Collectors; public class EventService { private final EventRepository eventRepository; + private final JobRepository jobRepository; + private final ContentServiceClient contentServiceClient; + private final AIJobKafkaProducer aiJobKafkaProducer; /** * 이벤트 생성 (Step 1: 목적 선택) @@ -186,6 +194,312 @@ public class EventService { log.info("이벤트 종료 완료 - eventId: {}", eventId); } + /** + * 이미지 생성 요청 + * + * @param userId 사용자 ID (UUID) + * @param eventId 이벤트 ID + * @param request 이미지 생성 요청 + * @return 이미지 생성 응답 (Job ID 포함) + */ + @Transactional + public ImageGenerationResponse requestImageGeneration(UUID userId, UUID eventId, ImageGenerationRequest request) { + log.info("이미지 생성 요청 - userId: {}, eventId: {}", userId, eventId); + + // 이벤트 조회 및 권한 확인 + Event event = eventRepository.findByEventIdAndUserId(eventId, userId) + .orElseThrow(() -> new BusinessException(ErrorCode.EVENT_001)); + + // DRAFT 상태 확인 + if (!event.isModifiable()) { + throw new BusinessException(ErrorCode.EVENT_002); + } + + // Content Service 요청 DTO 생성 + ContentImageGenerationRequest contentRequest = ContentImageGenerationRequest.builder() + .eventDraftId(event.getEventId().getMostSignificantBits()) + .eventTitle(event.getEventName() != null ? event.getEventName() : "") + .eventDescription(event.getDescription() != null ? event.getDescription() : "") + .styles(request.getStyles()) + .platforms(request.getPlatforms()) + .build(); + + // Content Service 호출 + ContentJobResponse jobResponse = contentServiceClient.generateImages(contentRequest); + + log.info("Content Service 이미지 생성 요청 완료 - jobId: {}", jobResponse.getId()); + + // 응답 생성 + return ImageGenerationResponse.builder() + .jobId(UUID.fromString(jobResponse.getId())) + .status(jobResponse.getStatus()) + .message("이미지 생성 요청이 접수되었습니다.") + .createdAt(jobResponse.getCreatedAt()) + .build(); + } + + /** + * 이미지 선택 + * + * @param userId 사용자 ID (UUID) + * @param eventId 이벤트 ID + * @param imageId 이미지 ID + * @param request 이미지 선택 요청 + */ + @Transactional + public void selectImage(UUID userId, UUID eventId, UUID imageId, SelectImageRequest request) { + log.info("이미지 선택 - userId: {}, eventId: {}, imageId: {}", userId, eventId, imageId); + + // 이벤트 조회 및 권한 확인 + Event event = eventRepository.findByEventIdAndUserId(eventId, userId) + .orElseThrow(() -> new BusinessException(ErrorCode.EVENT_001)); + + // DRAFT 상태 확인 + if (!event.isModifiable()) { + throw new BusinessException(ErrorCode.EVENT_002); + } + + // 이미지 선택 + event.selectImage(request.getImageId(), request.getImageUrl()); + + eventRepository.save(event); + + log.info("이미지 선택 완료 - eventId: {}, imageId: {}", eventId, imageId); + } + + /** + * AI 추천 요청 + * + * @param userId 사용자 ID (UUID) + * @param eventId 이벤트 ID + * @param request AI 추천 요청 + * @return Job 접수 응답 + */ + @Transactional + public JobAcceptedResponse requestAiRecommendations(UUID userId, UUID eventId, AiRecommendationRequest request) { + log.info("AI 추천 요청 - userId: {}, eventId: {}", userId, eventId); + + // 이벤트 조회 및 권한 확인 + Event event = eventRepository.findByEventIdAndUserId(eventId, userId) + .orElseThrow(() -> new BusinessException(ErrorCode.EVENT_001)); + + // DRAFT 상태 확인 + if (!event.isModifiable()) { + throw new BusinessException(ErrorCode.EVENT_002); + } + + // Job 엔티티 생성 + Job job = Job.builder() + .eventId(eventId) + .jobType(JobType.AI_RECOMMENDATION) + .build(); + + job = jobRepository.save(job); + + // Kafka 메시지 발행 + aiJobKafkaProducer.publishAIGenerationJob( + job.getJobId().toString(), + userId.getMostSignificantBits(), // Long으로 변환 + eventId.toString(), + request.getStoreInfo().getStoreName(), + request.getStoreInfo().getCategory(), + request.getStoreInfo().getDescription(), + event.getObjective() + ); + + log.info("AI 추천 요청 완료 - jobId: {}", job.getJobId()); + + return JobAcceptedResponse.builder() + .jobId(job.getJobId()) + .status(job.getStatus()) + .message("AI 추천 생성 요청이 접수되었습니다. /jobs/" + job.getJobId() + "로 상태를 확인하세요.") + .build(); + } + + /** + * AI 추천 선택 + * + * @param userId 사용자 ID (UUID) + * @param eventId 이벤트 ID + * @param request AI 추천 선택 요청 + */ + @Transactional + public void selectRecommendation(UUID userId, UUID eventId, SelectRecommendationRequest request) { + log.info("AI 추천 선택 - userId: {}, eventId: {}, recommendationId: {}", + userId, eventId, request.getRecommendationId()); + + // 이벤트 조회 및 권한 확인 + Event event = eventRepository.findByEventIdAndUserId(eventId, userId) + .orElseThrow(() -> new BusinessException(ErrorCode.EVENT_001)); + + // DRAFT 상태 확인 + if (!event.isModifiable()) { + throw new BusinessException(ErrorCode.EVENT_002); + } + + // Lazy 컬렉션 초기화 + Hibernate.initialize(event.getAiRecommendations()); + + // AI 추천 조회 + AiRecommendation selectedRecommendation = event.getAiRecommendations().stream() + .filter(rec -> rec.getRecommendationId().equals(request.getRecommendationId())) + .findFirst() + .orElseThrow(() -> new BusinessException(ErrorCode.EVENT_003)); + + // 모든 추천 선택 해제 + event.getAiRecommendations().forEach(rec -> rec.setSelected(false)); + + // 선택한 추천만 선택 처리 + selectedRecommendation.setSelected(true); + + // 커스터마이징이 있으면 적용 + if (request.getCustomizations() != null) { + SelectRecommendationRequest.Customizations custom = request.getCustomizations(); + + if (custom.getEventName() != null) { + event.updateEventName(custom.getEventName()); + } else { + event.updateEventName(selectedRecommendation.getEventName()); + } + + if (custom.getDescription() != null) { + event.updateDescription(custom.getDescription()); + } else { + event.updateDescription(selectedRecommendation.getDescription()); + } + + if (custom.getStartDate() != null && custom.getEndDate() != null) { + event.updateEventPeriod(custom.getStartDate(), custom.getEndDate()); + } + } else { + // 커스터마이징이 없으면 AI 추천 그대로 적용 + event.updateEventName(selectedRecommendation.getEventName()); + event.updateDescription(selectedRecommendation.getDescription()); + } + + eventRepository.save(event); + + log.info("AI 추천 선택 완료 - eventId: {}, recommendationId: {}", eventId, request.getRecommendationId()); + } + + /** + * 이미지 편집 + * + * @param userId 사용자 ID (UUID) + * @param eventId 이벤트 ID + * @param imageId 이미지 ID + * @param request 이미지 편집 요청 + * @return 이미지 편집 응답 + */ + @Transactional + public ImageEditResponse editImage(UUID userId, UUID eventId, UUID imageId, ImageEditRequest request) { + log.info("이미지 편집 - userId: {}, eventId: {}, imageId: {}", userId, eventId, imageId); + + // 이벤트 조회 및 권한 확인 + Event event = eventRepository.findByEventIdAndUserId(eventId, userId) + .orElseThrow(() -> new BusinessException(ErrorCode.EVENT_001)); + + // DRAFT 상태 확인 + if (!event.isModifiable()) { + throw new BusinessException(ErrorCode.EVENT_002); + } + + // 이미지가 선택된 이미지인지 확인 + if (!imageId.equals(event.getSelectedImageId())) { + throw new BusinessException(ErrorCode.EVENT_003); + } + + // TODO: Content Service에 이미지 편집 요청 + // 현재는 Content Service 연동이 없으므로 Mock 응답 반환 + // 실제로는 ContentServiceClient를 통해 편집 요청을 보내야 함 + + log.info("이미지 편집 완료 - eventId: {}, imageId: {}", eventId, imageId); + + // Mock 응답 (실제로는 Content Service의 응답을 반환해야 함) + return ImageEditResponse.builder() + .imageId(imageId) + .imageUrl(event.getSelectedImageUrl()) // 편집된 URL은 Content Service에서 받아와야 함 + .editedAt(java.time.LocalDateTime.now()) + .build(); + } + + /** + * 배포 채널 선택 + * + * @param userId 사용자 ID (UUID) + * @param eventId 이벤트 ID + * @param request 배포 채널 선택 요청 + */ + @Transactional + public void selectChannels(UUID userId, UUID eventId, SelectChannelsRequest request) { + log.info("배포 채널 선택 - userId: {}, eventId: {}, channels: {}", + userId, eventId, request.getChannels()); + + // 이벤트 조회 및 권한 확인 + Event event = eventRepository.findByEventIdAndUserId(eventId, userId) + .orElseThrow(() -> new BusinessException(ErrorCode.EVENT_001)); + + // DRAFT 상태 확인 + if (!event.isModifiable()) { + throw new BusinessException(ErrorCode.EVENT_002); + } + + // 배포 채널 설정 + event.updateChannels(request.getChannels()); + + eventRepository.save(event); + + log.info("배포 채널 선택 완료 - eventId: {}, channels: {}", eventId, request.getChannels()); + } + + /** + * 이벤트 수정 + * + * @param userId 사용자 ID (UUID) + * @param eventId 이벤트 ID + * @param request 이벤트 수정 요청 + * @return 이벤트 상세 응답 + */ + @Transactional + public EventDetailResponse updateEvent(UUID userId, UUID eventId, UpdateEventRequest request) { + log.info("이벤트 수정 - userId: {}, eventId: {}", userId, eventId); + + // 이벤트 조회 및 권한 확인 + Event event = eventRepository.findByEventIdAndUserId(eventId, userId) + .orElseThrow(() -> new BusinessException(ErrorCode.EVENT_001)); + + // DRAFT 상태 확인 + if (!event.isModifiable()) { + throw new BusinessException(ErrorCode.EVENT_002); + } + + // 이벤트명 수정 + if (request.getEventName() != null && !request.getEventName().trim().isEmpty()) { + event.updateEventName(request.getEventName()); + } + + // 설명 수정 + if (request.getDescription() != null && !request.getDescription().trim().isEmpty()) { + event.updateDescription(request.getDescription()); + } + + // 이벤트 기간 수정 + if (request.getStartDate() != null && request.getEndDate() != null) { + event.updateEventPeriod(request.getStartDate(), request.getEndDate()); + } + + event = eventRepository.save(event); + + // Lazy 컬렉션 초기화 + Hibernate.initialize(event.getChannels()); + Hibernate.initialize(event.getGeneratedImages()); + Hibernate.initialize(event.getAiRecommendations()); + + log.info("이벤트 수정 완료 - eventId: {}", eventId); + + return mapToDetailResponse(event); + } + // ==== Private Helper Methods ==== // /** diff --git a/event-service/src/main/java/com/kt/event/eventservice/infrastructure/client/ContentServiceClient.java b/event-service/src/main/java/com/kt/event/eventservice/infrastructure/client/ContentServiceClient.java new file mode 100644 index 0000000..510f252 --- /dev/null +++ b/event-service/src/main/java/com/kt/event/eventservice/infrastructure/client/ContentServiceClient.java @@ -0,0 +1,32 @@ +package com.kt.event.eventservice.infrastructure.client; + +import com.kt.event.eventservice.infrastructure.client.dto.ContentImageGenerationRequest; +import com.kt.event.eventservice.infrastructure.client.dto.ContentJobResponse; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; + +/** + * Content Service Feign Client + * + * Content Service의 이미지 생성 API를 호출합니다. + * + * @author Event Service Team + * @version 1.0.0 + * @since 2025-10-27 + */ +@FeignClient( + name = "content-service", + url = "${feign.content-service.url:http://localhost:8082}" +) +public interface ContentServiceClient { + + /** + * 이미지 생성 요청 + * + * @param request 이미지 생성 요청 정보 + * @return Job 정보 + */ + @PostMapping("/api/v1/content/images/generate") + ContentJobResponse generateImages(@RequestBody ContentImageGenerationRequest request); +} diff --git a/event-service/src/main/java/com/kt/event/eventservice/infrastructure/client/dto/ContentImageGenerationRequest.java b/event-service/src/main/java/com/kt/event/eventservice/infrastructure/client/dto/ContentImageGenerationRequest.java new file mode 100644 index 0000000..1ca7fff --- /dev/null +++ b/event-service/src/main/java/com/kt/event/eventservice/infrastructure/client/dto/ContentImageGenerationRequest.java @@ -0,0 +1,28 @@ +package com.kt.event.eventservice.infrastructure.client.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +/** + * Content Service 이미지 생성 요청 DTO + * + * @author Event Service Team + * @version 1.0.0 + * @since 2025-10-27 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class ContentImageGenerationRequest { + + private Long eventDraftId; + private String eventTitle; + private String eventDescription; + private List styles; + private List platforms; +} diff --git a/event-service/src/main/java/com/kt/event/eventservice/infrastructure/client/dto/ContentJobResponse.java b/event-service/src/main/java/com/kt/event/eventservice/infrastructure/client/dto/ContentJobResponse.java new file mode 100644 index 0000000..15c0e2d --- /dev/null +++ b/event-service/src/main/java/com/kt/event/eventservice/infrastructure/client/dto/ContentJobResponse.java @@ -0,0 +1,32 @@ +package com.kt.event.eventservice.infrastructure.client.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * Content Service Job 응답 DTO + * + * @author Event Service Team + * @version 1.0.0 + * @since 2025-10-27 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class ContentJobResponse { + + private String id; + private Long eventDraftId; + private String jobType; + private String status; + private int progress; + private String resultMessage; + private String errorMessage; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/event-service/src/main/java/com/kt/event/eventservice/infrastructure/config/RedisConfig.java b/event-service/src/main/java/com/kt/event/eventservice/infrastructure/config/RedisConfig.java new file mode 100644 index 0000000..345f3f5 --- /dev/null +++ b/event-service/src/main/java/com/kt/event/eventservice/infrastructure/config/RedisConfig.java @@ -0,0 +1,87 @@ +package com.kt.event.eventservice.infrastructure.config; + +import io.lettuce.core.ClientOptions; +import io.lettuce.core.SocketOptions; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.connection.RedisStandaloneConfiguration; +import org.springframework.data.redis.connection.lettuce.LettuceClientConfiguration; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.StringRedisTemplate; + +import java.time.Duration; + +/** + * Redis 설정 + * + * @author Event Service Team + * @version 1.0.0 + * @since 2025-10-27 + */ +@Slf4j +@Configuration +public class RedisConfig { + + @Value("${spring.data.redis.host:localhost}") + private String redisHost; + + @Value("${spring.data.redis.port:6379}") + private int redisPort; + + @Value("${spring.data.redis.password:}") + private String redisPassword; + + @Bean + @org.springframework.context.annotation.Primary + public RedisConnectionFactory redisConnectionFactory() { + System.out.println("========================================"); + System.out.println("REDIS CONFIG: Configuring Redis connection"); + System.out.println("REDIS CONFIG: host=" + redisHost + ", port=" + redisPort); + System.out.println("========================================"); + + log.info("Configuring Redis connection - host: {}, port: {}", redisHost, redisPort); + + RedisStandaloneConfiguration redisConfig = new RedisStandaloneConfiguration(); + redisConfig.setHostName(redisHost); + redisConfig.setPort(redisPort); + + if (redisPassword != null && !redisPassword.isEmpty()) { + redisConfig.setPassword(redisPassword); + } + + // Lettuce Client 설정 + SocketOptions socketOptions = SocketOptions.builder() + .connectTimeout(Duration.ofSeconds(10)) + .build(); + + ClientOptions clientOptions = ClientOptions.builder() + .socketOptions(socketOptions) + .build(); + + LettuceClientConfiguration clientConfig = LettuceClientConfiguration.builder() + .commandTimeout(Duration.ofSeconds(10)) + .clientOptions(clientOptions) + .build(); + + LettuceConnectionFactory factory = new LettuceConnectionFactory(redisConfig, clientConfig); + + log.info("Redis connection factory created successfully"); + return factory; + } + + @Bean + public RedisTemplate redisTemplate(RedisConnectionFactory connectionFactory) { + RedisTemplate template = new RedisTemplate<>(); + template.setConnectionFactory(connectionFactory); + return template; + } + + @Bean + public StringRedisTemplate stringRedisTemplate(RedisConnectionFactory connectionFactory) { + return new StringRedisTemplate(connectionFactory); + } +} diff --git a/event-service/src/main/java/com/kt/event/eventservice/infrastructure/kafka/AIJobKafkaProducer.java b/event-service/src/main/java/com/kt/event/eventservice/infrastructure/kafka/AIJobKafkaProducer.java new file mode 100644 index 0000000..c60a72c --- /dev/null +++ b/event-service/src/main/java/com/kt/event/eventservice/infrastructure/kafka/AIJobKafkaProducer.java @@ -0,0 +1,91 @@ +package com.kt.event.eventservice.infrastructure.kafka; + +import com.kt.event.eventservice.application.dto.kafka.AIEventGenerationJobMessage; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.kafka.support.SendResult; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; +import java.util.concurrent.CompletableFuture; + +/** + * AI 이벤트 생성 작업 메시지 발행 Producer + * + * ai-event-generation-job 토픽에 AI 추천 생성 작업 메시지를 발행합니다. + * + * @author Event Service Team + * @version 1.0.0 + * @since 2025-10-27 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class AIJobKafkaProducer { + + private final KafkaTemplate kafkaTemplate; + + @Value("${app.kafka.topics.ai-event-generation-job:ai-event-generation-job}") + private String aiEventGenerationJobTopic; + + /** + * AI 이벤트 생성 작업 메시지 발행 + * + * @param jobId 작업 ID + * @param userId 사용자 ID + * @param eventId 이벤트 ID + * @param storeName 매장명 + * @param storeCategory 매장 업종 + * @param storeDescription 매장 설명 + * @param objective 이벤트 목적 + */ + public void publishAIGenerationJob( + String jobId, + Long userId, + String eventId, + String storeName, + String storeCategory, + String storeDescription, + String objective) { + + AIEventGenerationJobMessage message = AIEventGenerationJobMessage.builder() + .jobId(jobId) + .userId(userId) + .status("PENDING") + .createdAt(LocalDateTime.now()) + .build(); + + publishMessage(message); + } + + /** + * AI 이벤트 생성 작업 메시지 발행 + * + * @param message AIEventGenerationJobMessage 객체 + */ + public void publishMessage(AIEventGenerationJobMessage message) { + try { + CompletableFuture> future = + kafkaTemplate.send(aiEventGenerationJobTopic, message.getJobId(), message); + + future.whenComplete((result, ex) -> { + if (ex == null) { + log.info("AI 작업 메시지 발행 성공 - Topic: {}, JobId: {}, Offset: {}", + aiEventGenerationJobTopic, + message.getJobId(), + result.getRecordMetadata().offset()); + } else { + log.error("AI 작업 메시지 발행 실패 - Topic: {}, JobId: {}, Error: {}", + aiEventGenerationJobTopic, + message.getJobId(), + ex.getMessage(), ex); + } + }); + } catch (Exception e) { + log.error("AI 작업 메시지 발행 중 예외 발생 - JobId: {}, Error: {}", + message.getJobId(), e.getMessage(), e); + } + } +} diff --git a/event-service/src/main/java/com/kt/event/eventservice/presentation/controller/EventController.java b/event-service/src/main/java/com/kt/event/eventservice/presentation/controller/EventController.java index 0902ba0..41cbb74 100644 --- a/event-service/src/main/java/com/kt/event/eventservice/presentation/controller/EventController.java +++ b/event-service/src/main/java/com/kt/event/eventservice/presentation/controller/EventController.java @@ -3,9 +3,8 @@ package com.kt.event.eventservice.presentation.controller; import com.kt.event.common.dto.ApiResponse; import com.kt.event.common.dto.PageResponse; import com.kt.event.common.security.UserPrincipal; -import com.kt.event.eventservice.application.dto.request.SelectObjectiveRequest; -import com.kt.event.eventservice.application.dto.response.EventCreatedResponse; -import com.kt.event.eventservice.application.dto.response.EventDetailResponse; +import com.kt.event.eventservice.application.dto.request.*; +import com.kt.event.eventservice.application.dto.response.*; import com.kt.event.eventservice.application.service.EventService; import com.kt.event.eventservice.domain.enums.EventStatus; import io.swagger.v3.oas.annotations.Operation; @@ -203,4 +202,201 @@ public class EventController { return ResponseEntity.ok(ApiResponse.success(null)); } + + /** + * 이미지 생성 요청 + * + * @param eventId 이벤트 ID + * @param request 이미지 생성 요청 + * @param userPrincipal 인증된 사용자 정보 + * @return 이미지 생성 응답 (Job ID 포함) + */ + @PostMapping("/{eventId}/images") + @Operation(summary = "이미지 생성 요청", description = "AI를 통해 이벤트 이미지를 생성합니다.") + public ResponseEntity> requestImageGeneration( + @PathVariable UUID eventId, + @Valid @RequestBody ImageGenerationRequest request, + @AuthenticationPrincipal UserPrincipal userPrincipal) { + + log.info("이미지 생성 요청 API 호출 - userId: {}, eventId: {}", + userPrincipal.getUserId(), eventId); + + ImageGenerationResponse response = eventService.requestImageGeneration( + userPrincipal.getUserId(), + eventId, + request + ); + + return ResponseEntity.status(HttpStatus.ACCEPTED) + .body(ApiResponse.success(response)); + } + + /** + * 이미지 선택 + * + * @param eventId 이벤트 ID + * @param imageId 이미지 ID + * @param request 이미지 선택 요청 + * @param userPrincipal 인증된 사용자 정보 + * @return 성공 응답 + */ + @PutMapping("/{eventId}/images/{imageId}/select") + @Operation(summary = "이미지 선택", description = "생성된 이미지 중 하나를 선택합니다.") + public ResponseEntity> selectImage( + @PathVariable UUID eventId, + @PathVariable UUID imageId, + @Valid @RequestBody SelectImageRequest request, + @AuthenticationPrincipal UserPrincipal userPrincipal) { + + log.info("이미지 선택 API 호출 - userId: {}, eventId: {}, imageId: {}", + userPrincipal.getUserId(), eventId, imageId); + + eventService.selectImage( + userPrincipal.getUserId(), + eventId, + imageId, + request + ); + + return ResponseEntity.ok(ApiResponse.success(null)); + } + + /** + * AI 추천 요청 (Step 2) + * + * @param eventId 이벤트 ID + * @param request AI 추천 요청 + * @param userPrincipal 인증된 사용자 정보 + * @return AI 추천 요청 응답 (Job ID 포함) + */ + @PostMapping("/{eventId}/ai-recommendations") + @Operation(summary = "AI 추천 요청", description = "AI 서비스에 이벤트 추천 생성을 요청합니다.") + public ResponseEntity> requestAiRecommendations( + @PathVariable UUID eventId, + @Valid @RequestBody AiRecommendationRequest request, + @AuthenticationPrincipal UserPrincipal userPrincipal) { + + log.info("AI 추천 요청 API 호출 - userId: {}, eventId: {}", + userPrincipal.getUserId(), eventId); + + JobAcceptedResponse response = eventService.requestAiRecommendations( + userPrincipal.getUserId(), + eventId, + request + ); + + return ResponseEntity.status(HttpStatus.ACCEPTED) + .body(ApiResponse.success(response)); + } + + /** + * AI 추천 선택 (Step 2-2) + * + * @param eventId 이벤트 ID + * @param request AI 추천 선택 요청 + * @param userPrincipal 인증된 사용자 정보 + * @return 성공 응답 + */ + @PutMapping("/{eventId}/recommendations") + @Operation(summary = "AI 추천 선택", description = "AI가 생성한 추천 중 하나를 선택하고 커스터마이징합니다.") + public ResponseEntity> selectRecommendation( + @PathVariable UUID eventId, + @Valid @RequestBody SelectRecommendationRequest request, + @AuthenticationPrincipal UserPrincipal userPrincipal) { + + log.info("AI 추천 선택 API 호출 - userId: {}, eventId: {}, recommendationId: {}", + userPrincipal.getUserId(), eventId, request.getRecommendationId()); + + eventService.selectRecommendation( + userPrincipal.getUserId(), + eventId, + request + ); + + return ResponseEntity.ok(ApiResponse.success(null)); + } + + /** + * 이미지 편집 (Step 3-3) + * + * @param eventId 이벤트 ID + * @param imageId 이미지 ID + * @param request 이미지 편집 요청 + * @param userPrincipal 인증된 사용자 정보 + * @return 이미지 편집 응답 + */ + @PutMapping("/{eventId}/images/{imageId}/edit") + @Operation(summary = "이미지 편집", description = "선택된 이미지를 편집합니다.") + public ResponseEntity> editImage( + @PathVariable UUID eventId, + @PathVariable UUID imageId, + @Valid @RequestBody ImageEditRequest request, + @AuthenticationPrincipal UserPrincipal userPrincipal) { + + log.info("이미지 편집 API 호출 - userId: {}, eventId: {}, imageId: {}", + userPrincipal.getUserId(), eventId, imageId); + + ImageEditResponse response = eventService.editImage( + userPrincipal.getUserId(), + eventId, + imageId, + request + ); + + return ResponseEntity.ok(ApiResponse.success(response)); + } + + /** + * 배포 채널 선택 (Step 4) + * + * @param eventId 이벤트 ID + * @param request 배포 채널 선택 요청 + * @param userPrincipal 인증된 사용자 정보 + * @return 성공 응답 + */ + @PutMapping("/{eventId}/channels") + @Operation(summary = "배포 채널 선택", description = "이벤트를 배포할 채널을 선택합니다.") + public ResponseEntity> selectChannels( + @PathVariable UUID eventId, + @Valid @RequestBody SelectChannelsRequest request, + @AuthenticationPrincipal UserPrincipal userPrincipal) { + + log.info("배포 채널 선택 API 호출 - userId: {}, eventId: {}, channels: {}", + userPrincipal.getUserId(), eventId, request.getChannels()); + + eventService.selectChannels( + userPrincipal.getUserId(), + eventId, + request + ); + + return ResponseEntity.ok(ApiResponse.success(null)); + } + + /** + * 이벤트 수정 + * + * @param eventId 이벤트 ID + * @param request 이벤트 수정 요청 + * @param userPrincipal 인증된 사용자 정보 + * @return 성공 응답 + */ + @PutMapping("/{eventId}") + @Operation(summary = "이벤트 수정", description = "기존 이벤트의 정보를 수정합니다. DRAFT 상태만 수정 가능합니다.") + public ResponseEntity> updateEvent( + @PathVariable UUID eventId, + @Valid @RequestBody UpdateEventRequest request, + @AuthenticationPrincipal UserPrincipal userPrincipal) { + + log.info("이벤트 수정 API 호출 - userId: {}, eventId: {}", + userPrincipal.getUserId(), eventId); + + EventDetailResponse response = eventService.updateEvent( + userPrincipal.getUserId(), + eventId, + request + ); + + return ResponseEntity.ok(ApiResponse.success(response)); + } } diff --git a/event-service/src/main/java/com/kt/event/eventservice/presentation/controller/RedisTestController.java b/event-service/src/main/java/com/kt/event/eventservice/presentation/controller/RedisTestController.java new file mode 100644 index 0000000..0bdebde --- /dev/null +++ b/event-service/src/main/java/com/kt/event/eventservice/presentation/controller/RedisTestController.java @@ -0,0 +1,39 @@ +package com.kt.event.eventservice.presentation.controller; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.web.bind.annotation.*; + +import java.time.Duration; + +/** + * Redis 연결 테스트 컨트롤러 + */ +@Slf4j +@RestController +@RequestMapping("/api/v1/redis-test") +@RequiredArgsConstructor +public class RedisTestController { + + private final StringRedisTemplate redisTemplate; + + @GetMapping("/ping") + public String ping() { + try { + String key = "test:ping"; + String value = "pong:" + System.currentTimeMillis(); + + log.info("Redis test - setting key: {}, value: {}", key, value); + redisTemplate.opsForValue().set(key, value, Duration.ofSeconds(60)); + + String result = redisTemplate.opsForValue().get(key); + log.info("Redis test - retrieved value: {}", result); + + return "Redis OK - " + result; + } catch (Exception e) { + log.error("Redis connection failed", e); + return "Redis FAILED - " + e.getMessage(); + } + } +} diff --git a/event-service/src/main/resources/application.yml b/event-service/src/main/resources/application.yml index 11d145b..17d2870 100644 --- a/event-service/src/main/resources/application.yml +++ b/event-service/src/main/resources/application.yml @@ -36,11 +36,15 @@ spring: host: ${REDIS_HOST:localhost} port: ${REDIS_PORT:6379} password: ${REDIS_PASSWORD:} + timeout: 60000ms + connect-timeout: 60000ms lettuce: pool: max-active: 10 max-idle: 5 min-idle: 2 + max-wait: -1ms + shutdown-timeout: 200ms # Kafka Configuration kafka: @@ -75,13 +79,17 @@ management: web: exposure: include: health,info,metrics,prometheus + base-path: /actuator endpoint: health: show-details: always + show-components: always health: redis: + enabled: false + livenessState: enabled: true - db: + readinessState: enabled: true # Logging Configuration @@ -90,6 +98,8 @@ logging: root: INFO com.kt.event: ${LOG_LEVEL:DEBUG} org.springframework: INFO + org.springframework.data.redis: DEBUG + io.lettuce.core: DEBUG org.hibernate.SQL: ${SQL_LOG_LEVEL:DEBUG} org.hibernate.type.descriptor.sql.BasicBinder: TRACE pattern: @@ -115,6 +125,10 @@ feign: readTimeout: 10000 loggerLevel: basic + # Content Service Client + content-service: + url: ${CONTENT_SERVICE_URL:http://localhost:8082} + # Distribution Service Client distribution-service: url: ${DISTRIBUTION_SERVICE_URL:http://localhost:8084} @@ -140,3 +154,8 @@ app: timeout: ai-generation: 300000 # 5분 (밀리초 단위) image-generation: 300000 # 5분 (밀리초 단위) + +# JWT Configuration +jwt: + secret: ${JWT_SECRET:default-jwt-secret-key-for-development-minimum-32-bytes-required} + expiration: 86400000 # 24시간 (밀리초 단위) diff --git a/generate-test-token.py b/generate-test-token.py new file mode 100644 index 0000000..70aaf7e --- /dev/null +++ b/generate-test-token.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +""" +JWT 테스트 토큰 생성 스크립트 +Event Service API 테스트용 +""" + +import jwt +import datetime +import uuid + +# JWT Secret (run-event-service.ps1과 동일) +JWT_SECRET = "kt-event-marketing-jwt-secret-key-for-development-only-minimum-256-bits-required" + +# 유효기간을 매우 길게 설정 (테스트용) +EXPIRATION_DAYS = 365 + +# 테스트 사용자 정보 +USER_ID = str(uuid.uuid4()) +STORE_ID = str(uuid.uuid4()) +EMAIL = "test@example.com" +NAME = "Test User" +ROLES = ["ROLE_USER"] + +def generate_access_token(): + """Access Token 생성""" + now = datetime.datetime.utcnow() + expiry = now + datetime.timedelta(days=EXPIRATION_DAYS) + + payload = { + 'sub': USER_ID, + 'storeId': STORE_ID, + 'email': EMAIL, + 'name': NAME, + 'roles': ROLES, + 'type': 'access', + 'iat': now, + 'exp': expiry + } + + token = jwt.encode(payload, JWT_SECRET, algorithm='HS256') + return token + +if __name__ == '__main__': + print("=" * 80) + print("JWT 테스트 토큰 생성") + print("=" * 80) + print() + print(f"User ID: {USER_ID}") + print(f"Store ID: {STORE_ID}") + print(f"Email: {EMAIL}") + print(f"Name: {NAME}") + print(f"Roles: {ROLES}") + print() + print("=" * 80) + print("Access Token:") + print("=" * 80) + + token = generate_access_token() + print(token) + print() + print("=" * 80) + print("사용 방법:") + print("=" * 80) + print("curl -H \"Authorization: Bearer \" http://localhost:8081/api/v1/events") + print() diff --git a/run-event-service.ps1 b/run-event-service.ps1 new file mode 100644 index 0000000..087d337 --- /dev/null +++ b/run-event-service.ps1 @@ -0,0 +1,22 @@ +# Event Service 실행 스크립트 + +$env:SERVER_PORT="8081" +$env:DB_HOST="20.249.177.232" +$env:DB_PORT="5432" +$env:DB_NAME="eventdb" +$env:DB_USERNAME="eventuser" +$env:DB_PASSWORD="Hi5Jessica!" +$env:REDIS_HOST="localhost" +$env:REDIS_PORT="6379" +$env:REDIS_PASSWORD="" +$env:KAFKA_BOOTSTRAP_SERVERS="20.249.182.13:9095,4.217.131.59:9095" +$env:DDL_AUTO="update" +$env:LOG_LEVEL="DEBUG" +$env:SQL_LOG_LEVEL="DEBUG" +$env:CONTENT_SERVICE_URL="http://localhost:8082" +$env:DISTRIBUTION_SERVICE_URL="http://localhost:8084" +$env:JWT_SECRET="kt-event-marketing-jwt-secret-key-for-development-only-minimum-256-bits-required" + +Write-Host "Starting Event Service on port 8081..." -ForegroundColor Green +Write-Host "Logs will be saved to logs/event-service.log" -ForegroundColor Yellow +./gradlew event-service:bootRun 2>&1 | Tee-Object -FilePath logs/event-service.log diff --git a/test-token.txt b/test-token.txt new file mode 100644 index 0000000..9e5b876 --- /dev/null +++ b/test-token.txt @@ -0,0 +1,22 @@ +C:\Users\KTDS\home\workspace\kt-event-marketing\generate-test-token.py:26: DeprecationWarning: datetime.datetime.utcnow() is deprecated and scheduled for removal in a future version. Use timezone-aware objects to represent datetimes in UTC: datetime.datetime.now(datetime.UTC). + now = datetime.datetime.utcnow() +================================================================================ +JWT ׽Ʈ ū +================================================================================ + +User ID: 6db043d0-b303-4577-b9dd-6d366cc59fa0 +Store ID: 34000028-01fd-4ed1-975c-35f7c88b6547 +Email: test@example.com +Name: Test User +Roles: ['ROLE_USER'] + +================================================================================ +Access Token: +================================================================================ +eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI2ZGIwNDNkMC1iMzAzLTQ1NzctYjlkZC02ZDM2NmNjNTlmYTAiLCJzdG9yZUlkIjoiMzQwMDAwMjgtMDFmZC00ZWQxLTk3NWMtMzVmN2M4OGI2NTQ3IiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIiwibmFtZSI6IlRlc3QgVXNlciIsInJvbGVzIjpbIlJPTEVfVVNFUiJdLCJ0eXBlIjoiYWNjZXNzIiwiaWF0IjoxNzYxNTQ5MjkxLCJleHAiOjE3OTMwODUyOTF9.PfQ_NhXRjdfsmQn0NcAKgxcje2XaIL-TlQk_f_DVU38 + +================================================================================ + : +================================================================================ +curl -H "Authorization: Bearer " http://localhost:8081/api/v1/events +