diff --git a/.gitignore b/.gitignore index b1f9379..04ee081 100644 --- a/.gitignore +++ b/.gitignore @@ -33,5 +33,15 @@ tmp/ temp/ *.tmp -# Docker (로컬 개발용) -backing-service/docker-compose.yml +# Kubernetes Secrets (민감한 정보 포함) +k8s/**/secret.yaml +k8s/**/*-secret.yaml +k8s/**/*-prod.yaml +k8s/**/*-dev.yaml +k8s/**/*-local.yaml + +# IntelliJ 실행 프로파일 (민감한 환경 변수 포함 가능) +.run/*.run.xml + +# Gradle (로컬 환경 설정) +gradle.properties 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/exception/ErrorCode.java b/common/src/main/java/com/kt/event/common/exception/ErrorCode.java index 0065a7a..dbba5c4 100644 --- a/common/src/main/java/com/kt/event/common/exception/ErrorCode.java +++ b/common/src/main/java/com/kt/event/common/exception/ErrorCode.java @@ -18,6 +18,10 @@ public enum ErrorCode { COMMON_004("COMMON_004", "서버 내부 오류가 발생했습니다"), COMMON_005("COMMON_005", "지원하지 않는 작업입니다"), + // 일반 에러 상수 (Legacy 호환용) + NOT_FOUND("NOT_FOUND", "요청한 리소스를 찾을 수 없습니다"), + INVALID_INPUT_VALUE("INVALID_INPUT_VALUE", "유효하지 않은 입력값입니다"), + // 인증/인가 에러 (AUTH_XXX) AUTH_001("AUTH_001", "인증에 실패했습니다"), AUTH_002("AUTH_002", "유효하지 않은 토큰입니다"), 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..7bd50c3 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 @@ -12,6 +12,7 @@ import javax.crypto.SecretKey; import java.nio.charset.StandardCharsets; import java.util.Date; import java.util.List; +import java.util.UUID; /** * JWT 토큰 생성 및 검증 제공자 @@ -49,17 +50,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(UUID userId, UUID 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.toString()) .claim("email", email) .claim("name", name) .claim("roles", roles) @@ -76,7 +79,7 @@ public class JwtTokenProvider { * @param userId 사용자 ID * @return Refresh Token */ - public String createRefreshToken(Long userId) { + public String createRefreshToken(UUID userId) { Date now = new Date(); Date expiryDate = new Date(now.getTime() + refreshTokenValidityMs); @@ -95,9 +98,9 @@ public class JwtTokenProvider { * @param token JWT 토큰 * @return 사용자 ID */ - public Long getUserIdFromToken(String token) { + public UUID getUserIdFromToken(String 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) { 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 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..ff99809 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 @@ -1,6 +1,7 @@ package com.kt.event.common.security; import lombok.AllArgsConstructor; +import lombok.Builder; import lombok.Getter; import org.springframework.security.core.GrantedAuthority; 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.List; +import java.util.UUID; import java.util.stream.Collectors; /** @@ -15,13 +17,19 @@ import java.util.stream.Collectors; * JWT 토큰에서 추출한 사용자 정보를 담는 객체 */ @Getter +@Builder @AllArgsConstructor public class UserPrincipal implements UserDetails { /** * 사용자 ID */ - private final Long userId; + private final UUID userId; + + /** + * 매장 ID + */ + private final UUID storeId; /** * 사용자 이메일 diff --git a/develop/database/sql/event-service-ddl.sql b/develop/database/sql/event-service-ddl.sql new file mode 100644 index 0000000..548698b --- /dev/null +++ b/develop/database/sql/event-service-ddl.sql @@ -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'); 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..af3323a 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 6 네이티브로 배열 타입 지원하므로 별도 라이브러리 불필요 + // 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..e3fd04e --- /dev/null +++ b/event-service/src/main/java/com/kt/event/eventservice/EventServiceApplication.java @@ -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); + } +} diff --git a/event-service/src/main/java/com/kt/event/eventservice/application/dto/kafka/AIEventGenerationJobMessage.java b/event-service/src/main/java/com/kt/event/eventservice/application/dto/kafka/AIEventGenerationJobMessage.java new file mode 100644 index 0000000..966778f --- /dev/null +++ b/event-service/src/main/java/com/kt/event/eventservice/application/dto/kafka/AIEventGenerationJobMessage.java @@ -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 targetKeywords; + + @JsonProperty("recommended_benefits") + private List recommendedBenefits; + + @JsonProperty("start_date") + private String startDate; + + @JsonProperty("end_date") + private String endDate; + } +} diff --git a/event-service/src/main/java/com/kt/event/eventservice/application/dto/kafka/EventCreatedMessage.java b/event-service/src/main/java/com/kt/event/eventservice/application/dto/kafka/EventCreatedMessage.java new file mode 100644 index 0000000..d971374 --- /dev/null +++ b/event-service/src/main/java/com/kt/event/eventservice/application/dto/kafka/EventCreatedMessage.java @@ -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; +} diff --git a/event-service/src/main/java/com/kt/event/eventservice/application/dto/kafka/ImageGenerationJobMessage.java b/event-service/src/main/java/com/kt/event/eventservice/application/dto/kafka/ImageGenerationJobMessage.java new file mode 100644 index 0000000..dd52243 --- /dev/null +++ b/event-service/src/main/java/com/kt/event/eventservice/application/dto/kafka/ImageGenerationJobMessage.java @@ -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; +} 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..5e0ba67 --- /dev/null +++ b/event-service/src/main/java/com/kt/event/eventservice/application/service/EventService.java @@ -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 getEvents( + UUID userId, + EventStatus status, + String search, + String objective, + Pageable pageable) { + + log.info("이벤트 목록 조회 - userId: {}, status: {}, search: {}, objective: {}", + userId, status, search, objective); + + Page 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(); + } +} 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/config/DevAuthenticationFilter.java b/event-service/src/main/java/com/kt/event/eventservice/config/DevAuthenticationFilter.java new file mode 100644 index 0000000..fb56ea8 --- /dev/null +++ b/event-service/src/main/java/com/kt/event/eventservice/config/DevAuthenticationFilter.java @@ -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); + } +} diff --git a/event-service/src/main/java/com/kt/event/eventservice/config/KafkaConfig.java b/event-service/src/main/java/com/kt/event/eventservice/config/KafkaConfig.java new file mode 100644 index 0000000..0391c46 --- /dev/null +++ b/event-service/src/main/java/com/kt/event/eventservice/config/KafkaConfig.java @@ -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 producerFactory() { + Map 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 kafkaTemplate() { + return new KafkaTemplate<>(producerFactory()); + } + + /** + * Kafka Consumer 설정 + * + * @return ConsumerFactory 인스턴스 + */ + @Bean + public ConsumerFactory consumerFactory() { + Map 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 kafkaListenerContainerFactory() { + ConcurrentKafkaListenerContainerFactory factory = + new ConcurrentKafkaListenerContainerFactory<>(); + factory.setConsumerFactory(consumerFactory()); + factory.setConcurrency(3); // 동시 처리 스레드 수 + factory.getContainerProperties().setAckMode(ContainerProperties.AckMode.MANUAL); + return factory; + } +} diff --git a/event-service/src/main/java/com/kt/event/eventservice/config/SecurityConfig.java b/event-service/src/main/java/com/kt/event/eventservice/config/SecurityConfig.java new file mode 100644 index 0000000..5aea9e1 --- /dev/null +++ b/event-service/src/main/java/com/kt/event/eventservice/config/SecurityConfig.java @@ -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(); + } +} 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..9602b65 --- /dev/null +++ b/event-service/src/main/java/com/kt/event/eventservice/domain/entity/Event.java @@ -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 channels = new ArrayList<>(); + + @OneToMany(mappedBy = "event", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY) + @Builder.Default + private Set generatedImages = new HashSet<>(); + + @OneToMany(mappedBy = "event", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY) + @Builder.Default + private Set 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 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; + } +} 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..22add09 --- /dev/null +++ b/event-service/src/main/java/com/kt/event/eventservice/domain/repository/EventRepository.java @@ -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 { + + /** + * 사용자 ID와 이벤트 ID로 조회 + */ + @Query("SELECT DISTINCT e FROM Event e " + + "LEFT JOIN FETCH e.channels " + + "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/infrastructure/kafka/AIJobKafkaConsumer.java b/event-service/src/main/java/com/kt/event/eventservice/infrastructure/kafka/AIJobKafkaConsumer.java new file mode 100644 index 0000000..f4f1608 --- /dev/null +++ b/event-service/src/main/java/com/kt/event/eventservice/infrastructure/kafka/AIJobKafkaConsumer.java @@ -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()); + } + } +} diff --git a/event-service/src/main/java/com/kt/event/eventservice/infrastructure/kafka/EventKafkaProducer.java b/event-service/src/main/java/com/kt/event/eventservice/infrastructure/kafka/EventKafkaProducer.java new file mode 100644 index 0000000..a409831 --- /dev/null +++ b/event-service/src/main/java/com/kt/event/eventservice/infrastructure/kafka/EventKafkaProducer.java @@ -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 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> 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); + } + } +} diff --git a/event-service/src/main/java/com/kt/event/eventservice/infrastructure/kafka/ImageJobKafkaConsumer.java b/event-service/src/main/java/com/kt/event/eventservice/infrastructure/kafka/ImageJobKafkaConsumer.java new file mode 100644 index 0000000..f66f3e7 --- /dev/null +++ b/event-service/src/main/java/com/kt/event/eventservice/infrastructure/kafka/ImageJobKafkaConsumer.java @@ -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()); + } + } +} 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..0902ba0 --- /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/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> 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..149be77 --- /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/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> 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분 (밀리초 단위)