Merge pull request #6 from ktds-dg0501/feature/event

feature/event
This commit is contained in:
Hayoung Song 2025-10-27 13:43:44 +09:00 committed by GitHub
commit 42f0665f5e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
38 changed files with 2989 additions and 9 deletions

14
.gitignore vendored
View File

@ -33,5 +33,15 @@ tmp/
temp/ temp/
*.tmp *.tmp
# Docker (로컬 개발용) # Kubernetes Secrets (민감한 정보 포함)
backing-service/docker-compose.yml k8s/**/secret.yaml
k8s/**/*-secret.yaml
k8s/**/*-prod.yaml
k8s/**/*-dev.yaml
k8s/**/*-local.yaml
# IntelliJ 실행 프로파일 (민감한 환경 변수 포함 가능)
.run/*.run.xml
# Gradle (로컬 환경 설정)
gradle.properties

View File

@ -0,0 +1,27 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="EventServiceApplication" type="SpringBootApplicationConfigurationType" factoryName="Spring Boot" folderName="Event Service">
<option name="ACTIVE_PROFILES" />
<option name="ENABLE_LAUNCH_OPTIMIZATION" value="true" />
<envs>
<env name="DB_HOST" value="20.249.177.232" />
<env name="DB_PORT" value="5432" />
<env name="DB_NAME" value="eventdb" />
<env name="DB_USERNAME" value="eventuser" />
<env name="DB_PASSWORD" value="Hi5Jessica!" />
<env name="REDIS_HOST" value="localhost" />
<env name="REDIS_PORT" value="6379" />
<env name="REDIS_PASSWORD" value="" />
<env name="KAFKA_BOOTSTRAP_SERVERS" value="localhost:9092" />
<env name="SERVER_PORT" value="8081" />
<env name="DDL_AUTO" value="update" />
<env name="LOG_LEVEL" value="DEBUG" />
<env name="SQL_LOG_LEVEL" value="DEBUG" />
<env name="DISTRIBUTION_SERVICE_URL" value="http://localhost:8084" />
</envs>
<module name="kt-event-marketing.event-service.main" />
<option name="SPRING_BOOT_MAIN_CLASS" value="com.kt.event.eventservice.EventServiceApplication" />
<method v="2">
<option name="Make" enabled="true" />
</method>
</configuration>
</component>

View File

@ -18,6 +18,10 @@ public enum ErrorCode {
COMMON_004("COMMON_004", "서버 내부 오류가 발생했습니다"), COMMON_004("COMMON_004", "서버 내부 오류가 발생했습니다"),
COMMON_005("COMMON_005", "지원하지 않는 작업입니다"), COMMON_005("COMMON_005", "지원하지 않는 작업입니다"),
// 일반 에러 상수 (Legacy 호환용)
NOT_FOUND("NOT_FOUND", "요청한 리소스를 찾을 수 없습니다"),
INVALID_INPUT_VALUE("INVALID_INPUT_VALUE", "유효하지 않은 입력값입니다"),
// 인증/인가 에러 (AUTH_XXX) // 인증/인가 에러 (AUTH_XXX)
AUTH_001("AUTH_001", "인증에 실패했습니다"), AUTH_001("AUTH_001", "인증에 실패했습니다"),
AUTH_002("AUTH_002", "유효하지 않은 토큰입니다"), AUTH_002("AUTH_002", "유효하지 않은 토큰입니다"),

View File

@ -12,6 +12,7 @@ import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.util.Date; import java.util.Date;
import java.util.List; import java.util.List;
import java.util.UUID;
/** /**
* JWT 토큰 생성 검증 제공자 * JWT 토큰 생성 검증 제공자
@ -49,17 +50,19 @@ public class JwtTokenProvider {
* Access Token 생성 * Access Token 생성
* *
* @param userId 사용자 ID * @param userId 사용자 ID
* @param storeId 매장 ID
* @param email 이메일 * @param email 이메일
* @param name 이름 * @param name 이름
* @param roles 역할 목록 * @param roles 역할 목록
* @return Access Token * @return Access Token
*/ */
public String createAccessToken(Long userId, String email, String name, List<String> roles) { public String createAccessToken(UUID userId, UUID storeId, String email, String name, List<String> roles) {
Date now = new Date(); Date now = new Date();
Date expiryDate = new Date(now.getTime() + accessTokenValidityMs); Date expiryDate = new Date(now.getTime() + accessTokenValidityMs);
return Jwts.builder() return Jwts.builder()
.subject(userId.toString()) .subject(userId.toString())
.claim("storeId", storeId.toString())
.claim("email", email) .claim("email", email)
.claim("name", name) .claim("name", name)
.claim("roles", roles) .claim("roles", roles)
@ -76,7 +79,7 @@ public class JwtTokenProvider {
* @param userId 사용자 ID * @param userId 사용자 ID
* @return Refresh Token * @return Refresh Token
*/ */
public String createRefreshToken(Long userId) { public String createRefreshToken(UUID userId) {
Date now = new Date(); Date now = new Date();
Date expiryDate = new Date(now.getTime() + refreshTokenValidityMs); Date expiryDate = new Date(now.getTime() + refreshTokenValidityMs);
@ -95,9 +98,9 @@ public class JwtTokenProvider {
* @param token JWT 토큰 * @param token JWT 토큰
* @return 사용자 ID * @return 사용자 ID
*/ */
public Long getUserIdFromToken(String token) { public UUID getUserIdFromToken(String token) {
Claims claims = parseToken(token); Claims claims = parseToken(token);
return Long.parseLong(claims.getSubject()); return UUID.fromString(claims.getSubject());
} }
/** /**
@ -109,13 +112,14 @@ public class JwtTokenProvider {
public UserPrincipal getUserPrincipalFromToken(String token) { public UserPrincipal getUserPrincipalFromToken(String token) {
Claims claims = parseToken(token); Claims claims = parseToken(token);
Long userId = Long.parseLong(claims.getSubject()); UUID userId = UUID.fromString(claims.getSubject());
UUID storeId = UUID.fromString(claims.get("storeId", String.class));
String email = claims.get("email", String.class); String email = claims.get("email", String.class);
String name = claims.get("name", String.class); String name = claims.get("name", String.class);
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
List<String> roles = claims.get("roles", List.class); List<String> roles = claims.get("roles", List.class);
return new UserPrincipal(userId, email, name, roles); return new UserPrincipal(userId, storeId, email, name, roles);
} }
/** /**

View File

@ -1,6 +1,7 @@
package com.kt.event.common.security; package com.kt.event.common.security;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter; import lombok.Getter;
import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority;
@ -8,6 +9,7 @@ import org.springframework.security.core.userdetails.UserDetails;
import java.util.Collection; import java.util.Collection;
import java.util.List; import java.util.List;
import java.util.UUID;
import java.util.stream.Collectors; import java.util.stream.Collectors;
/** /**
@ -15,13 +17,19 @@ import java.util.stream.Collectors;
* JWT 토큰에서 추출한 사용자 정보를 담는 객체 * JWT 토큰에서 추출한 사용자 정보를 담는 객체
*/ */
@Getter @Getter
@Builder
@AllArgsConstructor @AllArgsConstructor
public class UserPrincipal implements UserDetails { public class UserPrincipal implements UserDetails {
/** /**
* 사용자 ID * 사용자 ID
*/ */
private final Long userId; private final UUID userId;
/**
* 매장 ID
*/
private final UUID storeId;
/** /**
* 사용자 이메일 * 사용자 이메일

View File

@ -0,0 +1,270 @@
-- ============================================
-- Event Service Database DDL
-- ============================================
-- Description: Event Service 데이터베이스 테이블 생성 스크립트
-- Database: PostgreSQL 15+
-- Author: Event Service Team
-- Version: 1.0.0
-- Created: 2025-10-24
-- ============================================
-- UUID 확장 활성화 (PostgreSQL)
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- ============================================
-- 1. events 테이블
-- ============================================
-- 이벤트 마스터 테이블
-- 이벤트의 전체 생명주기(생성, 수정, 배포, 종료)를 관리
-- ============================================
CREATE TABLE IF NOT EXISTS events (
event_id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL,
store_id UUID NOT NULL,
event_name VARCHAR(200),
description TEXT,
objective VARCHAR(100) NOT NULL,
start_date DATE,
end_date DATE,
status VARCHAR(20) NOT NULL DEFAULT 'DRAFT',
selected_image_id UUID,
selected_image_url VARCHAR(500),
created_at TIMESTAMP NOT NULL, -- Managed by JPA @CreatedDate
updated_at TIMESTAMP NOT NULL, -- Managed by JPA @LastModifiedDate
-- 제약조건
CONSTRAINT chk_event_period CHECK (start_date IS NULL OR end_date IS NULL OR start_date <= end_date),
CONSTRAINT chk_event_status CHECK (status IN ('DRAFT', 'PUBLISHED', 'ENDED'))
);
-- 인덱스
CREATE INDEX idx_events_user_id ON events(user_id);
CREATE INDEX idx_events_store_id ON events(store_id);
CREATE INDEX idx_events_status ON events(status);
CREATE INDEX idx_events_created_at ON events(created_at);
-- 복합 인덱스 (쿼리 성능 최적화)
CREATE INDEX idx_events_user_status_created ON events(user_id, status, created_at DESC);
-- 주석
COMMENT ON TABLE events IS '이벤트 마스터 테이블';
COMMENT ON COLUMN events.event_id IS '이벤트 ID (PK)';
COMMENT ON COLUMN events.user_id IS '사용자 ID';
COMMENT ON COLUMN events.store_id IS '매장 ID';
COMMENT ON COLUMN events.event_name IS '이벤트명';
COMMENT ON COLUMN events.description IS '이벤트 설명';
COMMENT ON COLUMN events.objective IS '이벤트 목적';
COMMENT ON COLUMN events.start_date IS '이벤트 시작일';
COMMENT ON COLUMN events.end_date IS '이벤트 종료일';
COMMENT ON COLUMN events.status IS '이벤트 상태 (DRAFT/PUBLISHED/ENDED)';
COMMENT ON COLUMN events.selected_image_id IS '선택된 이미지 ID';
COMMENT ON COLUMN events.selected_image_url IS '선택된 이미지 URL';
COMMENT ON COLUMN events.created_at IS '생성일시';
COMMENT ON COLUMN events.updated_at IS '수정일시';
-- ============================================
-- 2. event_channels 테이블
-- ============================================
-- 이벤트 배포 채널 테이블
-- 이벤트별 배포 채널 정보 관리 (ElementCollection)
-- ============================================
CREATE TABLE IF NOT EXISTS event_channels (
event_id UUID NOT NULL,
channel VARCHAR(50) NOT NULL,
-- 제약조건
-- CONSTRAINT fk_event_channels_event FOREIGN KEY (event_id)
-- REFERENCES events(event_id) ON DELETE CASCADE,
CONSTRAINT pk_event_channels PRIMARY KEY (event_id, channel)
);
-- 인덱스
CREATE INDEX idx_event_channels_event_id ON event_channels(event_id);
-- 주석
COMMENT ON TABLE event_channels IS '이벤트 배포 채널 테이블';
COMMENT ON COLUMN event_channels.event_id IS '이벤트 ID (FK)';
COMMENT ON COLUMN event_channels.channel IS '배포 채널 (예: 카카오톡, 인스타그램 등)';
-- ============================================
-- 3. generated_images 테이블
-- ============================================
-- 생성된 이미지 테이블
-- 이벤트별로 생성된 이미지를 관리
-- ============================================
CREATE TABLE IF NOT EXISTS generated_images (
image_id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
event_id UUID NOT NULL,
image_url VARCHAR(500) NOT NULL,
style VARCHAR(50),
platform VARCHAR(50),
is_selected BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMP NOT NULL, -- Managed by JPA @CreatedDate
updated_at TIMESTAMP NOT NULL, -- Managed by JPA @LastModifiedDate
-- 제약조건
-- CONSTRAINT fk_generated_images_event FOREIGN KEY (event_id)
-- REFERENCES events(event_id) ON DELETE CASCADE
);
-- 인덱스
CREATE INDEX idx_generated_images_event_id ON generated_images(event_id);
CREATE INDEX idx_generated_images_is_selected ON generated_images(is_selected);
-- 복합 인덱스 (이벤트별 선택 이미지 조회 최적화)
CREATE INDEX idx_generated_images_event_selected ON generated_images(event_id, is_selected);
-- 주석
COMMENT ON TABLE generated_images IS '생성된 이미지 테이블';
COMMENT ON COLUMN generated_images.image_id IS '이미지 ID (PK)';
COMMENT ON COLUMN generated_images.event_id IS '이벤트 ID (FK)';
COMMENT ON COLUMN generated_images.image_url IS '이미지 URL';
COMMENT ON COLUMN generated_images.style IS '이미지 스타일';
COMMENT ON COLUMN generated_images.platform IS '플랫폼 (예: 인스타그램, 페이스북 등)';
COMMENT ON COLUMN generated_images.is_selected IS '선택 여부';
COMMENT ON COLUMN generated_images.created_at IS '생성일시';
COMMENT ON COLUMN generated_images.updated_at IS '수정일시';
-- ============================================
-- 4. ai_recommendations 테이블
-- ============================================
-- AI 추천 테이블
-- AI가 추천한 이벤트 기획안을 관리
-- ============================================
CREATE TABLE IF NOT EXISTS ai_recommendations (
recommendation_id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
event_id UUID NOT NULL,
event_name VARCHAR(200) NOT NULL,
description TEXT,
promotion_type VARCHAR(50),
target_audience VARCHAR(100),
is_selected BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMP NOT NULL, -- Managed by JPA @CreatedDate
updated_at TIMESTAMP NOT NULL, -- Managed by JPA @LastModifiedDate
-- 제약조건
-- CONSTRAINT fk_ai_recommendations_event FOREIGN KEY (event_id)
-- REFERENCES events(event_id) ON DELETE CASCADE
);
-- 인덱스
CREATE INDEX idx_ai_recommendations_event_id ON ai_recommendations(event_id);
CREATE INDEX idx_ai_recommendations_is_selected ON ai_recommendations(is_selected);
-- 복합 인덱스 (이벤트별 선택 추천 조회 최적화)
CREATE INDEX idx_ai_recommendations_event_selected ON ai_recommendations(event_id, is_selected);
-- 주석
COMMENT ON TABLE ai_recommendations IS 'AI 추천 이벤트 기획안 테이블';
COMMENT ON COLUMN ai_recommendations.recommendation_id IS '추천 ID (PK)';
COMMENT ON COLUMN ai_recommendations.event_id IS '이벤트 ID (FK)';
COMMENT ON COLUMN ai_recommendations.event_name IS '추천 이벤트명';
COMMENT ON COLUMN ai_recommendations.description IS '추천 이벤트 설명';
COMMENT ON COLUMN ai_recommendations.promotion_type IS '프로모션 유형';
COMMENT ON COLUMN ai_recommendations.target_audience IS '타겟 고객층';
COMMENT ON COLUMN ai_recommendations.is_selected IS '선택 여부';
COMMENT ON COLUMN ai_recommendations.created_at IS '생성일시';
COMMENT ON COLUMN ai_recommendations.updated_at IS '수정일시';
-- ============================================
-- 5. jobs 테이블
-- ============================================
-- 비동기 작업 테이블
-- AI 추천 생성, 이미지 생성 등의 비동기 작업 상태를 관리
-- ============================================
CREATE TABLE IF NOT EXISTS jobs (
job_id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
event_id UUID NOT NULL,
job_type VARCHAR(30) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
progress INT NOT NULL DEFAULT 0,
result_key VARCHAR(200),
error_message VARCHAR(500),
completed_at TIMESTAMP,
created_at TIMESTAMP NOT NULL, -- Managed by JPA @CreatedDate
updated_at TIMESTAMP NOT NULL, -- Managed by JPA @LastModifiedDate
-- 제약조건
-- CONSTRAINT fk_jobs_event FOREIGN KEY (event_id)
-- REFERENCES events(event_id) ON DELETE CASCADE,
CONSTRAINT chk_job_type CHECK (job_type IN ('AI_RECOMMENDATION', 'IMAGE_GENERATION')),
CONSTRAINT chk_job_status CHECK (status IN ('PENDING', 'PROCESSING', 'COMPLETED', 'FAILED')),
CONSTRAINT chk_job_progress CHECK (progress >= 0 AND progress <= 100)
);
-- 인덱스
CREATE INDEX idx_jobs_event_id ON jobs(event_id);
CREATE INDEX idx_jobs_status ON jobs(status);
CREATE INDEX idx_jobs_created_at ON jobs(created_at);
-- 복합 인덱스 (상태별 최신 작업 조회 최적화)
CREATE INDEX idx_jobs_status_created ON jobs(status, created_at DESC);
-- 주석
COMMENT ON TABLE jobs IS '비동기 작업 테이블';
COMMENT ON COLUMN jobs.job_id IS '작업 ID (PK)';
COMMENT ON COLUMN jobs.event_id IS '이벤트 ID (연관 이벤트)';
COMMENT ON COLUMN jobs.job_type IS '작업 유형 (AI_RECOMMENDATION/IMAGE_GENERATION)';
COMMENT ON COLUMN jobs.status IS '작업 상태 (PENDING/PROCESSING/COMPLETED/FAILED)';
COMMENT ON COLUMN jobs.progress IS '작업 진행률 (0-100)';
COMMENT ON COLUMN jobs.result_key IS '결과 키 (Redis 캐시 키 또는 리소스 식별자)';
COMMENT ON COLUMN jobs.error_message IS '오류 메시지 (실패 시)';
COMMENT ON COLUMN jobs.completed_at IS '완료일시';
COMMENT ON COLUMN jobs.created_at IS '생성일시';
COMMENT ON COLUMN jobs.updated_at IS '수정일시';
-- ============================================
-- Trigger for updated_at (자동 업데이트)
-- ============================================
-- NOTE: updated_at 필드는 JPA @LastModifiedDate 어노테이션으로 관리됩니다.
-- 따라서 PostgreSQL Trigger는 사용하지 않습니다.
-- JPA 환경에서는 애플리케이션 레벨에서 자동으로 updated_at이 갱신됩니다.
--
-- 만약 JPA 외부에서 직접 SQL로 데이터를 수정하는 경우,
-- 아래 Trigger를 활성화할 수 있습니다.
-- updated_at 자동 업데이트 함수 (비활성화)
-- CREATE OR REPLACE FUNCTION update_updated_at_column()
-- RETURNS TRIGGER AS $$
-- BEGIN
-- NEW.updated_at = CURRENT_TIMESTAMP;
-- RETURN NEW;
-- END;
-- $$ language 'plpgsql';
-- events 테이블 트리거 (비활성화)
-- CREATE TRIGGER update_events_updated_at BEFORE UPDATE ON events
-- FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
-- generated_images 테이블 트리거 (비활성화)
-- CREATE TRIGGER update_generated_images_updated_at BEFORE UPDATE ON generated_images
-- FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
-- ai_recommendations 테이블 트리거 (비활성화)
-- CREATE TRIGGER update_ai_recommendations_updated_at BEFORE UPDATE ON ai_recommendations
-- FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
-- jobs 테이블 트리거 (비활성화)
-- CREATE TRIGGER update_jobs_updated_at BEFORE UPDATE ON jobs
-- FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
-- ============================================
-- 샘플 데이터 (선택 사항)
-- ============================================
-- 개발/테스트 환경에서만 사용
-- 샘플 이벤트
-- INSERT INTO events (event_id, user_id, store_id, event_name, description, objective, start_date, end_date, status)
-- VALUES
-- (uuid_generate_v4(), uuid_generate_v4(), uuid_generate_v4(), '신규 고객 환영 이벤트', '첫 방문 고객 10% 할인', '신규 고객 유치', '2025-11-01', '2025-11-30', 'DRAFT');

View File

@ -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<EventDetailResponse>
- **비즈니스 로직**:
- 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<Void>
- **비즈니스 로직**:
- DRAFT 상태만 삭제 가능 검증 (Event.isDeletable())
- 다른 상태(PUBLISHED, ENDED)는 삭제 불가
- EventService.deleteEvent() 호출
#### 5. POST /api/events/{eventId}/publish
- **설명**: 이벤트 배포 (DRAFT → PUBLISHED)
- **유저스토리**: UFR-EVENT-050
- **요청**: eventId (UUID)
- **응답**: ApiResponse<Void>
- **비즈니스 로직**:
- Event.publish() 메서드로 상태 전환
- Distribution Service 호출은 추후 추가 예정
- EventService.publishEvent() 호출
#### 6. POST /api/events/{eventId}/end
- **설명**: 이벤트 조기 종료 (PUBLISHED → ENDED)
- **유저스토리**: UFR-EVENT-060
- **요청**: eventId (UUID)
- **응답**: ApiResponse<Void>
- **비즈니스 로직**:
- 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

View File

@ -10,4 +10,7 @@ dependencies {
// Jackson for JSON // Jackson for JSON
implementation 'com.fasterxml.jackson.core:jackson-databind' implementation 'com.fasterxml.jackson.core:jackson-databind'
// Hibernate 6
// implementation 'com.vladmihalcea:hibernate-types-60:2.21.1'
} }

View File

@ -0,0 +1,37 @@
package com.kt.event.eventservice;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.security.servlet.UserDetailsServiceAutoConfiguration;
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"
},
exclude = {UserDetailsServiceAutoConfiguration.class}
)
@EnableJpaAuditing
@EnableKafka
@EnableFeignClients
public class EventServiceApplication {
public static void main(String[] args) {
SpringApplication.run(EventServiceApplication.class, args);
}
}

View File

@ -0,0 +1,95 @@
package com.kt.event.eventservice.application.dto.kafka;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.util.List;
/**
* AI 이벤트 생성 작업 메시지 DTO
*
* ai-event-generation-job 토픽에서 구독하는 메시지 형식
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AIEventGenerationJobMessage {
/**
* 작업 ID
*/
@JsonProperty("job_id")
private String jobId;
/**
* 사용자 ID
*/
@JsonProperty("user_id")
private Long userId;
/**
* 작업 상태 (PENDING, PROCESSING, COMPLETED, FAILED)
*/
@JsonProperty("status")
private String status;
/**
* AI 추천 결과 데이터
*/
@JsonProperty("ai_recommendation")
private AIRecommendationData aiRecommendation;
/**
* 에러 메시지 (실패 )
*/
@JsonProperty("error_message")
private String errorMessage;
/**
* 작업 생성 일시
*/
@JsonProperty("created_at")
private LocalDateTime createdAt;
/**
* 작업 완료/실패 일시
*/
@JsonProperty("completed_at")
private LocalDateTime completedAt;
/**
* AI 추천 데이터 내부 클래스
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class AIRecommendationData {
@JsonProperty("event_title")
private String eventTitle;
@JsonProperty("event_description")
private String eventDescription;
@JsonProperty("event_type")
private String eventType;
@JsonProperty("target_keywords")
private List<String> targetKeywords;
@JsonProperty("recommended_benefits")
private List<String> recommendedBenefits;
@JsonProperty("start_date")
private String startDate;
@JsonProperty("end_date")
private String endDate;
}
}

View File

@ -0,0 +1,57 @@
package com.kt.event.eventservice.application.dto.kafka;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* 이벤트 생성 완료 메시지 DTO
*
* event-created 토픽에 발행되는 메시지 형식
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class EventCreatedMessage {
/**
* 이벤트 ID
*/
@JsonProperty("event_id")
private Long eventId;
/**
* 사용자 ID
*/
@JsonProperty("user_id")
private Long userId;
/**
* 이벤트 제목
*/
@JsonProperty("title")
private String title;
/**
* 이벤트 생성 일시
*/
@JsonProperty("created_at")
private LocalDateTime createdAt;
/**
* 이벤트 타입 (COUPON, DISCOUNT, GIFT, POINT )
*/
@JsonProperty("event_type")
private String eventType;
/**
* 메시지 타임스탬프
*/
@JsonProperty("timestamp")
private LocalDateTime timestamp;
}

View File

@ -0,0 +1,75 @@
package com.kt.event.eventservice.application.dto.kafka;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* 이미지 생성 작업 메시지 DTO
*
* image-generation-job 토픽에서 구독하는 메시지 형식
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ImageGenerationJobMessage {
/**
* 작업 ID
*/
@JsonProperty("job_id")
private String jobId;
/**
* 이벤트 ID
*/
@JsonProperty("event_id")
private Long eventId;
/**
* 사용자 ID
*/
@JsonProperty("user_id")
private Long userId;
/**
* 작업 상태 (PENDING, PROCESSING, COMPLETED, FAILED)
*/
@JsonProperty("status")
private String status;
/**
* 생성된 이미지 URL
*/
@JsonProperty("image_url")
private String imageUrl;
/**
* 이미지 생성 프롬프트
*/
@JsonProperty("prompt")
private String prompt;
/**
* 에러 메시지 (실패 )
*/
@JsonProperty("error_message")
private String errorMessage;
/**
* 작업 생성 일시
*/
@JsonProperty("created_at")
private LocalDateTime createdAt;
/**
* 작업 완료/실패 일시
*/
@JsonProperty("completed_at")
private LocalDateTime completedAt;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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<GeneratedImageDto> generatedImages = new ArrayList<>();
@Builder.Default
private List<AiRecommendationDto> aiRecommendations = new ArrayList<>();
@Builder.Default
private List<String> 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;
}
}

View File

@ -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;
}

View File

@ -0,0 +1,236 @@
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.hibernate.Hibernate;
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 (UUID)
* @param storeId 매장 ID (UUID)
* @param request 목적 선택 요청
* @return 생성된 이벤트 응답
*/
@Transactional
public EventCreatedResponse createEvent(UUID userId, UUID storeId, SelectObjectiveRequest request) {
log.info("이벤트 생성 시작 - userId: {}, storeId: {}, objective: {}",
userId, storeId, request.getObjective());
// 이벤트 엔티티 생성
Event event = Event.builder()
.userId(userId)
.storeId(storeId)
.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 (UUID)
* @param eventId 이벤트 ID
* @return 이벤트 상세 응답
*/
public EventDetailResponse getEvent(UUID userId, UUID eventId) {
log.info("이벤트 조회 - userId: {}, eventId: {}", userId, eventId);
Event event = eventRepository.findByEventIdAndUserId(eventId, userId)
.orElseThrow(() -> new BusinessException(ErrorCode.EVENT_001));
// Lazy 컬렉션 초기화
Hibernate.initialize(event.getChannels());
Hibernate.initialize(event.getGeneratedImages());
Hibernate.initialize(event.getAiRecommendations());
return mapToDetailResponse(event);
}
/**
* 이벤트 목록 조회 (페이징, 필터링)
*
* @param userId 사용자 ID (UUID)
* @param status 상태 필터
* @param search 검색어
* @param objective 목적 필터
* @param pageable 페이징 정보
* @return 이벤트 목록
*/
public Page<EventDetailResponse> getEvents(
UUID userId,
EventStatus status,
String search,
String objective,
Pageable pageable) {
log.info("이벤트 목록 조회 - userId: {}, status: {}, search: {}, objective: {}",
userId, status, search, objective);
Page<Event> events = eventRepository.findEventsByUser(userId, status, search, objective, pageable);
return events.map(event -> {
// Lazy 컬렉션 초기화
Hibernate.initialize(event.getChannels());
Hibernate.initialize(event.getGeneratedImages());
Hibernate.initialize(event.getAiRecommendations());
return mapToDetailResponse(event);
});
}
/**
* 이벤트 삭제
*
* @param userId 사용자 ID (UUID)
* @param eventId 이벤트 ID
*/
@Transactional
public void deleteEvent(UUID userId, UUID eventId) {
log.info("이벤트 삭제 - userId: {}, eventId: {}", userId, eventId);
Event event = eventRepository.findByEventIdAndUserId(eventId, userId)
.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 (UUID)
* @param eventId 이벤트 ID
*/
@Transactional
public void publishEvent(UUID userId, UUID eventId) {
log.info("이벤트 배포 - userId: {}, eventId: {}", userId, eventId);
Event event = eventRepository.findByEventIdAndUserId(eventId, userId)
.orElseThrow(() -> new BusinessException(ErrorCode.EVENT_001));
// 배포 가능 여부 검증 상태 변경
event.publish();
eventRepository.save(event);
log.info("이벤트 배포 완료 - eventId: {}", eventId);
}
/**
* 이벤트 종료
*
* @param userId 사용자 ID (UUID)
* @param eventId 이벤트 ID
*/
@Transactional
public void endEvent(UUID userId, UUID eventId) {
log.info("이벤트 종료 - userId: {}, eventId: {}", userId, eventId);
Event event = eventRepository.findByEventIdAndUserId(eventId, userId)
.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();
}
}

View File

@ -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();
}
}

View File

@ -0,0 +1,53 @@
package com.kt.event.eventservice.config;
import com.kt.event.common.security.UserPrincipal;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.Collections;
import java.util.UUID;
/**
* 개발 환경용 인증 필터
*
* User Service가 구현되지 않은 개발 환경에서 테스트를 위해
* 기본 UserPrincipal을 자동으로 생성하여 SecurityContext에 설정합니다.
*
* TODO: 프로덕션 환경에서는 필터를 비활성화하고 실제 JWT 인증 필터를 사용해야 합니다.
*/
public class DevAuthenticationFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
// 이미 인증된 경우 스킵
if (SecurityContextHolder.getContext().getAuthentication() != null) {
filterChain.doFilter(request, response);
return;
}
// 개발용 기본 UserPrincipal 생성
UserPrincipal userPrincipal = new UserPrincipal(
UUID.fromString("11111111-1111-1111-1111-111111111111"), // userId
UUID.fromString("22222222-2222-2222-2222-222222222222"), // storeId
"dev@test.com", // email
"개발테스트사용자", // name
Collections.singletonList("USER") // roles
);
// Authentication 객체 생성 SecurityContext에 설정
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(userPrincipal, null, userPrincipal.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authentication);
filterChain.doFilter(request, response);
}
}

View File

@ -0,0 +1,107 @@
package com.kt.event.eventservice.config;
import org.apache.kafka.clients.consumer.ConsumerConfig;
import org.apache.kafka.clients.producer.ProducerConfig;
import org.apache.kafka.common.serialization.StringDeserializer;
import org.apache.kafka.common.serialization.StringSerializer;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.kafka.annotation.EnableKafka;
import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory;
import org.springframework.kafka.core.*;
import org.springframework.kafka.listener.ContainerProperties;
import org.springframework.kafka.support.serializer.JsonDeserializer;
import org.springframework.kafka.support.serializer.JsonSerializer;
import java.util.HashMap;
import java.util.Map;
/**
* Kafka 설정 클래스
*
* Producer와 Consumer 설정을 정의합니다.
* - Producer: event-created 토픽에 이벤트 발행
* - Consumer: ai-event-generation-job, image-generation-job 토픽 구독
*/
@Configuration
@EnableKafka
public class KafkaConfig {
@Value("${spring.kafka.bootstrap-servers}")
private String bootstrapServers;
@Value("${spring.kafka.consumer.group-id}")
private String consumerGroupId;
/**
* Kafka Producer 설정
*
* @return ProducerFactory 인스턴스
*/
@Bean
public ProducerFactory<String, Object> producerFactory() {
Map<String, Object> config = new HashMap<>();
config.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers);
config.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
config.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, JsonSerializer.class);
config.put(JsonSerializer.ADD_TYPE_INFO_HEADERS, false);
// Producer 성능 최적화 설정
config.put(ProducerConfig.ACKS_CONFIG, "all");
config.put(ProducerConfig.RETRIES_CONFIG, 3);
config.put(ProducerConfig.LINGER_MS_CONFIG, 1);
config.put(ProducerConfig.COMPRESSION_TYPE_CONFIG, "snappy");
return new DefaultKafkaProducerFactory<>(config);
}
/**
* KafkaTemplate 생성
*
* @return KafkaTemplate 인스턴스
*/
@Bean
public KafkaTemplate<String, Object> kafkaTemplate() {
return new KafkaTemplate<>(producerFactory());
}
/**
* Kafka Consumer 설정
*
* @return ConsumerFactory 인스턴스
*/
@Bean
public ConsumerFactory<String, Object> consumerFactory() {
Map<String, Object> config = new HashMap<>();
config.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers);
config.put(ConsumerConfig.GROUP_ID_CONFIG, consumerGroupId);
config.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);
config.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, JsonDeserializer.class);
config.put(JsonDeserializer.TRUSTED_PACKAGES, "*");
config.put(JsonDeserializer.USE_TYPE_INFO_HEADERS, false);
config.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest");
config.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, false);
// Consumer 성능 최적화 설정
config.put(ConsumerConfig.MAX_POLL_RECORDS_CONFIG, 500);
config.put(ConsumerConfig.MAX_POLL_INTERVAL_MS_CONFIG, 300000);
return new DefaultKafkaConsumerFactory<>(config);
}
/**
* Kafka Listener Container Factory 설정
*
* @return ConcurrentKafkaListenerContainerFactory 인스턴스
*/
@Bean
public ConcurrentKafkaListenerContainerFactory<String, Object> kafkaListenerContainerFactory() {
ConcurrentKafkaListenerContainerFactory<String, Object> factory =
new ConcurrentKafkaListenerContainerFactory<>();
factory.setConsumerFactory(consumerFactory());
factory.setConcurrency(3); // 동시 처리 스레드
factory.getContainerProperties().setAckMode(ContainerProperties.AckMode.MANUAL);
return factory;
}
}

View File

@ -0,0 +1,65 @@
package com.kt.event.eventservice.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
/**
* Spring Security 설정 클래스
*
* 현재 User Service가 구현되지 않았으므로 임시로 모든 API 접근을 허용합니다.
* TODO: User Service 구현 JWT 기반 인증/인가 활성화 필요
*/
@Configuration
@EnableWebSecurity
public class SecurityConfig {
/**
* Spring Security 필터 체인 설정
* - 모든 요청에 대해 인증 없이 접근 허용
* - CSRF 보호 비활성화 (개발 환경)
*
* @param http HttpSecurity 설정 객체
* @return SecurityFilterChain 보안 필터 체인
* @throws Exception 설정 예외 발생
*/
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
// CSRF 보호 비활성화 (개발 환경)
.csrf(AbstractHttpConfigurer::disable)
// CORS 설정
.cors(AbstractHttpConfigurer::disable)
// 로그인 비활성화
.formLogin(AbstractHttpConfigurer::disable)
// 로그아웃 비활성화
.logout(AbstractHttpConfigurer::disable)
// HTTP Basic 인증 비활성화
.httpBasic(AbstractHttpConfigurer::disable)
// 세션 관리 - STATELESS (세션 사용 )
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
// 요청 인증 설정
.authorizeHttpRequests(authz -> authz
// 모든 요청 허용 (개발 환경)
.anyRequest().permitAll()
)
// 개발용 인증 필터 추가 (User Service 구현 전까지 임시 사용)
.addFilterBefore(new DevAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}

View File

@ -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;
}

View File

@ -0,0 +1,209 @@
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.Fetch;
import org.hibernate.annotations.FetchMode;
import org.hibernate.annotations.GenericGenerator;
import java.time.LocalDate;
import java.util.*;
/**
* 이벤트 엔티티
*
* 이벤트의 전체 생명주기를 관리합니다.
* - 생성, 수정, 배포, 종료
* - 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", 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")
private LocalDate startDate;
@Column(name = "end_date")
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(fetch = FetchType.LAZY)
@CollectionTable(
name = "event_channels",
joinColumns = @JoinColumn(name = "event_id")
)
@Column(name = "channel", length = 50)
@Fetch(FetchMode.SUBSELECT)
@Builder.Default
private List<String> channels = new ArrayList<>();
@OneToMany(mappedBy = "event", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)
@Builder.Default
private Set<GeneratedImage> generatedImages = new HashSet<>();
@OneToMany(mappedBy = "event", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)
@Builder.Default
private Set<AiRecommendation> aiRecommendations = new HashSet<>();
// ==== 비즈니스 로직 ==== //
/**
* 이벤트명 수정
*/
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<String> channels) {
this.channels.clear();
this.channels.addAll(channels);
}
/**
* 이벤트 배포 (상태 변경: DRAFT PUBLISHED)
*/
public void publish() {
if (this.status != EventStatus.DRAFT) {
throw new IllegalStateException("DRAFT 상태에서만 배포할 수 있습니다.");
}
// 필수 데이터 검증
if (eventName == null || eventName.trim().isEmpty()) {
throw new IllegalStateException("이벤트명을 입력해야 합니다.");
}
if (startDate == null || endDate == null) {
throw new IllegalStateException("이벤트 기간을 설정해야 합니다.");
}
if (startDate.isAfter(endDate)) {
throw new IllegalStateException("시작일은 종료일보다 이전이어야 합니다.");
}
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;
}
}

View File

@ -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;
}

View File

@ -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();
}
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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<AiRecommendation, UUID> {
/**
* 이벤트별 AI 추천 목록 조회
*/
List<AiRecommendation> findByEventEventId(UUID eventId);
/**
* 이벤트별 선택된 AI 추천 조회
*/
AiRecommendation findByEventEventIdAndIsSelectedTrue(UUID eventId);
}

View File

@ -0,0 +1,56 @@
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<Event, UUID> {
/**
* 사용자 ID와 이벤트 ID로 조회
*/
@Query("SELECT DISTINCT e FROM Event e " +
"LEFT JOIN FETCH e.channels " +
"WHERE e.eventId = :eventId AND e.userId = :userId")
Optional<Event> 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<Event> 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);
}

View File

@ -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<GeneratedImage, UUID> {
/**
* 이벤트별 생성된 이미지 목록 조회
*/
List<GeneratedImage> findByEventEventId(UUID eventId);
/**
* 이벤트별 선택된 이미지 조회
*/
GeneratedImage findByEventEventIdAndIsSelectedTrue(UUID eventId);
}

View File

@ -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<Job, UUID> {
/**
* 이벤트별 작업 목록 조회
*/
List<Job> findByEventId(UUID eventId);
/**
* 이벤트 작업 유형별 조회
*/
Optional<Job> findByEventIdAndJobType(UUID eventId, JobType jobType);
/**
* 이벤트 작업 유형별 최신 작업 조회
*/
Optional<Job> findFirstByEventIdAndJobTypeOrderByCreatedAtDesc(UUID eventId, JobType jobType);
/**
* 상태별 작업 목록 조회
*/
List<Job> findByStatus(JobStatus status);
}

View File

@ -0,0 +1,102 @@
package com.kt.event.eventservice.infrastructure.kafka;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.kt.event.eventservice.application.dto.kafka.AIEventGenerationJobMessage;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.kafka.annotation.KafkaListener;
import org.springframework.kafka.support.Acknowledgment;
import org.springframework.kafka.support.KafkaHeaders;
import org.springframework.messaging.handler.annotation.Header;
import org.springframework.messaging.handler.annotation.Payload;
import org.springframework.stereotype.Component;
/**
* AI 이벤트 생성 작업 메시지 구독 Consumer
*
* ai-event-generation-job 토픽의 메시지를 구독하여 처리합니다.
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class AIJobKafkaConsumer {
private final ObjectMapper objectMapper;
/**
* AI 이벤트 생성 작업 메시지 수신 처리
*
* @param message AI 이벤트 생성 작업 메시지
* @param partition 파티션 번호
* @param offset 오프셋
* @param acknowledgment 수동 커밋용 Acknowledgment
*/
@KafkaListener(
topics = "${app.kafka.topics.ai-event-generation-job}",
groupId = "${spring.kafka.consumer.group-id}",
containerFactory = "kafkaListenerContainerFactory"
)
public void consumeAIEventGenerationJob(
@Payload String payload,
@Header(KafkaHeaders.RECEIVED_PARTITION) int partition,
@Header(KafkaHeaders.OFFSET) long offset,
Acknowledgment acknowledgment
) {
try {
log.info("AI 이벤트 생성 작업 메시지 수신 - Partition: {}, Offset: {}", partition, offset);
// JSON을 객체로 변환
AIEventGenerationJobMessage message = objectMapper.readValue(
payload,
AIEventGenerationJobMessage.class
);
log.info("AI 작업 메시지 파싱 완료 - JobId: {}, UserId: {}, Status: {}",
message.getJobId(), message.getUserId(), message.getStatus());
// 메시지 처리 로직
processAIEventGenerationJob(message);
// 수동 커밋
acknowledgment.acknowledge();
log.info("AI 이벤트 생성 작업 메시지 처리 완료 - JobId: {}", message.getJobId());
} catch (Exception e) {
log.error("AI 이벤트 생성 작업 메시지 처리 중 오류 발생 - Partition: {}, Offset: {}, Error: {}",
partition, offset, e.getMessage(), e);
// 에러 발생 시에도 커밋 (재처리 방지, DLQ 사용 권장)
acknowledgment.acknowledge();
}
}
/**
* AI 이벤트 생성 작업 처리
*
* @param message AI 이벤트 생성 작업 메시지
*/
private void processAIEventGenerationJob(AIEventGenerationJobMessage message) {
switch (message.getStatus()) {
case "COMPLETED":
log.info("AI 작업 완료 처리 - JobId: {}, UserId: {}",
message.getJobId(), message.getUserId());
// TODO: AI 추천 결과를 캐시 또는 DB에 저장
// TODO: 사용자에게 알림 전송
break;
case "FAILED":
log.error("AI 작업 실패 처리 - JobId: {}, Error: {}",
message.getJobId(), message.getErrorMessage());
// TODO: 실패 로그 저장 사용자 알림
break;
case "PROCESSING":
log.info("AI 작업 진행 중 - JobId: {}", message.getJobId());
// TODO: 작업 상태 업데이트
break;
default:
log.warn("알 수 없는 작업 상태 - JobId: {}, Status: {}",
message.getJobId(), message.getStatus());
}
}
}

View File

@ -0,0 +1,78 @@
package com.kt.event.eventservice.infrastructure.kafka;
import com.kt.event.eventservice.application.dto.kafka.EventCreatedMessage;
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;
/**
* 이벤트 생성 메시지 발행 Producer
*
* event-created 토픽에 이벤트 생성 완료 메시지를 발행합니다.
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class EventKafkaProducer {
private final KafkaTemplate<String, Object> kafkaTemplate;
@Value("${app.kafka.topics.event-created}")
private String eventCreatedTopic;
/**
* 이벤트 생성 완료 메시지 발행
*
* @param eventId 이벤트 ID
* @param userId 사용자 ID
* @param title 이벤트 제목
* @param eventType 이벤트 타입
*/
public void publishEventCreated(Long eventId, Long userId, String title, String eventType) {
EventCreatedMessage message = EventCreatedMessage.builder()
.eventId(eventId)
.userId(userId)
.title(title)
.eventType(eventType)
.createdAt(LocalDateTime.now())
.timestamp(LocalDateTime.now())
.build();
publishEventCreatedMessage(message);
}
/**
* 이벤트 생성 메시지 발행
*
* @param message EventCreatedMessage 객체
*/
public void publishEventCreatedMessage(EventCreatedMessage message) {
try {
CompletableFuture<SendResult<String, Object>> future =
kafkaTemplate.send(eventCreatedTopic, message.getEventId().toString(), message);
future.whenComplete((result, ex) -> {
if (ex == null) {
log.info("이벤트 생성 메시지 발행 성공 - Topic: {}, EventId: {}, Offset: {}",
eventCreatedTopic,
message.getEventId(),
result.getRecordMetadata().offset());
} else {
log.error("이벤트 생성 메시지 발행 실패 - Topic: {}, EventId: {}, Error: {}",
eventCreatedTopic,
message.getEventId(),
ex.getMessage(), ex);
}
});
} catch (Exception e) {
log.error("이벤트 생성 메시지 발행 중 예외 발생 - EventId: {}, Error: {}",
message.getEventId(), e.getMessage(), e);
}
}
}

View File

@ -0,0 +1,105 @@
package com.kt.event.eventservice.infrastructure.kafka;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.kt.event.eventservice.application.dto.kafka.ImageGenerationJobMessage;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.kafka.annotation.KafkaListener;
import org.springframework.kafka.support.Acknowledgment;
import org.springframework.kafka.support.KafkaHeaders;
import org.springframework.messaging.handler.annotation.Header;
import org.springframework.messaging.handler.annotation.Payload;
import org.springframework.stereotype.Component;
/**
* 이미지 생성 작업 메시지 구독 Consumer
*
* image-generation-job 토픽의 메시지를 구독하여 처리합니다.
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class ImageJobKafkaConsumer {
private final ObjectMapper objectMapper;
/**
* 이미지 생성 작업 메시지 수신 처리
*
* @param payload 메시지 페이로드 (JSON)
* @param partition 파티션 번호
* @param offset 오프셋
* @param acknowledgment 수동 커밋용 Acknowledgment
*/
@KafkaListener(
topics = "${app.kafka.topics.image-generation-job}",
groupId = "${spring.kafka.consumer.group-id}",
containerFactory = "kafkaListenerContainerFactory"
)
public void consumeImageGenerationJob(
@Payload String payload,
@Header(KafkaHeaders.RECEIVED_PARTITION) int partition,
@Header(KafkaHeaders.OFFSET) long offset,
Acknowledgment acknowledgment
) {
try {
log.info("이미지 생성 작업 메시지 수신 - Partition: {}, Offset: {}", partition, offset);
// JSON을 객체로 변환
ImageGenerationJobMessage message = objectMapper.readValue(
payload,
ImageGenerationJobMessage.class
);
log.info("이미지 작업 메시지 파싱 완료 - JobId: {}, EventId: {}, Status: {}",
message.getJobId(), message.getEventId(), message.getStatus());
// 메시지 처리 로직
processImageGenerationJob(message);
// 수동 커밋
acknowledgment.acknowledge();
log.info("이미지 생성 작업 메시지 처리 완료 - JobId: {}", message.getJobId());
} catch (Exception e) {
log.error("이미지 생성 작업 메시지 처리 중 오류 발생 - Partition: {}, Offset: {}, Error: {}",
partition, offset, e.getMessage(), e);
// 에러 발생 시에도 커밋 (재처리 방지, DLQ 사용 권장)
acknowledgment.acknowledge();
}
}
/**
* 이미지 생성 작업 처리
*
* @param message 이미지 생성 작업 메시지
*/
private void processImageGenerationJob(ImageGenerationJobMessage message) {
switch (message.getStatus()) {
case "COMPLETED":
log.info("이미지 작업 완료 처리 - JobId: {}, EventId: {}, ImageURL: {}",
message.getJobId(), message.getEventId(), message.getImageUrl());
// TODO: 생성된 이미지 URL을 캐시 또는 DB에 저장
// TODO: 이벤트 엔티티에 이미지 URL 업데이트
// TODO: 사용자에게 알림 전송
break;
case "FAILED":
log.error("이미지 작업 실패 처리 - JobId: {}, EventId: {}, Error: {}",
message.getJobId(), message.getEventId(), message.getErrorMessage());
// TODO: 실패 로그 저장 사용자 알림
// TODO: 재시도 로직 또는 기본 이미지 사용
break;
case "PROCESSING":
log.info("이미지 작업 진행 중 - JobId: {}, EventId: {}",
message.getJobId(), message.getEventId());
// TODO: 작업 상태 업데이트
break;
default:
log.warn("알 수 없는 작업 상태 - JobId: {}, EventId: {}, Status: {}",
message.getJobId(), message.getEventId(), message.getStatus());
}
}
}

View File

@ -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/v1/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<ApiResponse<EventCreatedResponse>> 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<ApiResponse<PageResponse<EventDetailResponse>>> 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<EventDetailResponse> events = eventService.getEvents(
userPrincipal.getUserId(),
status,
search,
objective,
pageable
);
PageResponse<EventDetailResponse> pageResponse = PageResponse.<EventDetailResponse>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<ApiResponse<EventDetailResponse>> 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<ApiResponse<Void>> 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<ApiResponse<Void>> 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<ApiResponse<Void>> 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));
}
}

View File

@ -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/v1/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<ApiResponse<JobStatusResponse>> getJobStatus(@PathVariable UUID jobId) {
log.info("Job 상태 조회 API 호출 - jobId: {}", jobId);
JobStatusResponse response = jobService.getJobStatus(jobId);
return ResponseEntity.ok(ApiResponse.success(response));
}
}

View File

@ -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분 (밀리초 단위)