Merge branch 'develop' into feature/user
This commit is contained in:
commit
ce3e01008a
@ -1,5 +1,5 @@
|
|||||||
@test-backend
|
@test-backend
|
||||||
'서비스실행파일작성가이드'에 따라 테스트를 해 주세요.
|
'서비스실행프로파일작성가이드'에 따라 테스트를 해 주세요.
|
||||||
프롬프트에 '[작성정보]'항목이 없으면 수행을 중단하고 안내 메시지를 표시해 주세요.
|
프롬프트에 '[작성정보]'항목이 없으면 수행을 중단하고 안내 메시지를 표시해 주세요.
|
||||||
DB나 Redis의 접근 정보는 지정할 필요 없습니다. 특별히 없으면 '[작성정보]'섹션에 '없음'이라고 하세요.
|
DB나 Redis의 접근 정보는 지정할 필요 없습니다. 특별히 없으면 '[작성정보]'섹션에 '없음'이라고 하세요.
|
||||||
{안내메시지}
|
{안내메시지}
|
||||||
|
|||||||
@ -15,7 +15,40 @@
|
|||||||
"Bash(git add:*)",
|
"Bash(git add:*)",
|
||||||
"Bash(git commit:*)",
|
"Bash(git commit:*)",
|
||||||
"Bash(git push)",
|
"Bash(git push)",
|
||||||
"Bash(git pull:*)"
|
"Bash(git pull:*)",
|
||||||
|
"Bash(netstat:*)",
|
||||||
|
"Bash(findstr:*)",
|
||||||
|
"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": [],
|
"deny": [],
|
||||||
"ask": []
|
"ask": []
|
||||||
|
|||||||
23
.gitignore
vendored
23
.gitignore
vendored
@ -20,6 +20,16 @@ Thumbs.db
|
|||||||
dist/
|
dist/
|
||||||
build/
|
build/
|
||||||
*.log
|
*.log
|
||||||
|
.gradle/
|
||||||
|
logs/
|
||||||
|
|
||||||
|
# Gradle
|
||||||
|
.gradle/
|
||||||
|
!gradle/wrapper/gradle-wrapper.jar
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs/
|
||||||
|
*.log
|
||||||
|
|
||||||
# Environment
|
# Environment
|
||||||
.env
|
.env
|
||||||
@ -30,3 +40,16 @@ build/
|
|||||||
tmp/
|
tmp/
|
||||||
temp/
|
temp/
|
||||||
*.tmp
|
*.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
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -1,2 +0,0 @@
|
|||||||
#Thu Oct 23 17:51:21 KST 2025
|
|
||||||
gradle.version=8.10
|
|
||||||
Binary file not shown.
Binary file not shown.
27
.run/EventServiceApplication.run.xml
Normal file
27
.run/EventServiceApplication.run.xml
Normal 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>
|
||||||
69
.run/ParticipationServiceApplication.run.xml
Normal file
69
.run/ParticipationServiceApplication.run.xml
Normal 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>
|
||||||
89
.run/analytics-service.run.xml
Normal file
89
.run/analytics-service.run.xml
Normal 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>
|
||||||
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
"liveServer.settings.port": 5501
|
|
||||||
}
|
|
||||||
84
analytics-service/.run/analytics-service.run.xml
Normal file
84
analytics-service/.run/analytics-service.run.xml
Normal 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>
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
158
analytics-service/src/main/resources/application.yml
Normal file
158
analytics-service/src/main/resources/application.yml
Normal 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} # 배치 활성화 여부
|
||||||
53
backing-service/docker-compose.yml
Normal file
53
backing-service/docker-compose.yml
Normal 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:
|
||||||
0
claude/check-mermaid.sh
Executable file → Normal file
0
claude/check-mermaid.sh
Executable file → Normal file
180
claude/make-run-profile.md
Normal file
180
claude/make-run-profile.md
Normal file
@ -0,0 +1,180 @@
|
|||||||
|
% Total % Received % Xferd Average Speed Time Time Time Current
|
||||||
|
Dload Upload Total Spent Left Speed
|
||||||
|
|
||||||
|
0 0 0 0 0 0 0 0 --:--:-- --:--:-- --:--:-- 0# 서비스실행파일작성가이드
|
||||||
|
|
||||||
|
[요청사항]
|
||||||
|
- <수행원칙>을 준용하여 수행
|
||||||
|
- <수행순서>에 따라 수행
|
||||||
|
- [결과파일] 안내에 따라 파일 작성
|
||||||
|
|
||||||
|
[가이드]
|
||||||
|
<수행원칙>
|
||||||
|
- 설정 Manifest(src/main/resources/application*.yml)의 각 항목의 값은 하드코딩하지 않고 환경변수 처리
|
||||||
|
- Kubernetes에 배포된 데이터베이스는 LoadBalacer유형의 Service를 만들어 연결
|
||||||
|
- MQ 이용 시 'MQ설치결과서'의 연결 정보를 실행 프로파일의 환경변수로 등록
|
||||||
|
<수행순서>
|
||||||
|
- 준비:
|
||||||
|
- 데이터베이스설치결과서(develop/database/exec/db-exec-dev.md) 분석
|
||||||
|
- 캐시설치결과서(develop/database/exec/cache-exec-dev.md) 분석
|
||||||
|
- MQ설치결과서(develop/mq/mq-exec-dev.md) 분석 - 연결 정보 확인
|
||||||
|
- kubectl get svc -n tripgen-dev | grep LoadBalancer 실행하여 External IP 목록 확인
|
||||||
|
- 실행:
|
||||||
|
- 각 서비스별를 서브에이젼트로 병렬 수행
|
||||||
|
- 설정 Manifest 수정
|
||||||
|
- 하드코딩 되어 있는 값이 있으면 환경변수로 변환
|
||||||
|
- 특히, 데이터베이스, MQ 등의 연결 정보는 반드시 환경변수로 변환해야 함
|
||||||
|
- 민감한 정보의 디퐅트값은 생략하거나 간략한 값으로 지정
|
||||||
|
- '<로그설정>'을 참조하여 Log 파일 설정
|
||||||
|
- '<실행프로파일 작성 가이드>'에 따라 서비스 실행프로파일 작성
|
||||||
|
- LoadBalancer External IP를 DB_HOST, REDIS_HOST로 설정
|
||||||
|
- MQ 연결 정보를 application.yml의 환경변수명에 맞춰 설정
|
||||||
|
- 서비스 실행 및 오류 수정
|
||||||
|
- '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 프로그램 이용**
|
||||||
|
- 오류 수정 후 필요 시 실행파일의 환경변수를 올바르게 변경
|
||||||
|
- 서비스 정상 시작 확인 후 서비스 중지
|
||||||
|
- 결과: {service-name}/.run
|
||||||
|
<서비스 중지 방법>
|
||||||
|
- Window
|
||||||
|
- netstat -ano | findstr :{PORT}
|
||||||
|
- powershell "Stop-Process -Id {Process number} -Force"
|
||||||
|
- Linux/Mac
|
||||||
|
- netstat -ano | grep {PORT}
|
||||||
|
- kill -9 {Process number}
|
||||||
|
<로그설정>
|
||||||
|
- **application.yml 로그 파일 설정**:
|
||||||
|
```yaml
|
||||||
|
logging:
|
||||||
|
file:
|
||||||
|
name: ${LOG_FILE:logs/trip-service.log}
|
||||||
|
logback:
|
||||||
|
rollingpolicy:
|
||||||
|
max-file-size: 10MB
|
||||||
|
max-history: 7
|
||||||
|
total-size-cap: 100MB
|
||||||
|
```
|
||||||
|
|
||||||
|
<실행프로파일 작성 가이드>
|
||||||
|
- {service-name}/.run/{service-name}.run.xml 파일로 작성
|
||||||
|
- Spring Boot가 아니고 **Gradle 실행 프로파일**이어야 함: '[실행프로파일 예시]' 참조
|
||||||
|
- Kubernetes에 배포된 데이터베이스의 LoadBalancer Service 확인:
|
||||||
|
- kubectl get svc -n {namespace} | grep LoadBalancer 명령으로 LoadBalancer IP 확인
|
||||||
|
- 각 서비스별 데이터베이스의 LoadBalancer External IP를 DB_HOST로 사용
|
||||||
|
- 캐시(Redis)의 LoadBalancer External IP를 REDIS_HOST로 사용
|
||||||
|
- MQ 연결 설정:
|
||||||
|
- MQ설치결과서(develop/mq/mq-exec-dev.md)에서 연결 정보 확인
|
||||||
|
- MQ 유형에 따른 연결 정보 설정 예시:
|
||||||
|
- RabbitMQ: RABBITMQ_HOST, RABBITMQ_PORT, RABBITMQ_USERNAME, RABBITMQ_PASSWORD
|
||||||
|
- Kafka: KAFKA_BOOTSTRAP_SERVERS, KAFKA_SECURITY_PROTOCOL
|
||||||
|
- Azure Service Bus: SERVICE_BUS_CONNECTION_STRING
|
||||||
|
- AWS SQS: AWS_REGION, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY
|
||||||
|
- Redis (Pub/Sub): REDIS_HOST, REDIS_PORT, REDIS_PASSWORD
|
||||||
|
- ActiveMQ: ACTIVEMQ_BROKER_URL, ACTIVEMQ_USER, ACTIVEMQ_PASSWORD
|
||||||
|
- 기타 MQ: 해당 MQ의 연결에 필요한 호스트, 포트, 인증정보, 연결문자열 등을 환경변수로 설정
|
||||||
|
- application.yml에 정의된 환경변수명 확인 후 매핑
|
||||||
|
- 백킹서비스 연결 정보 매핑:
|
||||||
|
- 데이터베이스설치결과서에서 각 서비스별 DB 인증 정보 확인
|
||||||
|
- 캐시설치결과서에서 각 서비스별 Redis 인증 정보 확인
|
||||||
|
- LoadBalancer의 External IP를 호스트로 사용 (내부 DNS 아님)
|
||||||
|
- 개발모드의 DDL_AUTO값은 update로 함
|
||||||
|
- JWT Secret Key는 모든 서비스가 동일해야 함
|
||||||
|
- application.yaml의 환경변수와 일치하도록 환경변수 설정
|
||||||
|
- application.yaml의 민감 정보는 기본값으로 지정하지 않고 실제 백킹서비스 정보로 지정
|
||||||
|
- 백킹서비스 연결 확인 결과를 바탕으로 정확한 값을 지정
|
||||||
|
- 기존에 파일이 있으면 내용을 분석하여 항목 추가/수정/삭제
|
||||||
|
|
||||||
|
[실행프로파일 예시]
|
||||||
|
```
|
||||||
|
<component name="ProjectRunConfigurationManager">
|
||||||
|
<configuration default="false" name="user-service" type="GradleRunConfiguration" factoryName="Gradle">
|
||||||
|
<ExternalSystemSettings>
|
||||||
|
<option name="env">
|
||||||
|
<map>
|
||||||
|
<entry key="ACCOUNT_LOCK_DURATION_MINUTES" value="30" />
|
||||||
|
<entry key="CACHE_TTL" value="1800" />
|
||||||
|
<entry key="DB_HOST" value="20.249.197.193" /> <!-- LoadBalancer External IP 사용 -->
|
||||||
|
<entry key="DB_NAME" value="tripgen_user_db" />
|
||||||
|
<entry key="DB_PASSWORD" value="tripgen_user_123" />
|
||||||
|
<entry key="DB_PORT" value="5432" />
|
||||||
|
<entry key="DB_USERNAME" value="tripgen_user" />
|
||||||
|
<entry key="FILE_BASE_URL" value="http://localhost:8081" />
|
||||||
|
<entry key="FILE_MAX_SIZE" value="5242880" />
|
||||||
|
<entry key="FILE_UPLOAD_PATH" value="/app/uploads" />
|
||||||
|
<entry key="JPA_DDL_AUTO" value="update" />
|
||||||
|
<entry key="JPA_SHOW_SQL" value="true" />
|
||||||
|
<entry key="JWT_ACCESS_TOKEN_EXPIRATION" value="86400" />
|
||||||
|
<entry key="JWT_REFRESH_TOKEN_EXPIRATION" value="604800" />
|
||||||
|
<entry key="JWT_SECRET" value="dev-jwt-secret-key-for-development-only" />
|
||||||
|
<entry key="LOG_LEVEL_APP" value="DEBUG" />
|
||||||
|
<entry key="LOG_LEVEL_ROOT" value="INFO" />
|
||||||
|
<entry key="LOG_LEVEL_SECURITY" value="DEBUG" />
|
||||||
|
<entry key="MAX_LOGIN_ATTEMPTS" value="5" />
|
||||||
|
<entry key="PASSWORD_MIN_LENGTH" value="8" />
|
||||||
|
<entry key="REDIS_DATABASE" value="0" />
|
||||||
|
<entry key="REDIS_HOST" value="20.214.121.28" /> <!-- Redis LoadBalancer External IP 사용 -->
|
||||||
|
<entry key="REDIS_PASSWORD" value="" />
|
||||||
|
<entry key="REDIS_PORT" value="6379" />
|
||||||
|
<entry key="SERVER_PORT" value="8081" />
|
||||||
|
<entry key="SPRING_PROFILES_ACTIVE" value="dev" />
|
||||||
|
<!-- MQ 사용하는 서비스의 경우 MQ 유형에 맞게 추가 -->
|
||||||
|
<!-- Azure Service Bus 예시 -->
|
||||||
|
<entry key="SERVICE_BUS_CONNECTION_STRING" value="Endpoint=sb://...;SharedAccessKeyName=...;SharedAccessKey=..." />
|
||||||
|
<!-- RabbitMQ 예시 -->
|
||||||
|
<entry key="RABBITMQ_HOST" value="20.xxx.xxx.xxx" />
|
||||||
|
<entry key="RABBITMQ_PORT" value="5672" />
|
||||||
|
<!-- Kafka 예시 -->
|
||||||
|
<entry key="KAFKA_BOOTSTRAP_SERVERS" value="20.xxx.xxx.xxx:9092" />
|
||||||
|
<!-- 기타 MQ의 경우 해당 MQ에 필요한 연결 정보를 환경변수로 추가 -->
|
||||||
|
</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="user-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
|
||||||
|
100 9115 100 9115 0 0 28105 0 --:--:-- --:--:-- --:--:-- 28219" />
|
||||||
|
<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>
|
||||||
|
```
|
||||||
|
|
||||||
|
[참고자료]
|
||||||
|
- 데이터베이스설치결과서: develop/database/exec/db-exec-dev.md
|
||||||
|
- 각 서비스별 DB 연결 정보 (사용자명, 비밀번호, DB명)
|
||||||
|
- LoadBalancer Service External IP 목록
|
||||||
|
- 캐시설치결과서: develop/database/exec/cache-exec-dev.md
|
||||||
|
- 각 서비스별 Redis 연결 정보
|
||||||
|
- LoadBalancer Service External IP 목록
|
||||||
|
- MQ설치결과서: develop/mq/mq-exec-dev.md
|
||||||
|
- MQ 유형 및 연결 정보
|
||||||
|
- 연결에 필요한 호스트, 포트, 인증 정보
|
||||||
|
- LoadBalancer Service External IP (해당하는 경우)
|
||||||
|
|
||||||
48
claude/test-backend.md
Normal file
48
claude/test-backend.md
Normal 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
|
||||||
@ -18,6 +18,10 @@ public enum ErrorCode {
|
|||||||
COMMON_004("COMMON_004", "서버 내부 오류가 발생했습니다"),
|
COMMON_004("COMMON_004", "서버 내부 오류가 발생했습니다"),
|
||||||
COMMON_005("COMMON_005", "지원하지 않는 작업입니다"),
|
COMMON_005("COMMON_005", "지원하지 않는 작업입니다"),
|
||||||
|
|
||||||
|
// 일반 에러 상수 (Legacy 호환용)
|
||||||
|
NOT_FOUND("NOT_FOUND", "요청한 리소스를 찾을 수 없습니다"),
|
||||||
|
INVALID_INPUT_VALUE("INVALID_INPUT_VALUE", "유효하지 않은 입력값입니다"),
|
||||||
|
|
||||||
// 인증/인가 에러 (AUTH_XXX)
|
// 인증/인가 에러 (AUTH_XXX)
|
||||||
AUTH_001("AUTH_001", "인증에 실패했습니다"),
|
AUTH_001("AUTH_001", "인증에 실패했습니다"),
|
||||||
AUTH_002("AUTH_002", "유효하지 않은 토큰입니다"),
|
AUTH_002("AUTH_002", "유효하지 않은 토큰입니다"),
|
||||||
@ -64,11 +68,14 @@ public enum ErrorCode {
|
|||||||
DIST_004("DIST_004", "배포 상태를 찾을 수 없습니다"),
|
DIST_004("DIST_004", "배포 상태를 찾을 수 없습니다"),
|
||||||
|
|
||||||
// 참여 에러 (PART_XXX)
|
// 참여 에러 (PART_XXX)
|
||||||
PART_001("PART_001", "이미 참여한 이벤트입니다"),
|
DUPLICATE_PARTICIPATION("PART_001", "이미 참여한 이벤트입니다"),
|
||||||
PART_002("PART_002", "이벤트 참여 기간이 아닙니다"),
|
EVENT_NOT_ACTIVE("PART_002", "이벤트 참여 기간이 아닙니다"),
|
||||||
PART_003("PART_003", "참여자를 찾을 수 없습니다"),
|
PARTICIPANT_NOT_FOUND("PART_003", "참여자를 찾을 수 없습니다"),
|
||||||
PART_004("PART_004", "당첨자 추첨에 실패했습니다"),
|
DRAW_FAILED("PART_004", "당첨자 추첨에 실패했습니다"),
|
||||||
PART_005("PART_005", "이벤트가 종료되었습니다"),
|
EVENT_ENDED("PART_005", "이벤트가 종료되었습니다"),
|
||||||
|
ALREADY_DRAWN("PART_006", "이미 당첨자 추첨이 완료되었습니다"),
|
||||||
|
INSUFFICIENT_PARTICIPANTS("PART_007", "참여자 수가 당첨자 수보다 적습니다"),
|
||||||
|
NO_WINNERS_YET("PART_008", "아직 당첨자 추첨이 진행되지 않았습니다"),
|
||||||
|
|
||||||
// 분석 에러 (ANALYTICS_XXX)
|
// 분석 에러 (ANALYTICS_XXX)
|
||||||
ANALYTICS_001("ANALYTICS_001", "분석 데이터를 찾을 수 없습니다"),
|
ANALYTICS_001("ANALYTICS_001", "분석 데이터를 찾을 수 없습니다"),
|
||||||
|
|||||||
@ -2,6 +2,8 @@ package com.kt.event.common.exception;
|
|||||||
|
|
||||||
import com.kt.event.common.dto.ErrorResponse;
|
import com.kt.event.common.dto.ErrorResponse;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
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.HttpStatus;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.security.access.AccessDeniedException;
|
import org.springframework.security.access.AccessDeniedException;
|
||||||
@ -161,6 +163,66 @@ public class GlobalExceptionHandler {
|
|||||||
.body(errorResponse);
|
.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);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 일반 예외 처리
|
* 일반 예외 처리
|
||||||
*
|
*
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import javax.crypto.SecretKey;
|
|||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.util.Date;
|
import java.util.Date;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* JWT 토큰 생성 및 검증 제공자
|
* JWT 토큰 생성 및 검증 제공자
|
||||||
@ -55,6 +56,7 @@ public class JwtTokenProvider {
|
|||||||
* @param roles 역할 목록
|
* @param roles 역할 목록
|
||||||
* @return Access Token
|
* @return Access Token
|
||||||
*/
|
*/
|
||||||
|
|
||||||
public String createAccessToken(Long userId, Long storeId, 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 now = new Date();
|
||||||
Date expiryDate = new Date(now.getTime() + accessTokenValidityMs);
|
Date expiryDate = new Date(now.getTime() + accessTokenValidityMs);
|
||||||
@ -78,7 +80,7 @@ public class JwtTokenProvider {
|
|||||||
* @param userId 사용자 ID
|
* @param userId 사용자 ID
|
||||||
* @return Refresh Token
|
* @return Refresh Token
|
||||||
*/
|
*/
|
||||||
public String createRefreshToken(Long userId) {
|
public String createRefreshToken(UUID userId) {
|
||||||
Date now = new Date();
|
Date now = new Date();
|
||||||
Date expiryDate = new Date(now.getTime() + refreshTokenValidityMs);
|
Date expiryDate = new Date(now.getTime() + refreshTokenValidityMs);
|
||||||
|
|
||||||
@ -97,9 +99,9 @@ public class JwtTokenProvider {
|
|||||||
* @param token JWT 토큰
|
* @param token JWT 토큰
|
||||||
* @return 사용자 ID
|
* @return 사용자 ID
|
||||||
*/
|
*/
|
||||||
public Long getUserIdFromToken(String token) {
|
public UUID getUserIdFromToken(String token) {
|
||||||
Claims claims = parseToken(token);
|
Claims claims = parseToken(token);
|
||||||
return Long.parseLong(claims.getSubject());
|
return UUID.fromString(claims.getSubject());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
package com.kt.event.common.security;
|
package com.kt.event.common.security;
|
||||||
|
|
||||||
import lombok.AllArgsConstructor;
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
import lombok.Getter;
|
import lombok.Getter;
|
||||||
import org.springframework.security.core.GrantedAuthority;
|
import org.springframework.security.core.GrantedAuthority;
|
||||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||||
@ -8,6 +9,7 @@ import org.springframework.security.core.userdetails.UserDetails;
|
|||||||
|
|
||||||
import java.util.Collection;
|
import java.util.Collection;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -15,13 +17,19 @@ import java.util.stream.Collectors;
|
|||||||
* JWT 토큰에서 추출한 사용자 정보를 담는 객체
|
* JWT 토큰에서 추출한 사용자 정보를 담는 객체
|
||||||
*/
|
*/
|
||||||
@Getter
|
@Getter
|
||||||
|
@Builder
|
||||||
@AllArgsConstructor
|
@AllArgsConstructor
|
||||||
public class UserPrincipal implements UserDetails {
|
public class UserPrincipal implements UserDetails {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 사용자 ID
|
* 사용자 ID
|
||||||
*/
|
*/
|
||||||
private final Long userId;
|
private final UUID userId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 매장 ID
|
||||||
|
*/
|
||||||
|
private final UUID storeId;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 매장 ID
|
* 매장 ID
|
||||||
|
|||||||
@ -1,7 +1,10 @@
|
|||||||
dependencies {
|
configurations {
|
||||||
// Kafka Consumer
|
// Exclude JPA and PostgreSQL from inherited dependencies (Phase 3: Redis migration)
|
||||||
implementation 'org.springframework.kafka:spring-kafka'
|
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
|
// Redis for AI data reading and image URL caching
|
||||||
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
|
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
|
||||||
|
|
||||||
|
|||||||
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user