Merge branch 'develop' into feature/ai

This commit is contained in:
SWPARK 2025-10-27 16:36:03 +09:00 committed by GitHub
commit c126c71e00
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
244 changed files with 19355 additions and 159 deletions

BIN
.DS_Store vendored

Binary file not shown.

View File

@ -16,28 +16,39 @@
"Bash(git commit:*)",
"Bash(git push)",
"Bash(git pull:*)",
"Bash(./gradlew ai-service:compileJava:*)",
"Bash(./gradlew ai-service:build:*)",
"Bash(.\\gradlew ai-service:compileJava:*)",
"Bash(./gradlew.bat:*)",
"Bash(if [ ! -d \"ai-service/.run\" ])",
"Bash(then mkdir \"ai-service/.run\")",
"Bash(./gradlew:*)",
"Bash(python:*)",
"Bash(then mkdir -p \"ai-service/.run\")",
"Bash(if [ ! -d \"tools\" ])",
"Bash(then mkdir tools)",
"Bash(if [ ! -d \"logs\" ])",
"Bash(then mkdir logs)",
"Bash(netstat:*)",
"Bash(findstr:*)",
"Bash(..gradlew.bat test --tests \"com.kt.ai.test.integration.kafka.AIJobConsumerIntegrationTest\" --info)",
"Bash(.gradlew.bat ai-service:test:*)",
"Bash(cmd /c \"gradlew.bat ai-service:test --tests com.kt.ai.test.integration.kafka.AIJobConsumerIntegrationTest\")",
"Bash(timeout 120 cmd:*)",
"Bash(cmd /c:*)",
"Bash(Select-String -Pattern \"(test|BUILD|FAILED|SUCCESS)\")",
"Bash(Select-Object -Last 20)"
"Bash(./gradlew analytics-service:compileJava:*)",
"Bash(python -m json.tool:*)",
"Bash(powershell:*)"
"Bash(./gradlew participation-service:compileJava:*)",
"Bash(find:*)",
"Bash(netstat:*)",
"Bash(findstr:*)",
"Bash(docker-compose up:*)",
"Bash(docker --version:*)",
"Bash(timeout 60 bash:*)",
"Bash(docker ps:*)",
"Bash(docker exec:*)",
"Bash(docker-compose down:*)",
"Bash(git rm:*)",
"Bash(git restore:*)",
"Bash(./gradlew participation-service:test:*)",
"Bash(timeout 30 bash:*)",
"Bash(helm list:*)",
"Bash(helm upgrade:*)",
"Bash(helm repo add:*)",
"Bash(helm repo update:*)",
"Bash(kubectl get:*)",
"Bash(python3:*)",
"Bash(timeout 120 bash -c 'while true; do sleep 5; kubectl get pods -n kt-event-marketing | grep kafka | grep -v Running && continue; echo \"\"\"\"All Kafka pods are Running!\"\"\"\"; break; done')",
"Bash(kubectl delete:*)",
"Bash(kubectl logs:*)",
"Bash(kubectl describe:*)",
"Bash(kubectl exec:*)",
"mcp__context7__resolve-library-id",
"mcp__context7__get-library-docs",
"Bash(python -m json.tool:*)"
],
"deny": [],
"ask": []

23
.gitignore vendored
View File

@ -21,6 +21,16 @@ Thumbs.db
dist/
build/
*.log
.gradle/
logs/
# Gradle
.gradle/
!gradle/wrapper/gradle-wrapper.jar
# Logs
logs/
*.log
# Gradle
.gradle/
@ -38,3 +48,16 @@ gradle-app.setting
tmp/
temp/
*.tmp
# Kubernetes Secrets (민감한 정보 포함)
k8s/**/secret.yaml
k8s/**/*-secret.yaml
k8s/**/*-prod.yaml
k8s/**/*-dev.yaml
k8s/**/*-local.yaml
# IntelliJ 실행 프로파일 (민감한 환경 변수 포함 가능)
.run/*.run.xml
# Gradle (로컬 환경 설정)
gradle.properties

View File

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

View File

@ -0,0 +1,69 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="ParticipationServiceApplication" type="GradleRunConfiguration" factoryName="Gradle">
<ExternalSystemSettings>
<option name="env">
<map>
<!-- 서버 설정 -->
<entry key="SERVER_PORT" value="8084" />
<!-- 데이터베이스 설정 -->
<entry key="DB_HOST" value="4.230.72.147" />
<entry key="DB_PORT" value="5432" />
<entry key="DB_NAME" value="participationdb" />
<entry key="DB_USERNAME" value="eventuser" />
<entry key="DB_PASSWORD" value="Hi5Jessica!" />
<!-- JPA 설정 -->
<entry key="DDL_AUTO" value="none" />
<entry key="SHOW_SQL" value="true" />
<!-- Redis 설정 -->
<entry key="REDIS_HOST" value="20.214.210.71" />
<entry key="REDIS_PORT" value="6379" />
<entry key="REDIS_PASSWORD" value="Hi5Jessica!" />
<!-- Kafka 설정 -->
<entry key="KAFKA_BOOTSTRAP_SERVERS" value="20.249.182.13:9095,4.217.131.59:9095" />
<!-- JWT 설정 -->
<entry key="JWT_SECRET" value="kt-event-marketing-secret-key-for-development-only-change-in-production" />
<entry key="JWT_EXPIRATION" value="86400000" />
<!-- 로깅 설정 -->
<entry key="LOG_LEVEL" value="INFO" />
<entry key="LOG_FILE" value="logs/participation-service.log" />
</map>
</option>
<option name="executionName" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="externalSystemIdString" value="GRADLE" />
<option name="scriptParameters" value="" />
<option name="taskDescriptions">
<list />
</option>
<option name="taskNames">
<list>
<option value="participation-service:bootRun" />
</list>
</option>
<option name="vmOptions" />
</ExternalSystemSettings>
<ExternalSystemDebugServerProcess>true</ExternalSystemDebugServerProcess>
<ExternalSystemReattachDebugProcess>true</ExternalSystemReattachDebugProcess>
<EXTENSION ID="com.intellij.execution.ExternalSystemRunConfigurationJavaExtension">
<extension name="net.ashald.envfile">
<option name="IS_ENABLED" value="false" />
<option name="IS_SUBST" value="false" />
<option name="IS_PATH_MACRO_SUPPORTED" value="false" />
<option name="IS_IGNORE_MISSING_FILES" value="false" />
<option name="IS_ENABLE_EXPERIMENTAL_INTEGRATIONS" value="false" />
<ENTRIES>
<ENTRY IS_ENABLED="true" PARSER="runconfig" IS_EXECUTABLE="false" />
</ENTRIES>
</extension>
</EXTENSION>
<DebugAllEnabled>false</DebugAllEnabled>
<RunAsTest>false</RunAsTest>
<method v="2" />
</configuration>
</component>

View File

@ -0,0 +1,89 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="analytics-service" type="GradleRunConfiguration" factoryName="Gradle">
<ExternalSystemSettings>
<option name="env">
<map>
<!-- Database Settings -->
<entry key="DB_KIND" value="postgresql" />
<entry key="DB_HOST" value="4.230.49.9" />
<entry key="DB_PORT" value="5432" />
<entry key="DB_NAME" value="analyticdb" />
<entry key="DB_USERNAME" value="eventuser" />
<entry key="DB_PASSWORD" value="Hi5Jessica!" />
<!-- Redis Settings -->
<entry key="REDIS_HOST" value="20.214.210.71" />
<entry key="REDIS_PORT" value="6379" />
<entry key="REDIS_PASSWORD" value="Hi5Jessica!" />
<entry key="REDIS_DATABASE" value="5" />
<!-- Kafka Settings -->
<entry key="KAFKA_ENABLED" value="true" />
<entry key="KAFKA_BOOTSTRAP_SERVERS" value="4.230.50.63:9092" />
<entry key="KAFKA_CONSUMER_GROUP_ID" value="analytics-service" />
<!-- Sample Data Settings (MVP Only) -->
<!-- ⚠️ 실제 운영 환경에서는 false로 설정 (다른 서비스들이 이벤트 발행) -->
<entry key="SAMPLE_DATA_ENABLED" value="true" />
<!-- JPA Settings -->
<entry key="SHOW_SQL" value="true" />
<entry key="DDL_AUTO" value="update" />
<!-- Server Settings -->
<entry key="SERVER_PORT" value="8086" />
<!-- JWT Settings -->
<entry key="JWT_SECRET" value="dev-jwt-secret-key-for-development-only-analytics-service-2024" />
<entry key="JWT_ACCESS_TOKEN_VALIDITY" value="1800" />
<entry key="JWT_REFRESH_TOKEN_VALIDITY" value="86400" />
<!-- CORS Settings -->
<entry key="CORS_ALLOWED_ORIGINS" value="http://localhost:*" />
<!-- Logging Settings -->
<entry key="LOG_LEVEL_APP" value="DEBUG" />
<entry key="LOG_LEVEL_WEB" value="INFO" />
<entry key="LOG_LEVEL_SQL" value="DEBUG" />
<entry key="LOG_LEVEL_SQL_TYPE" value="TRACE" />
<entry key="LOG_FILE" value="logs/analytics-service.log" />
<!-- Batch Settings -->
<entry key="BATCH_ENABLED" value="true" />
<entry key="BATCH_REFRESH_INTERVAL" value="300000" />
<entry key="BATCH_INITIAL_DELAY" value="30000" />
</map>
</option>
<option name="executionName" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="externalSystemIdString" value="GRADLE" />
<option name="scriptParameters" value="" />
<option name="taskDescriptions">
<list />
</option>
<option name="taskNames">
<list>
<option value="analytics-service:bootRun" />
</list>
</option>
<option name="vmOptions" />
</ExternalSystemSettings>
<ExternalSystemDebugServerProcess>true</ExternalSystemDebugServerProcess>
<ExternalSystemReattachDebugProcess>true</ExternalSystemReattachDebugProcess>
<EXTENSION ID="com.intellij.execution.ExternalSystemRunConfigurationJavaExtension">
<extension name="net.ashald.envfile">
<option name="IS_ENABLED" value="false" />
<option name="IS_SUBST" value="false" />
<option name="IS_PATH_MACRO_SUPPORTED" value="false" />
<option name="IS_IGNORE_MISSING_FILES" value="false" />
<option name="IS_ENABLE_EXPERIMENTAL_INTEGRATIONS" value="false" />
<ENTRIES>
<ENTRY IS_ENABLED="true" PARSER="runconfig" IS_EXECUTABLE="false" />
</ENTRIES>
</extension>
</EXTENSION>
<DebugAllEnabled>false</DebugAllEnabled>
<RunAsTest>false</RunAsTest>
<method v="2" />
</configuration>
</component>

View File

@ -1,3 +0,0 @@
{
"liveServer.settings.port": 5501
}

View File

@ -0,0 +1,84 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="analytics-service" type="GradleRunConfiguration" factoryName="Gradle">
<ExternalSystemSettings>
<option name="env">
<map>
<!-- Database Configuration -->
<entry key="DB_KIND" value="postgresql" />
<entry key="DB_HOST" value="4.230.49.9" />
<entry key="DB_PORT" value="5432" />
<entry key="DB_NAME" value="analyticdb" />
<entry key="DB_USERNAME" value="eventuser" />
<entry key="DB_PASSWORD" value="Hi5Jessica!" />
<!-- JPA Configuration -->
<entry key="DDL_AUTO" value="update" />
<entry key="SHOW_SQL" value="true" />
<!-- Redis Configuration -->
<entry key="REDIS_HOST" value="20.214.210.71" />
<entry key="REDIS_PORT" value="6379" />
<entry key="REDIS_PASSWORD" value="Hi5Jessica!" />
<entry key="REDIS_DATABASE" value="5" />
<!-- Kafka Configuration (원격 서버) -->
<entry key="KAFKA_ENABLED" value="true" />
<entry key="KAFKA_BOOTSTRAP_SERVERS" value="20.249.182.13:9095,4.217.131.59:9095" />
<entry key="KAFKA_CONSUMER_GROUP_ID" value="analytics-service-consumers" />
<!-- Sample Data Configuration (MVP Only) -->
<!-- ⚠️ Kafka Producer로 이벤트 발행 (Consumer가 처리) -->
<entry key="SAMPLE_DATA_ENABLED" value="true" />
<!-- Server Configuration -->
<entry key="SERVER_PORT" value="8086" />
<!-- JWT Configuration -->
<entry key="JWT_SECRET" value="dev-jwt-secret-key-for-development-only-kt-event-marketing" />
<entry key="JWT_ACCESS_TOKEN_VALIDITY" value="1800" />
<entry key="JWT_REFRESH_TOKEN_VALIDITY" value="86400" />
<!-- CORS Configuration -->
<entry key="CORS_ALLOWED_ORIGINS" value="http://localhost:*" />
<!-- Logging Configuration -->
<entry key="LOG_FILE" value="logs/analytics-service.log" />
<entry key="LOG_LEVEL_APP" value="DEBUG" />
<entry key="LOG_LEVEL_WEB" value="INFO" />
<entry key="LOG_LEVEL_SQL" value="DEBUG" />
<entry key="LOG_LEVEL_SQL_TYPE" value="TRACE" />
</map>
</option>
<option name="executionName" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="externalSystemIdString" value="GRADLE" />
<option name="scriptParameters" value="" />
<option name="taskDescriptions">
<list />
</option>
<option name="taskNames">
<list>
<option value="analytics-service:bootRun" />
</list>
</option>
<option name="vmOptions" />
</ExternalSystemSettings>
<ExternalSystemDebugServerProcess>true</ExternalSystemDebugServerProcess>
<ExternalSystemReattachDebugProcess>true</ExternalSystemReattachDebugProcess>
<EXTENSION ID="com.intellij.execution.ExternalSystemRunConfigurationJavaExtension">
<extension name="net.ashald.envfile">
<option name="IS_ENABLED" value="false" />
<option name="IS_SUBST" value="false" />
<option name="IS_PATH_MACRO_SUPPORTED" value="false" />
<option name="IS_IGNORE_MISSING_FILES" value="false" />
<option name="IS_ENABLE_EXPERIMENTAL_INTEGRATIONS" value="false" />
<ENTRIES>
<ENTRY IS_ENABLED="true" PARSER="runconfig" IS_EXECUTABLE="false" />
</ENTRIES>
</extension>
</EXTENSION>
<DebugAllEnabled>false</DebugAllEnabled>
<RunAsTest>false</RunAsTest>
<method v="2" />
</configuration>
</component>

View File

@ -0,0 +1,29 @@
package com.kt.event.analytics;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.domain.EntityScan;
import org.springframework.cloud.openfeign.EnableFeignClients;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
import org.springframework.kafka.annotation.EnableKafka;
import org.springframework.scheduling.annotation.EnableScheduling;
/**
* Analytics Service 애플리케이션 메인 클래스
*
* 실시간 효과 측정 통합 대시보드를 제공하는 Analytics Service
*/
@SpringBootApplication(scanBasePackages = {"com.kt.event.analytics", "com.kt.event.common"})
@EntityScan(basePackages = {"com.kt.event.analytics.entity", "com.kt.event.common.entity"})
@EnableJpaRepositories(basePackages = "com.kt.event.analytics.repository")
@EnableJpaAuditing
@EnableFeignClients
@EnableKafka
@EnableScheduling
public class AnalyticsServiceApplication {
public static void main(String[] args) {
SpringApplication.run(AnalyticsServiceApplication.class, args);
}
}

View File

@ -0,0 +1,116 @@
package com.kt.event.analytics.batch;
import com.kt.event.analytics.entity.EventStats;
import com.kt.event.analytics.repository.EventStatsRepository;
import com.kt.event.analytics.service.AnalyticsService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
import java.util.List;
/**
* Analytics 배치 스케줄러
*
* 5분 단위로 Analytics 대시보드 데이터를 갱신하는 배치 작업
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class AnalyticsBatchScheduler {
private final AnalyticsService analyticsService;
private final EventStatsRepository eventStatsRepository;
private final RedisTemplate<String, String> redisTemplate;
/**
* 5분 단위 Analytics 데이터 갱신 배치
*
* - 이벤트마다 Redis 캐시 확인
* - 캐시 있음 건너뛰기 (1시간 유효)
* - 캐시 없음 PostgreSQL + 외부 API Redis 저장
*/
@Scheduled(fixedRate = 300000) // 5분 = 300,000ms
public void refreshAnalyticsDashboard() {
log.info("===== Analytics 배치 시작: {} =====", LocalDateTime.now());
try {
// 1. 모든 활성 이벤트 조회
List<EventStats> activeEvents = eventStatsRepository.findAll();
log.info("활성 이벤트 수: {}", activeEvents.size());
// 2. 이벤트별로 캐시 확인 갱신
int successCount = 0;
int skipCount = 0;
int failCount = 0;
for (EventStats event : activeEvents) {
String cacheKey = "analytics:dashboard:" + event.getEventId();
try {
// 2-1. Redis 캐시 확인
if (redisTemplate.hasKey(cacheKey)) {
log.debug("✅ 캐시 유효, 건너뜀: eventId={}", event.getEventId());
skipCount++;
continue;
}
// 2-2. 캐시 없음 데이터 갱신
log.info("캐시 만료, 갱신 시작: eventId={}, title={}",
event.getEventId(), event.getEventTitle());
// refresh=true로 호출하여 캐시 갱신 외부 API 호출
analyticsService.getDashboardData(event.getEventId(), null, null, true);
successCount++;
log.info("✅ 배치 갱신 완료: eventId={}", event.getEventId());
} catch (Exception e) {
failCount++;
log.error("❌ 배치 갱신 실패: eventId={}, error={}",
event.getEventId(), e.getMessage(), e);
}
}
log.info("===== Analytics 배치 완료: 성공={}, 건너뜀={}, 실패={}, 종료시각={} =====",
successCount, skipCount, failCount, LocalDateTime.now());
} catch (Exception e) {
log.error("Analytics 배치 실행 중 오류 발생: {}", e.getMessage(), e);
}
}
/**
* 초기 데이터 로딩 (애플리케이션 시작 30초 1회 실행)
*
* - 서버 시작 직후 캐시 워밍업
* - API 요청 응답 시간 단축
*/
@Scheduled(initialDelay = 30000, fixedDelay = Long.MAX_VALUE)
public void initialDataLoad() {
log.info("===== 초기 데이터 로딩 시작: {} =====", LocalDateTime.now());
try {
List<EventStats> allEvents = eventStatsRepository.findAll();
log.info("초기 로딩 대상 이벤트 수: {}", allEvents.size());
for (EventStats event : allEvents) {
try {
analyticsService.getDashboardData(event.getEventId(), null, null, true);
log.debug("초기 데이터 로딩 완료: eventId={}", event.getEventId());
} catch (Exception e) {
log.warn("초기 데이터 로딩 실패: eventId={}, error={}",
event.getEventId(), e.getMessage());
}
}
log.info("===== 초기 데이터 로딩 완료: {} =====", LocalDateTime.now());
} catch (Exception e) {
log.error("초기 데이터 로딩 중 오류 발생: {}", e.getMessage(), e);
}
}
}

View File

@ -0,0 +1,50 @@
package com.kt.event.analytics.config;
import org.apache.kafka.clients.consumer.ConsumerConfig;
import org.apache.kafka.common.serialization.StringDeserializer;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory;
import org.springframework.kafka.core.ConsumerFactory;
import org.springframework.kafka.core.DefaultKafkaConsumerFactory;
import java.util.HashMap;
import java.util.Map;
/**
* Kafka Consumer 설정
*/
@Configuration
@ConditionalOnProperty(name = "spring.kafka.enabled", havingValue = "true", matchIfMissing = true)
public class KafkaConsumerConfig {
@Value("${spring.kafka.bootstrap-servers}")
private String bootstrapServers;
@Value("${spring.kafka.consumer.group-id:analytics-service}")
private String groupId;
@Bean
public ConsumerFactory<String, String> consumerFactory() {
Map<String, Object> props = new HashMap<>();
props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers);
props.put(ConsumerConfig.GROUP_ID_CONFIG, groupId);
props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);
props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);
props.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest");
props.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, true);
return new DefaultKafkaConsumerFactory<>(props);
}
@Bean
public ConcurrentKafkaListenerContainerFactory<String, String> kafkaListenerContainerFactory() {
ConcurrentKafkaListenerContainerFactory<String, String> factory =
new ConcurrentKafkaListenerContainerFactory<>();
factory.setConsumerFactory(consumerFactory());
// Kafka Consumer 자동 시작 활성화
factory.setAutoStartup(true);
return factory;
}
}

View File

@ -0,0 +1,53 @@
package com.kt.event.analytics.config;
import org.apache.kafka.clients.admin.NewTopic;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.kafka.config.TopicBuilder;
/**
* Kafka 토픽 자동 생성 설정
*
* MVP 전용: 샘플 데이터용 토픽을 생성합니다.
* 실제 운영 토픽(event.created ) 구분하기 위해 "sample." 접두사 사용
*
* 서비스 시작 필요한 Kafka 토픽을 자동으로 생성합니다.
*/
@Configuration
@ConditionalOnProperty(name = "spring.kafka.enabled", havingValue = "true", matchIfMissing = false)
public class KafkaTopicConfig {
/**
* sample.event.created 토픽 (MVP 샘플 데이터용)
*/
@Bean
public NewTopic eventCreatedTopic() {
return TopicBuilder.name("sample.event.created")
.partitions(3)
.replicas(1)
.build();
}
/**
* sample.participant.registered 토픽 (MVP 샘플 데이터용)
*/
@Bean
public NewTopic participantRegisteredTopic() {
return TopicBuilder.name("sample.participant.registered")
.partitions(3)
.replicas(1)
.build();
}
/**
* sample.distribution.completed 토픽 (MVP 샘플 데이터용)
*/
@Bean
public NewTopic distributionCompletedTopic() {
return TopicBuilder.name("sample.distribution.completed")
.partitions(3)
.replicas(1)
.build();
}
}

View File

@ -0,0 +1,35 @@
package com.kt.event.analytics.config;
import io.lettuce.core.ReadFrom;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.connection.lettuce.LettuceClientConfiguration;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.StringRedisSerializer;
/**
* Redis 캐시 설정
*/
@Configuration
public class RedisConfig {
@Bean
public RedisTemplate<String, String> redisTemplate(RedisConnectionFactory connectionFactory) {
RedisTemplate<String, String> template = new RedisTemplate<>();
template.setConnectionFactory(connectionFactory);
template.setKeySerializer(new StringRedisSerializer());
template.setValueSerializer(new StringRedisSerializer());
template.setHashKeySerializer(new StringRedisSerializer());
template.setHashValueSerializer(new StringRedisSerializer());
// Read-only 오류 방지: 마스터 노드 우선 사용
if (connectionFactory instanceof LettuceConnectionFactory) {
LettuceConnectionFactory lettuceFactory = (LettuceConnectionFactory) connectionFactory;
lettuceFactory.setValidateConnection(true);
}
return template;
}
}

View File

@ -0,0 +1,27 @@
package com.kt.event.analytics.config;
import io.github.resilience4j.circuitbreaker.CircuitBreakerConfig;
import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.time.Duration;
/**
* Resilience4j Circuit Breaker 설정
*/
@Configuration
public class Resilience4jConfig {
@Bean
public CircuitBreakerRegistry circuitBreakerRegistry() {
CircuitBreakerConfig config = CircuitBreakerConfig.custom()
.failureRateThreshold(50)
.waitDurationInOpenState(Duration.ofSeconds(30))
.slidingWindowSize(10)
.permittedNumberOfCallsInHalfOpenState(3)
.build();
return CircuitBreakerRegistry.of(config);
}
}

View File

@ -0,0 +1,361 @@
package com.kt.event.analytics.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.kt.event.analytics.messaging.event.DistributionCompletedEvent;
import com.kt.event.analytics.messaging.event.EventCreatedEvent;
import com.kt.event.analytics.messaging.event.ParticipantRegisteredEvent;
import com.kt.event.analytics.repository.ChannelStatsRepository;
import com.kt.event.analytics.repository.EventStatsRepository;
import com.kt.event.analytics.repository.TimelineDataRepository;
import jakarta.annotation.PreDestroy;
import jakarta.persistence.EntityManager;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
import java.util.Random;
import java.util.UUID;
/**
* 샘플 데이터 로더 (Kafka Producer 방식)
*
* MVP 전용: 다른 마이크로서비스(Event, Participant, Distribution)
* 없는 환경에서 해당 서비스들의 역할을 시뮬레이션합니다.
*
* 실제 운영: Analytics Service는 순수 Consumer 역할만 수행해야 하며,
* 클래스는 비활성화되어야 합니다.
* SAMPLE_DATA_ENABLED=false 설정
*
* - 서비스 시작 : Kafka 이벤트 발행하여 샘플 데이터 자동 생성
* - 서비스 종료 : PostgreSQL 전체 데이터 삭제
*
* 활성화 조건: spring.sample-data.enabled=true (기본값: true)
*/
@Slf4j
@Component
@ConditionalOnProperty(name = "spring.sample-data.enabled", havingValue = "true", matchIfMissing = true)
@RequiredArgsConstructor
public class SampleDataLoader implements ApplicationRunner {
private final KafkaTemplate<String, String> kafkaTemplate;
private final ObjectMapper objectMapper;
private final EventStatsRepository eventStatsRepository;
private final ChannelStatsRepository channelStatsRepository;
private final TimelineDataRepository timelineDataRepository;
private final EntityManager entityManager;
private final RedisTemplate<String, String> redisTemplate;
private final Random random = new Random();
// Kafka Topic Names (MVP용 샘플 토픽)
private static final String EVENT_CREATED_TOPIC = "sample.event.created";
private static final String PARTICIPANT_REGISTERED_TOPIC = "sample.participant.registered";
private static final String DISTRIBUTION_COMPLETED_TOPIC = "sample.distribution.completed";
@Override
@Transactional
public void run(ApplicationArguments args) {
log.info("========================================");
log.info("🚀 서비스 시작: Kafka 이벤트 발행하여 샘플 데이터 생성");
log.info("========================================");
// 항상 기존 데이터 삭제 새로 생성
long existingCount = eventStatsRepository.count();
if (existingCount > 0) {
log.info("기존 데이터 {} 건 삭제 중...", existingCount);
timelineDataRepository.deleteAll();
channelStatsRepository.deleteAll();
eventStatsRepository.deleteAll();
// 삭제 커밋 보장
entityManager.flush();
entityManager.clear();
log.info("✅ 기존 데이터 삭제 완료");
}
// Redis 멱등성 삭제 (새로운 이벤트 처리를 위해)
log.info("Redis 멱등성 키 삭제 중...");
redisTemplate.delete("processed_events");
redisTemplate.delete("distribution_completed");
redisTemplate.delete("processed_participants");
log.info("✅ Redis 멱등성 키 삭제 완료");
try {
// 1. EventCreated 이벤트 발행 (3개 이벤트)
publishEventCreatedEvents();
log.info("⏳ EventStats 생성 대기 중... (5초)");
Thread.sleep(5000); // EventCreatedConsumer가 EventStats 생성할 시간
// 2. DistributionCompleted 이벤트 발행 ( 이벤트당 4개 채널)
publishDistributionCompletedEvents();
log.info("⏳ ChannelStats 생성 대기 중... (3초)");
Thread.sleep(3000); // DistributionCompletedConsumer가 ChannelStats 생성할 시간
// 3. ParticipantRegistered 이벤트 발행 ( 이벤트당 다수 참여자)
publishParticipantRegisteredEvents();
log.info("========================================");
log.info("🎉 Kafka 이벤트 발행 완료! (Consumer가 처리 중...)");
log.info("========================================");
log.info("발행된 이벤트:");
log.info(" - EventCreated: 3건");
log.info(" - DistributionCompleted: 3건 (각 이벤트당 4개 채널 배열)");
log.info(" - ParticipantRegistered: 180건 (MVP 테스트용)");
log.info("========================================");
// Consumer 처리 대기 (5초)
log.info("⏳ 참여자 수 업데이트 대기 중... (5초)");
Thread.sleep(5000);
// 4. TimelineData 생성 (시간대별 데이터)
createTimelineData();
log.info("✅ TimelineData 생성 완료");
} catch (Exception e) {
log.error("샘플 데이터 적재 중 오류 발생", e);
}
}
/**
* 서비스 종료 전체 데이터 삭제
*/
@PreDestroy
@Transactional
public void onShutdown() {
log.info("========================================");
log.info("🛑 서비스 종료: PostgreSQL 전체 데이터 삭제");
log.info("========================================");
try {
long timelineCount = timelineDataRepository.count();
long channelCount = channelStatsRepository.count();
long eventCount = eventStatsRepository.count();
log.info("삭제 대상: 이벤트={}, 채널={}, 타임라인={}",
eventCount, channelCount, timelineCount);
timelineDataRepository.deleteAll();
channelStatsRepository.deleteAll();
eventStatsRepository.deleteAll();
// 삭제 커밋 보장
entityManager.flush();
entityManager.clear();
log.info("✅ 모든 샘플 데이터 삭제 완료!");
log.info("========================================");
} catch (Exception e) {
log.error("샘플 데이터 삭제 중 오류 발생", e);
}
}
/**
* EventCreated 이벤트 발행
*/
private void publishEventCreatedEvents() throws Exception {
// 이벤트 1: 신년맞이 할인 이벤트 (진행중, 높은 성과)
EventCreatedEvent event1 = EventCreatedEvent.builder()
.eventId("evt_2025012301")
.eventTitle("신년맞이 20% 할인 이벤트")
.storeId("store_001")
.totalInvestment(new BigDecimal("5000000"))
.status("ACTIVE")
.build();
publishEvent(EVENT_CREATED_TOPIC, event1);
// 이벤트 2: 설날 특가 이벤트 (진행중, 중간 성과)
EventCreatedEvent event2 = EventCreatedEvent.builder()
.eventId("evt_2025020101")
.eventTitle("설날 특가 선물세트 이벤트")
.storeId("store_001")
.totalInvestment(new BigDecimal("3500000"))
.status("ACTIVE")
.build();
publishEvent(EVENT_CREATED_TOPIC, event2);
// 이벤트 3: 겨울 신메뉴 런칭 이벤트 (종료, 저조한 성과)
EventCreatedEvent event3 = EventCreatedEvent.builder()
.eventId("evt_2025011501")
.eventTitle("겨울 신메뉴 런칭 이벤트")
.storeId("store_001")
.totalInvestment(new BigDecimal("2000000"))
.status("COMPLETED")
.build();
publishEvent(EVENT_CREATED_TOPIC, event3);
log.info("✅ EventCreated 이벤트 3건 발행 완료");
}
/**
* DistributionCompleted 이벤트 발행 (설계서 기준 - 이벤트당 1번 발행, 여러 채널 배열)
*/
private void publishDistributionCompletedEvents() throws Exception {
String[] eventIds = {"evt_2025012301", "evt_2025020101", "evt_2025011501"};
int[][] expectedViews = {
{5000, 10000, 3000, 2000}, // 이벤트1: 우리동네TV, 지니TV, 링고비즈, SNS
{3500, 7000, 2000, 1500}, // 이벤트2
{1500, 3000, 1000, 500} // 이벤트3
};
for (int i = 0; i < eventIds.length; i++) {
String eventId = eventIds[i];
// 4개 채널을 배열로 구성
List<DistributionCompletedEvent.ChannelDistribution> channels = new ArrayList<>();
// 1. 우리동네TV (TV)
channels.add(DistributionCompletedEvent.ChannelDistribution.builder()
.channel("우리동네TV")
.channelType("TV")
.status("SUCCESS")
.expectedViews(expectedViews[i][0])
.build());
// 2. 지니TV (TV)
channels.add(DistributionCompletedEvent.ChannelDistribution.builder()
.channel("지니TV")
.channelType("TV")
.status("SUCCESS")
.expectedViews(expectedViews[i][1])
.build());
// 3. 링고비즈 (CALL)
channels.add(DistributionCompletedEvent.ChannelDistribution.builder()
.channel("링고비즈")
.channelType("CALL")
.status("SUCCESS")
.expectedViews(expectedViews[i][2])
.build());
// 4. SNS (SNS)
channels.add(DistributionCompletedEvent.ChannelDistribution.builder()
.channel("SNS")
.channelType("SNS")
.status("SUCCESS")
.expectedViews(expectedViews[i][3])
.build());
// 이벤트 발행 (채널 배열 포함)
DistributionCompletedEvent event = DistributionCompletedEvent.builder()
.eventId(eventId)
.distributedChannels(channels)
.completedAt(java.time.LocalDateTime.now())
.build();
publishEvent(DISTRIBUTION_COMPLETED_TOPIC, event);
}
log.info("✅ DistributionCompleted 이벤트 3건 발행 완료 (3 이벤트 × 4 채널 배열)");
}
/**
* ParticipantRegistered 이벤트 발행
*/
private void publishParticipantRegisteredEvents() throws Exception {
String[] eventIds = {"evt_2025012301", "evt_2025020101", "evt_2025011501"};
int[] totalParticipants = {100, 50, 30}; // MVP 테스트용 샘플 데이터 ( 180명)
String[] channels = {"우리동네TV", "지니TV", "링고비즈", "SNS"};
int totalPublished = 0;
for (int i = 0; i < eventIds.length; i++) {
String eventId = eventIds[i];
int participants = totalParticipants[i];
// 이벤트에 대해 참여자 수만큼 ParticipantRegistered 이벤트 발행
for (int j = 0; j < participants; j++) {
String participantId = UUID.randomUUID().toString();
String channel = channels[j % channels.length]; // 채널 순환 배정
ParticipantRegisteredEvent event = ParticipantRegisteredEvent.builder()
.eventId(eventId)
.participantId(participantId)
.channel(channel)
.build();
publishEvent(PARTICIPANT_REGISTERED_TOPIC, event);
totalPublished++;
}
}
log.info("✅ ParticipantRegistered 이벤트 {}건 발행 완료", totalPublished);
}
/**
* TimelineData 생성 (시간대별 샘플 데이터)
*
* - 이벤트마다 30일 daily 데이터 생성
* - 참여자 , 조회수, 참여행동, 전환수, 누적 참여자
*/
private void createTimelineData() {
log.info("📊 TimelineData 생성 시작...");
String[] eventIds = {"evt_2025012301", "evt_2025020101", "evt_2025011501"};
// 이벤트별 기준 참여자 (이벤트 성과에 따라 다름)
int[] baseParticipants = {20, 12, 5}; // 이벤트1(높음), 이벤트2(중간), 이벤트3(낮음)
for (int eventIndex = 0; eventIndex < eventIds.length; eventIndex++) {
String eventId = eventIds[eventIndex];
int baseParticipant = baseParticipants[eventIndex];
int cumulativeParticipants = 0;
// 30일 데이터 생성 (2024-09-24부터)
java.time.LocalDateTime startDate = java.time.LocalDateTime.of(2024, 9, 24, 0, 0);
for (int day = 0; day < 30; day++) {
java.time.LocalDateTime timestamp = startDate.plusDays(day);
// 랜덤한 참여자 생성 (기준값 ± 50%)
int dailyParticipants = baseParticipant + random.nextInt(baseParticipant + 1);
cumulativeParticipants += dailyParticipants;
// 조회수는 참여자의 3~5배
int dailyViews = dailyParticipants * (3 + random.nextInt(3));
// 참여행동은 참여자의 1~2배
int dailyEngagement = dailyParticipants * (1 + random.nextInt(2));
// 전환수는 참여자의 50~80%
int dailyConversions = (int) (dailyParticipants * (0.5 + random.nextDouble() * 0.3));
// TimelineData 생성
com.kt.event.analytics.entity.TimelineData timelineData =
com.kt.event.analytics.entity.TimelineData.builder()
.eventId(eventId)
.timestamp(timestamp)
.participants(dailyParticipants)
.views(dailyViews)
.engagement(dailyEngagement)
.conversions(dailyConversions)
.cumulativeParticipants(cumulativeParticipants)
.build();
timelineDataRepository.save(timelineData);
}
log.info("✅ TimelineData 생성 완료: eventId={}, 30일 데이터", eventId);
}
log.info("✅ 전체 TimelineData 생성 완료: 3개 이벤트 × 30일 = 90건");
}
/**
* Kafka 이벤트 발행 공통 메서드
*/
private void publishEvent(String topic, Object event) throws Exception {
String jsonMessage = objectMapper.writeValueAsString(event);
kafkaTemplate.send(topic, jsonMessage);
}
}

View File

@ -0,0 +1,79 @@
package com.kt.event.analytics.config;
import com.kt.event.common.security.JwtAuthenticationFilter;
import com.kt.event.common.security.JwtTokenProvider;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
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;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.Arrays;
/**
* Spring Security 설정
* JWT 기반 인증 API 보안 설정
*/
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtTokenProvider jwtTokenProvider;
@Value("${cors.allowed-origins:http://localhost:*}")
private String allowedOrigins;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.csrf(AbstractHttpConfigurer::disable)
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
// Actuator endpoints
.requestMatchers("/actuator/**").permitAll()
// Swagger UI endpoints
.requestMatchers("/swagger-ui/**", "/swagger-ui.html", "/v3/api-docs/**", "/swagger-resources/**", "/webjars/**").permitAll()
// Health check
.requestMatchers("/health").permitAll()
// Analytics API endpoints (테스트 개발 용도로 공개)
.requestMatchers("/api/**").permitAll()
// All other requests require authentication
.anyRequest().authenticated()
)
.addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider),
UsernamePasswordAuthenticationFilter.class)
.build();
}
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
String[] origins = allowedOrigins.split(",");
configuration.setAllowedOriginPatterns(Arrays.asList(origins));
configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"));
configuration.setAllowedHeaders(Arrays.asList(
"Authorization", "Content-Type", "X-Requested-With", "Accept",
"Origin", "Access-Control-Request-Method", "Access-Control-Request-Headers"
));
configuration.setAllowCredentials(true);
configuration.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
}

View File

@ -0,0 +1,63 @@
package com.kt.event.analytics.config;
import io.swagger.v3.oas.models.Components;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Contact;
import io.swagger.v3.oas.models.info.Info;
import io.swagger.v3.oas.models.security.SecurityRequirement;
import io.swagger.v3.oas.models.security.SecurityScheme;
import io.swagger.v3.oas.models.servers.Server;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* Swagger/OpenAPI 설정
* Analytics Service API 문서화를 위한 설정
*/
@Configuration
public class SwaggerConfig {
@Bean
public OpenAPI openAPI() {
return new OpenAPI()
.info(apiInfo())
.addServersItem(new Server()
.url("http://localhost:8086")
.description("Local Development"))
.addServersItem(new Server()
.url("{protocol}://{host}:{port}")
.description("Custom Server")
.variables(new io.swagger.v3.oas.models.servers.ServerVariables()
.addServerVariable("protocol", new io.swagger.v3.oas.models.servers.ServerVariable()
._default("http")
.description("Protocol (http or https)")
.addEnumItem("http")
.addEnumItem("https"))
.addServerVariable("host", new io.swagger.v3.oas.models.servers.ServerVariable()
._default("localhost")
.description("Server host"))
.addServerVariable("port", new io.swagger.v3.oas.models.servers.ServerVariable()
._default("8086")
.description("Server port"))))
.addSecurityItem(new SecurityRequirement().addList("Bearer Authentication"))
.components(new Components()
.addSecuritySchemes("Bearer Authentication", createAPIKeyScheme()));
}
private Info apiInfo() {
return new Info()
.title("Analytics Service API")
.description("실시간 효과 측정 및 통합 대시보드를 제공하는 Analytics Service API")
.version("1.0.0")
.contact(new Contact()
.name("Digital Garage Team")
.email("support@kt-event-marketing.com"));
}
private SecurityScheme createAPIKeyScheme() {
return new SecurityScheme()
.type(SecurityScheme.Type.HTTP)
.bearerFormat("JWT")
.scheme("bearer");
}
}

View File

@ -0,0 +1,71 @@
package com.kt.event.analytics.controller;
import com.kt.event.analytics.dto.response.AnalyticsDashboardResponse;
import com.kt.event.analytics.service.AnalyticsService;
import com.kt.event.common.dto.ApiResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDateTime;
/**
* Analytics Dashboard Controller
*
* 이벤트 성과 대시보드 API
*/
@Tag(name = "Analytics", description = "이벤트 성과 분석 및 대시보드 API")
@Slf4j
@RestController
@RequestMapping("/api/v1/events")
@RequiredArgsConstructor
public class AnalyticsDashboardController {
private final AnalyticsService analyticsService;
/**
* 성과 대시보드 조회
*
* @param eventId 이벤트 ID
* @param startDate 조회 시작 날짜
* @param endDate 조회 종료 날짜
* @param refresh 캐시 갱신 여부
* @return 성과 대시보드
*/
@Operation(
summary = "성과 대시보드 조회",
description = "이벤트의 전체 성과를 통합하여 조회합니다."
)
@GetMapping("/{eventId}/analytics")
public ResponseEntity<ApiResponse<AnalyticsDashboardResponse>> getEventAnalytics(
@Parameter(description = "이벤트 ID", required = true)
@PathVariable String eventId,
@Parameter(description = "조회 시작 날짜 (ISO 8601 format)")
@RequestParam(required = false)
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
LocalDateTime startDate,
@Parameter(description = "조회 종료 날짜 (ISO 8601 format)")
@RequestParam(required = false)
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
LocalDateTime endDate,
@Parameter(description = "캐시 갱신 여부 (true인 경우 외부 API 호출)")
@RequestParam(required = false, defaultValue = "false")
Boolean refresh
) {
log.info("성과 대시보드 조회 API 호출: eventId={}, refresh={}", eventId, refresh);
AnalyticsDashboardResponse response = analyticsService.getDashboardData(
eventId, startDate, endDate, refresh
);
return ResponseEntity.ok(ApiResponse.success(response));
}
}

View File

@ -0,0 +1,73 @@
package com.kt.event.analytics.controller;
import com.kt.event.analytics.dto.response.ChannelAnalyticsResponse;
import com.kt.event.analytics.service.ChannelAnalyticsService;
import com.kt.event.common.dto.ApiResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
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.*;
import java.util.Arrays;
import java.util.List;
/**
* Channel Analytics Controller
*
* 채널별 성과 분석 API
*/
@Tag(name = "Channels", description = "채널별 성과 분석 API")
@Slf4j
@RestController
@RequestMapping("/api/v1/events")
@RequiredArgsConstructor
public class ChannelAnalyticsController {
private final ChannelAnalyticsService channelAnalyticsService;
/**
* 채널별 성과 분석
*
* @param eventId 이벤트 ID
* @param channels 조회할 채널 목록 (쉼표로 구분)
* @param sortBy 정렬 기준
* @param order 정렬 순서
* @return 채널별 성과 분석
*/
@Operation(
summary = "채널별 성과 분석",
description = "각 배포 채널별 성과를 상세하게 분석합니다."
)
@GetMapping("/{eventId}/analytics/channels")
public ResponseEntity<ApiResponse<ChannelAnalyticsResponse>> getChannelAnalytics(
@Parameter(description = "이벤트 ID", required = true)
@PathVariable String eventId,
@Parameter(description = "조회할 채널 목록 (쉼표로 구분, 미지정 시 전체)")
@RequestParam(required = false)
String channels,
@Parameter(description = "정렬 기준 (views, participants, engagement_rate, conversion_rate, roi)")
@RequestParam(required = false, defaultValue = "roi")
String sortBy,
@Parameter(description = "정렬 순서 (asc, desc)")
@RequestParam(required = false, defaultValue = "desc")
String order
) {
log.info("채널별 성과 분석 API 호출: eventId={}, sortBy={}", eventId, sortBy);
List<String> channelList = channels != null && !channels.isBlank()
? Arrays.asList(channels.split(","))
: null;
ChannelAnalyticsResponse response = channelAnalyticsService.getChannelAnalytics(
eventId, channelList, sortBy, order
);
return ResponseEntity.ok(ApiResponse.success(response));
}
}

View File

@ -0,0 +1,54 @@
package com.kt.event.analytics.controller;
import com.kt.event.analytics.dto.response.RoiAnalyticsResponse;
import com.kt.event.analytics.service.RoiAnalyticsService;
import com.kt.event.common.dto.ApiResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
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.*;
/**
* ROI Analytics Controller
*
* 투자 대비 수익률 분석 API
*/
@Tag(name = "ROI", description = "투자 대비 수익률 분석 API")
@Slf4j
@RestController
@RequestMapping("/api/v1/events")
@RequiredArgsConstructor
public class RoiAnalyticsController {
private final RoiAnalyticsService roiAnalyticsService;
/**
* 투자 대비 수익률 상세
*
* @param eventId 이벤트 ID
* @param includeProjection 예상 수익 포함 여부
* @return ROI 상세 분석
*/
@Operation(
summary = "투자 대비 수익률 상세",
description = "이벤트의 투자 대비 수익률을 상세하게 분석합니다."
)
@GetMapping("/{eventId}/analytics/roi")
public ResponseEntity<ApiResponse<RoiAnalyticsResponse>> getRoiAnalytics(
@Parameter(description = "이벤트 ID", required = true)
@PathVariable String eventId,
@Parameter(description = "예상 수익 포함 여부")
@RequestParam(required = false, defaultValue = "true")
Boolean includeProjection
) {
log.info("ROI 상세 분석 API 호출: eventId={}, includeProjection={}", eventId, includeProjection);
RoiAnalyticsResponse response = roiAnalyticsService.getRoiAnalytics(eventId, includeProjection);
return ResponseEntity.ok(ApiResponse.success(response));
}
}

View File

@ -0,0 +1,82 @@
package com.kt.event.analytics.controller;
import com.kt.event.analytics.dto.response.TimelineAnalyticsResponse;
import com.kt.event.analytics.service.TimelineAnalyticsService;
import com.kt.event.common.dto.ApiResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDateTime;
import java.util.Arrays;
import java.util.List;
/**
* Timeline Analytics Controller
*
* 시간대별 분석 API
*/
@Tag(name = "Timeline", description = "시간대별 분석 API")
@Slf4j
@RestController
@RequestMapping("/api/v1/events")
@RequiredArgsConstructor
public class TimelineAnalyticsController {
private final TimelineAnalyticsService timelineAnalyticsService;
/**
* 시간대별 참여 추이
*
* @param eventId 이벤트 ID
* @param interval 시간 간격 단위
* @param startDate 조회 시작 날짜
* @param endDate 조회 종료 날짜
* @param metrics 조회할 지표 목록
* @return 시간대별 참여 추이
*/
@Operation(
summary = "시간대별 참여 추이",
description = "이벤트 기간 동안의 시간대별 참여 추이를 분석합니다."
)
@GetMapping("/{eventId}/analytics/timeline")
public ResponseEntity<ApiResponse<TimelineAnalyticsResponse>> getTimelineAnalytics(
@Parameter(description = "이벤트 ID", required = true)
@PathVariable String eventId,
@Parameter(description = "시간 간격 단위 (hourly, daily, weekly)")
@RequestParam(required = false, defaultValue = "daily")
String interval,
@Parameter(description = "조회 시작 날짜 (ISO 8601 format)")
@RequestParam(required = false)
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
LocalDateTime startDate,
@Parameter(description = "조회 종료 날짜 (ISO 8601 format)")
@RequestParam(required = false)
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
LocalDateTime endDate,
@Parameter(description = "조회할 지표 목록 (쉼표로 구분)")
@RequestParam(required = false)
String metrics
) {
log.info("시간대별 참여 추이 API 호출: eventId={}, interval={}", eventId, interval);
List<String> metricList = metrics != null && !metrics.isBlank()
? Arrays.asList(metrics.split(","))
: null;
TimelineAnalyticsResponse response = timelineAnalyticsService.getTimelineAnalytics(
eventId, interval, startDate, endDate, metricList
);
return ResponseEntity.ok(ApiResponse.success(response));
}
}

View File

@ -0,0 +1,59 @@
package com.kt.event.analytics.dto.response;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.util.List;
/**
* 이벤트 성과 대시보드 응답
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AnalyticsDashboardResponse {
/**
* 이벤트 ID
*/
private String eventId;
/**
* 이벤트 제목
*/
private String eventTitle;
/**
* 조회 기간 정보
*/
private PeriodInfo period;
/**
* 성과 요약
*/
private AnalyticsSummary summary;
/**
* 채널별 성과 요약
*/
private List<ChannelSummary> channelPerformance;
/**
* ROI 요약
*/
private RoiSummary roi;
/**
* 마지막 업데이트 시간
*/
private LocalDateTime lastUpdatedAt;
/**
* 데이터 출처 (real-time, cached, fallback)
*/
private String dataSource;
}

View File

@ -0,0 +1,51 @@
package com.kt.event.analytics.dto.response;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 성과 요약
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AnalyticsSummary {
/**
* 참여자
*/
private Integer totalParticipants;
/**
* 조회수
*/
private Integer totalViews;
/**
* 도달
*/
private Integer totalReach;
/**
* 참여율 (%)
*/
private Double engagementRate;
/**
* 전환율 (%)
*/
private Double conversionRate;
/**
* 평균 참여 시간 ()
*/
private Integer averageEngagementTime;
/**
* SNS 반응 통계
*/
private SocialInteractionStats socialInteractions;
}

View File

@ -0,0 +1,46 @@
package com.kt.event.analytics.dto.response;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 채널별 상세 분석
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ChannelAnalytics {
/**
* 채널명
*/
private String channelName;
/**
* 채널 유형
*/
private String channelType;
/**
* 채널 지표
*/
private ChannelMetrics metrics;
/**
* 성과 지표
*/
private ChannelPerformance performance;
/**
* 비용 정보
*/
private ChannelCosts costs;
/**
* 외부 API 연동 상태 (success, fallback, failed)
*/
private String externalApiStatus;
}

View File

@ -0,0 +1,39 @@
package com.kt.event.analytics.dto.response;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.util.List;
/**
* 채널별 성과 분석 응답
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ChannelAnalyticsResponse {
/**
* 이벤트 ID
*/
private String eventId;
/**
* 채널별 상세 분석
*/
private List<ChannelAnalytics> channels;
/**
* 채널 비교 분석
*/
private ChannelComparison comparison;
/**
* 마지막 업데이트 시간
*/
private LocalDateTime lastUpdatedAt;
}

View File

@ -0,0 +1,28 @@
package com.kt.event.analytics.dto.response;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.Map;
/**
* 채널 비교 분석
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ChannelComparison {
/**
* 최고 성과 채널
*/
private Map<String, String> bestPerforming;
/**
* 전체 채널 평균 지표
*/
private Map<String, Double> averageMetrics;
}

View File

@ -0,0 +1,43 @@
package com.kt.event.analytics.dto.response;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.math.BigDecimal;
/**
* 채널별 비용
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ChannelCosts {
/**
* 배포 비용 ()
*/
private BigDecimal distributionCost;
/**
* 조회당 비용 (CPV, )
*/
private Double costPerView;
/**
* 클릭당 비용 (CPC, )
*/
private Double costPerClick;
/**
* 고객 획득 비용 (CPA, )
*/
private Double costPerAcquisition;
/**
* ROI (%)
*/
private Double roi;
}

View File

@ -0,0 +1,51 @@
package com.kt.event.analytics.dto.response;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 채널 지표
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ChannelMetrics {
/**
* 노출
*/
private Integer impressions;
/**
* 조회수
*/
private Integer views;
/**
* 클릭
*/
private Integer clicks;
/**
* 참여자
*/
private Integer participants;
/**
* 전환
*/
private Integer conversions;
/**
* SNS 반응 통계
*/
private SocialInteractionStats socialInteractions;
/**
* 링고비즈 통화 통계
*/
private VoiceCallStats voiceCallStats;
}

View File

@ -0,0 +1,41 @@
package com.kt.event.analytics.dto.response;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 채널 성과 지표
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ChannelPerformance {
/**
* 클릭률 (CTR, %)
*/
private Double clickThroughRate;
/**
* 참여율 (%)
*/
private Double engagementRate;
/**
* 전환율 (%)
*/
private Double conversionRate;
/**
* 평균 참여 시간 ()
*/
private Integer averageEngagementTime;
/**
* 이탈율 (%)
*/
private Double bounceRate;
}

View File

@ -0,0 +1,46 @@
package com.kt.event.analytics.dto.response;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 채널별 성과 요약
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ChannelSummary {
/**
* 채널명
*/
private String channelName;
/**
* 조회수
*/
private Integer views;
/**
* 참여자
*/
private Integer participants;
/**
* 참여율 (%)
*/
private Double engagementRate;
/**
* 전환율 (%)
*/
private Double conversionRate;
/**
* ROI (%)
*/
private Double roi;
}

View File

@ -0,0 +1,36 @@
package com.kt.event.analytics.dto.response;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 비용 효율성
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class CostEfficiency {
/**
* 참여자당 비용 ()
*/
private Double costPerParticipant;
/**
* 전환당 비용 ()
*/
private Double costPerConversion;
/**
* 조회당 비용 ()
*/
private Double costPerView;
/**
* 참여자당 수익 ()
*/
private Double revenuePerParticipant;
}

View File

@ -0,0 +1,45 @@
package com.kt.event.analytics.dto.response;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.math.BigDecimal;
import java.util.List;
import java.util.Map;
/**
* 투자 비용 상세
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class InvestmentDetails {
/**
* 콘텐츠 제작비 ()
*/
private BigDecimal contentCreation;
/**
* 배포 비용 ()
*/
private BigDecimal distribution;
/**
* 운영 비용 ()
*/
private BigDecimal operation;
/**
* 투자 비용 ()
*/
private BigDecimal total;
/**
* 채널별 비용 상세
*/
private List<Map<String, Object>> breakdown;
}

View File

@ -0,0 +1,38 @@
package com.kt.event.analytics.dto.response;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* 피크 타임 정보
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class PeakTimeInfo {
/**
* 피크 시간
*/
private LocalDateTime timestamp;
/**
* 피크 지표 (participants, views, engagement, conversions)
*/
private String metric;
/**
* 피크
*/
private Integer value;
/**
* 피크 설명
*/
private String description;
}

View File

@ -0,0 +1,33 @@
package com.kt.event.analytics.dto.response;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* 조회 기간 정보
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class PeriodInfo {
/**
* 조회 시작 날짜
*/
private LocalDateTime startDate;
/**
* 조회 종료 날짜
*/
private LocalDateTime endDate;
/**
* 기간 ()
*/
private Integer durationDays;
}

View File

@ -0,0 +1,38 @@
package com.kt.event.analytics.dto.response;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.math.BigDecimal;
/**
* 수익 상세
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class RevenueDetails {
/**
* 직접 매출 ()
*/
private BigDecimal directSales;
/**
* 예상 추가 매출 ()
*/
private BigDecimal expectedSales;
/**
* 브랜드 가치 향상 추정액 ()
*/
private BigDecimal brandValue;
/**
* 수익 ()
*/
private BigDecimal total;
}

View File

@ -0,0 +1,38 @@
package com.kt.event.analytics.dto.response;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.math.BigDecimal;
/**
* 수익 예측
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class RevenueProjection {
/**
* 현재 누적 수익 ()
*/
private BigDecimal currentRevenue;
/**
* 예상 최종 수익 ()
*/
private BigDecimal projectedFinalRevenue;
/**
* 예측 신뢰도 (%)
*/
private Double confidenceLevel;
/**
* 예측 기반
*/
private String basedOn;
}

View File

@ -0,0 +1,53 @@
package com.kt.event.analytics.dto.response;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* ROI 상세 분석 응답
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class RoiAnalyticsResponse {
/**
* 이벤트 ID
*/
private String eventId;
/**
* 투자 비용 상세
*/
private InvestmentDetails investment;
/**
* 수익 상세
*/
private RevenueDetails revenue;
/**
* ROI 계산
*/
private RoiCalculation roi;
/**
* 비용 효율성
*/
private CostEfficiency costEfficiency;
/**
* 수익 예측
*/
private RevenueProjection projection;
/**
* 마지막 업데이트 시간
*/
private LocalDateTime lastUpdatedAt;
}

View File

@ -0,0 +1,39 @@
package com.kt.event.analytics.dto.response;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* ROI 계산
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class RoiCalculation {
/**
* 순이익 ()
*/
private BigDecimal netProfit;
/**
* ROI (%)
*/
private Double roiPercentage;
/**
* 손익분기점 도달 시점
*/
private LocalDateTime breakEvenPoint;
/**
* 투자 회수 기간 ()
*/
private Integer paybackPeriod;
}

View File

@ -0,0 +1,43 @@
package com.kt.event.analytics.dto.response;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.math.BigDecimal;
/**
* ROI 요약
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class RoiSummary {
/**
* 투자 비용 ()
*/
private BigDecimal totalInvestment;
/**
* 예상 매출 증대 ()
*/
private BigDecimal expectedRevenue;
/**
* 순이익 ()
*/
private BigDecimal netProfit;
/**
* ROI (%)
*/
private Double roi;
/**
* 고객 획득 비용 (CPA, )
*/
private Double costPerAcquisition;
}

View File

@ -0,0 +1,31 @@
package com.kt.event.analytics.dto.response;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* SNS 반응 통계
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class SocialInteractionStats {
/**
* 좋아요
*/
private Integer likes;
/**
* 댓글
*/
private Integer comments;
/**
* 공유
*/
private Integer shares;
}

View File

@ -0,0 +1,49 @@
package com.kt.event.analytics.dto.response;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.util.List;
/**
* 시간대별 참여 추이 응답
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class TimelineAnalyticsResponse {
/**
* 이벤트 ID
*/
private String eventId;
/**
* 시간 간격 (hourly, daily, weekly)
*/
private String interval;
/**
* 시간대별 데이터
*/
private List<TimelineDataPoint> dataPoints;
/**
* 추세 분석
*/
private TrendAnalysis trends;
/**
* 피크 타임 정보
*/
private List<PeakTimeInfo> peakTimes;
/**
* 마지막 업데이트 시간
*/
private LocalDateTime lastUpdatedAt;
}

View File

@ -0,0 +1,48 @@
package com.kt.event.analytics.dto.response;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* 시간대별 데이터 포인트
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class TimelineDataPoint {
/**
* 시간
*/
private LocalDateTime timestamp;
/**
* 참여자
*/
private Integer participants;
/**
* 조회수
*/
private Integer views;
/**
* 참여 행동
*/
private Integer engagement;
/**
* 전환
*/
private Integer conversions;
/**
* 누적 참여자
*/
private Integer cumulativeParticipants;
}

View File

@ -0,0 +1,36 @@
package com.kt.event.analytics.dto.response;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 추세 분석
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class TrendAnalysis {
/**
* 전체 추세 (increasing, stable, decreasing)
*/
private String overallTrend;
/**
* 증가율 (%)
*/
private Double growthRate;
/**
* 예상 참여자 (기간 종료 시점)
*/
private Integer projectedParticipants;
/**
* 피크 기간
*/
private String peakPeriod;
}

View File

@ -0,0 +1,36 @@
package com.kt.event.analytics.dto.response;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 링고비즈 음성 통화 통계
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class VoiceCallStats {
/**
* 통화
*/
private Integer totalCalls;
/**
* 완료된 통화
*/
private Integer completedCalls;
/**
* 평균 통화 시간 ()
*/
private Integer averageDuration;
/**
* 통화 완료율 (%)
*/
private Double completionRate;
}

View File

@ -0,0 +1,128 @@
package com.kt.event.analytics.entity;
import com.kt.event.common.entity.BaseTimeEntity;
import jakarta.persistence.*;
import lombok.*;
import java.math.BigDecimal;
/**
* 채널별 통계 엔티티
*
* 배포 채널별 성과 데이터를 저장
*/
@Entity
@Table(name = "channel_stats", indexes = {
@Index(name = "idx_event_id", columnList = "event_id"),
@Index(name = "idx_event_channel", columnList = "event_id, channel_name")
})
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class ChannelStats extends BaseTimeEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
/**
* 이벤트 ID
*/
@Column(name = "event_id", nullable = false, length = 50)
private String eventId;
/**
* 채널명 (우리동네TV, 지니TV, 링고비즈, SNS)
*/
@Column(name = "channel_name", nullable = false, length = 50)
private String channelName;
/**
* 채널 유형
*/
@Column(name = "channel_type", length = 30)
private String channelType;
/**
* 노출
*/
@Column(nullable = false)
@Builder.Default
private Integer impressions = 0;
/**
* 조회수
*/
@Column(nullable = false)
@Builder.Default
private Integer views = 0;
/**
* 클릭
*/
@Column(nullable = false)
@Builder.Default
private Integer clicks = 0;
/**
* 참여자
*/
@Column(nullable = false)
@Builder.Default
private Integer participants = 0;
/**
* 전환
*/
@Column(nullable = false)
@Builder.Default
private Integer conversions = 0;
/**
* 배포 비용 ()
*/
@Column(name = "distribution_cost", precision = 15, scale = 2)
@Builder.Default
private BigDecimal distributionCost = BigDecimal.ZERO;
/**
* 좋아요 (SNS 전용)
*/
@Builder.Default
private Integer likes = 0;
/**
* 댓글 (SNS 전용)
*/
@Builder.Default
private Integer comments = 0;
/**
* 공유 (SNS 전용)
*/
@Builder.Default
private Integer shares = 0;
/**
* 통화 (링고비즈 전용)
*/
@Column(name = "total_calls")
@Builder.Default
private Integer totalCalls = 0;
/**
* 완료된 통화 (링고비즈 전용)
*/
@Column(name = "completed_calls")
@Builder.Default
private Integer completedCalls = 0;
/**
* 평균 통화 시간 () (링고비즈 전용)
*/
@Column(name = "average_duration")
@Builder.Default
private Integer averageDuration = 0;
}

View File

@ -0,0 +1,106 @@
package com.kt.event.analytics.entity;
import com.kt.event.common.entity.BaseTimeEntity;
import jakarta.persistence.*;
import lombok.*;
import java.math.BigDecimal;
/**
* 이벤트 통계 엔티티
*
* Kafka Event Subscription을 통해 실시간으로 업데이트되는 이벤트 통계 정보
*/
@Entity
@Table(name = "event_stats")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class EventStats extends BaseTimeEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
/**
* 이벤트 ID
*/
@Column(nullable = false, unique = true, length = 50)
private String eventId;
/**
* 이벤트 제목
*/
@Column(nullable = false, length = 200)
private String eventTitle;
/**
* 매장 ID (소유자)
*/
@Column(nullable = false, length = 50)
private String storeId;
/**
* 참여자
*/
@Column(nullable = false)
@Builder.Default
private Integer totalParticipants = 0;
/**
* 노출 (모든 채널의 노출 합계)
*/
@Column(nullable = false)
@Builder.Default
private Integer totalViews = 0;
/**
* 예상 ROI (%)
*/
@Column(precision = 10, scale = 2)
@Builder.Default
private BigDecimal estimatedRoi = BigDecimal.ZERO;
/**
* 매출 증가율 (%)
*/
@Column(precision = 10, scale = 2)
@Builder.Default
private BigDecimal salesGrowthRate = BigDecimal.ZERO;
/**
* 투자 비용 ()
*/
@Column(precision = 15, scale = 2)
@Builder.Default
private BigDecimal totalInvestment = BigDecimal.ZERO;
/**
* 예상 수익 ()
*/
@Column(precision = 15, scale = 2)
@Builder.Default
private BigDecimal expectedRevenue = BigDecimal.ZERO;
/**
* 이벤트 상태
*/
@Column(length = 20)
private String status;
/**
* 참여자 증가
*/
public void incrementParticipants() {
this.totalParticipants++;
}
/**
* 참여자 증가 (특정 )
*/
public void incrementParticipants(int count) {
this.totalParticipants += count;
}
}

View File

@ -0,0 +1,75 @@
package com.kt.event.analytics.entity;
import com.kt.event.common.entity.BaseTimeEntity;
import jakarta.persistence.*;
import lombok.*;
import java.time.LocalDateTime;
/**
* 시간대별 데이터 엔티티
*
* 이벤트 기간 동안의 시간대별 참여 추이 데이터
*/
@Entity
@Table(name = "timeline_data", indexes = {
@Index(name = "idx_event_timestamp", columnList = "event_id, timestamp")
})
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class TimelineData extends BaseTimeEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
/**
* 이벤트 ID
*/
@Column(name = "event_id", nullable = false, length = 50)
private String eventId;
/**
* 시간 (집계 기준 시간)
*/
@Column(nullable = false)
private LocalDateTime timestamp;
/**
* 참여자
*/
@Column(nullable = false)
@Builder.Default
private Integer participants = 0;
/**
* 조회수
*/
@Column(nullable = false)
@Builder.Default
private Integer views = 0;
/**
* 참여 행동
*/
@Column(nullable = false)
@Builder.Default
private Integer engagement = 0;
/**
* 전환
*/
@Column(nullable = false)
@Builder.Default
private Integer conversions = 0;
/**
* 누적 참여자
*/
@Column(name = "cumulative_participants", nullable = false)
@Builder.Default
private Integer cumulativeParticipants = 0;
}

View File

@ -0,0 +1,145 @@
package com.kt.event.analytics.messaging.consumer;
import com.kt.event.analytics.entity.ChannelStats;
import com.kt.event.analytics.messaging.event.DistributionCompletedEvent;
import com.kt.event.analytics.repository.ChannelStatsRepository;
import com.kt.event.analytics.repository.EventStatsRepository;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.kafka.annotation.KafkaListener;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.concurrent.TimeUnit;
/**
* 배포 완료 Consumer
*
* 배포 완료 채널 통계 업데이트
*/
@Slf4j
@Component
@ConditionalOnProperty(name = "spring.kafka.enabled", havingValue = "true", matchIfMissing = false)
@RequiredArgsConstructor
public class DistributionCompletedConsumer {
private final ChannelStatsRepository channelStatsRepository;
private final EventStatsRepository eventStatsRepository;
private final ObjectMapper objectMapper;
private final RedisTemplate<String, String> redisTemplate;
private static final String PROCESSED_DISTRIBUTIONS_KEY = "distribution_completed";
private static final String CACHE_KEY_PREFIX = "analytics:dashboard:";
private static final long IDEMPOTENCY_TTL_DAYS = 7;
/**
* DistributionCompleted 이벤트 처리 (설계서 기준 - 여러 채널 배열)
*/
@KafkaListener(topics = "sample.distribution.completed", groupId = "${spring.kafka.consumer.group-id}")
public void handleDistributionCompleted(String message) {
try {
log.info("📩 DistributionCompleted 이벤트 수신: {}", message);
DistributionCompletedEvent event = objectMapper.readValue(message, DistributionCompletedEvent.class);
String eventId = event.getEventId();
// 1. 멱등성 체크 (중복 처리 방지) - eventId 기반
Boolean isProcessed = redisTemplate.opsForSet().isMember(PROCESSED_DISTRIBUTIONS_KEY, eventId);
if (Boolean.TRUE.equals(isProcessed)) {
log.warn("⚠️ 중복 이벤트 스킵 (이미 처리됨): eventId={}", eventId);
return;
}
// 2. 채널 배열 루프 처리 (설계서: distributedChannels 배열)
if (event.getDistributedChannels() != null && !event.getDistributedChannels().isEmpty()) {
for (DistributionCompletedEvent.ChannelDistribution channel : event.getDistributedChannels()) {
processChannelStats(eventId, channel);
}
log.info("✅ 채널 통계 일괄 업데이트 완료: eventId={}, channelCount={}",
eventId, event.getDistributedChannels().size());
} else {
log.warn("⚠️ 배포된 채널 없음: eventId={}", eventId);
}
// 3. EventStats의 totalViews 업데이트 (모든 채널 노출 합계)
updateTotalViews(eventId);
// 4. 캐시 무효화 (다음 조회 최신 배포 통계 반영)
String cacheKey = CACHE_KEY_PREFIX + eventId;
redisTemplate.delete(cacheKey);
log.debug("🗑️ 캐시 무효화: {}", cacheKey);
// 5. 멱등성 처리 완료 기록 (7일 TTL) - eventId 기반
redisTemplate.opsForSet().add(PROCESSED_DISTRIBUTIONS_KEY, eventId);
redisTemplate.expire(PROCESSED_DISTRIBUTIONS_KEY, IDEMPOTENCY_TTL_DAYS, TimeUnit.DAYS);
log.debug("✅ 멱등성 기록: eventId={}", eventId);
} catch (Exception e) {
log.error("❌ DistributionCompleted 이벤트 처리 실패: {}", e.getMessage(), e);
throw new RuntimeException("DistributionCompleted 처리 실패", e);
}
}
/**
* 개별 채널 통계 처리
*/
private void processChannelStats(String eventId, DistributionCompletedEvent.ChannelDistribution channel) {
try {
String channelName = channel.getChannel();
// 채널 통계 생성 또는 업데이트
ChannelStats channelStats = channelStatsRepository
.findByEventIdAndChannelName(eventId, channelName)
.orElse(ChannelStats.builder()
.eventId(eventId)
.channelName(channelName)
.channelType(channel.getChannelType())
.build());
// 예상 노출 저장
if (channel.getExpectedViews() != null) {
channelStats.setImpressions(channel.getExpectedViews());
}
channelStatsRepository.save(channelStats);
log.debug("✅ 채널 통계 저장: eventId={}, channel={}, expectedViews={}",
eventId, channelName, channel.getExpectedViews());
} catch (Exception e) {
log.error("❌ 채널 통계 처리 실패: eventId={}, channel={}", eventId, channel.getChannel(), e);
}
}
/**
* 모든 채널의 예상 노출 수를 합산하여 EventStats.totalViews 업데이트
*/
private void updateTotalViews(String eventId) {
try {
// 모든 채널 통계 조회
List<ChannelStats> channelStatsList = channelStatsRepository.findByEventId(eventId);
// 노출 계산
int totalViews = channelStatsList.stream()
.mapToInt(ChannelStats::getImpressions)
.sum();
// EventStats 업데이트
eventStatsRepository.findByEventId(eventId)
.ifPresentOrElse(
eventStats -> {
eventStats.setTotalViews(totalViews);
eventStatsRepository.save(eventStats);
log.info("✅ 총 노출 수 업데이트: eventId={}, totalViews={}", eventId, totalViews);
},
() -> log.warn("⚠️ 이벤트 통계 없음: eventId={}", eventId)
);
} catch (Exception e) {
log.error("❌ totalViews 업데이트 실패: eventId={}", eventId, e);
}
}
}

View File

@ -0,0 +1,81 @@
package com.kt.event.analytics.messaging.consumer;
import com.kt.event.analytics.entity.EventStats;
import com.kt.event.analytics.messaging.event.EventCreatedEvent;
import com.kt.event.analytics.repository.EventStatsRepository;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.kafka.annotation.KafkaListener;
import org.springframework.stereotype.Component;
import java.util.concurrent.TimeUnit;
/**
* 이벤트 생성 Consumer
*
* 이벤트 생성 Analytics 통계 초기화
*/
@Slf4j
@Component
@ConditionalOnProperty(name = "spring.kafka.enabled", havingValue = "true", matchIfMissing = false)
@RequiredArgsConstructor
public class EventCreatedConsumer {
private final EventStatsRepository eventStatsRepository;
private final ObjectMapper objectMapper;
private final RedisTemplate<String, String> redisTemplate;
private static final String PROCESSED_EVENTS_KEY = "processed_events";
private static final String CACHE_KEY_PREFIX = "analytics:dashboard:";
private static final long IDEMPOTENCY_TTL_DAYS = 7;
/**
* EventCreated 이벤트 처리 (MVP용 샘플 토픽)
*/
@KafkaListener(topics = "sample.event.created", groupId = "${spring.kafka.consumer.group-id}")
public void handleEventCreated(String message) {
try {
log.info("📩 EventCreated 이벤트 수신: {}", message);
EventCreatedEvent event = objectMapper.readValue(message, EventCreatedEvent.class);
String eventId = event.getEventId();
// 1. 멱등성 체크 (중복 처리 방지)
Boolean isProcessed = redisTemplate.opsForSet().isMember(PROCESSED_EVENTS_KEY, eventId);
if (Boolean.TRUE.equals(isProcessed)) {
log.warn("⚠️ 중복 이벤트 스킵 (이미 처리됨): eventId={}", eventId);
return;
}
// 2. 이벤트 통계 초기화
EventStats eventStats = EventStats.builder()
.eventId(eventId)
.eventTitle(event.getEventTitle())
.storeId(event.getStoreId())
.totalParticipants(0)
.totalInvestment(event.getTotalInvestment())
.status(event.getStatus())
.build();
eventStatsRepository.save(eventStats);
log.info("✅ 이벤트 통계 초기화 완료: eventId={}", eventId);
// 3. 캐시 무효화 (다음 조회 최신 데이터 반영)
String cacheKey = CACHE_KEY_PREFIX + eventId;
redisTemplate.delete(cacheKey);
log.debug("🗑️ 캐시 무효화: {}", cacheKey);
// 4. 멱등성 처리 완료 기록 (7일 TTL)
redisTemplate.opsForSet().add(PROCESSED_EVENTS_KEY, eventId);
redisTemplate.expire(PROCESSED_EVENTS_KEY, IDEMPOTENCY_TTL_DAYS, TimeUnit.DAYS);
log.debug("✅ 멱등성 기록: eventId={}", eventId);
} catch (Exception e) {
log.error("❌ EventCreated 이벤트 처리 실패: {}", e.getMessage(), e);
throw new RuntimeException("EventCreated 처리 실패", e);
}
}
}

View File

@ -0,0 +1,81 @@
package com.kt.event.analytics.messaging.consumer;
import com.kt.event.analytics.entity.EventStats;
import com.kt.event.analytics.messaging.event.ParticipantRegisteredEvent;
import com.kt.event.analytics.repository.EventStatsRepository;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.kafka.annotation.KafkaListener;
import org.springframework.stereotype.Component;
import java.util.concurrent.TimeUnit;
/**
* 참여자 등록 Consumer
*
* 참여자 등록 실시간 참여자 업데이트
*/
@Slf4j
@Component
@ConditionalOnProperty(name = "spring.kafka.enabled", havingValue = "true", matchIfMissing = false)
@RequiredArgsConstructor
public class ParticipantRegisteredConsumer {
private final EventStatsRepository eventStatsRepository;
private final ObjectMapper objectMapper;
private final RedisTemplate<String, String> redisTemplate;
private static final String PROCESSED_PARTICIPANTS_KEY = "processed_participants";
private static final String CACHE_KEY_PREFIX = "analytics:dashboard:";
private static final long IDEMPOTENCY_TTL_DAYS = 7;
/**
* ParticipantRegistered 이벤트 처리 (MVP용 샘플 토픽)
*/
@KafkaListener(topics = "sample.participant.registered", groupId = "${spring.kafka.consumer.group-id}")
public void handleParticipantRegistered(String message) {
try {
log.info("📩 ParticipantRegistered 이벤트 수신: {}", message);
ParticipantRegisteredEvent event = objectMapper.readValue(message, ParticipantRegisteredEvent.class);
String participantId = event.getParticipantId();
String eventId = event.getEventId();
// 1. 멱등성 체크 (중복 처리 방지)
Boolean isProcessed = redisTemplate.opsForSet().isMember(PROCESSED_PARTICIPANTS_KEY, participantId);
if (Boolean.TRUE.equals(isProcessed)) {
log.warn("⚠️ 중복 이벤트 스킵 (이미 처리됨): participantId={}", participantId);
return;
}
// 2. 이벤트 통계 업데이트 (참여자 +1)
eventStatsRepository.findByEventId(eventId)
.ifPresentOrElse(
eventStats -> {
eventStats.incrementParticipants();
eventStatsRepository.save(eventStats);
log.info("✅ 참여자 수 업데이트: eventId={}, totalParticipants={}",
eventId, eventStats.getTotalParticipants());
},
() -> log.warn("⚠️ 이벤트 통계 없음: eventId={}", eventId)
);
// 3. 캐시 무효화 (다음 조회 최신 참여자 반영)
String cacheKey = CACHE_KEY_PREFIX + eventId;
redisTemplate.delete(cacheKey);
log.debug("🗑️ 캐시 무효화: {}", cacheKey);
// 4. 멱등성 처리 완료 기록 (7일 TTL)
redisTemplate.opsForSet().add(PROCESSED_PARTICIPANTS_KEY, participantId);
redisTemplate.expire(PROCESSED_PARTICIPANTS_KEY, IDEMPOTENCY_TTL_DAYS, TimeUnit.DAYS);
log.debug("✅ 멱등성 기록: participantId={}", participantId);
} catch (Exception e) {
log.error("❌ ParticipantRegistered 이벤트 처리 실패: {}", e.getMessage(), e);
throw new RuntimeException("ParticipantRegistered 처리 실패", e);
}
}
}

View File

@ -0,0 +1,66 @@
package com.kt.event.analytics.messaging.event;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.util.List;
/**
* 배포 완료 이벤트 (설계서 기준)
*
* Distribution Service가 이벤트의 모든 채널 배포 완료 발행
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class DistributionCompletedEvent {
/**
* 이벤트 ID
*/
private String eventId;
/**
* 배포된 채널 목록 (여러 채널을 배열로 포함)
*/
private List<ChannelDistribution> distributedChannels;
/**
* 배포 완료 시각
*/
private LocalDateTime completedAt;
/**
* 개별 채널 배포 정보
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class ChannelDistribution {
/**
* 채널명 (우리동네TV, 지니TV, 링고비즈, SNS)
*/
private String channel;
/**
* 채널 유형 (TV, CALL, SNS)
*/
private String channelType;
/**
* 배포 상태 (SUCCESS, FAILURE)
*/
private String status;
/**
* 예상 노출
*/
private Integer expectedViews;
}
}

View File

@ -0,0 +1,43 @@
package com.kt.event.analytics.messaging.event;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.math.BigDecimal;
/**
* 이벤트 생성 이벤트
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class EventCreatedEvent {
/**
* 이벤트 ID
*/
private String eventId;
/**
* 이벤트 제목
*/
private String eventTitle;
/**
* 매장 ID
*/
private String storeId;
/**
* 투자 비용
*/
private BigDecimal totalInvestment;
/**
* 이벤트 상태
*/
private String status;
}

View File

@ -0,0 +1,31 @@
package com.kt.event.analytics.messaging.event;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 참여자 등록 이벤트
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ParticipantRegisteredEvent {
/**
* 이벤트 ID
*/
private String eventId;
/**
* 참여자 ID
*/
private String participantId;
/**
* 참여 채널
*/
private String channel;
}

View File

@ -0,0 +1,32 @@
package com.kt.event.analytics.repository;
import com.kt.event.analytics.entity.ChannelStats;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
/**
* 채널 통계 Repository
*/
@Repository
public interface ChannelStatsRepository extends JpaRepository<ChannelStats, Long> {
/**
* 이벤트 ID로 모든 채널 통계 조회
*
* @param eventId 이벤트 ID
* @return 채널 통계 목록
*/
List<ChannelStats> findByEventId(String eventId);
/**
* 이벤트 ID와 채널명으로 통계 조회
*
* @param eventId 이벤트 ID
* @param channelName 채널명
* @return 채널 통계
*/
Optional<ChannelStats> findByEventIdAndChannelName(String eventId, String channelName);
}

View File

@ -0,0 +1,31 @@
package com.kt.event.analytics.repository;
import com.kt.event.analytics.entity.EventStats;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.Optional;
/**
* 이벤트 통계 Repository
*/
@Repository
public interface EventStatsRepository extends JpaRepository<EventStats, Long> {
/**
* 이벤트 ID로 통계 조회
*
* @param eventId 이벤트 ID
* @return 이벤트 통계
*/
Optional<EventStats> findByEventId(String eventId);
/**
* 매장 ID와 이벤트 ID로 통계 조회
*
* @param storeId 매장 ID
* @param eventId 이벤트 ID
* @return 이벤트 통계
*/
Optional<EventStats> findByStoreIdAndEventId(String storeId, String eventId);
}

View File

@ -0,0 +1,40 @@
package com.kt.event.analytics.repository;
import com.kt.event.analytics.entity.TimelineData;
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.time.LocalDateTime;
import java.util.List;
/**
* 시간대별 데이터 Repository
*/
@Repository
public interface TimelineDataRepository extends JpaRepository<TimelineData, Long> {
/**
* 이벤트 ID로 시간대별 데이터 조회 (시간 정렬)
*
* @param eventId 이벤트 ID
* @return 시간대별 데이터 목록
*/
List<TimelineData> findByEventIdOrderByTimestampAsc(String eventId);
/**
* 이벤트 ID와 기간으로 시간대별 데이터 조회
*
* @param eventId 이벤트 ID
* @param startDate 시작 날짜
* @param endDate 종료 날짜
* @return 시간대별 데이터 목록
*/
@Query("SELECT t FROM TimelineData t WHERE t.eventId = :eventId AND t.timestamp BETWEEN :startDate AND :endDate ORDER BY t.timestamp ASC")
List<TimelineData> findByEventIdAndTimestampBetween(
@Param("eventId") String eventId,
@Param("startDate") LocalDateTime startDate,
@Param("endDate") LocalDateTime endDate
);
}

View File

@ -0,0 +1,216 @@
package com.kt.event.analytics.service;
import com.kt.event.analytics.dto.response.*;
import com.kt.event.analytics.entity.ChannelStats;
import com.kt.event.analytics.entity.EventStats;
import com.kt.event.analytics.repository.ChannelStatsRepository;
import com.kt.event.analytics.repository.EventStatsRepository;
import com.kt.event.common.exception.BusinessException;
import com.kt.event.common.exception.ErrorCode;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Duration;
import java.time.LocalDateTime;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeUnit;
/**
* Analytics Service
*
* 이벤트 성과 대시보드 데이터를 제공하는 서비스
*/
@Slf4j
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class AnalyticsService {
private final EventStatsRepository eventStatsRepository;
private final ChannelStatsRepository channelStatsRepository;
private final ExternalChannelService externalChannelService;
private final ROICalculator roiCalculator;
private final RedisTemplate<String, String> redisTemplate;
private final ObjectMapper objectMapper;
private static final String CACHE_KEY_PREFIX = "analytics:dashboard:";
private static final long CACHE_TTL = 3600; // 1시간 (단일 캐시)
/**
* 대시보드 데이터 조회
*
* @param eventId 이벤트 ID
* @param startDate 조회 시작 날짜 (선택)
* @param endDate 조회 종료 날짜 (선택)
* @param refresh 캐시 갱신 여부
* @return 대시보드 응답
*/
public AnalyticsDashboardResponse getDashboardData(String eventId, LocalDateTime startDate, LocalDateTime endDate, boolean refresh) {
log.info("대시보드 데이터 조회 시작: eventId={}, refresh={}", eventId, refresh);
String cacheKey = CACHE_KEY_PREFIX + eventId;
// 1. Redis 캐시 조회 (refresh가 false일 때만)
if (!refresh) {
String cachedData = redisTemplate.opsForValue().get(cacheKey);
if (cachedData != null) {
try {
log.info("✅ 캐시 HIT: {} (1시간 캐시)", cacheKey);
return objectMapper.readValue(cachedData, AnalyticsDashboardResponse.class);
} catch (JsonProcessingException e) {
log.warn("캐시 데이터 역직렬화 실패: {}", e.getMessage());
}
}
}
// 2. 캐시 MISS: 데이터 통합 작업
log.info("캐시 MISS 또는 refresh=true: PostgreSQL + 외부 API 호출");
// 2-1. Analytics DB 조회 (PostgreSQL)
EventStats eventStats = eventStatsRepository.findByEventId(eventId)
.orElseThrow(() -> new BusinessException(ErrorCode.EVENT_001));
List<ChannelStats> channelStatsList = channelStatsRepository.findByEventId(eventId);
log.debug("PostgreSQL 조회 완료: eventId={}, 채널 수={}", eventId, channelStatsList.size());
// 2-2. 외부 채널 API 병렬 호출 (Circuit Breaker 적용)
try {
externalChannelService.updateChannelStatsFromExternalAPIs(eventId, channelStatsList);
log.info("외부 API 호출 성공: eventId={}", eventId);
} catch (Exception e) {
log.warn("외부 API 호출 실패, PostgreSQL 샘플 데이터 사용: eventId={}, error={}",
eventId, e.getMessage());
// Fallback: PostgreSQL 샘플 데이터만 사용
}
// 3. 대시보드 데이터 구성
AnalyticsDashboardResponse response = buildDashboardData(eventStats, channelStatsList, startDate, endDate);
// 4. Redis 캐싱 (1시간 TTL)
try {
String jsonData = objectMapper.writeValueAsString(response);
redisTemplate.opsForValue().set(cacheKey, jsonData, CACHE_TTL, TimeUnit.SECONDS);
log.info("✅ Redis 캐시 저장 완료: {} (TTL: 1시간)", cacheKey);
} catch (JsonProcessingException e) {
log.warn("캐시 데이터 직렬화 실패: {}", e.getMessage());
} catch (Exception e) {
log.warn("캐시 저장 실패 (무시하고 계속 진행): {}", e.getMessage());
}
return response;
}
/**
* 대시보드 데이터 구성
*/
private AnalyticsDashboardResponse buildDashboardData(EventStats eventStats, List<ChannelStats> channelStatsList,
LocalDateTime startDate, LocalDateTime endDate) {
// 기간 정보
PeriodInfo period = buildPeriodInfo(startDate, endDate);
// 성과 요약
AnalyticsSummary summary = buildAnalyticsSummary(eventStats, channelStatsList);
// 채널별 성과 요약
List<ChannelSummary> channelPerformance = buildChannelPerformance(channelStatsList, eventStats.getTotalInvestment());
// ROI 요약
RoiSummary roiSummary = roiCalculator.calculateRoiSummary(eventStats);
return AnalyticsDashboardResponse.builder()
.eventId(eventStats.getEventId())
.eventTitle(eventStats.getEventTitle())
.period(period)
.summary(summary)
.channelPerformance(channelPerformance)
.roi(roiSummary)
.lastUpdatedAt(LocalDateTime.now())
.dataSource("cached")
.build();
}
/**
* 기간 정보 구성
*/
private PeriodInfo buildPeriodInfo(LocalDateTime startDate, LocalDateTime endDate) {
LocalDateTime start = startDate != null ? startDate : LocalDateTime.now().minusDays(30);
LocalDateTime end = endDate != null ? endDate : LocalDateTime.now();
long durationDays = ChronoUnit.DAYS.between(start, end);
return PeriodInfo.builder()
.startDate(start)
.endDate(end)
.durationDays((int) durationDays)
.build();
}
/**
* 성과 요약 구성
*/
private AnalyticsSummary buildAnalyticsSummary(EventStats eventStats, List<ChannelStats> channelStatsList) {
int totalViews = channelStatsList.stream()
.mapToInt(ChannelStats::getViews)
.sum();
int totalReach = channelStatsList.stream()
.mapToInt(ChannelStats::getImpressions)
.sum();
double engagementRate = totalViews > 0 ? (eventStats.getTotalParticipants() * 100.0 / totalViews) : 0.0;
double conversionRate = totalViews > 0 ? (eventStats.getTotalParticipants() * 100.0 / totalViews) : 0.0;
// SNS 반응 통계 집계
int totalLikes = channelStatsList.stream().mapToInt(ChannelStats::getLikes).sum();
int totalComments = channelStatsList.stream().mapToInt(ChannelStats::getComments).sum();
int totalShares = channelStatsList.stream().mapToInt(ChannelStats::getShares).sum();
SocialInteractionStats socialStats = SocialInteractionStats.builder()
.likes(totalLikes)
.comments(totalComments)
.shares(totalShares)
.build();
return AnalyticsSummary.builder()
.totalParticipants(eventStats.getTotalParticipants())
.totalViews(totalViews)
.totalReach(totalReach)
.engagementRate(Math.round(engagementRate * 10.0) / 10.0)
.conversionRate(Math.round(conversionRate * 10.0) / 10.0)
.averageEngagementTime(145) // 고정값 (실제로는 외부 API에서 가져와야 )
.socialInteractions(socialStats)
.build();
}
/**
* 채널별 성과 구성
*/
private List<ChannelSummary> buildChannelPerformance(List<ChannelStats> channelStatsList, java.math.BigDecimal totalInvestment) {
List<ChannelSummary> summaries = new ArrayList<>();
for (ChannelStats stats : channelStatsList) {
double engagementRate = stats.getViews() > 0 ? (stats.getParticipants() * 100.0 / stats.getViews()) : 0.0;
double conversionRate = stats.getViews() > 0 ? (stats.getConversions() * 100.0 / stats.getViews()) : 0.0;
double roi = stats.getDistributionCost().compareTo(java.math.BigDecimal.ZERO) > 0 ?
(stats.getParticipants() * 100.0 / stats.getDistributionCost().doubleValue()) : 0.0;
summaries.add(ChannelSummary.builder()
.channelName(stats.getChannelName())
.views(stats.getViews())
.participants(stats.getParticipants())
.engagementRate(Math.round(engagementRate * 10.0) / 10.0)
.conversionRate(Math.round(conversionRate * 10.0) / 10.0)
.roi(Math.round(roi * 10.0) / 10.0)
.build());
}
return summaries;
}
}

View File

@ -0,0 +1,241 @@
package com.kt.event.analytics.service;
import com.kt.event.analytics.dto.response.*;
import com.kt.event.analytics.entity.ChannelStats;
import com.kt.event.analytics.repository.ChannelStatsRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDateTime;
import java.util.*;
import java.util.stream.Collectors;
/**
* 채널별 분석 Service
*/
@Slf4j
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class ChannelAnalyticsService {
private final ChannelStatsRepository channelStatsRepository;
private final ExternalChannelService externalChannelService;
/**
* 채널별 성과 분석
*/
public ChannelAnalyticsResponse getChannelAnalytics(String eventId, List<String> channels, String sortBy, String order) {
log.info("채널별 성과 분석 조회: eventId={}", eventId);
List<ChannelStats> channelStatsList = channelStatsRepository.findByEventId(eventId);
// 외부 API 호출하여 최신 데이터 반영
externalChannelService.updateChannelStatsFromExternalAPIs(eventId, channelStatsList);
// 필터링 (특정 채널만 조회)
if (channels != null && !channels.isEmpty()) {
channelStatsList = channelStatsList.stream()
.filter(stats -> channels.contains(stats.getChannelName()))
.collect(Collectors.toList());
}
// 채널별 상세 분석 구성
List<ChannelAnalytics> channelAnalytics = buildChannelAnalytics(channelStatsList);
// 정렬
channelAnalytics = sortChannelAnalytics(channelAnalytics, sortBy, order);
// 채널 비교 분석
ChannelComparison comparison = buildChannelComparison(channelAnalytics);
return ChannelAnalyticsResponse.builder()
.eventId(eventId)
.channels(channelAnalytics)
.comparison(comparison)
.lastUpdatedAt(LocalDateTime.now())
.build();
}
/**
* 채널별 상세 분석 구성
*/
private List<ChannelAnalytics> buildChannelAnalytics(List<ChannelStats> channelStatsList) {
return channelStatsList.stream()
.map(this::buildChannelAnalytics)
.collect(Collectors.toList());
}
private ChannelAnalytics buildChannelAnalytics(ChannelStats stats) {
ChannelMetrics metrics = buildChannelMetrics(stats);
ChannelPerformance performance = buildChannelPerformance(stats);
ChannelCosts costs = buildChannelCosts(stats);
return ChannelAnalytics.builder()
.channelName(stats.getChannelName())
.channelType(stats.getChannelType())
.metrics(metrics)
.performance(performance)
.costs(costs)
.externalApiStatus("success")
.build();
}
/**
* 채널 지표 구성
*/
private ChannelMetrics buildChannelMetrics(ChannelStats stats) {
SocialInteractionStats socialStats = null;
if (stats.getLikes() > 0 || stats.getComments() > 0 || stats.getShares() > 0) {
socialStats = SocialInteractionStats.builder()
.likes(stats.getLikes())
.comments(stats.getComments())
.shares(stats.getShares())
.build();
}
VoiceCallStats voiceStats = null;
if (stats.getTotalCalls() > 0) {
double completionRate = stats.getTotalCalls() > 0 ?
(stats.getCompletedCalls() * 100.0 / stats.getTotalCalls()) : 0.0;
voiceStats = VoiceCallStats.builder()
.totalCalls(stats.getTotalCalls())
.completedCalls(stats.getCompletedCalls())
.averageDuration(stats.getAverageDuration())
.completionRate(Math.round(completionRate * 10.0) / 10.0)
.build();
}
return ChannelMetrics.builder()
.impressions(stats.getImpressions())
.views(stats.getViews())
.clicks(stats.getClicks())
.participants(stats.getParticipants())
.conversions(stats.getConversions())
.socialInteractions(socialStats)
.voiceCallStats(voiceStats)
.build();
}
/**
* 채널 성과 지표 구성
*/
private ChannelPerformance buildChannelPerformance(ChannelStats stats) {
double ctr = stats.getImpressions() > 0 ? (stats.getClicks() * 100.0 / stats.getImpressions()) : 0.0;
double engagementRate = stats.getViews() > 0 ? (stats.getParticipants() * 100.0 / stats.getViews()) : 0.0;
double conversionRate = stats.getViews() > 0 ? (stats.getConversions() * 100.0 / stats.getViews()) : 0.0;
return ChannelPerformance.builder()
.clickThroughRate(Math.round(ctr * 10.0) / 10.0)
.engagementRate(Math.round(engagementRate * 10.0) / 10.0)
.conversionRate(Math.round(conversionRate * 10.0) / 10.0)
.averageEngagementTime(165)
.bounceRate(35.8)
.build();
}
/**
* 채널 비용 구성
*/
private ChannelCosts buildChannelCosts(ChannelStats stats) {
double cpv = stats.getViews() > 0 ?
stats.getDistributionCost().divide(BigDecimal.valueOf(stats.getViews()), 2, RoundingMode.HALF_UP).doubleValue() : 0.0;
double cpc = stats.getClicks() > 0 ?
stats.getDistributionCost().divide(BigDecimal.valueOf(stats.getClicks()), 2, RoundingMode.HALF_UP).doubleValue() : 0.0;
double cpa = stats.getParticipants() > 0 ?
stats.getDistributionCost().divide(BigDecimal.valueOf(stats.getParticipants()), 2, RoundingMode.HALF_UP).doubleValue() : 0.0;
double roi = stats.getDistributionCost().compareTo(BigDecimal.ZERO) > 0 ?
(stats.getParticipants() * 100.0 / stats.getDistributionCost().doubleValue()) : 0.0;
return ChannelCosts.builder()
.distributionCost(stats.getDistributionCost())
.costPerView(Math.round(cpv * 100.0) / 100.0)
.costPerClick(Math.round(cpc * 100.0) / 100.0)
.costPerAcquisition(Math.round(cpa * 100.0) / 100.0)
.roi(Math.round(roi * 10.0) / 10.0)
.build();
}
/**
* 채널 정렬
*/
private List<ChannelAnalytics> sortChannelAnalytics(List<ChannelAnalytics> channelAnalytics, String sortBy, String order) {
Comparator<ChannelAnalytics> comparator = switch (sortBy != null ? sortBy : "roi") {
case "views" -> Comparator.comparing(c -> c.getMetrics().getViews());
case "participants" -> Comparator.comparing(c -> c.getMetrics().getParticipants());
case "engagement_rate" -> Comparator.comparing(c -> c.getPerformance().getEngagementRate());
case "conversion_rate" -> Comparator.comparing(c -> c.getPerformance().getConversionRate());
default -> Comparator.comparing(c -> c.getCosts().getRoi());
};
if ("asc".equals(order)) {
channelAnalytics.sort(comparator);
} else {
channelAnalytics.sort(comparator.reversed());
}
return channelAnalytics;
}
/**
* 채널 비교 분석 구성
*/
private ChannelComparison buildChannelComparison(List<ChannelAnalytics> channelAnalytics) {
if (channelAnalytics.isEmpty()) {
return null;
}
// 최고 성과 채널 찾기
String bestByViews = channelAnalytics.stream()
.max(Comparator.comparing(c -> c.getMetrics().getViews()))
.map(ChannelAnalytics::getChannelName)
.orElse(null);
String bestByEngagement = channelAnalytics.stream()
.max(Comparator.comparing(c -> c.getPerformance().getEngagementRate()))
.map(ChannelAnalytics::getChannelName)
.orElse(null);
String bestByRoi = channelAnalytics.stream()
.max(Comparator.comparing(c -> c.getCosts().getRoi()))
.map(ChannelAnalytics::getChannelName)
.orElse(null);
Map<String, String> bestPerforming = new HashMap<>();
bestPerforming.put("byViews", bestByViews);
bestPerforming.put("byEngagement", bestByEngagement);
bestPerforming.put("byRoi", bestByRoi);
// 평균 지표 계산
double avgEngagementRate = channelAnalytics.stream()
.mapToDouble(c -> c.getPerformance().getEngagementRate())
.average()
.orElse(0.0);
double avgConversionRate = channelAnalytics.stream()
.mapToDouble(c -> c.getPerformance().getConversionRate())
.average()
.orElse(0.0);
double avgRoi = channelAnalytics.stream()
.mapToDouble(c -> c.getCosts().getRoi())
.average()
.orElse(0.0);
Map<String, Double> averageMetrics = new HashMap<>();
averageMetrics.put("engagementRate", Math.round(avgEngagementRate * 10.0) / 10.0);
averageMetrics.put("conversionRate", Math.round(avgConversionRate * 10.0) / 10.0);
averageMetrics.put("roi", Math.round(avgRoi * 10.0) / 10.0);
return ChannelComparison.builder()
.bestPerforming(bestPerforming)
.averageMetrics(averageMetrics)
.build();
}
}

View File

@ -0,0 +1,142 @@
package com.kt.event.analytics.service;
import com.kt.event.analytics.entity.ChannelStats;
import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.concurrent.CompletableFuture;
/**
* 외부 채널 Service
*
* 외부 API 호출 Circuit Breaker 적용
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class ExternalChannelService {
/**
* 외부 채널 API에서 통계 업데이트
*
* @param eventId 이벤트 ID
* @param channelStatsList 채널 통계 목록
*/
public void updateChannelStatsFromExternalAPIs(String eventId, List<ChannelStats> channelStatsList) {
log.info("외부 채널 API 병렬 호출 시작: eventId={}", eventId);
List<CompletableFuture<Void>> futures = channelStatsList.stream()
.map(channelStats -> CompletableFuture.runAsync(() ->
updateChannelStatsFromAPI(eventId, channelStats)))
.toList();
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
log.info("외부 채널 API 병렬 호출 완료: eventId={}", eventId);
}
/**
* 개별 채널 통계 업데이트
*/
private void updateChannelStatsFromAPI(String eventId, ChannelStats channelStats) {
String channelName = channelStats.getChannelName();
log.debug("채널 통계 업데이트: eventId={}, channel={}", eventId, channelName);
switch (channelName) {
case "우리동네TV" -> updateWooriTVStats(eventId, channelStats);
case "지니TV" -> updateGenieTVStats(eventId, channelStats);
case "링고비즈" -> updateRingoBizStats(eventId, channelStats);
case "SNS" -> updateSNSStats(eventId, channelStats);
default -> log.warn("알 수 없는 채널: {}", channelName);
}
}
/**
* 우리동네TV 통계 업데이트
*/
@CircuitBreaker(name = "wooriTV", fallbackMethod = "wooriTVFallback")
private void updateWooriTVStats(String eventId, ChannelStats channelStats) {
log.debug("우리동네TV API 호출: eventId={}", eventId);
// 실제 API 호출 로직 (Feign Client 사용)
// 예시 데이터 설정
channelStats.setViews(45000);
channelStats.setClicks(5500);
channelStats.setImpressions(120000);
}
/**
* 우리동네TV Fallback
*/
private void wooriTVFallback(String eventId, ChannelStats channelStats, Exception e) {
log.warn("우리동네TV API 장애, Fallback 실행: eventId={}, error={}", eventId, e.getMessage());
// Fallback 데이터 (캐시 또는 기본값)
channelStats.setViews(0);
channelStats.setClicks(0);
}
/**
* 지니TV 통계 업데이트
*/
@CircuitBreaker(name = "genieTV", fallbackMethod = "genieTVFallback")
private void updateGenieTVStats(String eventId, ChannelStats channelStats) {
log.debug("지니TV API 호출: eventId={}", eventId);
// 예시 데이터 설정
channelStats.setViews(30000);
channelStats.setClicks(3000);
channelStats.setImpressions(80000);
}
/**
* 지니TV Fallback
*/
private void genieTVFallback(String eventId, ChannelStats channelStats, Exception e) {
log.warn("지니TV API 장애, Fallback 실행: eventId={}, error={}", eventId, e.getMessage());
channelStats.setViews(0);
channelStats.setClicks(0);
}
/**
* 링고비즈 통계 업데이트
*/
@CircuitBreaker(name = "ringoBiz", fallbackMethod = "ringoBizFallback")
private void updateRingoBizStats(String eventId, ChannelStats channelStats) {
log.debug("링고비즈 API 호출: eventId={}", eventId);
// 예시 데이터 설정
channelStats.setTotalCalls(3000);
channelStats.setCompletedCalls(2500);
channelStats.setAverageDuration(45);
}
/**
* 링고비즈 Fallback
*/
private void ringoBizFallback(String eventId, ChannelStats channelStats, Exception e) {
log.warn("링고비즈 API 장애, Fallback 실행: eventId={}, error={}", eventId, e.getMessage());
channelStats.setTotalCalls(0);
channelStats.setCompletedCalls(0);
}
/**
* SNS 통계 업데이트
*/
@CircuitBreaker(name = "sns", fallbackMethod = "snsFallback")
private void updateSNSStats(String eventId, ChannelStats channelStats) {
log.debug("SNS API 호출: eventId={}", eventId);
// 예시 데이터 설정
channelStats.setLikes(3450);
channelStats.setComments(890);
channelStats.setShares(1250);
}
/**
* SNS Fallback
*/
private void snsFallback(String eventId, ChannelStats channelStats, Exception e) {
log.warn("SNS API 장애, Fallback 실행: eventId={}, error={}", eventId, e.getMessage());
channelStats.setLikes(0);
channelStats.setComments(0);
channelStats.setShares(0);
}
}

View File

@ -0,0 +1,202 @@
package com.kt.event.analytics.service;
import com.kt.event.analytics.dto.response.*;
import com.kt.event.analytics.entity.ChannelStats;
import com.kt.event.analytics.entity.EventStats;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDateTime;
import java.util.List;
/**
* ROI 계산 유틸리티
*
* 이벤트의 투자 대비 수익률을 계산하는 비즈니스 로직
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class ROICalculator {
/**
* ROI 상세 계산
*
* @param eventStats 이벤트 통계
* @param channelStats 채널별 통계
* @return ROI 상세 분석 결과
*/
public RoiAnalyticsResponse calculateDetailedRoi(EventStats eventStats, List<ChannelStats> channelStats) {
log.debug("ROI 상세 계산 시작: eventId={}", eventStats.getEventId());
// 투자 비용 계산
InvestmentDetails investment = calculateInvestment(eventStats, channelStats);
// 수익 계산
RevenueDetails revenue = calculateRevenue(eventStats);
// ROI 계산
RoiCalculation roiCalc = calculateRoi(investment, revenue);
// 비용 효율성 계산
CostEfficiency costEfficiency = calculateCostEfficiency(investment, revenue, eventStats);
// 수익 예측
RevenueProjection projection = projectRevenue(revenue, eventStats);
return RoiAnalyticsResponse.builder()
.eventId(eventStats.getEventId())
.investment(investment)
.revenue(revenue)
.roi(roiCalc)
.costEfficiency(costEfficiency)
.projection(projection)
.lastUpdatedAt(LocalDateTime.now())
.build();
}
/**
* 투자 비용 계산
*/
private InvestmentDetails calculateInvestment(EventStats eventStats, List<ChannelStats> channelStats) {
BigDecimal distributionCost = channelStats.stream()
.map(ChannelStats::getDistributionCost)
.reduce(BigDecimal.ZERO, BigDecimal::add);
BigDecimal contentCreation = eventStats.getTotalInvestment()
.multiply(BigDecimal.valueOf(0.4)); // 전체 투자의 40% 콘텐츠 제작비로 가정
BigDecimal operation = eventStats.getTotalInvestment()
.multiply(BigDecimal.valueOf(0.1)); // 10% 운영비로 가정
return InvestmentDetails.builder()
.contentCreation(contentCreation)
.distribution(distributionCost)
.operation(operation)
.total(eventStats.getTotalInvestment())
.build();
}
/**
* 수익 계산
*/
private RevenueDetails calculateRevenue(EventStats eventStats) {
BigDecimal directSales = eventStats.getExpectedRevenue()
.multiply(BigDecimal.valueOf(0.66)); // 예상 수익의 66% 직접 매출로 가정
BigDecimal expectedSales = eventStats.getExpectedRevenue()
.multiply(BigDecimal.valueOf(0.34)); // 34% 예상 추가 매출로 가정
BigDecimal brandValue = BigDecimal.ZERO; // 브랜드 가치는 별도 계산 필요
return RevenueDetails.builder()
.directSales(directSales)
.expectedSales(expectedSales)
.brandValue(brandValue)
.total(eventStats.getExpectedRevenue())
.build();
}
/**
* ROI 계산
*/
private RoiCalculation calculateRoi(InvestmentDetails investment, RevenueDetails revenue) {
BigDecimal netProfit = revenue.getTotal().subtract(investment.getTotal());
double roiPercentage = 0.0;
if (investment.getTotal().compareTo(BigDecimal.ZERO) > 0) {
roiPercentage = netProfit.divide(investment.getTotal(), 4, RoundingMode.HALF_UP)
.multiply(BigDecimal.valueOf(100))
.doubleValue();
}
// 손익분기점 계산 (간단한 선형 모델)
LocalDateTime breakEvenPoint = null;
if (roiPercentage > 0) {
breakEvenPoint = LocalDateTime.now().minusDays(5); // 예시
}
Integer paybackPeriod = roiPercentage > 0 ? 10 : null; // 예시
return RoiCalculation.builder()
.netProfit(netProfit)
.roiPercentage(roiPercentage)
.breakEvenPoint(breakEvenPoint)
.paybackPeriod(paybackPeriod)
.build();
}
/**
* 비용 효율성 계산
*/
private CostEfficiency calculateCostEfficiency(InvestmentDetails investment, RevenueDetails revenue, EventStats eventStats) {
double costPerParticipant = 0.0;
double costPerConversion = 0.0;
double costPerView = 0.0;
double revenuePerParticipant = 0.0;
if (eventStats.getTotalParticipants() > 0) {
costPerParticipant = investment.getTotal()
.divide(BigDecimal.valueOf(eventStats.getTotalParticipants()), 2, RoundingMode.HALF_UP)
.doubleValue();
revenuePerParticipant = revenue.getTotal()
.divide(BigDecimal.valueOf(eventStats.getTotalParticipants()), 2, RoundingMode.HALF_UP)
.doubleValue();
}
return CostEfficiency.builder()
.costPerParticipant(costPerParticipant)
.costPerConversion(costPerConversion)
.costPerView(costPerView)
.revenuePerParticipant(revenuePerParticipant)
.build();
}
/**
* 수익 예측
*/
private RevenueProjection projectRevenue(RevenueDetails revenue, EventStats eventStats) {
BigDecimal projectedFinal = revenue.getTotal()
.multiply(BigDecimal.valueOf(1.1)); // 현재 수익의 110% 예측
return RevenueProjection.builder()
.currentRevenue(revenue.getTotal())
.projectedFinalRevenue(projectedFinal)
.confidenceLevel(85.5)
.basedOn("현재 추세 및 과거 유사 이벤트 데이터")
.build();
}
/**
* ROI 요약 계산
*/
public RoiSummary calculateRoiSummary(EventStats eventStats) {
BigDecimal netProfit = eventStats.getExpectedRevenue().subtract(eventStats.getTotalInvestment());
double roi = 0.0;
if (eventStats.getTotalInvestment().compareTo(BigDecimal.ZERO) > 0) {
roi = netProfit.divide(eventStats.getTotalInvestment(), 4, RoundingMode.HALF_UP)
.multiply(BigDecimal.valueOf(100))
.doubleValue();
}
double cpa = 0.0;
if (eventStats.getTotalParticipants() > 0) {
cpa = eventStats.getTotalInvestment()
.divide(BigDecimal.valueOf(eventStats.getTotalParticipants()), 2, RoundingMode.HALF_UP)
.doubleValue();
}
return RoiSummary.builder()
.totalInvestment(eventStats.getTotalInvestment())
.expectedRevenue(eventStats.getExpectedRevenue())
.netProfit(netProfit)
.roi(roi)
.costPerAcquisition(cpa)
.build();
}
}

View File

@ -0,0 +1,53 @@
package com.kt.event.analytics.service;
import com.kt.event.analytics.dto.response.RoiAnalyticsResponse;
import com.kt.event.analytics.entity.ChannelStats;
import com.kt.event.analytics.entity.EventStats;
import com.kt.event.analytics.repository.ChannelStatsRepository;
import com.kt.event.analytics.repository.EventStatsRepository;
import com.kt.event.common.exception.BusinessException;
import com.kt.event.common.exception.ErrorCode;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
/**
* ROI 분석 Service
*/
@Slf4j
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class RoiAnalyticsService {
private final EventStatsRepository eventStatsRepository;
private final ChannelStatsRepository channelStatsRepository;
private final ROICalculator roiCalculator;
/**
* ROI 상세 분석 조회
*/
public RoiAnalyticsResponse getRoiAnalytics(String eventId, boolean includeProjection) {
log.info("ROI 상세 분석 조회: eventId={}, includeProjection={}", eventId, includeProjection);
// 이벤트 통계 조회
EventStats eventStats = eventStatsRepository.findByEventId(eventId)
.orElseThrow(() -> new BusinessException(ErrorCode.EVENT_001));
// 채널별 통계 조회
List<ChannelStats> channelStatsList = channelStatsRepository.findByEventId(eventId);
// ROI 상세 계산
RoiAnalyticsResponse response = roiCalculator.calculateDetailedRoi(eventStats, channelStatsList);
// 예측 데이터 제외 옵션
if (!includeProjection) {
response.setProjection(null);
}
return response;
}
}

View File

@ -0,0 +1,206 @@
package com.kt.event.analytics.service;
import com.kt.event.analytics.dto.response.*;
import com.kt.event.analytics.entity.TimelineData;
import com.kt.event.analytics.repository.TimelineDataRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.stream.Collectors;
/**
* 시간대별 분석 Service
*/
@Slf4j
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class TimelineAnalyticsService {
private final TimelineDataRepository timelineDataRepository;
/**
* 시간대별 참여 추이 조회
*/
public TimelineAnalyticsResponse getTimelineAnalytics(String eventId, String interval,
LocalDateTime startDate, LocalDateTime endDate,
List<String> metrics) {
log.info("시간대별 참여 추이 조회: eventId={}, interval={}", eventId, interval);
// 시간대별 데이터 조회
List<TimelineData> timelineDataList;
if (startDate != null && endDate != null) {
timelineDataList = timelineDataRepository.findByEventIdAndTimestampBetween(eventId, startDate, endDate);
} else {
timelineDataList = timelineDataRepository.findByEventIdOrderByTimestampAsc(eventId);
}
// 시간대별 데이터 포인트 구성
List<TimelineDataPoint> dataPoints = buildTimelineDataPoints(timelineDataList);
// 추세 분석
TrendAnalysis trends = buildTrendAnalysis(dataPoints);
// 피크 타임 분석
List<PeakTimeInfo> peakTimes = buildPeakTimes(dataPoints);
return TimelineAnalyticsResponse.builder()
.eventId(eventId)
.interval(interval != null ? interval : "daily")
.dataPoints(dataPoints)
.trends(trends)
.peakTimes(peakTimes)
.lastUpdatedAt(LocalDateTime.now())
.build();
}
/**
* 시간대별 데이터 포인트 구성
*/
private List<TimelineDataPoint> buildTimelineDataPoints(List<TimelineData> timelineDataList) {
return timelineDataList.stream()
.map(data -> TimelineDataPoint.builder()
.timestamp(data.getTimestamp())
.participants(data.getParticipants())
.views(data.getViews())
.engagement(data.getEngagement())
.conversions(data.getConversions())
.cumulativeParticipants(data.getCumulativeParticipants())
.build())
.collect(Collectors.toList());
}
/**
* 추세 분석 구성
*/
private TrendAnalysis buildTrendAnalysis(List<TimelineDataPoint> dataPoints) {
if (dataPoints.isEmpty()) {
return null;
}
// 전체 추세 계산
String overallTrend = calculateOverallTrend(dataPoints);
// 증가율 계산
double growthRate = calculateGrowthRate(dataPoints);
// 예상 참여자
int projectedParticipants = calculateProjectedParticipants(dataPoints);
// 피크 기간 계산
String peakPeriod = calculatePeakPeriod(dataPoints);
return TrendAnalysis.builder()
.overallTrend(overallTrend)
.growthRate(Math.round(growthRate * 10.0) / 10.0)
.projectedParticipants(projectedParticipants)
.peakPeriod(peakPeriod)
.build();
}
/**
* 전체 추세 계산
*/
private String calculateOverallTrend(List<TimelineDataPoint> dataPoints) {
if (dataPoints.size() < 2) {
return "stable";
}
int firstHalfParticipants = dataPoints.stream()
.limit(dataPoints.size() / 2)
.mapToInt(TimelineDataPoint::getParticipants)
.sum();
int secondHalfParticipants = dataPoints.stream()
.skip(dataPoints.size() / 2)
.mapToInt(TimelineDataPoint::getParticipants)
.sum();
if (secondHalfParticipants > firstHalfParticipants * 1.1) {
return "increasing";
} else if (secondHalfParticipants < firstHalfParticipants * 0.9) {
return "decreasing";
} else {
return "stable";
}
}
/**
* 증가율 계산
*/
private double calculateGrowthRate(List<TimelineDataPoint> dataPoints) {
if (dataPoints.size() < 2) {
return 0.0;
}
int firstParticipants = dataPoints.get(0).getParticipants();
int lastParticipants = dataPoints.get(dataPoints.size() - 1).getParticipants();
if (firstParticipants == 0) {
return 0.0;
}
return ((lastParticipants - firstParticipants) * 100.0 / firstParticipants);
}
/**
* 예상 참여자 계산
*/
private int calculateProjectedParticipants(List<TimelineDataPoint> dataPoints) {
if (dataPoints.isEmpty()) {
return 0;
}
return dataPoints.get(dataPoints.size() - 1).getCumulativeParticipants();
}
/**
* 피크 기간 계산
*/
private String calculatePeakPeriod(List<TimelineDataPoint> dataPoints) {
TimelineDataPoint peakPoint = dataPoints.stream()
.max(Comparator.comparing(TimelineDataPoint::getParticipants))
.orElse(null);
if (peakPoint == null) {
return "";
}
return peakPoint.getTimestamp().toLocalDate().toString();
}
/**
* 피크 타임 구성
*/
private List<PeakTimeInfo> buildPeakTimes(List<TimelineDataPoint> dataPoints) {
List<PeakTimeInfo> peakTimes = new ArrayList<>();
// 참여자 피크
dataPoints.stream()
.max(Comparator.comparing(TimelineDataPoint::getParticipants))
.ifPresent(point -> peakTimes.add(PeakTimeInfo.builder()
.timestamp(point.getTimestamp())
.metric("participants")
.value(point.getParticipants())
.description("최대 참여자 수")
.build()));
// 조회수 피크
dataPoints.stream()
.max(Comparator.comparing(TimelineDataPoint::getViews))
.ifPresent(point -> peakTimes.add(PeakTimeInfo.builder()
.timestamp(point.getTimestamp())
.metric("views")
.value(point.getViews())
.description("최대 조회수")
.build()));
return peakTimes;
}
}

View File

@ -0,0 +1,158 @@
spring:
application:
name: analytics-service
# Database
datasource:
url: jdbc:${DB_KIND:postgresql}://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:analytics_db}
username: ${DB_USERNAME:analytics_user}
password: ${DB_PASSWORD:analytics_pass}
driver-class-name: org.postgresql.Driver
hikari:
maximum-pool-size: 20
minimum-idle: 5
connection-timeout: 30000
idle-timeout: 600000
max-lifetime: 1800000
leak-detection-threshold: 60000
# JPA
jpa:
show-sql: ${SHOW_SQL:true}
properties:
hibernate:
format_sql: true
use_sql_comments: true
hibernate:
ddl-auto: ${DDL_AUTO:update}
# Redis
data:
redis:
host: ${REDIS_HOST:20.214.210.71}
port: ${REDIS_PORT:6379}
password: ${REDIS_PASSWORD:Hi5Jessica!}
timeout: 2000ms
lettuce:
pool:
max-active: 8
max-idle: 8
min-idle: 0
max-wait: -1ms
database: ${REDIS_DATABASE:5}
# Kafka (원격 서버 사용)
kafka:
enabled: ${KAFKA_ENABLED:true}
bootstrap-servers: ${KAFKA_BOOTSTRAP_SERVERS:20.249.182.13:9095,4.217.131.59:9095}
consumer:
group-id: ${KAFKA_CONSUMER_GROUP_ID:analytics-service}
auto-offset-reset: earliest
enable-auto-commit: true
key-deserializer: org.apache.kafka.common.serialization.StringDeserializer
value-deserializer: org.apache.kafka.common.serialization.StringDeserializer
producer:
key-serializer: org.apache.kafka.common.serialization.StringSerializer
value-serializer: org.apache.kafka.common.serialization.StringSerializer
acks: all
retries: 3
properties:
connections.max.idle.ms: 540000
request.timeout.ms: 30000
session.timeout.ms: 30000
heartbeat.interval.ms: 3000
max.poll.interval.ms: 300000
# Sample Data (MVP Only)
# ⚠️ 실제 운영: false로 설정 (다른 서비스들이 이벤트 발행)
# ⚠️ MVP 환경: true로 설정 (SampleDataLoader가 이벤트 발행)
sample-data:
enabled: ${SAMPLE_DATA_ENABLED:true}
# Server
server:
port: ${SERVER_PORT:8086}
# JWT
jwt:
secret: ${JWT_SECRET:}
access-token-validity: ${JWT_ACCESS_TOKEN_VALIDITY:1800}
refresh-token-validity: ${JWT_REFRESH_TOKEN_VALIDITY:86400}
# CORS Configuration
cors:
allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:*}
# Actuator
management:
endpoints:
web:
exposure:
include: health,info,metrics,prometheus
base-path: /actuator
endpoint:
health:
show-details: always
show-components: always
health:
livenessState:
enabled: true
readinessState:
enabled: true
# OpenAPI Documentation
springdoc:
api-docs:
path: /v3/api-docs
swagger-ui:
path: /swagger-ui.html
tags-sorter: alpha
operations-sorter: alpha
show-actuator: false
# Logging
logging:
level:
com.kt.event.analytics: ${LOG_LEVEL_APP:DEBUG}
org.springframework.web: ${LOG_LEVEL_WEB:INFO}
org.hibernate.SQL: ${LOG_LEVEL_SQL:DEBUG}
org.hibernate.type: ${LOG_LEVEL_SQL_TYPE:TRACE}
pattern:
console: "%d{yyyy-MM-dd HH:mm:ss} - %msg%n"
file: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n"
file:
name: ${LOG_FILE:logs/analytics-service.log}
logback:
rollingpolicy:
max-file-size: 10MB
max-history: 7
total-size-cap: 100MB
# Resilience4j Circuit Breaker
resilience4j:
circuitbreaker:
instances:
wooriTV:
failure-rate-threshold: 50
wait-duration-in-open-state: 30s
sliding-window-size: 10
permitted-number-of-calls-in-half-open-state: 3
genieTV:
failure-rate-threshold: 50
wait-duration-in-open-state: 30s
sliding-window-size: 10
ringoBiz:
failure-rate-threshold: 50
wait-duration-in-open-state: 30s
sliding-window-size: 10
sns:
failure-rate-threshold: 50
wait-duration-in-open-state: 30s
sliding-window-size: 10
# Batch Scheduler
batch:
analytics:
refresh-interval: ${BATCH_REFRESH_INTERVAL:300000} # 5분 (밀리초)
initial-delay: ${BATCH_INITIAL_DELAY:30000} # 30초 (밀리초)
enabled: ${BATCH_ENABLED:true} # 배치 활성화 여부

View File

@ -0,0 +1,53 @@
version: '3.8'
services:
# PostgreSQL - Participation Service
postgres-participation:
image: postgres:15-alpine
container_name: participation-db
environment:
POSTGRES_DB: participation_db
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- "5432:5432"
volumes:
- postgres-participation-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s
timeout: 5s
retries: 5
# Kafka
zookeeper:
image: confluentinc/cp-zookeeper:7.5.0
container_name: zookeeper
environment:
ZOOKEEPER_CLIENT_PORT: 2181
ZOOKEEPER_TICK_TIME: 2000
ports:
- "2181:2181"
kafka:
image: confluentinc/cp-kafka:7.5.0
container_name: kafka
depends_on:
- zookeeper
ports:
- "9092:9092"
environment:
KAFKA_BROKER_ID: 1
KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181
KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://localhost:9092
KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1
KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1
healthcheck:
test: ["CMD-SHELL", "kafka-broker-api-versions --bootstrap-server localhost:9092"]
interval: 10s
timeout: 10s
retries: 5
volumes:
postgres-participation-data:

View File

@ -29,6 +29,8 @@ primary:
# 성능 최적화 설정
extraEnvVars:
- name: POSTGRESQL_READ_ONLY_MODE
value: "no"
- name: POSTGRESQL_SHARED_BUFFERS
value: "1GB"
- name: POSTGRESQL_EFFECTIVE_CACHE_SIZE

0
claude/check-mermaid.sh Executable file → Normal file
View File

48
claude/test-backend.md Normal file
View File

@ -0,0 +1,48 @@
# 백엔드 테스트 가이드
[요청사항]
- <테스트원칙>을 준용하여 수행
- <테스트순서>에 따라 수행
- [결과파일] 안내에 따라 파일 작성
[가이드]
<테스트원칙>
- 설정 Manifest(src/main/resources/application*.yml)의 각 항목의 값은 하드코딩하지 않고 환경변수 처리
- Kubernetes에 배포된 데이터베이스는 LoadBalacer유형의 Service를 만들어 연결
<테스트순서>
- 준비:
- 설정 Manifest(src/main/resources/application*.yml)와 실행 프로파일({service-name}.run.xml 내부에 있음)의 일치여부 검사 및 수정
- 실행:
- 'curl'명령을 이용한 테스트 및 오류 수정
- 서비스 의존관계를 고려하여 테스트 순서 결정
- 순서에 따라 순차적으로 각 서비스의 Controller에서 API 스펙 확인 후 API 테스트
- API경로와 DTO클래스를 확인하여 정확한 request data 구성
- 소스 수정 후 테스트 절차
- 컴파일 및 오류 수정: {프로젝트 루트}/gradlew {service-name}:compileJava
- 컴파일 성공 후 서비스 재시작 요청: 서비스 시작은 인간에게 요청
- 만약 직접 서비스를 실행하려면 '<서비스 시작 방법>'으로 수행
- 서비스 중지는 '<서비스 중지 방법>'을 참조 수행
- 설정 Manifest 수정 시 민감 정보는 기본값으로 지정하지 않고 '<실행프로파일 작성 가이드>'를 참조하여 실행 프로파일에 값을 지정함
- 실행 결과 로그는 'logs' 디렉토리 하위에 생성
- 결과: test-backend.md
<실행프로파일 작성 가이드>
- {service-name}/.run/{service-name}.run.xml 파일로 작성
- Kubernetes에 배포된 데이터베이스의 LoadBalancer Service 확인:
- kubectl get svc -n {namespace} | grep LoadBalancer 명령으로 LoadBalancer IP 확인
- 각 서비스별 데이터베이스의 LoadBalancer External IP를 DB_HOST로 사용
- 캐시(Redis)의 LoadBalancer External IP를 REDIS_HOST로 사용
<서비스 시작 방법>
- 'IntelliJ서비스실행기'를 'tools' 디렉토리에 다운로드
- python 또는 python3 명령으로 백그라우드로 실행하고 결과 로그를 분석
nohup python3 tools/run-intellij-service-profile.py {service-name} > logs/{service-name}.log 2>&1 & echo "Started {service-name} with PID: $!"
- 서비스 실행은 다른 방법 사용하지 말고 **반드시 python 프로그램 이용**
<서비스 중지 방법>
- Window
- netstat -ano | findstr :{PORT}
- powershell "Stop-Process -Id {Process number} -Force"
- Linux/Mac
- netstat -ano | grep {PORT}
- kill -9 {Process number}
[결과파일]
- develop/dev/test-backend.md

View File

@ -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", "유효하지 않은 토큰입니다"),
@ -64,11 +68,14 @@ public enum ErrorCode {
DIST_004("DIST_004", "배포 상태를 찾을 수 없습니다"),
// 참여 에러 (PART_XXX)
PART_001("PART_001", "이미 참여한 이벤트입니다"),
PART_002("PART_002", "이벤트 참여 기간이 아닙니다"),
PART_003("PART_003", "참여자를 찾을 수 없습니다"),
PART_004("PART_004", "당첨자 추첨에 실패했습니다"),
PART_005("PART_005", "이벤트가 종료되었습니다"),
DUPLICATE_PARTICIPATION("PART_001", "이미 참여한 이벤트입니다"),
EVENT_NOT_ACTIVE("PART_002", "이벤트 참여 기간이 아닙니다"),
PARTICIPANT_NOT_FOUND("PART_003", "참여자를 찾을 수 없습니다"),
DRAW_FAILED("PART_004", "당첨자 추첨에 실패했습니다"),
EVENT_ENDED("PART_005", "이벤트가 종료되었습니다"),
ALREADY_DRAWN("PART_006", "이미 당첨자 추첨이 완료되었습니다"),
INSUFFICIENT_PARTICIPANTS("PART_007", "참여자 수가 당첨자 수보다 적습니다"),
NO_WINNERS_YET("PART_008", "아직 당첨자 추첨이 진행되지 않았습니다"),
// 분석 에러 (ANALYTICS_XXX)
ANALYTICS_001("ANALYTICS_001", "분석 데이터를 찾을 수 없습니다"),

View File

@ -2,6 +2,8 @@ package com.kt.event.common.exception;
import com.kt.event.common.dto.ErrorResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.data.mapping.PropertyReferenceException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.AccessDeniedException;
@ -161,6 +163,66 @@ public class GlobalExceptionHandler {
.body(errorResponse);
}
/**
* 데이터 무결성 제약 위반 예외 처리
*
* @param ex 데이터 무결성 예외
* @return 에러 응답
*/
@ExceptionHandler(DataIntegrityViolationException.class)
public ResponseEntity<ErrorResponse> handleDataIntegrityViolationException(DataIntegrityViolationException ex) {
log.warn("Data integrity violation: {}", ex.getMessage());
String message = "데이터 중복 또는 무결성 제약 위반이 발생했습니다";
String details = ex.getMessage();
// 중복 에러인 경우 메시지 개선
if (ex.getMessage() != null) {
if (ex.getMessage().contains("uk_event_phone") || ex.getMessage().contains("phone_number")) {
message = "이미 참여하신 이벤트입니다";
details = "동일한 전화번호로 이미 참여 기록이 있습니다";
} else if (ex.getMessage().contains("participant_id")) {
message = "참여 처리 중 오류가 발생했습니다";
details = "잠시 후 다시 시도해주세요";
}
}
ErrorResponse errorResponse = ErrorResponse.of(
ErrorCode.DUPLICATE_PARTICIPATION.getCode(),
message,
details
);
return ResponseEntity
.status(HttpStatus.CONFLICT)
.body(errorResponse);
}
/**
* 잘못된 정렬 필드 예외 처리
*
* @param ex 속성 참조 예외
* @return 에러 응답
*/
@ExceptionHandler(PropertyReferenceException.class)
public ResponseEntity<ErrorResponse> handlePropertyReferenceException(PropertyReferenceException ex) {
log.warn("Invalid sort property: {}", ex.getMessage());
String message = "잘못된 정렬 필드입니다";
String details = String.format("'%s' 필드는 존재하지 않습니다. 사용 가능한 필드: id, participantId, eventId, name, phoneNumber, email, storeVisited, bonusEntries, agreeMarketing, agreePrivacy, isWinner, winnerRank, wonAt, createdAt, updatedAt",
ex.getPropertyName());
ErrorResponse errorResponse = ErrorResponse.of(
ErrorCode.COMMON_003.getCode(),
message,
details
);
return ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.body(errorResponse);
}
/**
* 일반 예외 처리
*

View File

@ -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,20 @@ 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<String> roles) {
public String createAccessToken(Long userId, Long storeId, String email, String name, List<String> roles) {
Date now = new Date();
Date expiryDate = new Date(now.getTime() + accessTokenValidityMs);
return Jwts.builder()
.subject(userId.toString())
.claim("storeId", storeId != null ? storeId.toString() : null)
.claim("email", email)
.claim("name", name)
.claim("roles", roles)
@ -76,7 +80,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 +99,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());
}
/**
@ -110,12 +114,14 @@ public class JwtTokenProvider {
Claims claims = parseToken(token);
Long userId = Long.parseLong(claims.getSubject());
String storeIdStr = claims.get("storeId", String.class);
Long storeId = storeIdStr != null ? Long.parseLong(storeIdStr) : null;
String email = claims.get("email", String.class);
String name = claims.get("name", String.class);
@SuppressWarnings("unchecked")
List<String> roles = claims.get("roles", List.class);
return new UserPrincipal(userId, email, name, roles);
return new UserPrincipal(userId, storeId, email, name, roles);
}
/**

View File

@ -1,6 +1,7 @@
package com.kt.event.common.security;
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,6 +17,7 @@ import java.util.stream.Collectors;
* JWT 토큰에서 추출한 사용자 정보를 담는 객체
*/
@Getter
@Builder
@AllArgsConstructor
public class UserPrincipal implements UserDetails {
@ -23,6 +26,11 @@ public class UserPrincipal implements UserDetails {
*/
private final Long userId;
/**
* 매장 ID
*/
private final Long storeId;
/**
* 사용자 이메일
*/

View File

@ -1,7 +1,10 @@
dependencies {
// Kafka Consumer
implementation 'org.springframework.kafka:spring-kafka'
configurations {
// Exclude JPA and PostgreSQL from inherited dependencies (Phase 3: Redis migration)
implementation.exclude group: 'org.springframework.boot', module: 'spring-boot-starter-data-jpa'
implementation.exclude group: 'org.postgresql', module: 'postgresql'
}
dependencies {
// Redis for AI data reading and image URL caching
implementation 'org.springframework.boot:spring-boot-starter-data-redis'

View File

@ -0,0 +1,99 @@
package com.kt.event.content.biz.domain;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
/**
* 콘텐츠 도메인 모델
* 이벤트에 대한 전체 콘텐츠 정보 (이미지 목록 포함)
*/
@Getter
@Builder
@AllArgsConstructor
public class Content {
/**
* 콘텐츠 ID
*/
private final Long id;
/**
* 이벤트 ID (이벤트 초안 ID)
*/
private final Long eventDraftId;
/**
* 이벤트 제목
*/
private final String eventTitle;
/**
* 이벤트 설명
*/
private final String eventDescription;
/**
* 생성된 이미지 목록
*/
@Builder.Default
private final List<GeneratedImage> images = new ArrayList<>();
/**
* 생성일시
*/
private final LocalDateTime createdAt;
/**
* 수정일시
*/
private final LocalDateTime updatedAt;
/**
* 이미지 추가
*
* @param image 생성된 이미지
*/
public void addImage(GeneratedImage image) {
this.images.add(image);
}
/**
* 선택된 이미지 조회
*
* @return 선택된 이미지 목록
*/
public List<GeneratedImage> getSelectedImages() {
return images.stream()
.filter(GeneratedImage::isSelected)
.toList();
}
/**
* 특정 스타일의 이미지 조회
*
* @param style 이미지 스타일
* @return 해당 스타일의 이미지 목록
*/
public List<GeneratedImage> getImagesByStyle(ImageStyle style) {
return images.stream()
.filter(image -> image.getStyle() == style)
.toList();
}
/**
* 특정 플랫폼의 이미지 조회
*
* @param platform 플랫폼
* @return 해당 플랫폼의 이미지 목록
*/
public List<GeneratedImage> getImagesByPlatform(Platform platform) {
return images.stream()
.filter(image -> image.getPlatform() == platform)
.toList();
}
}

View File

@ -0,0 +1,76 @@
package com.kt.event.content.biz.domain;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import java.time.LocalDateTime;
/**
* 생성된 이미지 도메인 모델
* AI가 생성한 이미지의 비즈니스 정보
*/
@Getter
@Builder
@AllArgsConstructor
public class GeneratedImage {
/**
* 이미지 ID
*/
private final Long id;
/**
* 이벤트 ID (이벤트 초안 ID)
*/
private final Long eventDraftId;
/**
* 이미지 스타일
*/
private final ImageStyle style;
/**
* 플랫폼
*/
private final Platform platform;
/**
* CDN URL (Azure Blob Storage)
*/
private final String cdnUrl;
/**
* 프롬프트
*/
private final String prompt;
/**
* 선택 여부
*/
private boolean selected;
/**
* 생성일시
*/
private LocalDateTime createdAt;
/**
* 수정일시
*/
private LocalDateTime updatedAt;
/**
* 이미지 선택
*/
public void select() {
this.selected = true;
}
/**
* 이미지 선택 해제
*/
public void deselect() {
this.selected = false;
}
}

View File

@ -0,0 +1,32 @@
package com.kt.event.content.biz.domain;
/**
* 이미지 스타일 enum
* AI가 생성하는 이미지의 스타일 유형
*/
public enum ImageStyle {
/**
* 심플 스타일 - 깔끔하고 미니멀한 디자인
*/
SIMPLE("심플"),
/**
* 화려한 스타일 - 화려하고 풍부한 디자인
*/
FANCY("화려한"),
/**
* 트렌디 스타일 - 최신 트렌드를 반영한 디자인
*/
TRENDY("트렌디");
private final String displayName;
ImageStyle(String displayName) {
this.displayName = displayName;
}
public String getDisplayName() {
return displayName;
}
}

View File

@ -0,0 +1,140 @@
package com.kt.event.content.biz.domain;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import java.time.LocalDateTime;
/**
* Job 도메인 모델
* 이미지 생성 작업의 비즈니스 정보
*/
@Getter
@Builder
@AllArgsConstructor
public class Job {
/**
* Job 상태 enum
*/
public enum Status {
PENDING, // 대기
PROCESSING, // 처리
COMPLETED, // 완료
FAILED // 실패
}
/**
* Job ID
*/
private final String id;
/**
* 이벤트 ID (이벤트 초안 ID)
*/
private final Long eventDraftId;
/**
* Job 타입 (image-generation)
*/
private final String jobType;
/**
* Job 상태
*/
private Status status;
/**
* 진행률 (0-100)
*/
private int progress;
/**
* 결과 메시지
*/
private String resultMessage;
/**
* 에러 메시지
*/
private String errorMessage;
/**
* 생성일시
*/
private final LocalDateTime createdAt;
/**
* 수정일시
*/
private final LocalDateTime updatedAt;
/**
* Job 시작
*/
public void start() {
this.status = Status.PROCESSING;
this.progress = 0;
}
/**
* 진행률 업데이트
*
* @param progress 진행률 (0-100)
*/
public void updateProgress(int progress) {
if (progress < 0 || progress > 100) {
throw new IllegalArgumentException("진행률은 0-100 사이여야 합니다");
}
this.progress = progress;
}
/**
* Job 완료 처리
*
* @param resultMessage 결과 메시지
*/
public void complete(String resultMessage) {
this.status = Status.COMPLETED;
this.progress = 100;
this.resultMessage = resultMessage;
}
/**
* Job 실패 처리
*
* @param errorMessage 에러 메시지
*/
public void fail(String errorMessage) {
this.status = Status.FAILED;
this.errorMessage = errorMessage;
}
/**
* Job 진행 여부
*
* @return 진행 중이면 true
*/
public boolean isProcessing() {
return status == Status.PROCESSING;
}
/**
* Job 완료 여부
*
* @return 완료되었으면 true
*/
public boolean isCompleted() {
return status == Status.COMPLETED;
}
/**
* Job 실패 여부
*
* @return 실패했으면 true
*/
public boolean isFailed() {
return status == Status.FAILED;
}
}

View File

@ -0,0 +1,53 @@
package com.kt.event.content.biz.domain;
/**
* 플랫폼 enum
* 이미지가 배포될 SNS 플랫폼 유형
*/
public enum Platform {
/**
* Instagram - 1080x1080 정사각형
*/
INSTAGRAM("Instagram", 1080, 1080),
/**
* 네이버 블로그 - 800x600
*/
NAVER("네이버 블로그", 800, 600),
/**
* 카카오 채널 - 800x800 정사각형
*/
KAKAO("카카오 채널", 800, 800);
private final String displayName;
private final int width;
private final int height;
Platform(String displayName, int width, int height) {
this.displayName = displayName;
this.width = width;
this.height = height;
}
public String getDisplayName() {
return displayName;
}
public int getWidth() {
return width;
}
public int getHeight() {
return height;
}
/**
* 이미지 크기 문자열 반환
*
* @return 가로x세로 형식 (: 1080x1080)
*/
public String getSizeString() {
return width + "x" + height;
}
}

View File

@ -0,0 +1,40 @@
package com.kt.event.content.biz.dto;
import com.kt.event.content.biz.domain.ImageStyle;
import com.kt.event.content.biz.domain.Platform;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import java.util.List;
/**
* 콘텐츠 관련 커맨드 DTO
*/
public class ContentCommand {
/**
* 이미지 생성 요청 커맨드
*/
@Getter
@Builder
@AllArgsConstructor
public static class GenerateImages {
private Long eventDraftId;
private String eventTitle;
private String eventDescription;
private List<ImageStyle> styles;
private List<Platform> platforms;
}
/**
* 이미지 재생성 요청 커맨드
*/
@Getter
@Builder
@AllArgsConstructor
public static class RegenerateImage {
private Long imageId;
private String newPrompt;
}
}

View File

@ -0,0 +1,47 @@
package com.kt.event.content.biz.dto;
import com.kt.event.content.biz.domain.Content;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import java.time.LocalDateTime;
import java.util.List;
import java.util.stream.Collectors;
/**
* 콘텐츠 정보 DTO
*/
@Getter
@Builder
@AllArgsConstructor
public class ContentInfo {
private Long id;
private Long eventDraftId;
private String eventTitle;
private String eventDescription;
private List<ImageInfo> images;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
/**
* 도메인 모델로부터 생성
*
* @param content 콘텐츠 도메인 모델
* @return ContentInfo
*/
public static ContentInfo from(Content content) {
return ContentInfo.builder()
.id(content.getId())
.eventDraftId(content.getEventDraftId())
.eventTitle(content.getEventTitle())
.eventDescription(content.getEventDescription())
.images(content.getImages().stream()
.map(ImageInfo::from)
.collect(Collectors.toList()))
.createdAt(content.getCreatedAt())
.updatedAt(content.getUpdatedAt())
.build();
}
}

View File

@ -0,0 +1,49 @@
package com.kt.event.content.biz.dto;
import com.kt.event.content.biz.domain.GeneratedImage;
import com.kt.event.content.biz.domain.ImageStyle;
import com.kt.event.content.biz.domain.Platform;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import java.time.LocalDateTime;
/**
* 이미지 정보 DTO
*/
@Getter
@Builder
@AllArgsConstructor
public class ImageInfo {
private Long id;
private Long eventDraftId;
private ImageStyle style;
private Platform platform;
private String cdnUrl;
private String prompt;
private boolean selected;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
/**
* 도메인 모델로부터 생성
*
* @param image 이미지 도메인 모델
* @return ImageInfo
*/
public static ImageInfo from(GeneratedImage image) {
return ImageInfo.builder()
.id(image.getId())
.eventDraftId(image.getEventDraftId())
.style(image.getStyle())
.platform(image.getPlatform())
.cdnUrl(image.getCdnUrl())
.prompt(image.getPrompt())
.selected(image.isSelected())
.createdAt(image.getCreatedAt())
.updatedAt(image.getUpdatedAt())
.build();
}
}

View File

@ -0,0 +1,47 @@
package com.kt.event.content.biz.dto;
import com.kt.event.content.biz.domain.Job;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import java.time.LocalDateTime;
/**
* Job 정보 DTO
*/
@Getter
@Builder
@AllArgsConstructor
public class JobInfo {
private String id;
private Long eventDraftId;
private String jobType;
private Job.Status status;
private int progress;
private String resultMessage;
private String errorMessage;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
/**
* 도메인 모델로부터 생성
*
* @param job Job 도메인 모델
* @return JobInfo
*/
public static JobInfo from(Job job) {
return JobInfo.builder()
.id(job.getId())
.eventDraftId(job.getEventDraftId())
.jobType(job.getJobType())
.status(job.getStatus())
.progress(job.getProgress())
.resultMessage(job.getResultMessage())
.errorMessage(job.getErrorMessage())
.createdAt(job.getCreatedAt())
.updatedAt(job.getUpdatedAt())
.build();
}
}

View File

@ -0,0 +1,56 @@
package com.kt.event.content.biz.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.Map;
/**
* AI Service가 Redis에 저장한 이벤트 데이터 (읽기 전용)
*
* Key Pattern: ai:event:{eventDraftId}
* Data Type: Hash
* TTL: 24시간 (86400초)
*
* 예시:
* - ai:event:1
*
* Note: 데이터는 AI Service가 생성하고 Content Service는 읽기만 합니다.
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class RedisAIEventData {
/**
* 이벤트 초안 ID
*/
private Long eventDraftId;
/**
* 이벤트 제목
*/
private String eventTitle;
/**
* 이벤트 설명
*/
private String eventDescription;
/**
* 타겟 고객
*/
private String targetAudience;
/**
* 이벤트 목적
*/
private String eventObjective;
/**
* AI가 생성한 추가 데이터
*/
private Map<String, Object> additionalData;
}

View File

@ -0,0 +1,72 @@
package com.kt.event.content.biz.dto;
import com.kt.event.content.biz.domain.ImageStyle;
import com.kt.event.content.biz.domain.Platform;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* Redis에 저장되는 이미지 데이터 구조
*
* Key Pattern: content:image:{eventDraftId}:{style}:{platform}
* Data Type: String (JSON)
* TTL: 7일 (604800초)
*
* 예시:
* - content:image:1:FANCY:INSTAGRAM
* - content:image:1:SIMPLE:KAKAO
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class RedisImageData {
/**
* 이미지 고유 ID
*/
private Long id;
/**
* 이벤트 초안 ID
*/
private Long eventDraftId;
/**
* 이미지 스타일 (FANCY, SIMPLE, TRENDY)
*/
private ImageStyle style;
/**
* 플랫폼 (INSTAGRAM, KAKAO, NAVER)
*/
private Platform platform;
/**
* CDN 이미지 URL
*/
private String cdnUrl;
/**
* 이미지 생성 프롬프트
*/
private String prompt;
/**
* 선택 여부
*/
private Boolean selected;
/**
* 생성 일시
*/
private LocalDateTime createdAt;
/**
* 수정 일시
*/
private LocalDateTime updatedAt;
}

View File

@ -0,0 +1,70 @@
package com.kt.event.content.biz.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* Redis에 저장되는 Job 상태 정보
*
* Key Pattern: job:{jobId}
* Data Type: Hash
* TTL: 1시간 (3600초)
*
* 예시:
* - job:job-mock-7ada8bd3
* - job:job-regen-df2bb3a3
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class RedisJobData {
/**
* Job ID (: job-mock-7ada8bd3)
*/
private String id;
/**
* 이벤트 초안 ID
*/
private Long eventDraftId;
/**
* Job 타입 (image-generation, image-regeneration)
*/
private String jobType;
/**
* 상태 (PENDING, IN_PROGRESS, COMPLETED, FAILED)
*/
private String status;
/**
* 진행률 (0-100)
*/
private Integer progress;
/**
* 결과 메시지
*/
private String resultMessage;
/**
* 에러 메시지
*/
private String errorMessage;
/**
* 생성 일시
*/
private LocalDateTime createdAt;
/**
* 수정 일시
*/
private LocalDateTime updatedAt;
}

View File

@ -0,0 +1,38 @@
package com.kt.event.content.biz.service;
import com.kt.event.common.exception.BusinessException;
import com.kt.event.common.exception.ErrorCode;
import com.kt.event.content.biz.usecase.in.DeleteImageUseCase;
import com.kt.event.content.biz.usecase.out.ContentReader;
import com.kt.event.content.biz.usecase.out.ContentWriter;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* 이미지 삭제 서비스
*/
@Slf4j
@Service
@RequiredArgsConstructor
@Transactional
public class DeleteImageService implements DeleteImageUseCase {
private final ContentReader contentReader;
private final ContentWriter contentWriter;
@Override
public void execute(Long imageId) {
log.info("[DeleteImageService] 이미지 삭제 요청: imageId={}", imageId);
// 이미지 존재 확인
contentReader.findImageById(imageId)
.orElseThrow(() -> new BusinessException(ErrorCode.COMMON_001, "이미지를 찾을 수 없습니다"));
// 이미지 삭제
contentWriter.deleteImageById(imageId);
log.info("[DeleteImageService] 이미지 삭제 완료: imageId={}", imageId);
}
}

View File

@ -0,0 +1,32 @@
package com.kt.event.content.biz.service;
import com.kt.event.common.exception.BusinessException;
import com.kt.event.common.exception.ErrorCode;
import com.kt.event.content.biz.domain.Content;
import com.kt.event.content.biz.dto.ContentInfo;
import com.kt.event.content.biz.usecase.in.GetEventContentUseCase;
import com.kt.event.content.biz.usecase.out.ContentReader;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* 이벤트 콘텐츠 조회 서비스
*/
@Slf4j
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class GetEventContentService implements GetEventContentUseCase {
private final ContentReader contentReader;
@Override
public ContentInfo execute(Long eventDraftId) {
Content content = contentReader.findByEventDraftIdWithImages(eventDraftId)
.orElseThrow(() -> new BusinessException(ErrorCode.COMMON_001, "콘텐츠를 찾을 수 없습니다"));
return ContentInfo.from(content);
}
}

View File

@ -0,0 +1,32 @@
package com.kt.event.content.biz.service;
import com.kt.event.common.exception.BusinessException;
import com.kt.event.common.exception.ErrorCode;
import com.kt.event.content.biz.domain.GeneratedImage;
import com.kt.event.content.biz.dto.ImageInfo;
import com.kt.event.content.biz.usecase.in.GetImageDetailUseCase;
import com.kt.event.content.biz.usecase.out.ContentReader;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* 이미지 상세 조회 서비스
*/
@Slf4j
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class GetImageDetailService implements GetImageDetailUseCase {
private final ContentReader contentReader;
@Override
public ImageInfo execute(Long imageId) {
GeneratedImage image = contentReader.findImageById(imageId)
.orElseThrow(() -> new BusinessException(ErrorCode.COMMON_001, "이미지를 찾을 수 없습니다"));
return ImageInfo.from(image);
}
}

View File

@ -0,0 +1,41 @@
package com.kt.event.content.biz.service;
import com.kt.event.content.biz.domain.GeneratedImage;
import com.kt.event.content.biz.domain.ImageStyle;
import com.kt.event.content.biz.domain.Platform;
import com.kt.event.content.biz.dto.ImageInfo;
import com.kt.event.content.biz.usecase.in.GetImageListUseCase;
import com.kt.event.content.biz.usecase.out.ContentReader;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.stream.Collectors;
/**
* 이미지 목록 조회 서비스
*/
@Slf4j
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class GetImageListService implements GetImageListUseCase {
private final ContentReader contentReader;
@Override
public List<ImageInfo> execute(Long eventDraftId, ImageStyle style, Platform platform) {
log.info("이미지 목록 조회: eventDraftId={}, style={}, platform={}", eventDraftId, style, platform);
List<GeneratedImage> images = contentReader.findImagesByEventDraftId(eventDraftId);
// 필터링 적용
return images.stream()
.filter(image -> style == null || image.getStyle() == style)
.filter(image -> platform == null || image.getPlatform() == platform)
.map(ImageInfo::from)
.collect(Collectors.toList());
}
}

View File

@ -0,0 +1,47 @@
package com.kt.event.content.biz.service;
import com.kt.event.common.exception.BusinessException;
import com.kt.event.common.exception.ErrorCode;
import com.kt.event.content.biz.domain.Job;
import com.kt.event.content.biz.dto.JobInfo;
import com.kt.event.content.biz.dto.RedisJobData;
import com.kt.event.content.biz.usecase.in.GetJobStatusUseCase;
import com.kt.event.content.biz.usecase.out.JobReader;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* Job 관리 서비스
* Job 상태 조회 기능 제공
*/
@Slf4j
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class JobManagementService implements GetJobStatusUseCase {
private final JobReader jobReader;
@Override
public JobInfo execute(String jobId) {
RedisJobData jobData = jobReader.getJob(jobId)
.orElseThrow(() -> new BusinessException(ErrorCode.COMMON_001, "Job을 찾을 수 없습니다"));
// RedisJobData를 Job 도메인 객체로 변환
Job job = Job.builder()
.id(jobData.getId())
.eventDraftId(jobData.getEventDraftId())
.jobType(jobData.getJobType())
.status(Job.Status.valueOf(jobData.getStatus()))
.progress(jobData.getProgress())
.resultMessage(jobData.getResultMessage())
.errorMessage(jobData.getErrorMessage())
.createdAt(jobData.getCreatedAt())
.updatedAt(jobData.getUpdatedAt())
.build();
return JobInfo.from(job);
}
}

View File

@ -0,0 +1,154 @@
package com.kt.event.content.biz.service.mock;
import com.kt.event.content.biz.domain.Content;
import com.kt.event.content.biz.domain.GeneratedImage;
import com.kt.event.content.biz.domain.ImageStyle;
import com.kt.event.content.biz.domain.Job;
import com.kt.event.content.biz.domain.Platform;
import com.kt.event.content.biz.dto.ContentCommand;
import com.kt.event.content.biz.dto.JobInfo;
import com.kt.event.content.biz.dto.RedisJobData;
import com.kt.event.content.biz.usecase.in.GenerateImagesUseCase;
import com.kt.event.content.biz.usecase.out.ContentWriter;
import com.kt.event.content.biz.usecase.out.JobWriter;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Profile;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
/**
* Mock 이미지 생성 서비스 (테스트용)
* 실제 Kafka 연동 전까지 사용
*
* 테스트를 위해 실제로 Content와 GeneratedImage를 생성합니다.
*/
@Slf4j
@Service
@Profile({"local", "test", "dev"})
@RequiredArgsConstructor
public class MockGenerateImagesService implements GenerateImagesUseCase {
private final JobWriter jobWriter;
private final ContentWriter contentWriter;
@Override
public JobInfo execute(ContentCommand.GenerateImages command) {
log.info("[MOCK] 이미지 생성 요청: eventDraftId={}, styles={}, platforms={}",
command.getEventDraftId(), command.getStyles(), command.getPlatforms());
// Mock Job 생성
String jobId = "job-mock-" + UUID.randomUUID().toString().substring(0, 8);
Job job = Job.builder()
.id(jobId)
.eventDraftId(command.getEventDraftId())
.jobType("image-generation")
.status(Job.Status.PENDING)
.progress(0)
.createdAt(java.time.LocalDateTime.now())
.updatedAt(java.time.LocalDateTime.now())
.build();
// Job 저장 (Job 도메인을 RedisJobData로 변환)
RedisJobData jobData = RedisJobData.builder()
.id(job.getId())
.eventDraftId(job.getEventDraftId())
.jobType(job.getJobType())
.status(job.getStatus().name())
.progress(job.getProgress())
.createdAt(job.getCreatedAt())
.updatedAt(job.getUpdatedAt())
.build();
jobWriter.saveJob(jobData, 3600); // TTL 1시간
log.info("[MOCK] Job 생성 완료: jobId={}", jobId);
// 비동기로 이미지 생성 시뮬레이션
processImageGeneration(jobId, command);
return JobInfo.from(job);
}
@Async
private void processImageGeneration(String jobId, ContentCommand.GenerateImages command) {
try {
log.info("[MOCK] 이미지 생성 시작: jobId={}", jobId);
// 1초 대기 (이미지 생성 시뮬레이션)
Thread.sleep(1000);
// Content 생성 또는 조회
Content content = Content.builder()
.eventDraftId(command.getEventDraftId())
.eventTitle("Mock 이벤트 제목 " + command.getEventDraftId())
.eventDescription("Mock 이벤트 설명입니다. 테스트를 위한 Mock 데이터입니다.")
.createdAt(java.time.LocalDateTime.now())
.updatedAt(java.time.LocalDateTime.now())
.build();
Content savedContent = contentWriter.save(content);
log.info("[MOCK] Content 생성 완료: contentId={}", savedContent.getId());
// 스타일 x 플랫폼 조합으로 이미지 생성
List<ImageStyle> styles = command.getStyles() != null && !command.getStyles().isEmpty()
? command.getStyles()
: List.of(ImageStyle.FANCY, ImageStyle.SIMPLE);
List<Platform> platforms = command.getPlatforms() != null && !command.getPlatforms().isEmpty()
? command.getPlatforms()
: List.of(Platform.INSTAGRAM, Platform.KAKAO);
List<GeneratedImage> images = new ArrayList<>();
int count = 0;
for (ImageStyle style : styles) {
for (Platform platform : platforms) {
count++;
String mockCdnUrl = String.format(
"https://mock-cdn.azure.com/images/%d/%s_%s_%s.png",
command.getEventDraftId(),
style.name().toLowerCase(),
platform.name().toLowerCase(),
UUID.randomUUID().toString().substring(0, 8)
);
GeneratedImage image = GeneratedImage.builder()
.eventDraftId(command.getEventDraftId())
.style(style)
.platform(platform)
.cdnUrl(mockCdnUrl)
.prompt(String.format("Mock prompt for %s style on %s platform", style, platform))
.selected(false)
.createdAt(java.time.LocalDateTime.now())
.updatedAt(java.time.LocalDateTime.now())
.build();
// 번째 이미지를 선택된 이미지로 설정
if (count == 1) {
image.select();
}
GeneratedImage savedImage = contentWriter.saveImage(image);
images.add(savedImage);
log.info("[MOCK] 이미지 생성: imageId={}, style={}, platform={}",
savedImage.getId(), style, platform);
}
}
// Job 상태 업데이트: COMPLETED
String resultMessage = String.format("%d개의 이미지가 성공적으로 생성되었습니다.", images.size());
jobWriter.updateJobStatus(jobId, "COMPLETED", 100);
jobWriter.updateJobResult(jobId, resultMessage);
log.info("[MOCK] Job 완료: jobId={}, 생성된 이미지 수={}", jobId, images.size());
} catch (Exception e) {
log.error("[MOCK] 이미지 생성 실패: jobId={}", jobId, e);
// Job 상태 업데이트: FAILED
jobWriter.updateJobError(jobId, e.getMessage());
}
}
}

View File

@ -0,0 +1,62 @@
package com.kt.event.content.biz.service.mock;
import com.kt.event.content.biz.domain.Job;
import com.kt.event.content.biz.dto.ContentCommand;
import com.kt.event.content.biz.dto.JobInfo;
import com.kt.event.content.biz.dto.RedisJobData;
import com.kt.event.content.biz.usecase.in.RegenerateImageUseCase;
import com.kt.event.content.biz.usecase.out.JobWriter;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Service;
import java.util.UUID;
/**
* Mock 이미지 재생성 서비스 (테스트용)
* 실제 구현 전까지 사용
*/
@Slf4j
@Service
@Profile({"local", "test", "dev"})
@RequiredArgsConstructor
public class MockRegenerateImageService implements RegenerateImageUseCase {
private final JobWriter jobWriter;
@Override
public JobInfo execute(ContentCommand.RegenerateImage command) {
log.info("[MOCK] 이미지 재생성 요청: imageId={}", command.getImageId());
// Mock Job 생성
String jobId = "job-regen-" + UUID.randomUUID().toString().substring(0, 8);
Job job = Job.builder()
.id(jobId)
.eventDraftId(999L) // Mock event ID
.jobType("image-regeneration")
.status(Job.Status.PENDING)
.progress(0)
.createdAt(java.time.LocalDateTime.now())
.updatedAt(java.time.LocalDateTime.now())
.build();
// Job 저장 (Job 도메인을 RedisJobData로 변환)
RedisJobData jobData = RedisJobData.builder()
.id(job.getId())
.eventDraftId(job.getEventDraftId())
.jobType(job.getJobType())
.status(job.getStatus().name())
.progress(job.getProgress())
.createdAt(job.getCreatedAt())
.updatedAt(job.getUpdatedAt())
.build();
jobWriter.saveJob(jobData, 3600); // TTL 1시간
log.info("[MOCK] 재생성 Job 생성 완료: jobId={}", jobId);
return JobInfo.from(job);
}
}

View File

@ -0,0 +1,14 @@
package com.kt.event.content.biz.usecase.in;
/**
* 이미지 삭제 UseCase
*/
public interface DeleteImageUseCase {
/**
* 이미지 삭제
*
* @param imageId 삭제할 이미지 ID
*/
void execute(Long imageId);
}

View File

@ -0,0 +1,19 @@
package com.kt.event.content.biz.usecase.in;
import com.kt.event.content.biz.dto.ContentCommand;
import com.kt.event.content.biz.dto.JobInfo;
/**
* 이미지 생성 UseCase
* 비동기로 이미지 생성 작업을 시작
*/
public interface GenerateImagesUseCase {
/**
* 이미지 생성 요청
*
* @param command 이미지 생성 커맨드
* @return Job 정보
*/
JobInfo execute(ContentCommand.GenerateImages command);
}

View File

@ -0,0 +1,17 @@
package com.kt.event.content.biz.usecase.in;
import com.kt.event.content.biz.dto.ContentInfo;
/**
* 이벤트 콘텐츠 조회 UseCase
*/
public interface GetEventContentUseCase {
/**
* 이벤트 전체 콘텐츠 조회 (이미지 목록 포함)
*
* @param eventDraftId 이벤트 초안 ID
* @return 콘텐츠 정보
*/
ContentInfo execute(Long eventDraftId);
}

View File

@ -0,0 +1,17 @@
package com.kt.event.content.biz.usecase.in;
import com.kt.event.content.biz.dto.ImageInfo;
/**
* 이미지 상세 조회 UseCase
*/
public interface GetImageDetailUseCase {
/**
* 이미지 상세 정보 조회
*
* @param imageId 이미지 ID
* @return 이미지 정보
*/
ImageInfo execute(Long imageId);
}

View File

@ -0,0 +1,23 @@
package com.kt.event.content.biz.usecase.in;
import com.kt.event.content.biz.domain.ImageStyle;
import com.kt.event.content.biz.domain.Platform;
import com.kt.event.content.biz.dto.ImageInfo;
import java.util.List;
/**
* 이미지 목록 조회 UseCase
*/
public interface GetImageListUseCase {
/**
* 이벤트의 이미지 목록 조회 (필터링 지원)
*
* @param eventDraftId 이벤트 초안 ID
* @param style 이미지 스타일 필터 (null이면 전체)
* @param platform 플랫폼 필터 (null이면 전체)
* @return 이미지 정보 목록
*/
List<ImageInfo> execute(Long eventDraftId, ImageStyle style, Platform platform);
}

View File

@ -0,0 +1,17 @@
package com.kt.event.content.biz.usecase.in;
import com.kt.event.content.biz.dto.JobInfo;
/**
* Job 상태 조회 UseCase
*/
public interface GetJobStatusUseCase {
/**
* Job 상태 조회
*
* @param jobId Job ID
* @return Job 정보
*/
JobInfo execute(String jobId);
}

View File

@ -0,0 +1,18 @@
package com.kt.event.content.biz.usecase.in;
import com.kt.event.content.biz.dto.ContentCommand;
import com.kt.event.content.biz.dto.JobInfo;
/**
* 이미지 재생성 UseCase
*/
public interface RegenerateImageUseCase {
/**
* 이미지 재생성 요청
*
* @param command 이미지 재생성 커맨드
* @return Job 정보
*/
JobInfo execute(ContentCommand.RegenerateImage command);
}

View File

@ -0,0 +1,17 @@
package com.kt.event.content.biz.usecase.out;
/**
* CDN 업로드 포트
* Azure Blob Storage에 이미지 업로드
*/
public interface CDNUploader {
/**
* 이미지 업로드
*
* @param imageData 이미지 바이트 데이터
* @param fileName 파일명
* @return CDN URL
*/
String upload(byte[] imageData, String fileName);
}

View File

@ -0,0 +1,37 @@
package com.kt.event.content.biz.usecase.out;
import com.kt.event.content.biz.domain.Content;
import com.kt.event.content.biz.domain.GeneratedImage;
import java.util.List;
import java.util.Optional;
/**
* 콘텐츠 조회 포트
*/
public interface ContentReader {
/**
* 이벤트 초안 ID로 콘텐츠 조회 (이미지 목록 포함)
*
* @param eventDraftId 이벤트 초안 ID
* @return 콘텐츠 도메인 모델
*/
Optional<Content> findByEventDraftIdWithImages(Long eventDraftId);
/**
* 이미지 ID로 이미지 조회
*
* @param imageId 이미지 ID
* @return 이미지 도메인 모델
*/
Optional<GeneratedImage> findImageById(Long imageId);
/**
* 이벤트 초안 ID로 이미지 목록 조회
*
* @param eventDraftId 이벤트 초안 ID
* @return 이미지 도메인 모델 목록
*/
List<GeneratedImage> findImagesByEventDraftId(Long eventDraftId);
}

Some files were not shown because too many files have changed in this diff Show More