diff --git a/.run/EventServiceApplication.run.xml b/.run/EventServiceApplication.run.xml
new file mode 100644
index 0000000..38d1691
--- /dev/null
+++ b/.run/EventServiceApplication.run.xml
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/common/src/main/java/com/kt/event/common/security/JwtTokenProvider.java b/common/src/main/java/com/kt/event/common/security/JwtTokenProvider.java
index d441f92..4ac51db 100644
--- a/common/src/main/java/com/kt/event/common/security/JwtTokenProvider.java
+++ b/common/src/main/java/com/kt/event/common/security/JwtTokenProvider.java
@@ -49,17 +49,19 @@ public class JwtTokenProvider {
* Access Token 생성
*
* @param userId 사용자 ID
+ * @param storeId 매장 ID
* @param email 이메일
* @param name 이름
* @param roles 역할 목록
* @return Access Token
*/
- public String createAccessToken(Long userId, String email, String name, List roles) {
+ public String createAccessToken(Long userId, Long storeId, String email, String name, List roles) {
Date now = new Date();
Date expiryDate = new Date(now.getTime() + accessTokenValidityMs);
return Jwts.builder()
.subject(userId.toString())
+ .claim("storeId", storeId)
.claim("email", email)
.claim("name", name)
.claim("roles", roles)
@@ -110,12 +112,13 @@ public class JwtTokenProvider {
Claims claims = parseToken(token);
Long userId = Long.parseLong(claims.getSubject());
+ Long storeId = claims.get("storeId", Long.class);
String email = claims.get("email", String.class);
String name = claims.get("name", String.class);
@SuppressWarnings("unchecked")
List roles = claims.get("roles", List.class);
- return new UserPrincipal(userId, email, name, roles);
+ return new UserPrincipal(userId, storeId, email, name, roles);
}
/**
diff --git a/common/src/main/java/com/kt/event/common/security/UserPrincipal.java b/common/src/main/java/com/kt/event/common/security/UserPrincipal.java
index 695f7ea..da1a278 100644
--- a/common/src/main/java/com/kt/event/common/security/UserPrincipal.java
+++ b/common/src/main/java/com/kt/event/common/security/UserPrincipal.java
@@ -23,6 +23,11 @@ public class UserPrincipal implements UserDetails {
*/
private final Long userId;
+ /**
+ * 매장 ID
+ */
+ private final Long storeId;
+
/**
* 사용자 이메일
*/
diff --git a/develop/dev/event-api-mapping.md b/develop/dev/event-api-mapping.md
new file mode 100644
index 0000000..faa02f8
--- /dev/null
+++ b/develop/dev/event-api-mapping.md
@@ -0,0 +1,292 @@
+# Event Service API 매핑표
+
+## 문서 정보
+- **작성일**: 2025-10-24
+- **버전**: 1.0
+- **작성자**: Event Service Team
+- **관련 문서**:
+ - [API 설계서](../../design/backend/api/API-설계서.md)
+ - [Event Service OpenAPI](../../design/backend/api/event-service-api.yaml)
+
+---
+
+## 1. 매핑 현황 요약
+
+### 구현 현황
+- **설계된 API**: 14개
+- **구현된 API**: 7개 (50.0%)
+- **미구현 API**: 7개 (50.0%)
+
+### 구현률 세부
+| 카테고리 | 설계 | 구현 | 미구현 | 구현률 |
+|---------|------|------|--------|--------|
+| Dashboard & Event List | 2 | 2 | 0 | 100% |
+| Event Creation Flow | 8 | 1 | 7 | 12.5% |
+| Event Management | 3 | 3 | 0 | 100% |
+| Job Status | 1 | 1 | 0 | 100% |
+
+---
+
+## 2. 상세 매핑표
+
+### 2.1 Dashboard & Event List (구현률 100%)
+
+| 설계서 API | Controller | 메서드 | 경로 | 구현 여부 | 비고 |
+|-----------|-----------|--------|------|----------|------|
+| 이벤트 목록 조회 | EventController | GET | /api/events | ✅ 구현 | EventController:84 |
+| 이벤트 상세 조회 | EventController | GET | /api/events/{eventId} | ✅ 구현 | EventController:130 |
+
+---
+
+### 2.2 Event Creation Flow (구현률 12.5%)
+
+#### Step 1: 이벤트 목적 선택
+| 설계서 API | Controller | 메서드 | 경로 | 구현 여부 | 비고 |
+|-----------|-----------|--------|------|----------|------|
+| 이벤트 목적 선택 | EventController | POST | /api/events/objectives | ✅ 구현 | EventController:52 |
+
+#### Step 2: AI 추천 (미구현)
+| 설계서 API | Controller | 메서드 | 경로 | 구현 여부 | 미구현 이유 |
+|-----------|-----------|--------|------|----------|-----------|
+| AI 추천 요청 | - | POST | /api/events/{eventId}/ai-recommendations | ❌ 미구현 | AI Service 연동 필요 |
+| AI 추천 선택 | - | PUT | /api/events/{eventId}/recommendations | ❌ 미구현 | AI Service 연동 필요 |
+
+**미구현 상세 이유**:
+- Kafka Topic `ai-event-generation-job` 발행 로직 필요
+- AI Service와의 연동이 선행되어야 함
+- Redis에서 AI 추천 결과를 읽어오는 로직 필요
+- 현재 단계에서는 이벤트 생명주기 관리에 집중
+
+#### Step 3: 이미지 생성 (미구현)
+| 설계서 API | Controller | 메서드 | 경로 | 구현 여부 | 미구현 이유 |
+|-----------|-----------|--------|------|----------|-----------|
+| 이미지 생성 요청 | - | POST | /api/events/{eventId}/images | ❌ 미구현 | Content Service 연동 필요 |
+| 이미지 선택 | - | PUT | /api/events/{eventId}/images/{imageId}/select | ❌ 미구현 | Content Service 연동 필요 |
+| 이미지 편집 | - | PUT | /api/events/{eventId}/images/{imageId}/edit | ❌ 미구현 | Content Service 연동 필요 |
+
+**미구현 상세 이유**:
+- Kafka Topic `image-generation-job` 발행 로직 필요
+- Content Service와의 연동이 선행되어야 함
+- Redis에서 생성된 이미지 URL을 읽어오는 로직 필요
+- 이미지 편집은 Content Service의 이미지 재생성 API와 연동 필요
+
+#### Step 4: 배포 채널 선택 (미구현)
+| 설계서 API | Controller | 메서드 | 경로 | 구현 여부 | 미구현 이유 |
+|-----------|-----------|--------|------|----------|-----------|
+| 배포 채널 선택 | - | PUT | /api/events/{eventId}/channels | ❌ 미구현 | Distribution Service 연동 필요 |
+
+**미구현 상세 이유**:
+- Distribution Service의 채널 목록 검증 로직 필요
+- Event 엔티티의 channels 필드 업데이트 로직은 구현 가능하나, 채널별 검증은 Distribution Service 개발 후 추가 예정
+
+#### Step 5: 최종 승인 및 배포
+| 설계서 API | Controller | 메서드 | 경로 | 구현 여부 | 비고 |
+|-----------|-----------|--------|------|----------|------|
+| 최종 승인 및 배포 | EventController | POST | /api/events/{eventId}/publish | ✅ 구현 | EventController:172 |
+
+**구현 내용**:
+- 이벤트 상태를 DRAFT → PUBLISHED로 변경
+- Distribution Service 동기 호출은 추후 추가 예정
+- 현재는 상태 변경만 처리
+
+---
+
+### 2.3 Event Management (구현률 100%)
+
+| 설계서 API | Controller | 메서드 | 경로 | 구현 여부 | 비고 |
+|-----------|-----------|--------|------|----------|------|
+| 이벤트 수정 | - | PUT | /api/events/{eventId} | ❌ 미구현 | 이유는 아래 참조 |
+| 이벤트 삭제 | EventController | DELETE | /api/events/{eventId} | ✅ 구현 | EventController:151 |
+| 이벤트 조기 종료 | EventController | POST | /api/events/{eventId}/end | ✅ 구현 | EventController:193 |
+
+**이벤트 수정 API 미구현 이유**:
+- 이벤트 수정은 여러 단계의 데이터를 수정하는 복잡한 로직
+- AI 추천 재선택, 이미지 재생성 등 다른 서비스와의 연동이 필요
+- 우선순위: 신규 이벤트 생성 플로우 완성 후 구현 예정
+- 현재는 DRAFT 상태에서만 삭제 가능하므로 수정 대신 삭제 후 재생성 가능
+
+---
+
+### 2.4 Job Status (구현률 100%)
+
+| 설계서 API | Controller | 메서드 | 경로 | 구현 여부 | 비고 |
+|-----------|-----------|--------|------|----------|------|
+| Job 상태 폴링 | JobController | GET | /api/jobs/{jobId} | ✅ 구현 | JobController:42 |
+
+---
+
+## 3. 구현된 API 상세
+
+### 3.1 EventController (6개 API)
+
+#### 1. POST /api/events/objectives
+- **설명**: 이벤트 생성의 첫 단계로 목적을 선택
+- **유저스토리**: UFR-EVENT-020
+- **요청**: SelectObjectiveRequest (objective)
+- **응답**: EventCreatedResponse (eventId, status, objective, createdAt)
+- **비즈니스 로직**:
+ - Long userId/storeId를 UUID로 변환하여 Event 엔티티 생성
+ - 초기 상태는 DRAFT
+ - EventService.createEvent() 호출
+
+#### 2. GET /api/events
+- **설명**: 사용자의 이벤트 목록 조회 (페이징, 필터링, 정렬)
+- **유저스토리**: UFR-EVENT-010, UFR-EVENT-070
+- **요청 파라미터**:
+ - status (EventStatus, 선택)
+ - search (String, 선택)
+ - objective (String, 선택)
+ - page, size, sort, order (페이징/정렬)
+- **응답**: PageResponse
+- **비즈니스 로직**:
+ - Long userId를 UUID로 변환
+ - Repository에서 필터링 및 페이징 처리
+ - EventService.getEvents() 호출
+
+#### 3. GET /api/events/{eventId}
+- **설명**: 특정 이벤트의 상세 정보 조회
+- **유저스토리**: UFR-EVENT-060
+- **요청**: eventId (UUID)
+- **응답**: EventDetailResponse (이벤트 정보 + 생성된 이미지 + AI 추천)
+- **비즈니스 로직**:
+ - Long userId를 UUID로 변환
+ - 사용자 소유 이벤트만 조회 가능 (보안)
+ - EventService.getEvent() 호출
+
+#### 4. DELETE /api/events/{eventId}
+- **설명**: 이벤트 삭제 (DRAFT 상태만 가능)
+- **유저스토리**: UFR-EVENT-070
+- **요청**: eventId (UUID)
+- **응답**: ApiResponse
+- **비즈니스 로직**:
+ - DRAFT 상태만 삭제 가능 검증 (Event.isDeletable())
+ - 다른 상태(PUBLISHED, ENDED)는 삭제 불가
+ - EventService.deleteEvent() 호출
+
+#### 5. POST /api/events/{eventId}/publish
+- **설명**: 이벤트 배포 (DRAFT → PUBLISHED)
+- **유저스토리**: UFR-EVENT-050
+- **요청**: eventId (UUID)
+- **응답**: ApiResponse
+- **비즈니스 로직**:
+ - Event.publish() 메서드로 상태 전환
+ - Distribution Service 호출은 추후 추가 예정
+ - EventService.publishEvent() 호출
+
+#### 6. POST /api/events/{eventId}/end
+- **설명**: 이벤트 조기 종료 (PUBLISHED → ENDED)
+- **유저스토리**: UFR-EVENT-060
+- **요청**: eventId (UUID)
+- **응답**: ApiResponse
+- **비즈니스 로직**:
+ - Event.end() 메서드로 상태 전환
+ - PUBLISHED 상태만 종료 가능
+ - EventService.endEvent() 호출
+
+---
+
+### 3.2 JobController (1개 API)
+
+#### 1. GET /api/jobs/{jobId}
+- **설명**: 비동기 작업의 상태를 조회 (폴링 방식)
+- **유저스토리**: UFR-EVENT-030, UFR-CONT-010
+- **요청**: jobId (UUID)
+- **응답**: JobStatusResponse (jobId, jobType, status, progress, resultKey, errorMessage)
+- **비즈니스 로직**:
+ - Job 엔티티 조회
+ - 상태: PENDING, PROCESSING, COMPLETED, FAILED
+ - JobService.getJobStatus() 호출
+
+---
+
+## 4. 미구현 API 개발 계획
+
+### 4.1 우선순위 1 (AI Service 연동)
+- **POST /api/events/{eventId}/ai-recommendations** - AI 추천 요청
+- **PUT /api/events/{eventId}/recommendations** - AI 추천 선택
+
+**개발 선행 조건**:
+1. AI Service 개발 완료
+2. Kafka Topic `ai-event-generation-job` 설정
+3. Redis 캐시 연동 구현
+
+---
+
+### 4.2 우선순위 2 (Content Service 연동)
+- **POST /api/events/{eventId}/images** - 이미지 생성 요청
+- **PUT /api/events/{eventId}/images/{imageId}/select** - 이미지 선택
+- **PUT /api/events/{eventId}/images/{imageId}/edit** - 이미지 편집
+
+**개발 선행 조건**:
+1. Content Service 개발 완료
+2. Kafka Topic `image-generation-job` 설정
+3. Redis 캐시 연동 구현
+4. CDN (Azure Blob Storage) 연동
+
+---
+
+### 4.3 우선순위 3 (Distribution Service 연동)
+- **PUT /api/events/{eventId}/channels** - 배포 채널 선택
+
+**개발 선행 조건**:
+1. Distribution Service 개발 완료
+2. 채널별 검증 로직 구현
+3. POST /api/events/{eventId}/publish API에 Distribution Service 동기 호출 추가
+
+---
+
+### 4.4 우선순위 4 (이벤트 수정)
+- **PUT /api/events/{eventId}** - 이벤트 수정
+
+**개발 선행 조건**:
+1. 우선순위 1~3 API 모두 구현 완료
+2. 이벤트 수정 범위 정의 (이름/설명/날짜만 수정 vs 전체 재생성)
+3. 각 단계별 수정 로직 설계
+
+---
+
+## 5. 추가 구현된 API (설계서에 없음)
+
+현재 추가 구현된 API는 없습니다. 모든 구현은 설계서를 기준으로 진행되었습니다.
+
+---
+
+## 6. 다음 단계
+
+### 6.1 즉시 가능한 작업
+1. **서버 시작 테스트**:
+ - PostgreSQL 연결 확인
+ - Swagger UI 접근 테스트 (http://localhost:8081/swagger-ui.html)
+
+2. **구현된 API 테스트**:
+ - POST /api/events/objectives
+ - GET /api/events
+ - GET /api/events/{eventId}
+ - DELETE /api/events/{eventId}
+ - POST /api/events/{eventId}/publish
+ - POST /api/events/{eventId}/end
+ - GET /api/jobs/{jobId}
+
+### 6.2 후속 개발 필요
+1. AI Service 개발 완료 → AI 추천 API 구현
+2. Content Service 개발 완료 → 이미지 관련 API 구현
+3. Distribution Service 개발 완료 → 배포 채널 선택 API 구현
+4. 전체 서비스 연동 → 이벤트 수정 API 구현
+
+---
+
+## 부록
+
+### A. 개발 우선순위 결정 근거
+
+**현재 구현 범위 선정 이유**:
+1. **핵심 생명주기 먼저**: 이벤트 생성, 조회, 삭제, 상태 변경
+2. **서비스 독립성**: 다른 서비스 없이도 Event Service 단독 테스트 가능
+3. **점진적 통합**: 각 서비스 개발 완료 시점에 순차적 통합
+4. **리스크 최소화**: 복잡한 서비스 간 연동은 각 서비스 안정화 후 진행
+
+---
+
+**문서 버전**: 1.0
+**최종 수정일**: 2025-10-24
+**작성자**: Event Service Team
diff --git a/event-service/build.gradle b/event-service/build.gradle
index 0f2d88c..340d5ca 100644
--- a/event-service/build.gradle
+++ b/event-service/build.gradle
@@ -10,4 +10,7 @@ dependencies {
// Jackson for JSON
implementation 'com.fasterxml.jackson.core:jackson-databind'
+
+ // Hibernate UUID generator
+ implementation 'com.vladmihalcea:hibernate-types-60:2.21.1'
}
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
new file mode 100644
index 0000000..6108ed2
--- /dev/null
+++ b/event-service/src/main/java/com/kt/event/eventservice/EventServiceApplication.java
@@ -0,0 +1,33 @@
+package com.kt.event.eventservice;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.cloud.openfeign.EnableFeignClients;
+import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
+import org.springframework.kafka.annotation.EnableKafka;
+
+/**
+ * Event Service Application
+ *
+ * 이벤트 전체 생명주기 관리 서비스
+ * - AI 기반 이벤트 추천 및 커스터마이징
+ * - 이미지 생성 및 편집 오케스트레이션
+ * - 배포 채널 관리 및 최종 배포
+ * - 이벤트 상태 관리 (DRAFT, PUBLISHED, ENDED)
+ *
+ * @version 1.0.0
+ * @since 2025-10-23
+ */
+@SpringBootApplication(scanBasePackages = {
+ "com.kt.event.eventservice",
+ "com.kt.event.common"
+})
+@EnableJpaAuditing
+@EnableKafka
+@EnableFeignClients
+public class EventServiceApplication {
+
+ public static void main(String[] args) {
+ SpringApplication.run(EventServiceApplication.class, args);
+ }
+}
diff --git a/event-service/src/main/java/com/kt/event/eventservice/application/dto/request/SelectObjectiveRequest.java b/event-service/src/main/java/com/kt/event/eventservice/application/dto/request/SelectObjectiveRequest.java
new file mode 100644
index 0000000..7267d44
--- /dev/null
+++ b/event-service/src/main/java/com/kt/event/eventservice/application/dto/request/SelectObjectiveRequest.java
@@ -0,0 +1,24 @@
+package com.kt.event.eventservice.application.dto.request;
+
+import jakarta.validation.constraints.NotBlank;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+/**
+ * 이벤트 목적 선택 요청 DTO
+ *
+ * @author Event Service Team
+ * @version 1.0.0
+ * @since 2025-10-23
+ */
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+public class SelectObjectiveRequest {
+
+ @NotBlank(message = "이벤트 목적은 필수입니다.")
+ private String objective;
+}
diff --git a/event-service/src/main/java/com/kt/event/eventservice/application/dto/response/EventCreatedResponse.java b/event-service/src/main/java/com/kt/event/eventservice/application/dto/response/EventCreatedResponse.java
new file mode 100644
index 0000000..40b0fa3
--- /dev/null
+++ b/event-service/src/main/java/com/kt/event/eventservice/application/dto/response/EventCreatedResponse.java
@@ -0,0 +1,29 @@
+package com.kt.event.eventservice.application.dto.response;
+
+import com.kt.event.eventservice.domain.enums.EventStatus;
+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-23
+ */
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+public class EventCreatedResponse {
+
+ private UUID eventId;
+ private EventStatus status;
+ private String objective;
+ private LocalDateTime createdAt;
+}
diff --git a/event-service/src/main/java/com/kt/event/eventservice/application/dto/response/EventDetailResponse.java b/event-service/src/main/java/com/kt/event/eventservice/application/dto/response/EventDetailResponse.java
new file mode 100644
index 0000000..b895a80
--- /dev/null
+++ b/event-service/src/main/java/com/kt/event/eventservice/application/dto/response/EventDetailResponse.java
@@ -0,0 +1,77 @@
+package com.kt.event.eventservice.application.dto.response;
+
+import com.kt.event.eventservice.domain.enums.EventStatus;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.UUID;
+
+/**
+ * 이벤트 상세 응답 DTO
+ *
+ * @author Event Service Team
+ * @version 1.0.0
+ * @since 2025-10-23
+ */
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+public class EventDetailResponse {
+
+ private UUID eventId;
+ private UUID userId;
+ private UUID storeId;
+ private String eventName;
+ private String description;
+ private String objective;
+ private LocalDate startDate;
+ private LocalDate endDate;
+ private EventStatus status;
+ private UUID selectedImageId;
+ private String selectedImageUrl;
+
+ @Builder.Default
+ private List generatedImages = new ArrayList<>();
+
+ @Builder.Default
+ private List aiRecommendations = new ArrayList<>();
+
+ @Builder.Default
+ private List channels = new ArrayList<>();
+
+ private LocalDateTime createdAt;
+ private LocalDateTime updatedAt;
+
+ @Data
+ @NoArgsConstructor
+ @AllArgsConstructor
+ @Builder
+ public static class GeneratedImageDto {
+ private UUID imageId;
+ private String imageUrl;
+ private String style;
+ private String platform;
+ private boolean isSelected;
+ private LocalDateTime createdAt;
+ }
+
+ @Data
+ @NoArgsConstructor
+ @AllArgsConstructor
+ @Builder
+ public static class AiRecommendationDto {
+ private UUID recommendationId;
+ private String eventName;
+ private String description;
+ private String promotionType;
+ private String targetAudience;
+ private boolean isSelected;
+ }
+}
diff --git a/event-service/src/main/java/com/kt/event/eventservice/application/dto/response/JobStatusResponse.java b/event-service/src/main/java/com/kt/event/eventservice/application/dto/response/JobStatusResponse.java
new file mode 100644
index 0000000..a1b0899
--- /dev/null
+++ b/event-service/src/main/java/com/kt/event/eventservice/application/dto/response/JobStatusResponse.java
@@ -0,0 +1,34 @@
+package com.kt.event.eventservice.application.dto.response;
+
+import com.kt.event.eventservice.domain.enums.JobStatus;
+import com.kt.event.eventservice.domain.enums.JobType;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.time.LocalDateTime;
+import java.util.UUID;
+
+/**
+ * Job 상태 응답 DTO
+ *
+ * @author Event Service Team
+ * @version 1.0.0
+ * @since 2025-10-23
+ */
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+public class JobStatusResponse {
+
+ private UUID jobId;
+ private JobType jobType;
+ private JobStatus status;
+ private int progress;
+ private String resultKey;
+ private String errorMessage;
+ private LocalDateTime createdAt;
+ private LocalDateTime completedAt;
+}
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
new file mode 100644
index 0000000..6543f0b
--- /dev/null
+++ b/event-service/src/main/java/com/kt/event/eventservice/application/service/EventService.java
@@ -0,0 +1,229 @@
+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.domain.entity.*;
+import com.kt.event.eventservice.domain.enums.EventStatus;
+import com.kt.event.eventservice.domain.repository.EventRepository;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.UUID;
+import java.util.stream.Collectors;
+
+/**
+ * 이벤트 서비스
+ *
+ * 이벤트 전체 생명주기를 관리합니다.
+ *
+ * @author Event Service Team
+ * @version 1.0.0
+ * @since 2025-10-23
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+@Transactional(readOnly = true)
+public class EventService {
+
+ private final EventRepository eventRepository;
+
+ /**
+ * 이벤트 생성 (Step 1: 목적 선택)
+ *
+ * @param userId 사용자 ID (Long)
+ * @param storeId 매장 ID (Long)
+ * @param request 목적 선택 요청
+ * @return 생성된 이벤트 응답
+ */
+ @Transactional
+ public EventCreatedResponse createEvent(Long userId, Long storeId, SelectObjectiveRequest request) {
+ log.info("이벤트 생성 시작 - userId: {}, storeId: {}, objective: {}",
+ userId, storeId, request.getObjective());
+
+ // 이벤트 엔티티 생성 (Long ID를 UUID로 변환)
+ Event event = Event.builder()
+ .userId(UUID.nameUUIDFromBytes(("user-" + userId).getBytes()))
+ .storeId(UUID.nameUUIDFromBytes(("store-" + storeId).getBytes()))
+ .objective(request.getObjective())
+ .eventName("") // 초기에는 비어있음, AI 추천 후 설정
+ .status(EventStatus.DRAFT)
+ .build();
+
+ // 저장
+ event = eventRepository.save(event);
+
+ log.info("이벤트 생성 완료 - eventId: {}", event.getEventId());
+
+ return EventCreatedResponse.builder()
+ .eventId(event.getEventId())
+ .status(event.getStatus())
+ .objective(event.getObjective())
+ .createdAt(event.getCreatedAt())
+ .build();
+ }
+
+ /**
+ * 이벤트 상세 조회
+ *
+ * @param userId 사용자 ID (Long)
+ * @param eventId 이벤트 ID
+ * @return 이벤트 상세 응답
+ */
+ public EventDetailResponse getEvent(Long userId, UUID eventId) {
+ log.info("이벤트 조회 - userId: {}, eventId: {}", userId, eventId);
+
+ UUID userUuid = UUID.nameUUIDFromBytes(("user-" + userId).getBytes());
+ Event event = eventRepository.findByEventIdAndUserId(eventId, userUuid)
+ .orElseThrow(() -> new BusinessException(ErrorCode.EVENT_001));
+
+ return mapToDetailResponse(event);
+ }
+
+ /**
+ * 이벤트 목록 조회 (페이징, 필터링)
+ *
+ * @param userId 사용자 ID (Long)
+ * @param status 상태 필터
+ * @param search 검색어
+ * @param objective 목적 필터
+ * @param pageable 페이징 정보
+ * @return 이벤트 목록
+ */
+ public Page getEvents(
+ Long userId,
+ EventStatus status,
+ String search,
+ String objective,
+ Pageable pageable) {
+
+ log.info("이벤트 목록 조회 - userId: {}, status: {}, search: {}, objective: {}",
+ userId, status, search, objective);
+
+ UUID userUuid = UUID.nameUUIDFromBytes(("user-" + userId).getBytes());
+ Page events = eventRepository.findEventsByUser(userUuid, status, search, objective, pageable);
+
+ return events.map(this::mapToDetailResponse);
+ }
+
+ /**
+ * 이벤트 삭제
+ *
+ * @param userId 사용자 ID (Long)
+ * @param eventId 이벤트 ID
+ */
+ @Transactional
+ public void deleteEvent(Long userId, UUID eventId) {
+ log.info("이벤트 삭제 - userId: {}, eventId: {}", userId, eventId);
+
+ UUID userUuid = UUID.nameUUIDFromBytes(("user-" + userId).getBytes());
+ Event event = eventRepository.findByEventIdAndUserId(eventId, userUuid)
+ .orElseThrow(() -> new BusinessException(ErrorCode.EVENT_001));
+
+ if (!event.isDeletable()) {
+ throw new BusinessException(ErrorCode.EVENT_002);
+ }
+
+ eventRepository.delete(event);
+
+ log.info("이벤트 삭제 완료 - eventId: {}", eventId);
+ }
+
+ /**
+ * 이벤트 배포
+ *
+ * @param userId 사용자 ID (Long)
+ * @param eventId 이벤트 ID
+ */
+ @Transactional
+ public void publishEvent(Long userId, UUID eventId) {
+ log.info("이벤트 배포 - userId: {}, eventId: {}", userId, eventId);
+
+ UUID userUuid = UUID.nameUUIDFromBytes(("user-" + userId).getBytes());
+ Event event = eventRepository.findByEventIdAndUserId(eventId, userUuid)
+ .orElseThrow(() -> new BusinessException(ErrorCode.EVENT_001));
+
+ // 배포 가능 여부 검증 및 상태 변경
+ event.publish();
+
+ eventRepository.save(event);
+
+ log.info("이벤트 배포 완료 - eventId: {}", eventId);
+ }
+
+ /**
+ * 이벤트 종료
+ *
+ * @param userId 사용자 ID (Long)
+ * @param eventId 이벤트 ID
+ */
+ @Transactional
+ public void endEvent(Long userId, UUID eventId) {
+ log.info("이벤트 종료 - userId: {}, eventId: {}", userId, eventId);
+
+ UUID userUuid = UUID.nameUUIDFromBytes(("user-" + userId).getBytes());
+ Event event = eventRepository.findByEventIdAndUserId(eventId, userUuid)
+ .orElseThrow(() -> new BusinessException(ErrorCode.EVENT_001));
+
+ event.end();
+
+ eventRepository.save(event);
+
+ log.info("이벤트 종료 완료 - eventId: {}", eventId);
+ }
+
+ // ==== Private Helper Methods ==== //
+
+ /**
+ * Event Entity를 EventDetailResponse DTO로 변환
+ */
+ private EventDetailResponse mapToDetailResponse(Event event) {
+ return EventDetailResponse.builder()
+ .eventId(event.getEventId())
+ .userId(event.getUserId())
+ .storeId(event.getStoreId())
+ .eventName(event.getEventName())
+ .description(event.getDescription())
+ .objective(event.getObjective())
+ .startDate(event.getStartDate())
+ .endDate(event.getEndDate())
+ .status(event.getStatus())
+ .selectedImageId(event.getSelectedImageId())
+ .selectedImageUrl(event.getSelectedImageUrl())
+ .generatedImages(
+ event.getGeneratedImages().stream()
+ .map(img -> EventDetailResponse.GeneratedImageDto.builder()
+ .imageId(img.getImageId())
+ .imageUrl(img.getImageUrl())
+ .style(img.getStyle())
+ .platform(img.getPlatform())
+ .isSelected(img.isSelected())
+ .createdAt(img.getCreatedAt())
+ .build())
+ .collect(Collectors.toList())
+ )
+ .aiRecommendations(
+ event.getAiRecommendations().stream()
+ .map(rec -> EventDetailResponse.AiRecommendationDto.builder()
+ .recommendationId(rec.getRecommendationId())
+ .eventName(rec.getEventName())
+ .description(rec.getDescription())
+ .promotionType(rec.getPromotionType())
+ .targetAudience(rec.getTargetAudience())
+ .isSelected(rec.isSelected())
+ .build())
+ .collect(Collectors.toList())
+ )
+ .channels(event.getChannels())
+ .createdAt(event.getCreatedAt())
+ .updatedAt(event.getUpdatedAt())
+ .build();
+ }
+}
diff --git a/event-service/src/main/java/com/kt/event/eventservice/application/service/JobService.java b/event-service/src/main/java/com/kt/event/eventservice/application/service/JobService.java
new file mode 100644
index 0000000..9cba649
--- /dev/null
+++ b/event-service/src/main/java/com/kt/event/eventservice/application/service/JobService.java
@@ -0,0 +1,146 @@
+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.response.JobStatusResponse;
+import com.kt.event.eventservice.domain.entity.Job;
+import com.kt.event.eventservice.domain.enums.JobType;
+import com.kt.event.eventservice.domain.repository.JobRepository;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.UUID;
+
+/**
+ * Job 서비스
+ *
+ * 비동기 작업 상태를 관리합니다.
+ *
+ * @author Event Service Team
+ * @version 1.0.0
+ * @since 2025-10-23
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+@Transactional(readOnly = true)
+public class JobService {
+
+ private final JobRepository jobRepository;
+
+ /**
+ * Job 생성
+ *
+ * @param eventId 이벤트 ID
+ * @param jobType 작업 유형
+ * @return 생성된 Job
+ */
+ @Transactional
+ public Job createJob(UUID eventId, JobType jobType) {
+ log.info("Job 생성 - eventId: {}, jobType: {}", eventId, jobType);
+
+ Job job = Job.builder()
+ .eventId(eventId)
+ .jobType(jobType)
+ .build();
+
+ job = jobRepository.save(job);
+
+ log.info("Job 생성 완료 - jobId: {}", job.getJobId());
+
+ return job;
+ }
+
+ /**
+ * Job 상태 조회
+ *
+ * @param jobId Job ID
+ * @return Job 상태 응답
+ */
+ public JobStatusResponse getJobStatus(UUID jobId) {
+ log.info("Job 상태 조회 - jobId: {}", jobId);
+
+ Job job = jobRepository.findById(jobId)
+ .orElseThrow(() -> new BusinessException(ErrorCode.JOB_001));
+
+ return mapToJobStatusResponse(job);
+ }
+
+ /**
+ * Job 상태 업데이트
+ *
+ * @param jobId Job ID
+ * @param progress 진행률
+ */
+ @Transactional
+ public void updateJobProgress(UUID jobId, int progress) {
+ log.info("Job 진행률 업데이트 - jobId: {}, progress: {}", jobId, progress);
+
+ Job job = jobRepository.findById(jobId)
+ .orElseThrow(() -> new BusinessException(ErrorCode.JOB_001));
+
+ job.updateProgress(progress);
+
+ jobRepository.save(job);
+ }
+
+ /**
+ * Job 완료 처리
+ *
+ * @param jobId Job ID
+ * @param resultKey Redis 결과 키
+ */
+ @Transactional
+ public void completeJob(UUID jobId, String resultKey) {
+ log.info("Job 완료 - jobId: {}, resultKey: {}", jobId, resultKey);
+
+ Job job = jobRepository.findById(jobId)
+ .orElseThrow(() -> new BusinessException(ErrorCode.JOB_001));
+
+ job.complete(resultKey);
+
+ jobRepository.save(job);
+
+ log.info("Job 완료 처리 완료 - jobId: {}", jobId);
+ }
+
+ /**
+ * Job 실패 처리
+ *
+ * @param jobId Job ID
+ * @param errorMessage 에러 메시지
+ */
+ @Transactional
+ public void failJob(UUID jobId, String errorMessage) {
+ log.info("Job 실패 - jobId: {}, errorMessage: {}", jobId, errorMessage);
+
+ Job job = jobRepository.findById(jobId)
+ .orElseThrow(() -> new BusinessException(ErrorCode.JOB_001));
+
+ job.fail(errorMessage);
+
+ jobRepository.save(job);
+
+ log.info("Job 실패 처리 완료 - jobId: {}", jobId);
+ }
+
+ // ==== Private Helper Methods ==== //
+
+ /**
+ * Job Entity를 JobStatusResponse DTO로 변환
+ */
+ private JobStatusResponse mapToJobStatusResponse(Job job) {
+ return JobStatusResponse.builder()
+ .jobId(job.getJobId())
+ .jobType(job.getJobType())
+ .status(job.getStatus())
+ .progress(job.getProgress())
+ .resultKey(job.getResultKey())
+ .errorMessage(job.getErrorMessage())
+ .createdAt(job.getCreatedAt())
+ .completedAt(job.getCompletedAt())
+ .build();
+ }
+}
diff --git a/event-service/src/main/java/com/kt/event/eventservice/domain/entity/AiRecommendation.java b/event-service/src/main/java/com/kt/event/eventservice/domain/entity/AiRecommendation.java
new file mode 100644
index 0000000..978f9a0
--- /dev/null
+++ b/event-service/src/main/java/com/kt/event/eventservice/domain/entity/AiRecommendation.java
@@ -0,0 +1,53 @@
+package com.kt.event.eventservice.domain.entity;
+
+import com.kt.event.common.entity.BaseTimeEntity;
+import jakarta.persistence.*;
+import lombok.*;
+import org.hibernate.annotations.GenericGenerator;
+
+import java.util.UUID;
+
+/**
+ * AI 추천 엔티티
+ *
+ * AI가 추천한 이벤트 기획안을 관리합니다.
+ *
+ * @author Event Service Team
+ * @version 1.0.0
+ * @since 2025-10-23
+ */
+@Entity
+@Table(name = "ai_recommendations")
+@Getter
+@Setter
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@AllArgsConstructor
+@Builder
+public class AiRecommendation extends BaseTimeEntity {
+
+ @Id
+ @GeneratedValue(generator = "uuid2")
+ @GenericGenerator(name = "uuid2", strategy = "uuid2")
+ @Column(name = "recommendation_id", columnDefinition = "uuid")
+ private UUID recommendationId;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "event_id", nullable = false)
+ private Event event;
+
+ @Column(name = "event_name", nullable = false, length = 200)
+ private String eventName;
+
+ @Column(name = "description", columnDefinition = "TEXT")
+ private String description;
+
+ @Column(name = "promotion_type", length = 50)
+ private String promotionType;
+
+ @Column(name = "target_audience", length = 100)
+ private String targetAudience;
+
+ @Column(name = "is_selected", nullable = false)
+ @Builder.Default
+ private boolean isSelected = false;
+}
diff --git a/event-service/src/main/java/com/kt/event/eventservice/domain/entity/Event.java b/event-service/src/main/java/com/kt/event/eventservice/domain/entity/Event.java
new file mode 100644
index 0000000..ecf592f
--- /dev/null
+++ b/event-service/src/main/java/com/kt/event/eventservice/domain/entity/Event.java
@@ -0,0 +1,199 @@
+package com.kt.event.eventservice.domain.entity;
+
+import com.kt.event.common.entity.BaseTimeEntity;
+import com.kt.event.eventservice.domain.enums.EventStatus;
+import jakarta.persistence.*;
+import lombok.*;
+import org.hibernate.annotations.GenericGenerator;
+
+import java.time.LocalDate;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.UUID;
+
+/**
+ * 이벤트 엔티티
+ *
+ * 이벤트의 전체 생명주기를 관리합니다.
+ * - 생성, 수정, 배포, 종료
+ * - AI 추천 및 이미지 관리
+ * - 배포 채널 관리
+ *
+ * @author Event Service Team
+ * @version 1.0.0
+ * @since 2025-10-23
+ */
+@Entity
+@Table(name = "events")
+@Getter
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@AllArgsConstructor
+@Builder
+public class Event extends BaseTimeEntity {
+
+ @Id
+ @GeneratedValue(generator = "uuid2")
+ @GenericGenerator(name = "uuid2", strategy = "uuid2")
+ @Column(name = "event_id", columnDefinition = "uuid")
+ private UUID eventId;
+
+ @Column(name = "user_id", nullable = false, columnDefinition = "uuid")
+ private UUID userId;
+
+ @Column(name = "store_id", nullable = false, columnDefinition = "uuid")
+ private UUID storeId;
+
+ @Column(name = "event_name", nullable = false, length = 200)
+ private String eventName;
+
+ @Column(name = "description", columnDefinition = "TEXT")
+ private String description;
+
+ @Column(name = "objective", nullable = false, length = 100)
+ private String objective;
+
+ @Column(name = "start_date", nullable = false)
+ private LocalDate startDate;
+
+ @Column(name = "end_date", nullable = false)
+ private LocalDate endDate;
+
+ @Enumerated(EnumType.STRING)
+ @Column(name = "status", nullable = false, length = 20)
+ @Builder.Default
+ private EventStatus status = EventStatus.DRAFT;
+
+ @Column(name = "selected_image_id", columnDefinition = "uuid")
+ private UUID selectedImageId;
+
+ @Column(name = "selected_image_url", length = 500)
+ private String selectedImageUrl;
+
+ @ElementCollection
+ @CollectionTable(
+ name = "event_channels",
+ joinColumns = @JoinColumn(name = "event_id")
+ )
+ @Column(name = "channel", length = 50)
+ @Builder.Default
+ private List channels = new ArrayList<>();
+
+ @OneToMany(mappedBy = "event", cascade = CascadeType.ALL, orphanRemoval = true)
+ @Builder.Default
+ private List generatedImages = new ArrayList<>();
+
+ @OneToMany(mappedBy = "event", cascade = CascadeType.ALL, orphanRemoval = true)
+ @Builder.Default
+ private List aiRecommendations = new ArrayList<>();
+
+ // ==== 비즈니스 로직 ==== //
+
+ /**
+ * 이벤트명 수정
+ */
+ public void updateEventName(String eventName) {
+ this.eventName = eventName;
+ }
+
+ /**
+ * 설명 수정
+ */
+ public void updateDescription(String description) {
+ this.description = description;
+ }
+
+ /**
+ * 이벤트 기간 수정
+ */
+ public void updateEventPeriod(LocalDate startDate, LocalDate endDate) {
+ if (startDate.isAfter(endDate)) {
+ throw new IllegalArgumentException("시작일은 종료일보다 이전이어야 합니다.");
+ }
+ this.startDate = startDate;
+ this.endDate = endDate;
+ }
+
+ /**
+ * 이미지 선택
+ */
+ public void selectImage(UUID imageId, String imageUrl) {
+ this.selectedImageId = imageId;
+ this.selectedImageUrl = imageUrl;
+
+ // 기존 선택 해제
+ this.generatedImages.forEach(img -> img.setSelected(false));
+
+ // 새로운 이미지 선택
+ this.generatedImages.stream()
+ .filter(img -> img.getImageId().equals(imageId))
+ .findFirst()
+ .ifPresent(img -> img.setSelected(true));
+ }
+
+ /**
+ * 배포 채널 설정
+ */
+ public void updateChannels(List channels) {
+ this.channels.clear();
+ this.channels.addAll(channels);
+ }
+
+ /**
+ * 이벤트 배포 (상태 변경: DRAFT → PUBLISHED)
+ */
+ public void publish() {
+ if (this.status != EventStatus.DRAFT) {
+ throw new IllegalStateException("DRAFT 상태에서만 배포할 수 있습니다.");
+ }
+
+ // 필수 데이터 검증
+ if (selectedImageId == null) {
+ throw new IllegalStateException("이미지를 선택해야 합니다.");
+ }
+ if (channels.isEmpty()) {
+ throw new IllegalStateException("배포 채널을 선택해야 합니다.");
+ }
+
+ this.status = EventStatus.PUBLISHED;
+ }
+
+ /**
+ * 이벤트 종료
+ */
+ public void end() {
+ if (this.status != EventStatus.PUBLISHED) {
+ throw new IllegalStateException("PUBLISHED 상태에서만 종료할 수 있습니다.");
+ }
+ this.status = EventStatus.ENDED;
+ }
+
+ /**
+ * 생성된 이미지 추가
+ */
+ public void addGeneratedImage(GeneratedImage image) {
+ this.generatedImages.add(image);
+ image.setEvent(this);
+ }
+
+ /**
+ * AI 추천 추가
+ */
+ public void addAiRecommendation(AiRecommendation recommendation) {
+ this.aiRecommendations.add(recommendation);
+ recommendation.setEvent(this);
+ }
+
+ /**
+ * 수정 가능 여부 확인
+ */
+ public boolean isModifiable() {
+ return this.status == EventStatus.DRAFT;
+ }
+
+ /**
+ * 삭제 가능 여부 확인
+ */
+ public boolean isDeletable() {
+ return this.status == EventStatus.DRAFT;
+ }
+}
diff --git a/event-service/src/main/java/com/kt/event/eventservice/domain/entity/GeneratedImage.java b/event-service/src/main/java/com/kt/event/eventservice/domain/entity/GeneratedImage.java
new file mode 100644
index 0000000..1e3db69
--- /dev/null
+++ b/event-service/src/main/java/com/kt/event/eventservice/domain/entity/GeneratedImage.java
@@ -0,0 +1,50 @@
+package com.kt.event.eventservice.domain.entity;
+
+import com.kt.event.common.entity.BaseTimeEntity;
+import jakarta.persistence.*;
+import lombok.*;
+import org.hibernate.annotations.GenericGenerator;
+
+import java.util.UUID;
+
+/**
+ * 생성된 이미지 엔티티
+ *
+ * 이벤트별로 생성된 이미지를 관리합니다.
+ *
+ * @author Event Service Team
+ * @version 1.0.0
+ * @since 2025-10-23
+ */
+@Entity
+@Table(name = "generated_images")
+@Getter
+@Setter
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@AllArgsConstructor
+@Builder
+public class GeneratedImage extends BaseTimeEntity {
+
+ @Id
+ @GeneratedValue(generator = "uuid2")
+ @GenericGenerator(name = "uuid2", strategy = "uuid2")
+ @Column(name = "image_id", columnDefinition = "uuid")
+ private UUID imageId;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "event_id", nullable = false)
+ private Event event;
+
+ @Column(name = "image_url", nullable = false, length = 500)
+ private String imageUrl;
+
+ @Column(name = "style", length = 50)
+ private String style;
+
+ @Column(name = "platform", length = 50)
+ private String platform;
+
+ @Column(name = "is_selected", nullable = false)
+ @Builder.Default
+ private boolean isSelected = false;
+}
diff --git a/event-service/src/main/java/com/kt/event/eventservice/domain/entity/Job.java b/event-service/src/main/java/com/kt/event/eventservice/domain/entity/Job.java
new file mode 100644
index 0000000..818dc30
--- /dev/null
+++ b/event-service/src/main/java/com/kt/event/eventservice/domain/entity/Job.java
@@ -0,0 +1,100 @@
+package com.kt.event.eventservice.domain.entity;
+
+import com.kt.event.common.entity.BaseTimeEntity;
+import com.kt.event.eventservice.domain.enums.JobStatus;
+import com.kt.event.eventservice.domain.enums.JobType;
+import jakarta.persistence.*;
+import lombok.*;
+import org.hibernate.annotations.GenericGenerator;
+
+import java.time.LocalDateTime;
+import java.util.UUID;
+
+/**
+ * 비동기 작업 엔티티
+ *
+ * AI 추천 생성, 이미지 생성 등의 비동기 작업 상태를 관리합니다.
+ *
+ * @author Event Service Team
+ * @version 1.0.0
+ * @since 2025-10-23
+ */
+@Entity
+@Table(name = "jobs")
+@Getter
+@Setter
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@AllArgsConstructor
+@Builder
+public class Job extends BaseTimeEntity {
+
+ @Id
+ @GeneratedValue(generator = "uuid2")
+ @GenericGenerator(name = "uuid2", strategy = "uuid2")
+ @Column(name = "job_id", columnDefinition = "uuid")
+ private UUID jobId;
+
+ @Column(name = "event_id", nullable = false, columnDefinition = "uuid")
+ private UUID eventId;
+
+ @Enumerated(EnumType.STRING)
+ @Column(name = "job_type", nullable = false, length = 30)
+ private JobType jobType;
+
+ @Enumerated(EnumType.STRING)
+ @Column(name = "status", nullable = false, length = 20)
+ @Builder.Default
+ private JobStatus status = JobStatus.PENDING;
+
+ @Column(name = "progress", nullable = false)
+ @Builder.Default
+ private int progress = 0;
+
+ @Column(name = "result_key", length = 200)
+ private String resultKey;
+
+ @Column(name = "error_message", length = 500)
+ private String errorMessage;
+
+ @Column(name = "completed_at")
+ private LocalDateTime completedAt;
+
+ // ==== 비즈니스 로직 ==== //
+
+ /**
+ * 작업 시작
+ */
+ public void start() {
+ this.status = JobStatus.PROCESSING;
+ this.progress = 0;
+ }
+
+ /**
+ * 진행률 업데이트
+ */
+ public void updateProgress(int progress) {
+ if (progress < 0 || progress > 100) {
+ throw new IllegalArgumentException("진행률은 0~100 사이여야 합니다.");
+ }
+ this.progress = progress;
+ }
+
+ /**
+ * 작업 완료
+ */
+ public void complete(String resultKey) {
+ this.status = JobStatus.COMPLETED;
+ this.progress = 100;
+ this.resultKey = resultKey;
+ this.completedAt = LocalDateTime.now();
+ }
+
+ /**
+ * 작업 실패
+ */
+ public void fail(String errorMessage) {
+ this.status = JobStatus.FAILED;
+ this.errorMessage = errorMessage;
+ this.completedAt = LocalDateTime.now();
+ }
+}
diff --git a/event-service/src/main/java/com/kt/event/eventservice/domain/enums/EventStatus.java b/event-service/src/main/java/com/kt/event/eventservice/domain/enums/EventStatus.java
new file mode 100644
index 0000000..1ff1f7e
--- /dev/null
+++ b/event-service/src/main/java/com/kt/event/eventservice/domain/enums/EventStatus.java
@@ -0,0 +1,25 @@
+package com.kt.event.eventservice.domain.enums;
+
+/**
+ * 이벤트 상태
+ *
+ * @author Event Service Team
+ * @version 1.0.0
+ * @since 2025-10-23
+ */
+public enum EventStatus {
+ /**
+ * 임시 저장 (작성 중)
+ */
+ DRAFT,
+
+ /**
+ * 배포됨 (진행 중)
+ */
+ PUBLISHED,
+
+ /**
+ * 종료됨
+ */
+ ENDED
+}
diff --git a/event-service/src/main/java/com/kt/event/eventservice/domain/enums/JobStatus.java b/event-service/src/main/java/com/kt/event/eventservice/domain/enums/JobStatus.java
new file mode 100644
index 0000000..ad31da4
--- /dev/null
+++ b/event-service/src/main/java/com/kt/event/eventservice/domain/enums/JobStatus.java
@@ -0,0 +1,30 @@
+package com.kt.event.eventservice.domain.enums;
+
+/**
+ * 비동기 작업 상태
+ *
+ * @author Event Service Team
+ * @version 1.0.0
+ * @since 2025-10-23
+ */
+public enum JobStatus {
+ /**
+ * 대기 중
+ */
+ PENDING,
+
+ /**
+ * 처리 중
+ */
+ PROCESSING,
+
+ /**
+ * 완료
+ */
+ COMPLETED,
+
+ /**
+ * 실패
+ */
+ FAILED
+}
diff --git a/event-service/src/main/java/com/kt/event/eventservice/domain/enums/JobType.java b/event-service/src/main/java/com/kt/event/eventservice/domain/enums/JobType.java
new file mode 100644
index 0000000..aaa251a
--- /dev/null
+++ b/event-service/src/main/java/com/kt/event/eventservice/domain/enums/JobType.java
@@ -0,0 +1,20 @@
+package com.kt.event.eventservice.domain.enums;
+
+/**
+ * 비동기 작업 유형
+ *
+ * @author Event Service Team
+ * @version 1.0.0
+ * @since 2025-10-23
+ */
+public enum JobType {
+ /**
+ * AI 이벤트 추천 생성
+ */
+ AI_RECOMMENDATION,
+
+ /**
+ * 이미지 생성
+ */
+ IMAGE_GENERATION
+}
diff --git a/event-service/src/main/java/com/kt/event/eventservice/domain/repository/AiRecommendationRepository.java b/event-service/src/main/java/com/kt/event/eventservice/domain/repository/AiRecommendationRepository.java
new file mode 100644
index 0000000..7b0b58f
--- /dev/null
+++ b/event-service/src/main/java/com/kt/event/eventservice/domain/repository/AiRecommendationRepository.java
@@ -0,0 +1,29 @@
+package com.kt.event.eventservice.domain.repository;
+
+import com.kt.event.eventservice.domain.entity.AiRecommendation;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.stereotype.Repository;
+
+import java.util.List;
+import java.util.UUID;
+
+/**
+ * AI 추천 Repository
+ *
+ * @author Event Service Team
+ * @version 1.0.0
+ * @since 2025-10-23
+ */
+@Repository
+public interface AiRecommendationRepository extends JpaRepository {
+
+ /**
+ * 이벤트별 AI 추천 목록 조회
+ */
+ List findByEventEventId(UUID eventId);
+
+ /**
+ * 이벤트별 선택된 AI 추천 조회
+ */
+ AiRecommendation findByEventEventIdAndIsSelectedTrue(UUID eventId);
+}
diff --git a/event-service/src/main/java/com/kt/event/eventservice/domain/repository/EventRepository.java b/event-service/src/main/java/com/kt/event/eventservice/domain/repository/EventRepository.java
new file mode 100644
index 0000000..05470f5
--- /dev/null
+++ b/event-service/src/main/java/com/kt/event/eventservice/domain/repository/EventRepository.java
@@ -0,0 +1,57 @@
+package com.kt.event.eventservice.domain.repository;
+
+import com.kt.event.eventservice.domain.entity.Event;
+import com.kt.event.eventservice.domain.enums.EventStatus;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+import org.springframework.stereotype.Repository;
+
+import java.util.Optional;
+import java.util.UUID;
+
+/**
+ * 이벤트 Repository
+ *
+ * @author Event Service Team
+ * @version 1.0.0
+ * @since 2025-10-23
+ */
+@Repository
+public interface EventRepository extends JpaRepository {
+
+ /**
+ * 사용자 ID와 이벤트 ID로 조회
+ */
+ @Query("SELECT e FROM Event e " +
+ "LEFT JOIN FETCH e.generatedImages " +
+ "LEFT JOIN FETCH e.aiRecommendations " +
+ "WHERE e.eventId = :eventId AND e.userId = :userId")
+ Optional findByEventIdAndUserId(
+ @Param("eventId") UUID eventId,
+ @Param("userId") UUID userId
+ );
+
+ /**
+ * 사용자별 이벤트 목록 조회 (페이징, 상태 필터)
+ */
+ @Query("SELECT e FROM Event e " +
+ "WHERE e.userId = :userId " +
+ "AND (:status IS NULL OR e.status = :status) " +
+ "AND (:search IS NULL OR e.eventName LIKE %:search%) " +
+ "AND (:objective IS NULL OR e.objective = :objective)")
+ Page findEventsByUser(
+ @Param("userId") UUID userId,
+ @Param("status") EventStatus status,
+ @Param("search") String search,
+ @Param("objective") String objective,
+ Pageable pageable
+ );
+
+ /**
+ * 사용자별 이벤트 개수 조회 (상태별)
+ */
+ long countByUserIdAndStatus(UUID userId, EventStatus status);
+}
diff --git a/event-service/src/main/java/com/kt/event/eventservice/domain/repository/GeneratedImageRepository.java b/event-service/src/main/java/com/kt/event/eventservice/domain/repository/GeneratedImageRepository.java
new file mode 100644
index 0000000..203c267
--- /dev/null
+++ b/event-service/src/main/java/com/kt/event/eventservice/domain/repository/GeneratedImageRepository.java
@@ -0,0 +1,29 @@
+package com.kt.event.eventservice.domain.repository;
+
+import com.kt.event.eventservice.domain.entity.GeneratedImage;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.stereotype.Repository;
+
+import java.util.List;
+import java.util.UUID;
+
+/**
+ * 생성된 이미지 Repository
+ *
+ * @author Event Service Team
+ * @version 1.0.0
+ * @since 2025-10-23
+ */
+@Repository
+public interface GeneratedImageRepository extends JpaRepository {
+
+ /**
+ * 이벤트별 생성된 이미지 목록 조회
+ */
+ List findByEventEventId(UUID eventId);
+
+ /**
+ * 이벤트별 선택된 이미지 조회
+ */
+ GeneratedImage findByEventEventIdAndIsSelectedTrue(UUID eventId);
+}
diff --git a/event-service/src/main/java/com/kt/event/eventservice/domain/repository/JobRepository.java b/event-service/src/main/java/com/kt/event/eventservice/domain/repository/JobRepository.java
new file mode 100644
index 0000000..8673859
--- /dev/null
+++ b/event-service/src/main/java/com/kt/event/eventservice/domain/repository/JobRepository.java
@@ -0,0 +1,42 @@
+package com.kt.event.eventservice.domain.repository;
+
+import com.kt.event.eventservice.domain.entity.Job;
+import com.kt.event.eventservice.domain.enums.JobStatus;
+import com.kt.event.eventservice.domain.enums.JobType;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.stereotype.Repository;
+
+import java.util.List;
+import java.util.Optional;
+import java.util.UUID;
+
+/**
+ * 비동기 작업 Repository
+ *
+ * @author Event Service Team
+ * @version 1.0.0
+ * @since 2025-10-23
+ */
+@Repository
+public interface JobRepository extends JpaRepository {
+
+ /**
+ * 이벤트별 작업 목록 조회
+ */
+ List findByEventId(UUID eventId);
+
+ /**
+ * 이벤트 및 작업 유형별 조회
+ */
+ Optional findByEventIdAndJobType(UUID eventId, JobType jobType);
+
+ /**
+ * 이벤트 및 작업 유형별 최신 작업 조회
+ */
+ Optional findFirstByEventIdAndJobTypeOrderByCreatedAtDesc(UUID eventId, JobType jobType);
+
+ /**
+ * 상태별 작업 목록 조회
+ */
+ List findByStatus(JobStatus status);
+}
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
new file mode 100644
index 0000000..66c3583
--- /dev/null
+++ b/event-service/src/main/java/com/kt/event/eventservice/presentation/controller/EventController.java
@@ -0,0 +1,206 @@
+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.service.EventService;
+import com.kt.event.eventservice.domain.enums.EventStatus;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.PageRequest;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.domain.Sort;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.core.annotation.AuthenticationPrincipal;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.UUID;
+
+/**
+ * 이벤트 컨트롤러
+ *
+ * 이벤트 전체 생명주기 관리 API를 제공합니다.
+ *
+ * @author Event Service Team
+ * @version 1.0.0
+ * @since 2025-10-23
+ */
+@Slf4j
+@RestController
+@RequestMapping("/api/events")
+@RequiredArgsConstructor
+@Tag(name = "Event", description = "이벤트 관리 API")
+public class EventController {
+
+ private final EventService eventService;
+
+ /**
+ * 이벤트 목적 선택 (Step 1: 이벤트 생성)
+ *
+ * @param request 목적 선택 요청
+ * @param userPrincipal 인증된 사용자 정보
+ * @return 생성된 이벤트 응답
+ */
+ @PostMapping("/objectives")
+ @Operation(summary = "이벤트 목적 선택", description = "이벤트 생성의 첫 단계로 목적을 선택합니다.")
+ public ResponseEntity> selectObjective(
+ @Valid @RequestBody SelectObjectiveRequest request,
+ @AuthenticationPrincipal UserPrincipal userPrincipal) {
+
+ log.info("이벤트 목적 선택 API 호출 - userId: {}, objective: {}",
+ userPrincipal.getUserId(), request.getObjective());
+
+ EventCreatedResponse response = eventService.createEvent(
+ userPrincipal.getUserId(),
+ userPrincipal.getStoreId(),
+ request
+ );
+
+ return ResponseEntity.status(HttpStatus.CREATED)
+ .body(ApiResponse.success(response));
+ }
+
+ /**
+ * 이벤트 목록 조회
+ *
+ * @param status 상태 필터
+ * @param search 검색어
+ * @param objective 목적 필터
+ * @param page 페이지 번호
+ * @param size 페이지 크기
+ * @param sort 정렬 기준
+ * @param order 정렬 순서
+ * @param userPrincipal 인증된 사용자 정보
+ * @return 이벤트 목록 응답
+ */
+ @GetMapping
+ @Operation(summary = "이벤트 목록 조회", description = "사용자의 이벤트 목록을 조회합니다.")
+ public ResponseEntity>> getEvents(
+ @RequestParam(required = false) EventStatus status,
+ @RequestParam(required = false) String search,
+ @RequestParam(required = false) String objective,
+ @RequestParam(defaultValue = "0") int page,
+ @RequestParam(defaultValue = "20") int size,
+ @RequestParam(defaultValue = "createdAt") String sort,
+ @RequestParam(defaultValue = "desc") String order,
+ @AuthenticationPrincipal UserPrincipal userPrincipal) {
+
+ log.info("이벤트 목록 조회 API 호출 - userId: {}", userPrincipal.getUserId());
+
+ // Pageable 생성
+ Sort.Direction direction = "asc".equalsIgnoreCase(order) ? Sort.Direction.ASC : Sort.Direction.DESC;
+ Pageable pageable = PageRequest.of(page, size, Sort.by(direction, sort));
+
+ Page events = eventService.getEvents(
+ userPrincipal.getUserId(),
+ status,
+ search,
+ objective,
+ pageable
+ );
+
+ PageResponse pageResponse = PageResponse.builder()
+ .content(events.getContent())
+ .page(events.getNumber())
+ .size(events.getSize())
+ .totalElements(events.getTotalElements())
+ .totalPages(events.getTotalPages())
+ .first(events.isFirst())
+ .last(events.isLast())
+ .build();
+
+ return ResponseEntity.ok(ApiResponse.success(pageResponse));
+ }
+
+ /**
+ * 이벤트 상세 조회
+ *
+ * @param eventId 이벤트 ID
+ * @param userPrincipal 인증된 사용자 정보
+ * @return 이벤트 상세 응답
+ */
+ @GetMapping("/{eventId}")
+ @Operation(summary = "이벤트 상세 조회", description = "특정 이벤트의 상세 정보를 조회합니다.")
+ public ResponseEntity> getEvent(
+ @PathVariable UUID eventId,
+ @AuthenticationPrincipal UserPrincipal userPrincipal) {
+
+ log.info("이벤트 상세 조회 API 호출 - userId: {}, eventId: {}",
+ userPrincipal.getUserId(), eventId);
+
+ EventDetailResponse response = eventService.getEvent(userPrincipal.getUserId(), eventId);
+
+ return ResponseEntity.ok(ApiResponse.success(response));
+ }
+
+ /**
+ * 이벤트 삭제
+ *
+ * @param eventId 이벤트 ID
+ * @param userPrincipal 인증된 사용자 정보
+ * @return 성공 응답
+ */
+ @DeleteMapping("/{eventId}")
+ @Operation(summary = "이벤트 삭제", description = "이벤트를 삭제합니다. DRAFT 상태만 삭제 가능합니다.")
+ public ResponseEntity> deleteEvent(
+ @PathVariable UUID eventId,
+ @AuthenticationPrincipal UserPrincipal userPrincipal) {
+
+ log.info("이벤트 삭제 API 호출 - userId: {}, eventId: {}",
+ userPrincipal.getUserId(), eventId);
+
+ eventService.deleteEvent(userPrincipal.getUserId(), eventId);
+
+ return ResponseEntity.ok(ApiResponse.success(null));
+ }
+
+ /**
+ * 이벤트 배포
+ *
+ * @param eventId 이벤트 ID
+ * @param userPrincipal 인증된 사용자 정보
+ * @return 성공 응답
+ */
+ @PostMapping("/{eventId}/publish")
+ @Operation(summary = "이벤트 배포", description = "이벤트를 배포합니다. DRAFT → PUBLISHED 상태 변경.")
+ public ResponseEntity> publishEvent(
+ @PathVariable UUID eventId,
+ @AuthenticationPrincipal UserPrincipal userPrincipal) {
+
+ log.info("이벤트 배포 API 호출 - userId: {}, eventId: {}",
+ userPrincipal.getUserId(), eventId);
+
+ eventService.publishEvent(userPrincipal.getUserId(), eventId);
+
+ return ResponseEntity.ok(ApiResponse.success(null));
+ }
+
+ /**
+ * 이벤트 종료
+ *
+ * @param eventId 이벤트 ID
+ * @param userPrincipal 인증된 사용자 정보
+ * @return 성공 응답
+ */
+ @PostMapping("/{eventId}/end")
+ @Operation(summary = "이벤트 종료", description = "이벤트를 종료합니다. PUBLISHED → ENDED 상태 변경.")
+ public ResponseEntity> endEvent(
+ @PathVariable UUID eventId,
+ @AuthenticationPrincipal UserPrincipal userPrincipal) {
+
+ log.info("이벤트 종료 API 호출 - userId: {}, eventId: {}",
+ userPrincipal.getUserId(), eventId);
+
+ eventService.endEvent(userPrincipal.getUserId(), eventId);
+
+ return ResponseEntity.ok(ApiResponse.success(null));
+ }
+}
diff --git a/event-service/src/main/java/com/kt/event/eventservice/presentation/controller/JobController.java b/event-service/src/main/java/com/kt/event/eventservice/presentation/controller/JobController.java
new file mode 100644
index 0000000..954d057
--- /dev/null
+++ b/event-service/src/main/java/com/kt/event/eventservice/presentation/controller/JobController.java
@@ -0,0 +1,51 @@
+package com.kt.event.eventservice.presentation.controller;
+
+import com.kt.event.common.dto.ApiResponse;
+import com.kt.event.eventservice.application.dto.response.JobStatusResponse;
+import com.kt.event.eventservice.application.service.JobService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.UUID;
+
+/**
+ * Job 컨트롤러
+ *
+ * 비동기 작업 상태 조회 API를 제공합니다.
+ *
+ * @author Event Service Team
+ * @version 1.0.0
+ * @since 2025-10-23
+ */
+@Slf4j
+@RestController
+@RequestMapping("/api/jobs")
+@RequiredArgsConstructor
+@Tag(name = "Job", description = "비동기 작업 상태 조회 API")
+public class JobController {
+
+ private final JobService jobService;
+
+ /**
+ * Job 상태 조회
+ *
+ * @param jobId Job ID
+ * @return Job 상태 응답
+ */
+ @GetMapping("/{jobId}")
+ @Operation(summary = "Job 상태 조회", description = "비동기 작업의 상태를 조회합니다 (폴링 방식).")
+ public ResponseEntity> getJobStatus(@PathVariable UUID jobId) {
+ log.info("Job 상태 조회 API 호출 - jobId: {}", jobId);
+
+ JobStatusResponse response = jobService.getJobStatus(jobId);
+
+ return ResponseEntity.ok(ApiResponse.success(response));
+ }
+}
diff --git a/event-service/src/main/resources/application.yml b/event-service/src/main/resources/application.yml
new file mode 100644
index 0000000..11d145b
--- /dev/null
+++ b/event-service/src/main/resources/application.yml
@@ -0,0 +1,142 @@
+spring:
+ application:
+ name: event-service
+
+ # Database Configuration (PostgreSQL)
+ datasource:
+ url: jdbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:eventdb}
+ username: ${DB_USERNAME:eventuser}
+ password: ${DB_PASSWORD:eventpass}
+ driver-class-name: org.postgresql.Driver
+ hikari:
+ maximum-pool-size: 10
+ minimum-idle: 5
+ connection-timeout: 30000
+ idle-timeout: 600000
+ max-lifetime: 1800000
+
+ # JPA Configuration
+ jpa:
+ database-platform: org.hibernate.dialect.PostgreSQLDialect
+ hibernate:
+ ddl-auto: ${DDL_AUTO:update}
+ properties:
+ hibernate:
+ format_sql: true
+ show_sql: false
+ use_sql_comments: true
+ jdbc:
+ batch_size: 20
+ time_zone: Asia/Seoul
+ open-in-view: false
+
+ # Redis Configuration
+ data:
+ redis:
+ host: ${REDIS_HOST:localhost}
+ port: ${REDIS_PORT:6379}
+ password: ${REDIS_PASSWORD:}
+ lettuce:
+ pool:
+ max-active: 10
+ max-idle: 5
+ min-idle: 2
+
+ # Kafka Configuration
+ kafka:
+ bootstrap-servers: ${KAFKA_BOOTSTRAP_SERVERS:localhost:9092}
+ producer:
+ key-serializer: org.apache.kafka.common.serialization.StringSerializer
+ value-serializer: org.springframework.kafka.support.serializer.JsonSerializer
+ properties:
+ spring.json.add.type.headers: false
+ consumer:
+ group-id: event-service-consumers
+ key-deserializer: org.apache.kafka.common.serialization.StringDeserializer
+ value-deserializer: org.springframework.kafka.support.serializer.JsonDeserializer
+ properties:
+ spring.json.trusted.packages: "*"
+ spring.json.use.type.headers: false
+ auto-offset-reset: earliest
+ enable-auto-commit: false
+ listener:
+ ack-mode: manual
+
+# Server Configuration
+server:
+ port: ${SERVER_PORT:8080}
+ servlet:
+ context-path: /
+ shutdown: graceful
+
+# Actuator Configuration
+management:
+ endpoints:
+ web:
+ exposure:
+ include: health,info,metrics,prometheus
+ endpoint:
+ health:
+ show-details: always
+ health:
+ redis:
+ enabled: true
+ db:
+ enabled: true
+
+# Logging Configuration
+logging:
+ level:
+ root: INFO
+ com.kt.event: ${LOG_LEVEL:DEBUG}
+ org.springframework: INFO
+ org.hibernate.SQL: ${SQL_LOG_LEVEL:DEBUG}
+ org.hibernate.type.descriptor.sql.BasicBinder: TRACE
+ pattern:
+ console: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n"
+ file: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n"
+
+# Springdoc OpenAPI Configuration
+springdoc:
+ api-docs:
+ path: /api-docs
+ swagger-ui:
+ path: /swagger-ui.html
+ operations-sorter: method
+ tags-sorter: alpha
+ show-actuator: false
+
+# Feign Client Configuration
+feign:
+ client:
+ config:
+ default:
+ connectTimeout: 5000
+ readTimeout: 10000
+ loggerLevel: basic
+
+ # Distribution Service Client
+ distribution-service:
+ url: ${DISTRIBUTION_SERVICE_URL:http://localhost:8084}
+
+# Application Configuration
+app:
+ kafka:
+ topics:
+ ai-event-generation-job: ai-event-generation-job
+ image-generation-job: image-generation-job
+ event-created: event-created
+
+ redis:
+ ttl:
+ ai-result: 86400 # 24시간 (초 단위)
+ image-result: 604800 # 7일 (초 단위)
+ key-prefix:
+ ai-recommendation: "ai:recommendation:"
+ image-generation: "image:generation:"
+ job-status: "job:status:"
+
+ job:
+ timeout:
+ ai-generation: 300000 # 5분 (밀리초 단위)
+ image-generation: 300000 # 5분 (밀리초 단위)