Analytics 서비스 및 보안 기능 업데이트
- Analytics 서비스 구현 추가 (API, 소스 코드) - Event 서비스 소스 코드 추가 - 보안 관련 공통 컴포넌트 업데이트 (JWT, UserPrincipal, ErrorCode) - API 컨벤션 및 명세서 업데이트 - 데이터베이스 SQL 스크립트 추가 - 백엔드 개발 문서 및 테스트 가이드 추가 - Kafka 메시지 체크 도구 추가 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
package com.kt.event.eventservice.application.dto.kafka;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* AI 이벤트 생성 작업 메시지 DTO
|
||||
*
|
||||
* ai-event-generation-job 토픽에서 구독하는 메시지 형식
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class AIEventGenerationJobMessage {
|
||||
|
||||
/**
|
||||
* 작업 ID
|
||||
*/
|
||||
@JsonProperty("job_id")
|
||||
private String jobId;
|
||||
|
||||
/**
|
||||
* 사용자 ID
|
||||
*/
|
||||
@JsonProperty("user_id")
|
||||
private Long userId;
|
||||
|
||||
/**
|
||||
* 작업 상태 (PENDING, PROCESSING, COMPLETED, FAILED)
|
||||
*/
|
||||
@JsonProperty("status")
|
||||
private String status;
|
||||
|
||||
/**
|
||||
* AI 추천 결과 데이터
|
||||
*/
|
||||
@JsonProperty("ai_recommendation")
|
||||
private AIRecommendationData aiRecommendation;
|
||||
|
||||
/**
|
||||
* 에러 메시지 (실패 시)
|
||||
*/
|
||||
@JsonProperty("error_message")
|
||||
private String errorMessage;
|
||||
|
||||
/**
|
||||
* 작업 생성 일시
|
||||
*/
|
||||
@JsonProperty("created_at")
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
/**
|
||||
* 작업 완료/실패 일시
|
||||
*/
|
||||
@JsonProperty("completed_at")
|
||||
private LocalDateTime completedAt;
|
||||
|
||||
/**
|
||||
* AI 추천 데이터 내부 클래스
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public static class AIRecommendationData {
|
||||
|
||||
@JsonProperty("event_title")
|
||||
private String eventTitle;
|
||||
|
||||
@JsonProperty("event_description")
|
||||
private String eventDescription;
|
||||
|
||||
@JsonProperty("event_type")
|
||||
private String eventType;
|
||||
|
||||
@JsonProperty("target_keywords")
|
||||
private List<String> targetKeywords;
|
||||
|
||||
@JsonProperty("recommended_benefits")
|
||||
private List<String> recommendedBenefits;
|
||||
|
||||
@JsonProperty("start_date")
|
||||
private String startDate;
|
||||
|
||||
@JsonProperty("end_date")
|
||||
private String endDate;
|
||||
}
|
||||
}
|
||||
+57
@@ -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;
|
||||
}
|
||||
+75
@@ -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;
|
||||
}
|
||||
+24
@@ -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;
|
||||
}
|
||||
+29
@@ -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;
|
||||
}
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
package com.kt.event.eventservice.application.dto.response;
|
||||
|
||||
import com.kt.event.eventservice.domain.enums.EventStatus;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* 이벤트 상세 응답 DTO
|
||||
*
|
||||
* @author Event Service Team
|
||||
* @version 1.0.0
|
||||
* @since 2025-10-23
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class EventDetailResponse {
|
||||
|
||||
private UUID eventId;
|
||||
private UUID userId;
|
||||
private UUID storeId;
|
||||
private String eventName;
|
||||
private String description;
|
||||
private String objective;
|
||||
private LocalDate startDate;
|
||||
private LocalDate endDate;
|
||||
private EventStatus status;
|
||||
private UUID selectedImageId;
|
||||
private String selectedImageUrl;
|
||||
|
||||
@Builder.Default
|
||||
private List<GeneratedImageDto> generatedImages = new ArrayList<>();
|
||||
|
||||
@Builder.Default
|
||||
private List<AiRecommendationDto> aiRecommendations = new ArrayList<>();
|
||||
|
||||
@Builder.Default
|
||||
private List<String> channels = new ArrayList<>();
|
||||
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public static class GeneratedImageDto {
|
||||
private UUID imageId;
|
||||
private String imageUrl;
|
||||
private String style;
|
||||
private String platform;
|
||||
private boolean isSelected;
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public static class AiRecommendationDto {
|
||||
private UUID recommendationId;
|
||||
private String eventName;
|
||||
private String description;
|
||||
private String promotionType;
|
||||
private String targetAudience;
|
||||
private boolean isSelected;
|
||||
}
|
||||
}
|
||||
+34
@@ -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;
|
||||
}
|
||||
+236
@@ -0,0 +1,236 @@
|
||||
package com.kt.event.eventservice.application.service;
|
||||
|
||||
import com.kt.event.common.exception.BusinessException;
|
||||
import com.kt.event.common.exception.ErrorCode;
|
||||
import com.kt.event.eventservice.application.dto.request.SelectObjectiveRequest;
|
||||
import com.kt.event.eventservice.application.dto.response.EventCreatedResponse;
|
||||
import com.kt.event.eventservice.application.dto.response.EventDetailResponse;
|
||||
import com.kt.event.eventservice.domain.entity.*;
|
||||
import com.kt.event.eventservice.domain.enums.EventStatus;
|
||||
import com.kt.event.eventservice.domain.repository.EventRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.hibernate.Hibernate;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 이벤트 서비스
|
||||
*
|
||||
* 이벤트 전체 생명주기를 관리합니다.
|
||||
*
|
||||
* @author Event Service Team
|
||||
* @version 1.0.0
|
||||
* @since 2025-10-23
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Transactional(readOnly = true)
|
||||
public class EventService {
|
||||
|
||||
private final EventRepository eventRepository;
|
||||
|
||||
/**
|
||||
* 이벤트 생성 (Step 1: 목적 선택)
|
||||
*
|
||||
* @param userId 사용자 ID (UUID)
|
||||
* @param storeId 매장 ID (UUID)
|
||||
* @param request 목적 선택 요청
|
||||
* @return 생성된 이벤트 응답
|
||||
*/
|
||||
@Transactional
|
||||
public EventCreatedResponse createEvent(UUID userId, UUID storeId, SelectObjectiveRequest request) {
|
||||
log.info("이벤트 생성 시작 - userId: {}, storeId: {}, objective: {}",
|
||||
userId, storeId, request.getObjective());
|
||||
|
||||
// 이벤트 엔티티 생성
|
||||
Event event = Event.builder()
|
||||
.userId(userId)
|
||||
.storeId(storeId)
|
||||
.objective(request.getObjective())
|
||||
.eventName("") // 초기에는 비어있음, AI 추천 후 설정
|
||||
.status(EventStatus.DRAFT)
|
||||
.build();
|
||||
|
||||
// 저장
|
||||
event = eventRepository.save(event);
|
||||
|
||||
log.info("이벤트 생성 완료 - eventId: {}", event.getEventId());
|
||||
|
||||
return EventCreatedResponse.builder()
|
||||
.eventId(event.getEventId())
|
||||
.status(event.getStatus())
|
||||
.objective(event.getObjective())
|
||||
.createdAt(event.getCreatedAt())
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 이벤트 상세 조회
|
||||
*
|
||||
* @param userId 사용자 ID (UUID)
|
||||
* @param eventId 이벤트 ID
|
||||
* @return 이벤트 상세 응답
|
||||
*/
|
||||
public EventDetailResponse getEvent(UUID userId, UUID eventId) {
|
||||
log.info("이벤트 조회 - userId: {}, eventId: {}", userId, eventId);
|
||||
|
||||
Event event = eventRepository.findByEventIdAndUserId(eventId, userId)
|
||||
.orElseThrow(() -> new BusinessException(ErrorCode.EVENT_001));
|
||||
|
||||
// Lazy 컬렉션 초기화
|
||||
Hibernate.initialize(event.getChannels());
|
||||
Hibernate.initialize(event.getGeneratedImages());
|
||||
Hibernate.initialize(event.getAiRecommendations());
|
||||
|
||||
return mapToDetailResponse(event);
|
||||
}
|
||||
|
||||
/**
|
||||
* 이벤트 목록 조회 (페이징, 필터링)
|
||||
*
|
||||
* @param userId 사용자 ID (UUID)
|
||||
* @param status 상태 필터
|
||||
* @param search 검색어
|
||||
* @param objective 목적 필터
|
||||
* @param pageable 페이징 정보
|
||||
* @return 이벤트 목록
|
||||
*/
|
||||
public Page<EventDetailResponse> getEvents(
|
||||
UUID userId,
|
||||
EventStatus status,
|
||||
String search,
|
||||
String objective,
|
||||
Pageable pageable) {
|
||||
|
||||
log.info("이벤트 목록 조회 - userId: {}, status: {}, search: {}, objective: {}",
|
||||
userId, status, search, objective);
|
||||
|
||||
Page<Event> events = eventRepository.findEventsByUser(userId, status, search, objective, pageable);
|
||||
|
||||
return events.map(event -> {
|
||||
// Lazy 컬렉션 초기화
|
||||
Hibernate.initialize(event.getChannels());
|
||||
Hibernate.initialize(event.getGeneratedImages());
|
||||
Hibernate.initialize(event.getAiRecommendations());
|
||||
return mapToDetailResponse(event);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 이벤트 삭제
|
||||
*
|
||||
* @param userId 사용자 ID (UUID)
|
||||
* @param eventId 이벤트 ID
|
||||
*/
|
||||
@Transactional
|
||||
public void deleteEvent(UUID userId, UUID eventId) {
|
||||
log.info("이벤트 삭제 - userId: {}, eventId: {}", userId, eventId);
|
||||
|
||||
Event event = eventRepository.findByEventIdAndUserId(eventId, userId)
|
||||
.orElseThrow(() -> new BusinessException(ErrorCode.EVENT_001));
|
||||
|
||||
if (!event.isDeletable()) {
|
||||
throw new BusinessException(ErrorCode.EVENT_002);
|
||||
}
|
||||
|
||||
eventRepository.delete(event);
|
||||
|
||||
log.info("이벤트 삭제 완료 - eventId: {}", eventId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 이벤트 배포
|
||||
*
|
||||
* @param userId 사용자 ID (UUID)
|
||||
* @param eventId 이벤트 ID
|
||||
*/
|
||||
@Transactional
|
||||
public void publishEvent(UUID userId, UUID eventId) {
|
||||
log.info("이벤트 배포 - userId: {}, eventId: {}", userId, eventId);
|
||||
|
||||
Event event = eventRepository.findByEventIdAndUserId(eventId, userId)
|
||||
.orElseThrow(() -> new BusinessException(ErrorCode.EVENT_001));
|
||||
|
||||
// 배포 가능 여부 검증 및 상태 변경
|
||||
event.publish();
|
||||
|
||||
eventRepository.save(event);
|
||||
|
||||
log.info("이벤트 배포 완료 - eventId: {}", eventId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 이벤트 종료
|
||||
*
|
||||
* @param userId 사용자 ID (UUID)
|
||||
* @param eventId 이벤트 ID
|
||||
*/
|
||||
@Transactional
|
||||
public void endEvent(UUID userId, UUID eventId) {
|
||||
log.info("이벤트 종료 - userId: {}, eventId: {}", userId, eventId);
|
||||
|
||||
Event event = eventRepository.findByEventIdAndUserId(eventId, userId)
|
||||
.orElseThrow(() -> new BusinessException(ErrorCode.EVENT_001));
|
||||
|
||||
event.end();
|
||||
|
||||
eventRepository.save(event);
|
||||
|
||||
log.info("이벤트 종료 완료 - eventId: {}", eventId);
|
||||
}
|
||||
|
||||
// ==== Private Helper Methods ==== //
|
||||
|
||||
/**
|
||||
* Event Entity를 EventDetailResponse DTO로 변환
|
||||
*/
|
||||
private EventDetailResponse mapToDetailResponse(Event event) {
|
||||
return EventDetailResponse.builder()
|
||||
.eventId(event.getEventId())
|
||||
.userId(event.getUserId())
|
||||
.storeId(event.getStoreId())
|
||||
.eventName(event.getEventName())
|
||||
.description(event.getDescription())
|
||||
.objective(event.getObjective())
|
||||
.startDate(event.getStartDate())
|
||||
.endDate(event.getEndDate())
|
||||
.status(event.getStatus())
|
||||
.selectedImageId(event.getSelectedImageId())
|
||||
.selectedImageUrl(event.getSelectedImageUrl())
|
||||
.generatedImages(
|
||||
event.getGeneratedImages().stream()
|
||||
.map(img -> EventDetailResponse.GeneratedImageDto.builder()
|
||||
.imageId(img.getImageId())
|
||||
.imageUrl(img.getImageUrl())
|
||||
.style(img.getStyle())
|
||||
.platform(img.getPlatform())
|
||||
.isSelected(img.isSelected())
|
||||
.createdAt(img.getCreatedAt())
|
||||
.build())
|
||||
.collect(Collectors.toList())
|
||||
)
|
||||
.aiRecommendations(
|
||||
event.getAiRecommendations().stream()
|
||||
.map(rec -> EventDetailResponse.AiRecommendationDto.builder()
|
||||
.recommendationId(rec.getRecommendationId())
|
||||
.eventName(rec.getEventName())
|
||||
.description(rec.getDescription())
|
||||
.promotionType(rec.getPromotionType())
|
||||
.targetAudience(rec.getTargetAudience())
|
||||
.isSelected(rec.isSelected())
|
||||
.build())
|
||||
.collect(Collectors.toList())
|
||||
)
|
||||
.channels(event.getChannels())
|
||||
.createdAt(event.getCreatedAt())
|
||||
.updatedAt(event.getUpdatedAt())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
+146
@@ -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();
|
||||
}
|
||||
}
|
||||
+53
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package com.kt.event.eventservice.config;
|
||||
|
||||
import org.apache.kafka.clients.consumer.ConsumerConfig;
|
||||
import org.apache.kafka.clients.producer.ProducerConfig;
|
||||
import org.apache.kafka.common.serialization.StringDeserializer;
|
||||
import org.apache.kafka.common.serialization.StringSerializer;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.kafka.annotation.EnableKafka;
|
||||
import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory;
|
||||
import org.springframework.kafka.core.*;
|
||||
import org.springframework.kafka.listener.ContainerProperties;
|
||||
import org.springframework.kafka.support.serializer.JsonDeserializer;
|
||||
import org.springframework.kafka.support.serializer.JsonSerializer;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Kafka 설정 클래스
|
||||
*
|
||||
* Producer와 Consumer 설정을 정의합니다.
|
||||
* - Producer: event-created 토픽에 이벤트 발행
|
||||
* - Consumer: ai-event-generation-job, image-generation-job 토픽 구독
|
||||
*/
|
||||
@Configuration
|
||||
@EnableKafka
|
||||
public class KafkaConfig {
|
||||
|
||||
@Value("${spring.kafka.bootstrap-servers}")
|
||||
private String bootstrapServers;
|
||||
|
||||
@Value("${spring.kafka.consumer.group-id}")
|
||||
private String consumerGroupId;
|
||||
|
||||
/**
|
||||
* Kafka Producer 설정
|
||||
*
|
||||
* @return ProducerFactory 인스턴스
|
||||
*/
|
||||
@Bean
|
||||
public ProducerFactory<String, Object> producerFactory() {
|
||||
Map<String, Object> config = new HashMap<>();
|
||||
config.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers);
|
||||
config.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
|
||||
config.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, JsonSerializer.class);
|
||||
config.put(JsonSerializer.ADD_TYPE_INFO_HEADERS, false);
|
||||
|
||||
// Producer 성능 최적화 설정
|
||||
config.put(ProducerConfig.ACKS_CONFIG, "all");
|
||||
config.put(ProducerConfig.RETRIES_CONFIG, 3);
|
||||
config.put(ProducerConfig.LINGER_MS_CONFIG, 1);
|
||||
config.put(ProducerConfig.COMPRESSION_TYPE_CONFIG, "snappy");
|
||||
|
||||
return new DefaultKafkaProducerFactory<>(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* KafkaTemplate 빈 생성
|
||||
*
|
||||
* @return KafkaTemplate 인스턴스
|
||||
*/
|
||||
@Bean
|
||||
public KafkaTemplate<String, Object> kafkaTemplate() {
|
||||
return new KafkaTemplate<>(producerFactory());
|
||||
}
|
||||
|
||||
/**
|
||||
* Kafka Consumer 설정
|
||||
*
|
||||
* @return ConsumerFactory 인스턴스
|
||||
*/
|
||||
@Bean
|
||||
public ConsumerFactory<String, Object> consumerFactory() {
|
||||
Map<String, Object> config = new HashMap<>();
|
||||
config.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers);
|
||||
config.put(ConsumerConfig.GROUP_ID_CONFIG, consumerGroupId);
|
||||
config.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);
|
||||
config.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, JsonDeserializer.class);
|
||||
config.put(JsonDeserializer.TRUSTED_PACKAGES, "*");
|
||||
config.put(JsonDeserializer.USE_TYPE_INFO_HEADERS, false);
|
||||
config.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest");
|
||||
config.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, false);
|
||||
|
||||
// Consumer 성능 최적화 설정
|
||||
config.put(ConsumerConfig.MAX_POLL_RECORDS_CONFIG, 500);
|
||||
config.put(ConsumerConfig.MAX_POLL_INTERVAL_MS_CONFIG, 300000);
|
||||
|
||||
return new DefaultKafkaConsumerFactory<>(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Kafka Listener Container Factory 설정
|
||||
*
|
||||
* @return ConcurrentKafkaListenerContainerFactory 인스턴스
|
||||
*/
|
||||
@Bean
|
||||
public ConcurrentKafkaListenerContainerFactory<String, Object> kafkaListenerContainerFactory() {
|
||||
ConcurrentKafkaListenerContainerFactory<String, Object> factory =
|
||||
new ConcurrentKafkaListenerContainerFactory<>();
|
||||
factory.setConsumerFactory(consumerFactory());
|
||||
factory.setConcurrency(3); // 동시 처리 스레드 수
|
||||
factory.getContainerProperties().setAckMode(ContainerProperties.AckMode.MANUAL);
|
||||
return factory;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
+53
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
package com.kt.event.eventservice.domain.entity;
|
||||
|
||||
import com.kt.event.common.entity.BaseTimeEntity;
|
||||
import com.kt.event.eventservice.domain.enums.EventStatus;
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
import org.hibernate.annotations.Fetch;
|
||||
import org.hibernate.annotations.FetchMode;
|
||||
import org.hibernate.annotations.GenericGenerator;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* 이벤트 엔티티
|
||||
*
|
||||
* 이벤트의 전체 생명주기를 관리합니다.
|
||||
* - 생성, 수정, 배포, 종료
|
||||
* - AI 추천 및 이미지 관리
|
||||
* - 배포 채널 관리
|
||||
*
|
||||
* @author Event Service Team
|
||||
* @version 1.0.0
|
||||
* @since 2025-10-23
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "events")
|
||||
@Getter
|
||||
@NoArgsConstructor(access = AccessLevel.PROTECTED)
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class Event extends BaseTimeEntity {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(generator = "uuid2")
|
||||
@GenericGenerator(name = "uuid2", strategy = "uuid2")
|
||||
@Column(name = "event_id", columnDefinition = "uuid")
|
||||
private UUID eventId;
|
||||
|
||||
@Column(name = "user_id", nullable = false, columnDefinition = "uuid")
|
||||
private UUID userId;
|
||||
|
||||
@Column(name = "store_id", nullable = false, columnDefinition = "uuid")
|
||||
private UUID storeId;
|
||||
|
||||
@Column(name = "event_name", length = 200)
|
||||
private String eventName;
|
||||
|
||||
@Column(name = "description", columnDefinition = "TEXT")
|
||||
private String description;
|
||||
|
||||
@Column(name = "objective", nullable = false, length = 100)
|
||||
private String objective;
|
||||
|
||||
@Column(name = "start_date")
|
||||
private LocalDate startDate;
|
||||
|
||||
@Column(name = "end_date")
|
||||
private LocalDate endDate;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "status", nullable = false, length = 20)
|
||||
@Builder.Default
|
||||
private EventStatus status = EventStatus.DRAFT;
|
||||
|
||||
@Column(name = "selected_image_id", columnDefinition = "uuid")
|
||||
private UUID selectedImageId;
|
||||
|
||||
@Column(name = "selected_image_url", length = 500)
|
||||
private String selectedImageUrl;
|
||||
|
||||
@ElementCollection(fetch = FetchType.LAZY)
|
||||
@CollectionTable(
|
||||
name = "event_channels",
|
||||
joinColumns = @JoinColumn(name = "event_id")
|
||||
)
|
||||
@Column(name = "channel", length = 50)
|
||||
@Fetch(FetchMode.SUBSELECT)
|
||||
@Builder.Default
|
||||
private List<String> channels = new ArrayList<>();
|
||||
|
||||
@OneToMany(mappedBy = "event", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)
|
||||
@Builder.Default
|
||||
private Set<GeneratedImage> generatedImages = new HashSet<>();
|
||||
|
||||
@OneToMany(mappedBy = "event", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)
|
||||
@Builder.Default
|
||||
private Set<AiRecommendation> aiRecommendations = new HashSet<>();
|
||||
|
||||
// ==== 비즈니스 로직 ==== //
|
||||
|
||||
/**
|
||||
* 이벤트명 수정
|
||||
*/
|
||||
public void updateEventName(String eventName) {
|
||||
this.eventName = eventName;
|
||||
}
|
||||
|
||||
/**
|
||||
* 설명 수정
|
||||
*/
|
||||
public void updateDescription(String description) {
|
||||
this.description = description;
|
||||
}
|
||||
|
||||
/**
|
||||
* 이벤트 기간 수정
|
||||
*/
|
||||
public void updateEventPeriod(LocalDate startDate, LocalDate endDate) {
|
||||
if (startDate.isAfter(endDate)) {
|
||||
throw new IllegalArgumentException("시작일은 종료일보다 이전이어야 합니다.");
|
||||
}
|
||||
this.startDate = startDate;
|
||||
this.endDate = endDate;
|
||||
}
|
||||
|
||||
/**
|
||||
* 이미지 선택
|
||||
*/
|
||||
public void selectImage(UUID imageId, String imageUrl) {
|
||||
this.selectedImageId = imageId;
|
||||
this.selectedImageUrl = imageUrl;
|
||||
|
||||
// 기존 선택 해제
|
||||
this.generatedImages.forEach(img -> img.setSelected(false));
|
||||
|
||||
// 새로운 이미지 선택
|
||||
this.generatedImages.stream()
|
||||
.filter(img -> img.getImageId().equals(imageId))
|
||||
.findFirst()
|
||||
.ifPresent(img -> img.setSelected(true));
|
||||
}
|
||||
|
||||
/**
|
||||
* 배포 채널 설정
|
||||
*/
|
||||
public void updateChannels(List<String> channels) {
|
||||
this.channels.clear();
|
||||
this.channels.addAll(channels);
|
||||
}
|
||||
|
||||
/**
|
||||
* 이벤트 배포 (상태 변경: DRAFT → PUBLISHED)
|
||||
*/
|
||||
public void publish() {
|
||||
if (this.status != EventStatus.DRAFT) {
|
||||
throw new IllegalStateException("DRAFT 상태에서만 배포할 수 있습니다.");
|
||||
}
|
||||
|
||||
// 필수 데이터 검증
|
||||
if (eventName == null || eventName.trim().isEmpty()) {
|
||||
throw new IllegalStateException("이벤트명을 입력해야 합니다.");
|
||||
}
|
||||
if (startDate == null || endDate == null) {
|
||||
throw new IllegalStateException("이벤트 기간을 설정해야 합니다.");
|
||||
}
|
||||
if (startDate.isAfter(endDate)) {
|
||||
throw new IllegalStateException("시작일은 종료일보다 이전이어야 합니다.");
|
||||
}
|
||||
if (selectedImageId == null) {
|
||||
throw new IllegalStateException("이미지를 선택해야 합니다.");
|
||||
}
|
||||
if (channels.isEmpty()) {
|
||||
throw new IllegalStateException("배포 채널을 선택해야 합니다.");
|
||||
}
|
||||
|
||||
this.status = EventStatus.PUBLISHED;
|
||||
}
|
||||
|
||||
/**
|
||||
* 이벤트 종료
|
||||
*/
|
||||
public void end() {
|
||||
if (this.status != EventStatus.PUBLISHED) {
|
||||
throw new IllegalStateException("PUBLISHED 상태에서만 종료할 수 있습니다.");
|
||||
}
|
||||
this.status = EventStatus.ENDED;
|
||||
}
|
||||
|
||||
/**
|
||||
* 생성된 이미지 추가
|
||||
*/
|
||||
public void addGeneratedImage(GeneratedImage image) {
|
||||
this.generatedImages.add(image);
|
||||
image.setEvent(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* AI 추천 추가
|
||||
*/
|
||||
public void addAiRecommendation(AiRecommendation recommendation) {
|
||||
this.aiRecommendations.add(recommendation);
|
||||
recommendation.setEvent(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* 수정 가능 여부 확인
|
||||
*/
|
||||
public boolean isModifiable() {
|
||||
return this.status == EventStatus.DRAFT;
|
||||
}
|
||||
|
||||
/**
|
||||
* 삭제 가능 여부 확인
|
||||
*/
|
||||
public boolean isDeletable() {
|
||||
return this.status == EventStatus.DRAFT;
|
||||
}
|
||||
}
|
||||
+50
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
package com.kt.event.eventservice.domain.repository;
|
||||
|
||||
import com.kt.event.eventservice.domain.entity.AiRecommendation;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* AI 추천 Repository
|
||||
*
|
||||
* @author Event Service Team
|
||||
* @version 1.0.0
|
||||
* @since 2025-10-23
|
||||
*/
|
||||
@Repository
|
||||
public interface AiRecommendationRepository extends JpaRepository<AiRecommendation, UUID> {
|
||||
|
||||
/**
|
||||
* 이벤트별 AI 추천 목록 조회
|
||||
*/
|
||||
List<AiRecommendation> findByEventEventId(UUID eventId);
|
||||
|
||||
/**
|
||||
* 이벤트별 선택된 AI 추천 조회
|
||||
*/
|
||||
AiRecommendation findByEventEventIdAndIsSelectedTrue(UUID eventId);
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
package com.kt.event.eventservice.domain.repository;
|
||||
|
||||
import com.kt.event.eventservice.domain.entity.Event;
|
||||
import com.kt.event.eventservice.domain.enums.EventStatus;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* 이벤트 Repository
|
||||
*
|
||||
* @author Event Service Team
|
||||
* @version 1.0.0
|
||||
* @since 2025-10-23
|
||||
*/
|
||||
@Repository
|
||||
public interface EventRepository extends JpaRepository<Event, UUID> {
|
||||
|
||||
/**
|
||||
* 사용자 ID와 이벤트 ID로 조회
|
||||
*/
|
||||
@Query("SELECT DISTINCT e FROM Event e " +
|
||||
"LEFT JOIN FETCH e.channels " +
|
||||
"WHERE e.eventId = :eventId AND e.userId = :userId")
|
||||
Optional<Event> findByEventIdAndUserId(
|
||||
@Param("eventId") UUID eventId,
|
||||
@Param("userId") UUID userId
|
||||
);
|
||||
|
||||
/**
|
||||
* 사용자별 이벤트 목록 조회 (페이징, 상태 필터)
|
||||
*/
|
||||
@Query("SELECT e FROM Event e " +
|
||||
"WHERE e.userId = :userId " +
|
||||
"AND (:status IS NULL OR e.status = :status) " +
|
||||
"AND (:search IS NULL OR e.eventName LIKE %:search%) " +
|
||||
"AND (:objective IS NULL OR e.objective = :objective)")
|
||||
Page<Event> findEventsByUser(
|
||||
@Param("userId") UUID userId,
|
||||
@Param("status") EventStatus status,
|
||||
@Param("search") String search,
|
||||
@Param("objective") String objective,
|
||||
Pageable pageable
|
||||
);
|
||||
|
||||
/**
|
||||
* 사용자별 이벤트 개수 조회 (상태별)
|
||||
*/
|
||||
long countByUserIdAndStatus(UUID userId, EventStatus status);
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
package com.kt.event.eventservice.domain.repository;
|
||||
|
||||
import com.kt.event.eventservice.domain.entity.GeneratedImage;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* 생성된 이미지 Repository
|
||||
*
|
||||
* @author Event Service Team
|
||||
* @version 1.0.0
|
||||
* @since 2025-10-23
|
||||
*/
|
||||
@Repository
|
||||
public interface GeneratedImageRepository extends JpaRepository<GeneratedImage, UUID> {
|
||||
|
||||
/**
|
||||
* 이벤트별 생성된 이미지 목록 조회
|
||||
*/
|
||||
List<GeneratedImage> findByEventEventId(UUID eventId);
|
||||
|
||||
/**
|
||||
* 이벤트별 선택된 이미지 조회
|
||||
*/
|
||||
GeneratedImage findByEventEventIdAndIsSelectedTrue(UUID eventId);
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
package com.kt.event.eventservice.domain.repository;
|
||||
|
||||
import com.kt.event.eventservice.domain.entity.Job;
|
||||
import com.kt.event.eventservice.domain.enums.JobStatus;
|
||||
import com.kt.event.eventservice.domain.enums.JobType;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* 비동기 작업 Repository
|
||||
*
|
||||
* @author Event Service Team
|
||||
* @version 1.0.0
|
||||
* @since 2025-10-23
|
||||
*/
|
||||
@Repository
|
||||
public interface JobRepository extends JpaRepository<Job, UUID> {
|
||||
|
||||
/**
|
||||
* 이벤트별 작업 목록 조회
|
||||
*/
|
||||
List<Job> findByEventId(UUID eventId);
|
||||
|
||||
/**
|
||||
* 이벤트 및 작업 유형별 조회
|
||||
*/
|
||||
Optional<Job> findByEventIdAndJobType(UUID eventId, JobType jobType);
|
||||
|
||||
/**
|
||||
* 이벤트 및 작업 유형별 최신 작업 조회
|
||||
*/
|
||||
Optional<Job> findFirstByEventIdAndJobTypeOrderByCreatedAtDesc(UUID eventId, JobType jobType);
|
||||
|
||||
/**
|
||||
* 상태별 작업 목록 조회
|
||||
*/
|
||||
List<Job> findByStatus(JobStatus status);
|
||||
}
|
||||
+102
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
package com.kt.event.eventservice.infrastructure.kafka;
|
||||
|
||||
import com.kt.event.eventservice.application.dto.kafka.EventCreatedMessage;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.kafka.core.KafkaTemplate;
|
||||
import org.springframework.kafka.support.SendResult;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
/**
|
||||
* 이벤트 생성 메시지 발행 Producer
|
||||
*
|
||||
* event-created 토픽에 이벤트 생성 완료 메시지를 발행합니다.
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class EventKafkaProducer {
|
||||
|
||||
private final KafkaTemplate<String, Object> kafkaTemplate;
|
||||
|
||||
@Value("${app.kafka.topics.event-created}")
|
||||
private String eventCreatedTopic;
|
||||
|
||||
/**
|
||||
* 이벤트 생성 완료 메시지 발행
|
||||
*
|
||||
* @param eventId 이벤트 ID
|
||||
* @param userId 사용자 ID
|
||||
* @param title 이벤트 제목
|
||||
* @param eventType 이벤트 타입
|
||||
*/
|
||||
public void publishEventCreated(Long eventId, Long userId, String title, String eventType) {
|
||||
EventCreatedMessage message = EventCreatedMessage.builder()
|
||||
.eventId(eventId)
|
||||
.userId(userId)
|
||||
.title(title)
|
||||
.eventType(eventType)
|
||||
.createdAt(LocalDateTime.now())
|
||||
.timestamp(LocalDateTime.now())
|
||||
.build();
|
||||
|
||||
publishEventCreatedMessage(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* 이벤트 생성 메시지 발행
|
||||
*
|
||||
* @param message EventCreatedMessage 객체
|
||||
*/
|
||||
public void publishEventCreatedMessage(EventCreatedMessage message) {
|
||||
try {
|
||||
CompletableFuture<SendResult<String, Object>> future =
|
||||
kafkaTemplate.send(eventCreatedTopic, message.getEventId().toString(), message);
|
||||
|
||||
future.whenComplete((result, ex) -> {
|
||||
if (ex == null) {
|
||||
log.info("이벤트 생성 메시지 발행 성공 - Topic: {}, EventId: {}, Offset: {}",
|
||||
eventCreatedTopic,
|
||||
message.getEventId(),
|
||||
result.getRecordMetadata().offset());
|
||||
} else {
|
||||
log.error("이벤트 생성 메시지 발행 실패 - Topic: {}, EventId: {}, Error: {}",
|
||||
eventCreatedTopic,
|
||||
message.getEventId(),
|
||||
ex.getMessage(), ex);
|
||||
}
|
||||
});
|
||||
} catch (Exception e) {
|
||||
log.error("이벤트 생성 메시지 발행 중 예외 발생 - EventId: {}, Error: {}",
|
||||
message.getEventId(), e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
+105
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
+206
@@ -0,0 +1,206 @@
|
||||
package com.kt.event.eventservice.presentation.controller;
|
||||
|
||||
import com.kt.event.common.dto.ApiResponse;
|
||||
import com.kt.event.common.dto.PageResponse;
|
||||
import com.kt.event.common.security.UserPrincipal;
|
||||
import com.kt.event.eventservice.application.dto.request.SelectObjectiveRequest;
|
||||
import com.kt.event.eventservice.application.dto.response.EventCreatedResponse;
|
||||
import com.kt.event.eventservice.application.dto.response.EventDetailResponse;
|
||||
import com.kt.event.eventservice.application.service.EventService;
|
||||
import com.kt.event.eventservice.domain.enums.EventStatus;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* 이벤트 컨트롤러
|
||||
*
|
||||
* 이벤트 전체 생명주기 관리 API를 제공합니다.
|
||||
*
|
||||
* @author Event Service Team
|
||||
* @version 1.0.0
|
||||
* @since 2025-10-23
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/events")
|
||||
@RequiredArgsConstructor
|
||||
@Tag(name = "Event", description = "이벤트 관리 API")
|
||||
public class EventController {
|
||||
|
||||
private final EventService eventService;
|
||||
|
||||
/**
|
||||
* 이벤트 목적 선택 (Step 1: 이벤트 생성)
|
||||
*
|
||||
* @param request 목적 선택 요청
|
||||
* @param userPrincipal 인증된 사용자 정보
|
||||
* @return 생성된 이벤트 응답
|
||||
*/
|
||||
@PostMapping("/objectives")
|
||||
@Operation(summary = "이벤트 목적 선택", description = "이벤트 생성의 첫 단계로 목적을 선택합니다.")
|
||||
public ResponseEntity<ApiResponse<EventCreatedResponse>> selectObjective(
|
||||
@Valid @RequestBody SelectObjectiveRequest request,
|
||||
@AuthenticationPrincipal UserPrincipal userPrincipal) {
|
||||
|
||||
log.info("이벤트 목적 선택 API 호출 - userId: {}, objective: {}",
|
||||
userPrincipal.getUserId(), request.getObjective());
|
||||
|
||||
EventCreatedResponse response = eventService.createEvent(
|
||||
userPrincipal.getUserId(),
|
||||
userPrincipal.getStoreId(),
|
||||
request
|
||||
);
|
||||
|
||||
return ResponseEntity.status(HttpStatus.CREATED)
|
||||
.body(ApiResponse.success(response));
|
||||
}
|
||||
|
||||
/**
|
||||
* 이벤트 목록 조회
|
||||
*
|
||||
* @param status 상태 필터
|
||||
* @param search 검색어
|
||||
* @param objective 목적 필터
|
||||
* @param page 페이지 번호
|
||||
* @param size 페이지 크기
|
||||
* @param sort 정렬 기준
|
||||
* @param order 정렬 순서
|
||||
* @param userPrincipal 인증된 사용자 정보
|
||||
* @return 이벤트 목록 응답
|
||||
*/
|
||||
@GetMapping
|
||||
@Operation(summary = "이벤트 목록 조회", description = "사용자의 이벤트 목록을 조회합니다.")
|
||||
public ResponseEntity<ApiResponse<PageResponse<EventDetailResponse>>> getEvents(
|
||||
@RequestParam(required = false) EventStatus status,
|
||||
@RequestParam(required = false) String search,
|
||||
@RequestParam(required = false) String objective,
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "20") int size,
|
||||
@RequestParam(defaultValue = "createdAt") String sort,
|
||||
@RequestParam(defaultValue = "desc") String order,
|
||||
@AuthenticationPrincipal UserPrincipal userPrincipal) {
|
||||
|
||||
log.info("이벤트 목록 조회 API 호출 - userId: {}", userPrincipal.getUserId());
|
||||
|
||||
// Pageable 생성
|
||||
Sort.Direction direction = "asc".equalsIgnoreCase(order) ? Sort.Direction.ASC : Sort.Direction.DESC;
|
||||
Pageable pageable = PageRequest.of(page, size, Sort.by(direction, sort));
|
||||
|
||||
Page<EventDetailResponse> events = eventService.getEvents(
|
||||
userPrincipal.getUserId(),
|
||||
status,
|
||||
search,
|
||||
objective,
|
||||
pageable
|
||||
);
|
||||
|
||||
PageResponse<EventDetailResponse> pageResponse = PageResponse.<EventDetailResponse>builder()
|
||||
.content(events.getContent())
|
||||
.page(events.getNumber())
|
||||
.size(events.getSize())
|
||||
.totalElements(events.getTotalElements())
|
||||
.totalPages(events.getTotalPages())
|
||||
.first(events.isFirst())
|
||||
.last(events.isLast())
|
||||
.build();
|
||||
|
||||
return ResponseEntity.ok(ApiResponse.success(pageResponse));
|
||||
}
|
||||
|
||||
/**
|
||||
* 이벤트 상세 조회
|
||||
*
|
||||
* @param eventId 이벤트 ID
|
||||
* @param userPrincipal 인증된 사용자 정보
|
||||
* @return 이벤트 상세 응답
|
||||
*/
|
||||
@GetMapping("/{eventId}")
|
||||
@Operation(summary = "이벤트 상세 조회", description = "특정 이벤트의 상세 정보를 조회합니다.")
|
||||
public ResponseEntity<ApiResponse<EventDetailResponse>> getEvent(
|
||||
@PathVariable UUID eventId,
|
||||
@AuthenticationPrincipal UserPrincipal userPrincipal) {
|
||||
|
||||
log.info("이벤트 상세 조회 API 호출 - userId: {}, eventId: {}",
|
||||
userPrincipal.getUserId(), eventId);
|
||||
|
||||
EventDetailResponse response = eventService.getEvent(userPrincipal.getUserId(), eventId);
|
||||
|
||||
return ResponseEntity.ok(ApiResponse.success(response));
|
||||
}
|
||||
|
||||
/**
|
||||
* 이벤트 삭제
|
||||
*
|
||||
* @param eventId 이벤트 ID
|
||||
* @param userPrincipal 인증된 사용자 정보
|
||||
* @return 성공 응답
|
||||
*/
|
||||
@DeleteMapping("/{eventId}")
|
||||
@Operation(summary = "이벤트 삭제", description = "이벤트를 삭제합니다. DRAFT 상태만 삭제 가능합니다.")
|
||||
public ResponseEntity<ApiResponse<Void>> deleteEvent(
|
||||
@PathVariable UUID eventId,
|
||||
@AuthenticationPrincipal UserPrincipal userPrincipal) {
|
||||
|
||||
log.info("이벤트 삭제 API 호출 - userId: {}, eventId: {}",
|
||||
userPrincipal.getUserId(), eventId);
|
||||
|
||||
eventService.deleteEvent(userPrincipal.getUserId(), eventId);
|
||||
|
||||
return ResponseEntity.ok(ApiResponse.success(null));
|
||||
}
|
||||
|
||||
/**
|
||||
* 이벤트 배포
|
||||
*
|
||||
* @param eventId 이벤트 ID
|
||||
* @param userPrincipal 인증된 사용자 정보
|
||||
* @return 성공 응답
|
||||
*/
|
||||
@PostMapping("/{eventId}/publish")
|
||||
@Operation(summary = "이벤트 배포", description = "이벤트를 배포합니다. DRAFT → PUBLISHED 상태 변경.")
|
||||
public ResponseEntity<ApiResponse<Void>> publishEvent(
|
||||
@PathVariable UUID eventId,
|
||||
@AuthenticationPrincipal UserPrincipal userPrincipal) {
|
||||
|
||||
log.info("이벤트 배포 API 호출 - userId: {}, eventId: {}",
|
||||
userPrincipal.getUserId(), eventId);
|
||||
|
||||
eventService.publishEvent(userPrincipal.getUserId(), eventId);
|
||||
|
||||
return ResponseEntity.ok(ApiResponse.success(null));
|
||||
}
|
||||
|
||||
/**
|
||||
* 이벤트 종료
|
||||
*
|
||||
* @param eventId 이벤트 ID
|
||||
* @param userPrincipal 인증된 사용자 정보
|
||||
* @return 성공 응답
|
||||
*/
|
||||
@PostMapping("/{eventId}/end")
|
||||
@Operation(summary = "이벤트 종료", description = "이벤트를 종료합니다. PUBLISHED → ENDED 상태 변경.")
|
||||
public ResponseEntity<ApiResponse<Void>> endEvent(
|
||||
@PathVariable UUID eventId,
|
||||
@AuthenticationPrincipal UserPrincipal userPrincipal) {
|
||||
|
||||
log.info("이벤트 종료 API 호출 - userId: {}, eventId: {}",
|
||||
userPrincipal.getUserId(), eventId);
|
||||
|
||||
eventService.endEvent(userPrincipal.getUserId(), eventId);
|
||||
|
||||
return ResponseEntity.ok(ApiResponse.success(null));
|
||||
}
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
package com.kt.event.eventservice.presentation.controller;
|
||||
|
||||
import com.kt.event.common.dto.ApiResponse;
|
||||
import com.kt.event.eventservice.application.dto.response.JobStatusResponse;
|
||||
import com.kt.event.eventservice.application.service.JobService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Job 컨트롤러
|
||||
*
|
||||
* 비동기 작업 상태 조회 API를 제공합니다.
|
||||
*
|
||||
* @author Event Service Team
|
||||
* @version 1.0.0
|
||||
* @since 2025-10-23
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/jobs")
|
||||
@RequiredArgsConstructor
|
||||
@Tag(name = "Job", description = "비동기 작업 상태 조회 API")
|
||||
public class JobController {
|
||||
|
||||
private final JobService jobService;
|
||||
|
||||
/**
|
||||
* Job 상태 조회
|
||||
*
|
||||
* @param jobId Job ID
|
||||
* @return Job 상태 응답
|
||||
*/
|
||||
@GetMapping("/{jobId}")
|
||||
@Operation(summary = "Job 상태 조회", description = "비동기 작업의 상태를 조회합니다 (폴링 방식).")
|
||||
public ResponseEntity<ApiResponse<JobStatusResponse>> getJobStatus(@PathVariable UUID jobId) {
|
||||
log.info("Job 상태 조회 API 호출 - jobId: {}", jobId);
|
||||
|
||||
JobStatusResponse response = jobService.getJobStatus(jobId);
|
||||
|
||||
return ResponseEntity.ok(ApiResponse.success(response));
|
||||
}
|
||||
}
|
||||
@@ -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분 (밀리초 단위)
|
||||
Reference in New Issue
Block a user