diff --git a/.run/analytics-service.run.xml b/.run/analytics-service.run.xml
index 15941a1..de4144d 100644
--- a/.run/analytics-service.run.xml
+++ b/.run/analytics-service.run.xml
@@ -24,7 +24,7 @@
-
+
diff --git a/analytics-service/src/main/java/com/kt/event/analytics/batch/AnalyticsBatchScheduler.java b/analytics-service/src/main/java/com/kt/event/analytics/batch/AnalyticsBatchScheduler.java
index 82263fd..7cd2109 100644
--- a/analytics-service/src/main/java/com/kt/event/analytics/batch/AnalyticsBatchScheduler.java
+++ b/analytics-service/src/main/java/com/kt/event/analytics/batch/AnalyticsBatchScheduler.java
@@ -63,7 +63,7 @@ public class AnalyticsBatchScheduler {
event.getEventId(), event.getEventTitle());
// refresh=true로 호출하여 캐시 갱신 및 외부 API 호출
- analyticsService.getDashboardData(event.getEventId(), null, null, true);
+ analyticsService.getDashboardData(event.getEventId(), true);
successCount++;
log.info("✅ 배치 갱신 완료: eventId={}", event.getEventId());
@@ -99,7 +99,7 @@ public class AnalyticsBatchScheduler {
for (EventStats event : allEvents) {
try {
- analyticsService.getDashboardData(event.getEventId(), null, null, true);
+ analyticsService.getDashboardData(event.getEventId(), true);
log.debug("초기 데이터 로딩 완료: eventId={}", event.getEventId());
} catch (Exception e) {
log.warn("초기 데이터 로딩 실패: eventId={}, error={}",
diff --git a/analytics-service/src/main/java/com/kt/event/analytics/config/KafkaConsumerConfig.java b/analytics-service/src/main/java/com/kt/event/analytics/config/KafkaConsumerConfig.java
index 8ffefb7..e3c413b 100644
--- a/analytics-service/src/main/java/com/kt/event/analytics/config/KafkaConsumerConfig.java
+++ b/analytics-service/src/main/java/com/kt/event/analytics/config/KafkaConsumerConfig.java
@@ -17,7 +17,7 @@ import java.util.Map;
* Kafka Consumer 설정
*/
@Configuration
-@ConditionalOnProperty(name = "spring.kafka.enabled", havingValue = "true", matchIfMissing = true)
+@ConditionalOnProperty(name = "spring.kafka.enabled", havingValue = "true", matchIfMissing = false)
public class KafkaConsumerConfig {
@Value("${spring.kafka.bootstrap-servers}")
diff --git a/analytics-service/src/main/java/com/kt/event/analytics/config/KafkaProducerConfig.java b/analytics-service/src/main/java/com/kt/event/analytics/config/KafkaProducerConfig.java
new file mode 100644
index 0000000..145a84d
--- /dev/null
+++ b/analytics-service/src/main/java/com/kt/event/analytics/config/KafkaProducerConfig.java
@@ -0,0 +1,46 @@
+package com.kt.event.analytics.config;
+
+import org.apache.kafka.clients.producer.ProducerConfig;
+import org.apache.kafka.common.serialization.StringSerializer;
+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.core.DefaultKafkaProducerFactory;
+import org.springframework.kafka.core.KafkaTemplate;
+import org.springframework.kafka.core.ProducerFactory;
+
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * Kafka Producer 설정
+ *
+ * ⚠️ MVP 전용: SampleDataLoader가 Kafka 이벤트를 발행하기 위해 필요
+ * ⚠️ 실제 운영: Analytics Service는 순수 Consumer 역할만 수행하므로 Producer 불필요
+ *
+ * String 직렬화 방식 사용 (SampleDataLoader가 JSON 문자열을 직접 발행)
+ */
+@Configuration
+@ConditionalOnProperty(name = "spring.kafka.enabled", havingValue = "true", matchIfMissing = false)
+public class KafkaProducerConfig {
+
+ @Value("${spring.kafka.bootstrap-servers}")
+ private String bootstrapServers;
+
+ @Bean
+ public ProducerFactory producerFactory() {
+ Map configProps = new HashMap<>();
+ configProps.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers);
+ configProps.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
+ configProps.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
+ configProps.put(ProducerConfig.ACKS_CONFIG, "all");
+ configProps.put(ProducerConfig.RETRIES_CONFIG, 3);
+ return new DefaultKafkaProducerFactory<>(configProps);
+ }
+
+ @Bean
+ public KafkaTemplate kafkaTemplate() {
+ return new KafkaTemplate<>(producerFactory());
+ }
+}
diff --git a/analytics-service/src/main/java/com/kt/event/analytics/config/SampleDataLoader.java b/analytics-service/src/main/java/com/kt/event/analytics/config/SampleDataLoader.java
index 527e840..8df7f0e 100644
--- a/analytics-service/src/main/java/com/kt/event/analytics/config/SampleDataLoader.java
+++ b/analytics-service/src/main/java/com/kt/event/analytics/config/SampleDataLoader.java
@@ -11,19 +11,22 @@ import jakarta.annotation.PreDestroy;
import jakarta.persistence.EntityManager;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
+import org.apache.kafka.clients.admin.AdminClient;
+import org.apache.kafka.clients.admin.DeleteConsumerGroupOffsetsResult;
+import org.apache.kafka.clients.admin.ListConsumerGroupOffsetsResult;
+import org.apache.kafka.common.TopicPartition;
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.KafkaAdmin;
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;
+import java.util.*;
+import java.util.concurrent.TimeUnit;
/**
* 샘플 데이터 로더 (Kafka Producer 방식)
@@ -47,6 +50,7 @@ import java.util.UUID;
public class SampleDataLoader implements ApplicationRunner {
private final KafkaTemplate kafkaTemplate;
+ private final KafkaAdmin kafkaAdmin;
private final ObjectMapper objectMapper;
private final EventStatsRepository eventStatsRepository;
private final ChannelStatsRepository channelStatsRepository;
@@ -56,6 +60,8 @@ public class SampleDataLoader implements ApplicationRunner {
private final Random random = new Random();
+ private static final String CONSUMER_GROUP_ID = "analytics-service-consumers-v3";
+
// Kafka Topic Names (MVP용 샘플 토픽)
private static final String EVENT_CREATED_TOPIC = "sample.event.created";
private static final String PARTICIPANT_REGISTERED_TOPIC = "sample.participant.registered";
@@ -85,9 +91,9 @@ public class SampleDataLoader implements ApplicationRunner {
// Redis 멱등성 키 삭제 (새로운 이벤트 처리를 위해)
log.info("Redis 멱등성 키 삭제 중...");
- redisTemplate.delete("processed_events");
- redisTemplate.delete("distribution_completed");
- redisTemplate.delete("processed_participants");
+ redisTemplate.delete("processed_events_v2");
+ redisTemplate.delete("distribution_completed_v2");
+ redisTemplate.delete("processed_participants_v2");
log.info("✅ Redis 멱등성 키 삭제 완료");
try {
@@ -103,6 +109,8 @@ public class SampleDataLoader implements ApplicationRunner {
// 3. ParticipantRegistered 이벤트 발행 (각 이벤트당 다수 참여자)
publishParticipantRegisteredEvents();
+ log.info("⏳ 참여자 등록 이벤트 처리 대기 중... (20초)");
+ Thread.sleep(20000); // ParticipantRegisteredConsumer가 180개 이벤트 처리할 시간 (비관적 락 고려)
log.info("========================================");
log.info("🎉 Kafka 이벤트 발행 완료! (Consumer가 처리 중...)");
@@ -127,16 +135,17 @@ public class SampleDataLoader implements ApplicationRunner {
}
/**
- * 서비스 종료 시 전체 데이터 삭제
+ * 서비스 종료 시 전체 데이터 삭제 및 Consumer Offset 리셋
*/
@PreDestroy
@Transactional
public void onShutdown() {
log.info("========================================");
- log.info("🛑 서비스 종료: PostgreSQL 전체 데이터 삭제");
+ log.info("🛑 서비스 종료: PostgreSQL 전체 데이터 삭제 + Kafka Consumer Offset 리셋");
log.info("========================================");
try {
+ // 1. PostgreSQL 데이터 삭제
long timelineCount = timelineDataRepository.count();
long channelCount = channelStatsRepository.count();
long eventCount = eventStatsRepository.count();
@@ -153,6 +162,10 @@ public class SampleDataLoader implements ApplicationRunner {
entityManager.clear();
log.info("✅ 모든 샘플 데이터 삭제 완료!");
+
+ // 2. Kafka Consumer Offset 리셋 (다음 시작 시 처음부터 읽도록)
+ resetConsumerOffsets();
+
log.info("========================================");
} catch (Exception e) {
@@ -160,36 +173,78 @@ public class SampleDataLoader implements ApplicationRunner {
}
}
+ /**
+ * Kafka Consumer Group Offset 리셋
+ *
+ * 서비스 종료 시 Consumer offset을 삭제하여 다음 시작 시
+ * auto.offset.reset=earliest 설정에 따라 처음부터 읽도록 함
+ */
+ private void resetConsumerOffsets() {
+ try (AdminClient adminClient = AdminClient.create(kafkaAdmin.getConfigurationProperties())) {
+ log.info("🔄 Kafka Consumer Offset 리셋 시작: group={}", CONSUMER_GROUP_ID);
+
+ // 모든 토픽의 offset 삭제
+ Set partitions = new HashSet<>();
+
+ // 토픽별 파티션 추가 (설계서상 각 토픽은 3개 파티션)
+ for (int i = 0; i < 3; i++) {
+ partitions.add(new TopicPartition(EVENT_CREATED_TOPIC, i));
+ partitions.add(new TopicPartition(PARTICIPANT_REGISTERED_TOPIC, i));
+ partitions.add(new TopicPartition(DISTRIBUTION_COMPLETED_TOPIC, i));
+ }
+
+ // Consumer Group Offset 삭제
+ DeleteConsumerGroupOffsetsResult result = adminClient.deleteConsumerGroupOffsets(
+ CONSUMER_GROUP_ID,
+ partitions
+ );
+
+ // 완료 대기 (최대 10초)
+ result.all().get(10, TimeUnit.SECONDS);
+
+ log.info("✅ Kafka Consumer Offset 리셋 완료!");
+ log.info(" → 다음 시작 시 처음부터(earliest) 메시지를 읽습니다.");
+
+ } catch (Exception e) {
+ // Offset 리셋 실패는 치명적이지 않으므로 경고만 출력
+ log.warn("⚠️ Kafka Consumer Offset 리셋 실패 (무시 가능): {}", e.getMessage());
+ log.warn(" → 수동으로 Consumer Group ID를 변경하거나, Kafka 도구로 offset을 삭제하세요.");
+ }
+ }
+
/**
* EventCreated 이벤트 발행
*/
private void publishEventCreatedEvents() throws Exception {
- // 이벤트 1: 신년맞이 할인 이벤트 (진행중, 높은 성과)
+ // 이벤트 1: 신년맞이 할인 이벤트 (진행중, 높은 성과 - ROI 200%)
EventCreatedEvent event1 = EventCreatedEvent.builder()
.eventId("evt_2025012301")
.eventTitle("신년맞이 20% 할인 이벤트")
.storeId("store_001")
.totalInvestment(new BigDecimal("5000000"))
+ .expectedRevenue(new BigDecimal("15000000")) // 투자 대비 3배 수익
.status("ACTIVE")
.build();
publishEvent(EVENT_CREATED_TOPIC, event1);
- // 이벤트 2: 설날 특가 이벤트 (진행중, 중간 성과)
+ // 이벤트 2: 설날 특가 이벤트 (진행중, 중간 성과 - ROI 100%)
EventCreatedEvent event2 = EventCreatedEvent.builder()
.eventId("evt_2025020101")
.eventTitle("설날 특가 선물세트 이벤트")
.storeId("store_001")
.totalInvestment(new BigDecimal("3500000"))
+ .expectedRevenue(new BigDecimal("7000000")) // 투자 대비 2배 수익
.status("ACTIVE")
.build();
publishEvent(EVENT_CREATED_TOPIC, event2);
- // 이벤트 3: 겨울 신메뉴 런칭 이벤트 (종료, 저조한 성과)
+ // 이벤트 3: 겨울 신메뉴 런칭 이벤트 (종료, 저조한 성과 - ROI 50%)
EventCreatedEvent event3 = EventCreatedEvent.builder()
.eventId("evt_2025011501")
.eventTitle("겨울 신메뉴 런칭 이벤트")
.storeId("store_001")
.totalInvestment(new BigDecimal("2000000"))
+ .expectedRevenue(new BigDecimal("3000000")) // 투자 대비 1.5배 수익
.status("COMPLETED")
.build();
publishEvent(EVENT_CREATED_TOPIC, event3);
@@ -208,42 +263,63 @@ public class SampleDataLoader implements ApplicationRunner {
{1500, 3000, 1000, 500} // 이벤트3
};
+ // 각 이벤트의 총 투자 금액
+ BigDecimal[] totalInvestments = {
+ new BigDecimal("5000000"), // 이벤트1: 500만원
+ new BigDecimal("3500000"), // 이벤트2: 350만원
+ new BigDecimal("2000000") // 이벤트3: 200만원
+ };
+
+ // 채널 배포는 총 투자의 50%만 사용 (나머지는 경품/콘텐츠/운영비용)
+ double channelBudgetRatio = 0.50;
+
+ // 채널별 비용 비율 (채널 예산 내에서: 우리동네TV 30%, 지니TV 30%, 링고비즈 25%, SNS 15%)
+ double[] costRatios = {0.30, 0.30, 0.25, 0.15};
+
for (int i = 0; i < eventIds.length; i++) {
String eventId = eventIds[i];
+ BigDecimal totalInvestment = totalInvestments[i];
+
+ // 채널 배포 예산: 총 투자의 50%
+ BigDecimal channelBudget = totalInvestment.multiply(BigDecimal.valueOf(channelBudgetRatio));
// 4개 채널을 배열로 구성
List channels = new ArrayList<>();
- // 1. 우리동네TV (TV)
+ // 1. 우리동네TV (TV) - 채널 예산의 30%
channels.add(DistributionCompletedEvent.ChannelDistribution.builder()
.channel("우리동네TV")
.channelType("TV")
.status("SUCCESS")
.expectedViews(expectedViews[i][0])
+ .distributionCost(channelBudget.multiply(BigDecimal.valueOf(costRatios[0])))
.build());
- // 2. 지니TV (TV)
+ // 2. 지니TV (TV) - 채널 예산의 30%
channels.add(DistributionCompletedEvent.ChannelDistribution.builder()
.channel("지니TV")
.channelType("TV")
.status("SUCCESS")
.expectedViews(expectedViews[i][1])
+ .distributionCost(channelBudget.multiply(BigDecimal.valueOf(costRatios[1])))
.build());
- // 3. 링고비즈 (CALL)
+ // 3. 링고비즈 (CALL) - 채널 예산의 25%
channels.add(DistributionCompletedEvent.ChannelDistribution.builder()
.channel("링고비즈")
.channelType("CALL")
.status("SUCCESS")
.expectedViews(expectedViews[i][2])
+ .distributionCost(channelBudget.multiply(BigDecimal.valueOf(costRatios[2])))
.build());
- // 4. SNS (SNS)
+ // 4. SNS (SNS) - 채널 예산의 15%
channels.add(DistributionCompletedEvent.ChannelDistribution.builder()
.channel("SNS")
.channelType("SNS")
.status("SUCCESS")
.expectedViews(expectedViews[i][3])
+ .distributionCost(channelBudget.multiply(BigDecimal.valueOf(costRatios[3])))
.build());
// 이벤트 발행 (채널 배열 포함)
@@ -261,22 +337,40 @@ public class SampleDataLoader implements ApplicationRunner {
/**
* ParticipantRegistered 이벤트 발행
+ *
+ * 현실적인 참여 패턴 반영:
+ * - 총 120명의 고유 참여자 풀 생성
+ * - 일부 참여자는 여러 이벤트에 중복 참여
+ * - 이벤트1: 100명 (user001~user100)
+ * - 이벤트2: 50명 (user051~user100) → 50명이 이벤트1과 중복
+ * - 이벤트3: 30명 (user071~user100) → 30명이 이전 이벤트들과 중복
*/
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[][] participantRanges = {
+ {1, 100}, // 이벤트1: user001~user100 (100명)
+ {51, 100}, // 이벤트2: user051~user100 (50명, 이벤트1과 50명 중복)
+ {71, 100} // 이벤트3: user071~user100 (30명, 모두 중복)
+ };
+
int totalPublished = 0;
for (int i = 0; i < eventIds.length; i++) {
String eventId = eventIds[i];
- int participants = totalParticipants[i];
+ int startUser = participantRanges[i][0];
+ int endUser = participantRanges[i][1];
+ int eventParticipants = endUser - startUser + 1;
- // 각 이벤트에 대해 참여자 수만큼 ParticipantRegistered 이벤트 발행
- for (int j = 0; j < participants; j++) {
- String participantId = UUID.randomUUID().toString();
- String channel = channels[j % channels.length]; // 채널 순환 배정
+ log.info("이벤트 {} 참여자 발행 시작: user{:03d}~user{:03d} ({}명)",
+ eventId, startUser, endUser, eventParticipants);
+
+ // 각 참여자에 대해 ParticipantRegistered 이벤트 발행
+ for (int userId = startUser; userId <= endUser; userId++) {
+ String participantId = String.format("user%03d", userId); // user001, user002, ...
+ String channel = channels[(userId - 1) % channels.length]; // 채널 순환 배정
ParticipantRegisteredEvent event = ParticipantRegisteredEvent.builder()
.eventId(eventId)
@@ -288,19 +382,33 @@ public class SampleDataLoader implements ApplicationRunner {
totalPublished++;
// 동시성 충돌 방지: 10개마다 100ms 대기
- if ((j + 1) % 10 == 0) {
+ if (totalPublished % 10 == 0) {
Thread.sleep(100);
}
}
+
+ log.info("✅ 이벤트 {} 참여자 발행 완료: {}명", eventId, eventParticipants);
}
+ log.info("========================================");
log.info("✅ ParticipantRegistered 이벤트 {}건 발행 완료", totalPublished);
+ log.info("📊 참여 패턴:");
+ log.info(" - 총 고유 참여자: 100명 (user001~user100)");
+ log.info(" - 이벤트1 참여: 100명");
+ log.info(" - 이벤트2 참여: 50명 (이벤트1과 50명 중복)");
+ log.info(" - 이벤트3 참여: 30명 (이벤트1,2와 모두 중복)");
+ log.info(" - 3개 이벤트 모두 참여: 30명");
+ log.info(" - 2개 이벤트 참여: 20명");
+ log.info(" - 1개 이벤트만 참여: 50명");
+ log.info("========================================");
}
/**
* TimelineData 생성 (시간대별 샘플 데이터)
*
- * - 각 이벤트마다 30일 치 daily 데이터 생성
+ * - 각 이벤트마다 30일 × 24시간 = 720시간 치 hourly 데이터 생성
+ * - interval=hourly: 시간별 표시 (최근 7일 적합)
+ * - interval=daily: 일별 자동 집계 (30일 전체)
* - 참여자 수, 조회수, 참여행동, 전환수, 누적 참여자 수
*/
private void createTimelineData() {
@@ -308,52 +416,56 @@ public class SampleDataLoader implements ApplicationRunner {
String[] eventIds = {"evt_2025012301", "evt_2025020101", "evt_2025011501"};
- // 각 이벤트별 기준 참여자 수 (이벤트 성과에 따라 다름)
- int[] baseParticipants = {20, 12, 5}; // 이벤트1(높음), 이벤트2(중간), 이벤트3(낮음)
+ // 각 이벤트별 시간당 기준 참여자 수 (이벤트 성과에 따라 다름)
+ int[] baseParticipantsPerHour = {4, 2, 1}; // 이벤트1(높음), 이벤트2(중간), 이벤트3(낮음)
for (int eventIndex = 0; eventIndex < eventIds.length; eventIndex++) {
String eventId = eventIds[eventIndex];
- int baseParticipant = baseParticipants[eventIndex];
+ int baseParticipant = baseParticipantsPerHour[eventIndex];
int cumulativeParticipants = 0;
- // 30일 치 데이터 생성 (2024-09-24부터)
+ // 30일 치 hourly 데이터 생성 (2024-09-24 00:00부터)
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);
+ for (int hour = 0; hour < 24; hour++) {
+ java.time.LocalDateTime timestamp = startDate.plusDays(day).plusHours(hour);
- // 랜덤한 참여자 수 생성 (기준값 ± 50%)
- int dailyParticipants = baseParticipant + random.nextInt(baseParticipant + 1);
- cumulativeParticipants += dailyParticipants;
+ // 시간대별 참여자 수 변화 (낮 시간대 12~20시에 더 많음)
+ int hourMultiplier = (hour >= 12 && hour <= 20) ? 2 : 1;
+ int hourlyParticipants = (baseParticipant * hourMultiplier) + random.nextInt(baseParticipant + 1);
- // 조회수는 참여자의 3~5배
- int dailyViews = dailyParticipants * (3 + random.nextInt(3));
+ cumulativeParticipants += hourlyParticipants;
- // 참여행동은 참여자의 1~2배
- int dailyEngagement = dailyParticipants * (1 + random.nextInt(2));
+ // 조회수는 참여자의 3~5배
+ int hourlyViews = hourlyParticipants * (3 + random.nextInt(3));
- // 전환수는 참여자의 50~80%
- int dailyConversions = (int) (dailyParticipants * (0.5 + random.nextDouble() * 0.3));
+ // 참여행동은 참여자의 1~2배
+ int hourlyEngagement = hourlyParticipants * (1 + random.nextInt(2));
- // 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();
+ // 전환수는 참여자의 50~80%
+ int hourlyConversions = (int) (hourlyParticipants * (0.5 + random.nextDouble() * 0.3));
- timelineDataRepository.save(timelineData);
+ // TimelineData 생성
+ com.kt.event.analytics.entity.TimelineData timelineData =
+ com.kt.event.analytics.entity.TimelineData.builder()
+ .eventId(eventId)
+ .timestamp(timestamp)
+ .participants(hourlyParticipants)
+ .views(hourlyViews)
+ .engagement(hourlyEngagement)
+ .conversions(hourlyConversions)
+ .cumulativeParticipants(cumulativeParticipants)
+ .build();
+
+ timelineDataRepository.save(timelineData);
+ }
}
- log.info("✅ TimelineData 생성 완료: eventId={}, 30일 데이터", eventId);
+ log.info("✅ TimelineData 생성 완료: eventId={}, 30일 × 24시간 = 720건", eventId);
}
- log.info("✅ 전체 TimelineData 생성 완료: 3개 이벤트 × 30일 = 90건");
+ log.info("✅ 전체 TimelineData 생성 완료: 3개 이벤트 × 30일 × 24시간 = 2,160건");
}
/**
diff --git a/analytics-service/src/main/java/com/kt/event/analytics/controller/AnalyticsDashboardController.java b/analytics-service/src/main/java/com/kt/event/analytics/controller/AnalyticsDashboardController.java
index 2dc1d8a..a835be9 100644
--- a/analytics-service/src/main/java/com/kt/event/analytics/controller/AnalyticsDashboardController.java
+++ b/analytics-service/src/main/java/com/kt/event/analytics/controller/AnalyticsDashboardController.java
@@ -31,31 +31,19 @@ public class AnalyticsDashboardController {
/**
* 성과 대시보드 조회
*
- * @param eventId 이벤트 ID
- * @param startDate 조회 시작 날짜
- * @param endDate 조회 종료 날짜
- * @param refresh 캐시 갱신 여부
- * @return 성과 대시보드
+ * @param eventId 이벤트 ID
+ * @param refresh 캐시 갱신 여부
+ * @return 성과 대시보드 (이벤트 시작일 ~ 현재까지)
*/
@Operation(
summary = "성과 대시보드 조회",
- description = "이벤트의 전체 성과를 통합하여 조회합니다."
+ description = "이벤트의 전체 성과를 통합하여 조회합니다. (이벤트 시작일 ~ 현재까지)"
)
@GetMapping("/{eventId}/analytics")
public ResponseEntity> 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
@@ -63,7 +51,7 @@ public class AnalyticsDashboardController {
log.info("성과 대시보드 조회 API 호출: eventId={}, refresh={}", eventId, refresh);
AnalyticsDashboardResponse response = analyticsService.getDashboardData(
- eventId, startDate, endDate, refresh
+ eventId, refresh
);
return ResponseEntity.ok(ApiResponse.success(response));
diff --git a/analytics-service/src/main/java/com/kt/event/analytics/controller/DebugController.java b/analytics-service/src/main/java/com/kt/event/analytics/controller/DebugController.java
new file mode 100644
index 0000000..ba13f09
--- /dev/null
+++ b/analytics-service/src/main/java/com/kt/event/analytics/controller/DebugController.java
@@ -0,0 +1,75 @@
+package com.kt.event.analytics.controller;
+
+import com.kt.event.analytics.config.SampleDataLoader;
+import com.kt.event.common.dto.ApiResponse;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.boot.ApplicationArguments;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+/**
+ * 디버그 컨트롤러
+ *
+ * ⚠️ 개발/테스트 전용
+ */
+@Tag(name = "Debug", description = "디버그 API (개발/테스트 전용)")
+@Slf4j
+@RestController
+@RequestMapping("/api/debug")
+@RequiredArgsConstructor
+public class DebugController {
+
+ private final SampleDataLoader sampleDataLoader;
+
+ /**
+ * 샘플 데이터 수동 생성
+ */
+ @Operation(
+ summary = "샘플 데이터 수동 생성",
+ description = "SampleDataLoader를 수동으로 실행하여 샘플 데이터를 생성합니다."
+ )
+ @PostMapping("/reload-sample-data")
+ public ResponseEntity> reloadSampleData() {
+ try {
+ log.info("🔧 수동으로 샘플 데이터 생성 요청");
+
+ // SampleDataLoader 실행
+ sampleDataLoader.run(new ApplicationArguments() {
+ @Override
+ public String[] getSourceArgs() {
+ return new String[0];
+ }
+
+ @Override
+ public java.util.Set getOptionNames() {
+ return java.util.Collections.emptySet();
+ }
+
+ @Override
+ public boolean containsOption(String name) {
+ return false;
+ }
+
+ @Override
+ public java.util.List getOptionValues(String name) {
+ return null;
+ }
+
+ @Override
+ public java.util.List getNonOptionArgs() {
+ return java.util.Collections.emptyList();
+ }
+ });
+
+ return ResponseEntity.ok(ApiResponse.success("샘플 데이터 생성 완료"));
+ } catch (Exception e) {
+ log.error("❌ 샘플 데이터 생성 실패", e);
+ return ResponseEntity.ok(ApiResponse.success("샘플 데이터 생성 실패: " + e.getMessage()));
+ }
+ }
+}
diff --git a/analytics-service/src/main/java/com/kt/event/analytics/controller/TimelineAnalyticsController.java b/analytics-service/src/main/java/com/kt/event/analytics/controller/TimelineAnalyticsController.java
index 5fc882f..e7250cb 100644
--- a/analytics-service/src/main/java/com/kt/event/analytics/controller/TimelineAnalyticsController.java
+++ b/analytics-service/src/main/java/com/kt/event/analytics/controller/TimelineAnalyticsController.java
@@ -33,16 +33,14 @@ public class TimelineAnalyticsController {
/**
* 시간대별 참여 추이
*
- * @param eventId 이벤트 ID
- * @param interval 시간 간격 단위
- * @param startDate 조회 시작 날짜
- * @param endDate 조회 종료 날짜
- * @param metrics 조회할 지표 목록
- * @return 시간대별 참여 추이
+ * @param eventId 이벤트 ID
+ * @param interval 시간 간격 단위
+ * @param metrics 조회할 지표 목록
+ * @return 시간대별 참여 추이 (이벤트 시작일 ~ 현재까지)
*/
@Operation(
summary = "시간대별 참여 추이",
- description = "이벤트 기간 동안의 시간대별 참여 추이를 분석합니다."
+ description = "이벤트 기간 동안의 시간대별 참여 추이를 분석합니다. (이벤트 시작일 ~ 현재까지)"
)
@GetMapping("/{eventId}/analytics/timeline")
public ResponseEntity> getTimelineAnalytics(
@@ -53,16 +51,6 @@ public class TimelineAnalyticsController {
@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
@@ -74,7 +62,7 @@ public class TimelineAnalyticsController {
: null;
TimelineAnalyticsResponse response = timelineAnalyticsService.getTimelineAnalytics(
- eventId, interval, startDate, endDate, metricList
+ eventId, interval, metricList
);
return ResponseEntity.ok(ApiResponse.success(response));
diff --git a/analytics-service/src/main/java/com/kt/event/analytics/controller/UserAnalyticsDashboardController.java b/analytics-service/src/main/java/com/kt/event/analytics/controller/UserAnalyticsDashboardController.java
index 1822fde..c3820a9 100644
--- a/analytics-service/src/main/java/com/kt/event/analytics/controller/UserAnalyticsDashboardController.java
+++ b/analytics-service/src/main/java/com/kt/event/analytics/controller/UserAnalyticsDashboardController.java
@@ -31,31 +31,19 @@ public class UserAnalyticsDashboardController {
/**
* 사용자 전체 성과 대시보드 조회
*
- * @param userId 사용자 ID
- * @param startDate 조회 시작 날짜
- * @param endDate 조회 종료 날짜
- * @param refresh 캐시 갱신 여부
- * @return 전체 통합 성과 대시보드
+ * @param userId 사용자 ID
+ * @param refresh 캐시 갱신 여부
+ * @return 전체 통합 성과 대시보드 (userId 기반 전체 이벤트 조회)
*/
@Operation(
summary = "사용자 전체 성과 대시보드 조회",
- description = "사용자의 모든 이벤트 성과를 통합하여 조회합니다."
+ description = "사용자의 모든 이벤트 성과를 통합하여 조회합니다. (userId 기반 전체 이벤트 조회)"
)
@GetMapping("/{userId}/analytics")
public ResponseEntity> getUserAnalytics(
@Parameter(description = "사용자 ID", required = true)
@PathVariable String userId,
- @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, defaultValue = "false")
Boolean refresh
@@ -63,7 +51,7 @@ public class UserAnalyticsDashboardController {
log.info("사용자 전체 성과 대시보드 조회 API 호출: userId={}, refresh={}", userId, refresh);
UserAnalyticsDashboardResponse response = userAnalyticsService.getUserDashboardData(
- userId, startDate, endDate, refresh
+ userId, refresh
);
return ResponseEntity.ok(ApiResponse.success(response));
diff --git a/analytics-service/src/main/java/com/kt/event/analytics/controller/UserChannelAnalyticsController.java b/analytics-service/src/main/java/com/kt/event/analytics/controller/UserChannelAnalyticsController.java
index 2b68cb6..d3f729d 100644
--- a/analytics-service/src/main/java/com/kt/event/analytics/controller/UserChannelAnalyticsController.java
+++ b/analytics-service/src/main/java/com/kt/event/analytics/controller/UserChannelAnalyticsController.java
@@ -30,17 +30,13 @@ public class UserChannelAnalyticsController {
@Operation(
summary = "사용자 전체 채널별 성과 분석",
- description = "사용자의 모든 이벤트 채널 성과를 통합하여 분석합니다."
+ description = "사용자의 모든 이벤트 채널 성과를 통합하여 분석합니다. (전체 채널 무조건 표시)"
)
@GetMapping("/{userId}/analytics/channels")
public ResponseEntity> getUserChannelAnalytics(
@Parameter(description = "사용자 ID", required = true)
@PathVariable String userId,
- @Parameter(description = "조회할 채널 목록 (쉼표로 구분)")
- @RequestParam(required = false)
- String channels,
-
@Parameter(description = "정렬 기준")
@RequestParam(required = false, defaultValue = "participants")
String sortBy,
@@ -49,28 +45,14 @@ public class UserChannelAnalyticsController {
@RequestParam(required = false, defaultValue = "desc")
String order,
- @Parameter(description = "조회 시작 날짜")
- @RequestParam(required = false)
- @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
- LocalDateTime startDate,
-
- @Parameter(description = "조회 종료 날짜")
- @RequestParam(required = false)
- @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
- LocalDateTime endDate,
-
@Parameter(description = "캐시 갱신 여부")
@RequestParam(required = false, defaultValue = "false")
Boolean refresh
) {
log.info("사용자 채널 분석 API 호출: userId={}, sortBy={}", userId, sortBy);
- List channelList = channels != null && !channels.isBlank()
- ? Arrays.asList(channels.split(","))
- : null;
-
UserChannelAnalyticsResponse response = userChannelAnalyticsService.getUserChannelAnalytics(
- userId, channelList, sortBy, order, startDate, endDate, refresh
+ userId, sortBy, order, refresh
);
return ResponseEntity.ok(ApiResponse.success(response));
diff --git a/analytics-service/src/main/java/com/kt/event/analytics/controller/UserRoiAnalyticsController.java b/analytics-service/src/main/java/com/kt/event/analytics/controller/UserRoiAnalyticsController.java
index 58a098f..774ed11 100644
--- a/analytics-service/src/main/java/com/kt/event/analytics/controller/UserRoiAnalyticsController.java
+++ b/analytics-service/src/main/java/com/kt/event/analytics/controller/UserRoiAnalyticsController.java
@@ -28,7 +28,7 @@ public class UserRoiAnalyticsController {
@Operation(
summary = "사용자 전체 ROI 상세 분석",
- description = "사용자의 모든 이벤트 ROI를 통합하여 분석합니다."
+ description = "사용자의 모든 이벤트 ROI를 통합하여 분석합니다. (userId 기반 전체 이벤트 조회)"
)
@GetMapping("/{userId}/analytics/roi")
public ResponseEntity> getUserRoiAnalytics(
@@ -39,16 +39,6 @@ public class UserRoiAnalyticsController {
@RequestParam(required = false, defaultValue = "true")
Boolean includeProjection,
- @Parameter(description = "조회 시작 날짜")
- @RequestParam(required = false)
- @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
- LocalDateTime startDate,
-
- @Parameter(description = "조회 종료 날짜")
- @RequestParam(required = false)
- @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
- LocalDateTime endDate,
-
@Parameter(description = "캐시 갱신 여부")
@RequestParam(required = false, defaultValue = "false")
Boolean refresh
@@ -56,7 +46,7 @@ public class UserRoiAnalyticsController {
log.info("사용자 ROI 분석 API 호출: userId={}, includeProjection={}", userId, includeProjection);
UserRoiAnalyticsResponse response = userRoiAnalyticsService.getUserRoiAnalytics(
- userId, includeProjection, startDate, endDate, refresh
+ userId, includeProjection, refresh
);
return ResponseEntity.ok(ApiResponse.success(response));
diff --git a/analytics-service/src/main/java/com/kt/event/analytics/controller/UserTimelineAnalyticsController.java b/analytics-service/src/main/java/com/kt/event/analytics/controller/UserTimelineAnalyticsController.java
index 40fe700..1f69b0d 100644
--- a/analytics-service/src/main/java/com/kt/event/analytics/controller/UserTimelineAnalyticsController.java
+++ b/analytics-service/src/main/java/com/kt/event/analytics/controller/UserTimelineAnalyticsController.java
@@ -30,7 +30,7 @@ public class UserTimelineAnalyticsController {
@Operation(
summary = "사용자 전체 시간대별 참여 추이",
- description = "사용자의 모든 이벤트 시간대별 데이터를 통합하여 분석합니다."
+ description = "사용자의 모든 이벤트 시간대별 데이터를 통합하여 분석합니다. (userId 기반 전체 이벤트 조회)"
)
@GetMapping("/{userId}/analytics/timeline")
public ResponseEntity> getUserTimelineAnalytics(
@@ -41,16 +41,6 @@ public class UserTimelineAnalyticsController {
@RequestParam(required = false, defaultValue = "daily")
String interval,
- @Parameter(description = "조회 시작 날짜")
- @RequestParam(required = false)
- @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
- LocalDateTime startDate,
-
- @Parameter(description = "조회 종료 날짜")
- @RequestParam(required = false)
- @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
- LocalDateTime endDate,
-
@Parameter(description = "조회할 지표 목록 (쉼표로 구분)")
@RequestParam(required = false)
String metrics,
@@ -66,7 +56,7 @@ public class UserTimelineAnalyticsController {
: null;
UserTimelineAnalyticsResponse response = userTimelineAnalyticsService.getUserTimelineAnalytics(
- userId, interval, startDate, endDate, metricList, refresh
+ userId, interval, metricList, refresh
);
return ResponseEntity.ok(ApiResponse.success(response));
diff --git a/analytics-service/src/main/java/com/kt/event/analytics/dto/response/InvestmentDetails.java b/analytics-service/src/main/java/com/kt/event/analytics/dto/response/InvestmentDetails.java
index abff813..369518f 100644
--- a/analytics-service/src/main/java/com/kt/event/analytics/dto/response/InvestmentDetails.java
+++ b/analytics-service/src/main/java/com/kt/event/analytics/dto/response/InvestmentDetails.java
@@ -33,6 +33,16 @@ public class InvestmentDetails {
*/
private BigDecimal operation;
+ /**
+ * 경품 비용 (원)
+ */
+ private BigDecimal prizeCost;
+
+ /**
+ * 채널 비용 (원) - distribution과 동일한 값
+ */
+ private BigDecimal channelCost;
+
/**
* 총 투자 비용 (원)
*/
diff --git a/analytics-service/src/main/java/com/kt/event/analytics/dto/response/RevenueDetails.java b/analytics-service/src/main/java/com/kt/event/analytics/dto/response/RevenueDetails.java
index 873fe20..d98de44 100644
--- a/analytics-service/src/main/java/com/kt/event/analytics/dto/response/RevenueDetails.java
+++ b/analytics-service/src/main/java/com/kt/event/analytics/dto/response/RevenueDetails.java
@@ -26,6 +26,16 @@ public class RevenueDetails {
*/
private BigDecimal expectedSales;
+ /**
+ * 신규 고객 매출 (원)
+ */
+ private BigDecimal newCustomerRevenue;
+
+ /**
+ * 기존 고객 매출 (원)
+ */
+ private BigDecimal existingCustomerRevenue;
+
/**
* 브랜드 가치 향상 추정액 (원)
*/
diff --git a/analytics-service/src/main/java/com/kt/event/analytics/entity/ChannelStats.java b/analytics-service/src/main/java/com/kt/event/analytics/entity/ChannelStats.java
index 10696e1..e0fa32d 100644
--- a/analytics-service/src/main/java/com/kt/event/analytics/entity/ChannelStats.java
+++ b/analytics-service/src/main/java/com/kt/event/analytics/entity/ChannelStats.java
@@ -125,4 +125,11 @@ public class ChannelStats extends BaseTimeEntity {
@Column(name = "average_duration")
@Builder.Default
private Integer averageDuration = 0;
+
+ /**
+ * 참여자 수 증가
+ */
+ public void incrementParticipants() {
+ this.participants++;
+ }
}
diff --git a/analytics-service/src/main/java/com/kt/event/analytics/messaging/consumer/DistributionCompletedConsumer.java b/analytics-service/src/main/java/com/kt/event/analytics/messaging/consumer/DistributionCompletedConsumer.java
index 0d77956..388e4bf 100644
--- a/analytics-service/src/main/java/com/kt/event/analytics/messaging/consumer/DistributionCompletedConsumer.java
+++ b/analytics-service/src/main/java/com/kt/event/analytics/messaging/consumer/DistributionCompletedConsumer.java
@@ -32,7 +32,7 @@ public class DistributionCompletedConsumer {
private final ObjectMapper objectMapper;
private final RedisTemplate redisTemplate;
- private static final String PROCESSED_DISTRIBUTIONS_KEY = "distribution_completed";
+ private static final String PROCESSED_DISTRIBUTIONS_KEY = "distribution_completed_v2";
private static final String CACHE_KEY_PREFIX = "analytics:dashboard:";
private static final long IDEMPOTENCY_TTL_DAYS = 7;
@@ -109,10 +109,15 @@ public class DistributionCompletedConsumer {
channelStats.setImpressions(channel.getExpectedViews());
}
+ // 배포 비용 저장
+ if (channel.getDistributionCost() != null) {
+ channelStats.setDistributionCost(channel.getDistributionCost());
+ }
+
channelStatsRepository.save(channelStats);
- log.debug("✅ 채널 통계 저장: eventId={}, channel={}, expectedViews={}",
- eventId, channelName, channel.getExpectedViews());
+ log.debug("✅ 채널 통계 저장: eventId={}, channel={}, expectedViews={}, distributionCost={}",
+ eventId, channelName, channel.getExpectedViews(), channel.getDistributionCost());
} catch (Exception e) {
log.error("❌ 채널 통계 처리 실패: eventId={}, channel={}", eventId, channel.getChannel(), e);
diff --git a/analytics-service/src/main/java/com/kt/event/analytics/messaging/consumer/EventCreatedConsumer.java b/analytics-service/src/main/java/com/kt/event/analytics/messaging/consumer/EventCreatedConsumer.java
index f4be5ef..3f86256 100644
--- a/analytics-service/src/main/java/com/kt/event/analytics/messaging/consumer/EventCreatedConsumer.java
+++ b/analytics-service/src/main/java/com/kt/event/analytics/messaging/consumer/EventCreatedConsumer.java
@@ -12,6 +12,7 @@ import org.springframework.kafka.annotation.KafkaListener;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
+import java.math.BigDecimal;
import java.util.concurrent.TimeUnit;
/**
@@ -29,7 +30,7 @@ public class EventCreatedConsumer {
private final ObjectMapper objectMapper;
private final RedisTemplate redisTemplate;
- private static final String PROCESSED_EVENTS_KEY = "processed_events";
+ private static final String PROCESSED_EVENTS_KEY = "processed_events_v2";
private static final String CACHE_KEY_PREFIX = "analytics:dashboard:";
private static final long IDEMPOTENCY_TTL_DAYS = 7;
@@ -61,11 +62,13 @@ public class EventCreatedConsumer {
.userId(event.getStoreId()) // MVP: 1 user = 1 store, storeId를 userId로 매핑
.totalParticipants(0)
.totalInvestment(event.getTotalInvestment())
+ .expectedRevenue(event.getExpectedRevenue() != null ? event.getExpectedRevenue() : BigDecimal.ZERO)
.status(event.getStatus())
.build();
eventStatsRepository.save(eventStats);
- log.info("✅ 이벤트 통계 초기화 완료: eventId={}", eventId);
+ log.info("✅ 이벤트 통계 초기화 완료: eventId={}, userId={}, expectedRevenue={}",
+ eventId, eventStats.getUserId(), event.getExpectedRevenue());
// 3. 캐시 무효화 (다음 조회 시 최신 데이터 반영)
String cacheKey = CACHE_KEY_PREFIX + eventId;
diff --git a/analytics-service/src/main/java/com/kt/event/analytics/messaging/consumer/ParticipantRegisteredConsumer.java b/analytics-service/src/main/java/com/kt/event/analytics/messaging/consumer/ParticipantRegisteredConsumer.java
index 54d2fb5..a176aba 100644
--- a/analytics-service/src/main/java/com/kt/event/analytics/messaging/consumer/ParticipantRegisteredConsumer.java
+++ b/analytics-service/src/main/java/com/kt/event/analytics/messaging/consumer/ParticipantRegisteredConsumer.java
@@ -1,7 +1,9 @@
package com.kt.event.analytics.messaging.consumer;
+import com.kt.event.analytics.entity.ChannelStats;
import com.kt.event.analytics.entity.EventStats;
import com.kt.event.analytics.messaging.event.ParticipantRegisteredEvent;
+import com.kt.event.analytics.repository.ChannelStatsRepository;
import com.kt.event.analytics.repository.EventStatsRepository;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
@@ -26,10 +28,11 @@ import java.util.concurrent.TimeUnit;
public class ParticipantRegisteredConsumer {
private final EventStatsRepository eventStatsRepository;
+ private final ChannelStatsRepository channelStatsRepository;
private final ObjectMapper objectMapper;
private final RedisTemplate redisTemplate;
- private static final String PROCESSED_PARTICIPANTS_KEY = "processed_participants";
+ private static final String PROCESSED_PARTICIPANTS_KEY = "processed_participants_v2";
private static final String CACHE_KEY_PREFIX = "analytics:dashboard:";
private static final long IDEMPOTENCY_TTL_DAYS = 7;
@@ -47,11 +50,13 @@ public class ParticipantRegisteredConsumer {
ParticipantRegisteredEvent event = objectMapper.readValue(message, ParticipantRegisteredEvent.class);
String participantId = event.getParticipantId();
String eventId = event.getEventId();
+ String channel = event.getChannel();
- // ✅ 1. 멱등성 체크 (중복 처리 방지)
- Boolean isProcessed = redisTemplate.opsForSet().isMember(PROCESSED_PARTICIPANTS_KEY, participantId);
+ // ✅ 1. 멱등성 체크 (중복 처리 방지) - eventId:participantId 조합으로 체크
+ String idempotencyKey = eventId + ":" + participantId;
+ Boolean isProcessed = redisTemplate.opsForSet().isMember(PROCESSED_PARTICIPANTS_KEY, idempotencyKey);
if (Boolean.TRUE.equals(isProcessed)) {
- log.warn("⚠️ 중복 이벤트 스킵 (이미 처리됨): participantId={}", participantId);
+ log.warn("⚠️ 중복 이벤트 스킵 (이미 처리됨): eventId={}, participantId={}", eventId, participantId);
return;
}
@@ -67,15 +72,29 @@ public class ParticipantRegisteredConsumer {
() -> log.warn("⚠️ 이벤트 통계 없음: eventId={}", eventId)
);
- // 3. 캐시 무효화 (다음 조회 시 최신 참여자 수 반영)
+ // 3. 채널별 참여자 수 업데이트 - 비관적 락 적용
+ if (channel != null && !channel.isEmpty()) {
+ channelStatsRepository.findByEventIdAndChannelNameWithLock(eventId, channel)
+ .ifPresentOrElse(
+ channelStats -> {
+ channelStats.incrementParticipants();
+ channelStatsRepository.save(channelStats);
+ log.info("✅ 채널별 참여자 수 업데이트: eventId={}, channel={}, participants={}",
+ eventId, channel, channelStats.getParticipants());
+ },
+ () -> log.warn("⚠️ 채널 통계 없음: eventId={}, channel={}", eventId, channel)
+ );
+ }
+
+ // 4. 캐시 무효화 (다음 조회 시 최신 참여자 수 반영)
String cacheKey = CACHE_KEY_PREFIX + eventId;
redisTemplate.delete(cacheKey);
log.debug("🗑️ 캐시 무효화: {}", cacheKey);
- // 4. 멱등성 처리 완료 기록 (7일 TTL)
- redisTemplate.opsForSet().add(PROCESSED_PARTICIPANTS_KEY, participantId);
+ // 5. 멱등성 처리 완료 기록 (7일 TTL)
+ redisTemplate.opsForSet().add(PROCESSED_PARTICIPANTS_KEY, idempotencyKey);
redisTemplate.expire(PROCESSED_PARTICIPANTS_KEY, IDEMPOTENCY_TTL_DAYS, TimeUnit.DAYS);
- log.debug("✅ 멱등성 기록: participantId={}", participantId);
+ log.debug("✅ 멱등성 기록: eventId={}, participantId={}", eventId, participantId);
} catch (Exception e) {
log.error("❌ ParticipantRegistered 이벤트 처리 실패: {}", e.getMessage(), e);
diff --git a/analytics-service/src/main/java/com/kt/event/analytics/messaging/event/DistributionCompletedEvent.java b/analytics-service/src/main/java/com/kt/event/analytics/messaging/event/DistributionCompletedEvent.java
index 0883697..0996d14 100644
--- a/analytics-service/src/main/java/com/kt/event/analytics/messaging/event/DistributionCompletedEvent.java
+++ b/analytics-service/src/main/java/com/kt/event/analytics/messaging/event/DistributionCompletedEvent.java
@@ -62,5 +62,10 @@ public class DistributionCompletedEvent {
* 예상 노출 수
*/
private Integer expectedViews;
+
+ /**
+ * 배포 비용 (원)
+ */
+ private java.math.BigDecimal distributionCost;
}
}
diff --git a/analytics-service/src/main/java/com/kt/event/analytics/messaging/event/EventCreatedEvent.java b/analytics-service/src/main/java/com/kt/event/analytics/messaging/event/EventCreatedEvent.java
index db04917..a044a28 100644
--- a/analytics-service/src/main/java/com/kt/event/analytics/messaging/event/EventCreatedEvent.java
+++ b/analytics-service/src/main/java/com/kt/event/analytics/messaging/event/EventCreatedEvent.java
@@ -36,6 +36,11 @@ public class EventCreatedEvent {
*/
private BigDecimal totalInvestment;
+ /**
+ * 예상 수익
+ */
+ private BigDecimal expectedRevenue;
+
/**
* 이벤트 상태
*/
diff --git a/analytics-service/src/main/java/com/kt/event/analytics/repository/ChannelStatsRepository.java b/analytics-service/src/main/java/com/kt/event/analytics/repository/ChannelStatsRepository.java
index a049da6..87839de 100644
--- a/analytics-service/src/main/java/com/kt/event/analytics/repository/ChannelStatsRepository.java
+++ b/analytics-service/src/main/java/com/kt/event/analytics/repository/ChannelStatsRepository.java
@@ -1,7 +1,11 @@
package com.kt.event.analytics.repository;
import com.kt.event.analytics.entity.ChannelStats;
+import jakarta.persistence.LockModeType;
import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Lock;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.List;
@@ -30,6 +34,18 @@ public interface ChannelStatsRepository extends JpaRepository findByEventIdAndChannelName(String eventId, String channelName);
+ /**
+ * 이벤트 ID와 채널명으로 통계 조회 (비관적 락)
+ *
+ * @param eventId 이벤트 ID
+ * @param channelName 채널명
+ * @return 채널 통계
+ */
+ @Lock(LockModeType.PESSIMISTIC_WRITE)
+ @Query("SELECT c FROM ChannelStats c WHERE c.eventId = :eventId AND c.channelName = :channelName")
+ Optional findByEventIdAndChannelNameWithLock(@Param("eventId") String eventId,
+ @Param("channelName") String channelName);
+
/**
* 여러 이벤트 ID로 모든 채널 통계 조회
*
diff --git a/analytics-service/src/main/java/com/kt/event/analytics/service/AnalyticsService.java b/analytics-service/src/main/java/com/kt/event/analytics/service/AnalyticsService.java
index 4402e06..919f944 100644
--- a/analytics-service/src/main/java/com/kt/event/analytics/service/AnalyticsService.java
+++ b/analytics-service/src/main/java/com/kt/event/analytics/service/AnalyticsService.java
@@ -47,12 +47,10 @@ public class AnalyticsService {
* 대시보드 데이터 조회
*
* @param eventId 이벤트 ID
- * @param startDate 조회 시작 날짜 (선택)
- * @param endDate 조회 종료 날짜 (선택)
- * @param refresh 캐시 갱신 여부
- * @return 대시보드 응답
+ * @param refresh 캐시 갱신 여부
+ * @return 대시보드 응답 (이벤트 시작일 ~ 현재까지)
*/
- public AnalyticsDashboardResponse getDashboardData(String eventId, LocalDateTime startDate, LocalDateTime endDate, boolean refresh) {
+ public AnalyticsDashboardResponse getDashboardData(String eventId, boolean refresh) {
log.info("대시보드 데이터 조회 시작: eventId={}, refresh={}", eventId, refresh);
String cacheKey = CACHE_KEY_PREFIX + eventId;
@@ -91,7 +89,7 @@ public class AnalyticsService {
}
// 3. 대시보드 데이터 구성
- AnalyticsDashboardResponse response = buildDashboardData(eventStats, channelStatsList, startDate, endDate);
+ AnalyticsDashboardResponse response = buildDashboardData(eventStats, channelStatsList);
// 4. Redis 캐싱 (1시간 TTL)
try {
@@ -110,10 +108,9 @@ public class AnalyticsService {
/**
* 대시보드 데이터 구성
*/
- private AnalyticsDashboardResponse buildDashboardData(EventStats eventStats, List channelStatsList,
- LocalDateTime startDate, LocalDateTime endDate) {
- // 기간 정보
- PeriodInfo period = buildPeriodInfo(startDate, endDate);
+ private AnalyticsDashboardResponse buildDashboardData(EventStats eventStats, List channelStatsList) {
+ // 기간 정보 (이벤트 시작일 ~ 현재)
+ PeriodInfo period = buildPeriodInfo(eventStats);
// 성과 요약
AnalyticsSummary summary = buildAnalyticsSummary(eventStats, channelStatsList);
@@ -137,11 +134,11 @@ public class AnalyticsService {
}
/**
- * 기간 정보 구성
+ * 기간 정보 구성 (이벤트 생성일 ~ 현재)
*/
- private PeriodInfo buildPeriodInfo(LocalDateTime startDate, LocalDateTime endDate) {
- LocalDateTime start = startDate != null ? startDate : LocalDateTime.now().minusDays(30);
- LocalDateTime end = endDate != null ? endDate : LocalDateTime.now();
+ private PeriodInfo buildPeriodInfo(EventStats eventStats) {
+ LocalDateTime start = eventStats.getCreatedAt();
+ LocalDateTime end = LocalDateTime.now();
long durationDays = ChronoUnit.DAYS.between(start, end);
diff --git a/analytics-service/src/main/java/com/kt/event/analytics/service/TimelineAnalyticsService.java b/analytics-service/src/main/java/com/kt/event/analytics/service/TimelineAnalyticsService.java
index 789646d..550d130 100644
--- a/analytics-service/src/main/java/com/kt/event/analytics/service/TimelineAnalyticsService.java
+++ b/analytics-service/src/main/java/com/kt/event/analytics/service/TimelineAnalyticsService.java
@@ -26,20 +26,13 @@ public class TimelineAnalyticsService {
private final TimelineDataRepository timelineDataRepository;
/**
- * 시간대별 참여 추이 조회
+ * 시간대별 참여 추이 조회 (이벤트 전체 기간)
*/
- public TimelineAnalyticsResponse getTimelineAnalytics(String eventId, String interval,
- LocalDateTime startDate, LocalDateTime endDate,
- List metrics) {
+ public TimelineAnalyticsResponse getTimelineAnalytics(String eventId, String interval, List metrics) {
log.info("시간대별 참여 추이 조회: eventId={}, interval={}", eventId, interval);
- // 시간대별 데이터 조회
- List timelineDataList;
- if (startDate != null && endDate != null) {
- timelineDataList = timelineDataRepository.findByEventIdAndTimestampBetween(eventId, startDate, endDate);
- } else {
- timelineDataList = timelineDataRepository.findByEventIdOrderByTimestampAsc(eventId);
- }
+ // 시간대별 데이터 조회 (이벤트 전체 기간)
+ List timelineDataList = timelineDataRepository.findByEventIdOrderByTimestampAsc(eventId);
// 시간대별 데이터 포인트 구성
List dataPoints = buildTimelineDataPoints(timelineDataList);
diff --git a/analytics-service/src/main/java/com/kt/event/analytics/service/UserAnalyticsService.java b/analytics-service/src/main/java/com/kt/event/analytics/service/UserAnalyticsService.java
index 98a7b51..5b8ed29 100644
--- a/analytics-service/src/main/java/com/kt/event/analytics/service/UserAnalyticsService.java
+++ b/analytics-service/src/main/java/com/kt/event/analytics/service/UserAnalyticsService.java
@@ -44,13 +44,11 @@ public class UserAnalyticsService {
/**
* 사용자 전체 대시보드 데이터 조회
*
- * @param userId 사용자 ID
- * @param startDate 조회 시작 날짜 (선택)
- * @param endDate 조회 종료 날짜 (선택)
- * @param refresh 캐시 갱신 여부
- * @return 사용자 통합 대시보드 응답
+ * @param userId 사용자 ID
+ * @param refresh 캐시 갱신 여부
+ * @return 사용자 통합 대시보드 응답 (userId 기반 전체 이벤트 조회)
*/
- public UserAnalyticsDashboardResponse getUserDashboardData(String userId, LocalDateTime startDate, LocalDateTime endDate, boolean refresh) {
+ public UserAnalyticsDashboardResponse getUserDashboardData(String userId, boolean refresh) {
log.info("사용자 전체 대시보드 데이터 조회 시작: userId={}, refresh={}", userId, refresh);
String cacheKey = CACHE_KEY_PREFIX + userId;
@@ -75,7 +73,7 @@ public class UserAnalyticsService {
List allEvents = eventStatsRepository.findAllByUserId(userId);
if (allEvents.isEmpty()) {
log.warn("사용자에 이벤트가 없음: userId={}", userId);
- return buildEmptyResponse(userId, startDate, endDate);
+ return buildEmptyResponse(userId);
}
log.debug("사용자 이벤트 조회 완료: userId={}, 이벤트 수={}", userId, allEvents.size());
@@ -87,7 +85,7 @@ public class UserAnalyticsService {
List allChannelStats = channelStatsRepository.findByEventIdIn(eventIds);
// 3. 통합 대시보드 데이터 구성
- UserAnalyticsDashboardResponse response = buildUserDashboardData(userId, allEvents, allChannelStats, startDate, endDate);
+ UserAnalyticsDashboardResponse response = buildUserDashboardData(userId, allEvents, allChannelStats);
// 4. Redis 캐싱 (30분 TTL)
try {
@@ -104,10 +102,15 @@ public class UserAnalyticsService {
/**
* 빈 응답 생성 (이벤트가 없는 경우)
*/
- private UserAnalyticsDashboardResponse buildEmptyResponse(String userId, LocalDateTime startDate, LocalDateTime endDate) {
+ private UserAnalyticsDashboardResponse buildEmptyResponse(String userId) {
+ LocalDateTime now = LocalDateTime.now();
return UserAnalyticsDashboardResponse.builder()
.userId(userId)
- .period(buildPeriodInfo(startDate, endDate))
+ .period(PeriodInfo.builder()
+ .startDate(now)
+ .endDate(now)
+ .durationDays(0)
+ .build())
.totalEvents(0)
.activeEvents(0)
.overallSummary(buildEmptyAnalyticsSummary())
@@ -123,10 +126,9 @@ public class UserAnalyticsService {
* 사용자 통합 대시보드 데이터 구성
*/
private UserAnalyticsDashboardResponse buildUserDashboardData(String userId, List allEvents,
- List allChannelStats,
- LocalDateTime startDate, LocalDateTime endDate) {
- // 기간 정보
- PeriodInfo period = buildPeriodInfo(startDate, endDate);
+ List allChannelStats) {
+ // 기간 정보 (전체 이벤트의 최소/최대 날짜 기반)
+ PeriodInfo period = buildPeriodFromEvents(allEvents);
// 전체 이벤트 수 및 활성 이벤트 수
int totalEvents = allEvents.size();
@@ -300,15 +302,24 @@ public class UserAnalyticsService {
/**
* 기간 정보 구성
*/
- 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);
+ /**
+ * 전체 이벤트의 생성/수정 시간 기반으로 period 계산
+ */
+ private PeriodInfo buildPeriodFromEvents(List events) {
+ LocalDateTime start = events.stream()
+ .map(EventStats::getCreatedAt)
+ .min(LocalDateTime::compareTo)
+ .orElse(LocalDateTime.now());
+
+ LocalDateTime end = events.stream()
+ .map(EventStats::getUpdatedAt)
+ .max(LocalDateTime::compareTo)
+ .orElse(LocalDateTime.now());
return PeriodInfo.builder()
.startDate(start)
.endDate(end)
- .durationDays((int) durationDays)
+ .durationDays((int) ChronoUnit.DAYS.between(start, end))
.build();
}
diff --git a/analytics-service/src/main/java/com/kt/event/analytics/service/UserChannelAnalyticsService.java b/analytics-service/src/main/java/com/kt/event/analytics/service/UserChannelAnalyticsService.java
index 057b10e..8ad821d 100644
--- a/analytics-service/src/main/java/com/kt/event/analytics/service/UserChannelAnalyticsService.java
+++ b/analytics-service/src/main/java/com/kt/event/analytics/service/UserChannelAnalyticsService.java
@@ -42,10 +42,9 @@ public class UserChannelAnalyticsService {
private static final long CACHE_TTL = 1800; // 30분
/**
- * 사용자 전체 채널 분석 데이터 조회
+ * 사용자 전체 채널 분석 데이터 조회 (전체 채널 무조건 표시)
*/
- public UserChannelAnalyticsResponse getUserChannelAnalytics(String userId, List channels, String sortBy, String order,
- LocalDateTime startDate, LocalDateTime endDate, boolean refresh) {
+ public UserChannelAnalyticsResponse getUserChannelAnalytics(String userId, String sortBy, String order, boolean refresh) {
log.info("사용자 채널 분석 조회 시작: userId={}, refresh={}", userId, refresh);
String cacheKey = CACHE_KEY_PREFIX + userId;
@@ -66,14 +65,14 @@ public class UserChannelAnalyticsService {
// 2. 데이터 조회
List allEvents = eventStatsRepository.findAllByUserId(userId);
if (allEvents.isEmpty()) {
- return buildEmptyResponse(userId, startDate, endDate);
+ return buildEmptyResponse(userId);
}
List eventIds = allEvents.stream().map(EventStats::getEventId).collect(Collectors.toList());
List allChannelStats = channelStatsRepository.findByEventIdIn(eventIds);
- // 3. 응답 구성
- UserChannelAnalyticsResponse response = buildChannelAnalyticsResponse(userId, allEvents, allChannelStats, channels, sortBy, order, startDate, endDate);
+ // 3. 응답 구성 (전체 채널)
+ UserChannelAnalyticsResponse response = buildChannelAnalyticsResponse(userId, allEvents, allChannelStats, sortBy, order);
// 4. 캐싱
try {
@@ -87,10 +86,15 @@ public class UserChannelAnalyticsService {
return response;
}
- private UserChannelAnalyticsResponse buildEmptyResponse(String userId, LocalDateTime startDate, LocalDateTime endDate) {
+ private UserChannelAnalyticsResponse buildEmptyResponse(String userId) {
+ LocalDateTime now = LocalDateTime.now();
return UserChannelAnalyticsResponse.builder()
.userId(userId)
- .period(buildPeriodInfo(startDate, endDate))
+ .period(PeriodInfo.builder()
+ .startDate(now)
+ .endDate(now)
+ .durationDays(0)
+ .build())
.totalEvents(0)
.channels(new ArrayList<>())
.comparison(ChannelComparison.builder().build())
@@ -100,15 +104,10 @@ public class UserChannelAnalyticsService {
}
private UserChannelAnalyticsResponse buildChannelAnalyticsResponse(String userId, List allEvents,
- List allChannelStats, List channels,
- String sortBy, String order, LocalDateTime startDate, LocalDateTime endDate) {
- // 채널 필터링
- List filteredChannels = channels != null && !channels.isEmpty()
- ? allChannelStats.stream().filter(c -> channels.contains(c.getChannelName())).collect(Collectors.toList())
- : allChannelStats;
-
- // 채널별 집계
- List channelAnalyticsList = aggregateChannelAnalytics(filteredChannels);
+ List allChannelStats,
+ String sortBy, String order) {
+ // 채널별 집계 (전체 채널)
+ List channelAnalyticsList = aggregateChannelAnalytics(allChannelStats);
// 정렬
channelAnalyticsList = sortChannels(channelAnalyticsList, sortBy, order);
@@ -118,7 +117,7 @@ public class UserChannelAnalyticsService {
return UserChannelAnalyticsResponse.builder()
.userId(userId)
- .period(buildPeriodInfo(startDate, endDate))
+ .period(buildPeriodFromEvents(allEvents))
.totalEvents(allEvents.size())
.channels(channelAnalyticsList)
.comparison(comparison)
@@ -246,15 +245,24 @@ public class UserChannelAnalyticsService {
.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);
+ /**
+ * 전체 이벤트의 생성/수정 시간 기반으로 period 계산
+ */
+ private PeriodInfo buildPeriodFromEvents(List events) {
+ LocalDateTime start = events.stream()
+ .map(EventStats::getCreatedAt)
+ .min(LocalDateTime::compareTo)
+ .orElse(LocalDateTime.now());
+
+ LocalDateTime end = events.stream()
+ .map(EventStats::getUpdatedAt)
+ .max(LocalDateTime::compareTo)
+ .orElse(LocalDateTime.now());
return PeriodInfo.builder()
.startDate(start)
.endDate(end)
- .durationDays((int) durationDays)
+ .durationDays((int) ChronoUnit.DAYS.between(start, end))
.build();
}
}
diff --git a/analytics-service/src/main/java/com/kt/event/analytics/service/UserRoiAnalyticsService.java b/analytics-service/src/main/java/com/kt/event/analytics/service/UserRoiAnalyticsService.java
index 44ea2eb..f4ae1a8 100644
--- a/analytics-service/src/main/java/com/kt/event/analytics/service/UserRoiAnalyticsService.java
+++ b/analytics-service/src/main/java/com/kt/event/analytics/service/UserRoiAnalyticsService.java
@@ -1,7 +1,9 @@
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.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
@@ -31,14 +33,14 @@ import java.util.stream.Collectors;
public class UserRoiAnalyticsService {
private final EventStatsRepository eventStatsRepository;
+ private final ChannelStatsRepository channelStatsRepository;
private final RedisTemplate redisTemplate;
private final ObjectMapper objectMapper;
private static final String CACHE_KEY_PREFIX = "analytics:user:roi:";
private static final long CACHE_TTL = 1800;
- public UserRoiAnalyticsResponse getUserRoiAnalytics(String userId, boolean includeProjection,
- LocalDateTime startDate, LocalDateTime endDate, boolean refresh) {
+ public UserRoiAnalyticsResponse getUserRoiAnalytics(String userId, boolean includeProjection, boolean refresh) {
log.info("사용자 ROI 분석 조회 시작: userId={}, refresh={}", userId, refresh);
String cacheKey = CACHE_KEY_PREFIX + userId;
@@ -56,10 +58,10 @@ public class UserRoiAnalyticsService {
List allEvents = eventStatsRepository.findAllByUserId(userId);
if (allEvents.isEmpty()) {
- return buildEmptyResponse(userId, startDate, endDate);
+ return buildEmptyResponse(userId);
}
- UserRoiAnalyticsResponse response = buildRoiResponse(userId, allEvents, includeProjection, startDate, endDate);
+ UserRoiAnalyticsResponse response = buildRoiResponse(userId, allEvents, includeProjection);
try {
String jsonData = objectMapper.writeValueAsString(response);
@@ -71,13 +73,32 @@ public class UserRoiAnalyticsService {
return response;
}
- private UserRoiAnalyticsResponse buildEmptyResponse(String userId, LocalDateTime startDate, LocalDateTime endDate) {
+ private UserRoiAnalyticsResponse buildEmptyResponse(String userId) {
+ LocalDateTime now = LocalDateTime.now();
return UserRoiAnalyticsResponse.builder()
.userId(userId)
- .period(buildPeriodInfo(startDate, endDate))
+ .period(PeriodInfo.builder()
+ .startDate(now)
+ .endDate(now)
+ .durationDays(0)
+ .build())
.totalEvents(0)
- .overallInvestment(InvestmentDetails.builder().total(BigDecimal.ZERO).build())
- .overallRevenue(RevenueDetails.builder().total(BigDecimal.ZERO).build())
+ .overallInvestment(InvestmentDetails.builder()
+ .total(BigDecimal.ZERO)
+ .contentCreation(BigDecimal.ZERO)
+ .operation(BigDecimal.ZERO)
+ .distribution(BigDecimal.ZERO)
+ .prizeCost(BigDecimal.ZERO)
+ .channelCost(BigDecimal.ZERO)
+ .build())
+ .overallRevenue(RevenueDetails.builder()
+ .total(BigDecimal.ZERO)
+ .directSales(BigDecimal.ZERO)
+ .expectedSales(BigDecimal.ZERO)
+ .newCustomerRevenue(BigDecimal.ZERO)
+ .existingCustomerRevenue(BigDecimal.ZERO)
+ .brandValue(BigDecimal.ZERO)
+ .build())
.overallRoi(RoiCalculation.builder()
.netProfit(BigDecimal.ZERO)
.roiPercentage(0.0)
@@ -88,8 +109,7 @@ public class UserRoiAnalyticsService {
.build();
}
- private UserRoiAnalyticsResponse buildRoiResponse(String userId, List allEvents, boolean includeProjection,
- LocalDateTime startDate, LocalDateTime endDate) {
+ private UserRoiAnalyticsResponse buildRoiResponse(String userId, List allEvents, boolean includeProjection) {
BigDecimal totalInvestment = allEvents.stream().map(EventStats::getTotalInvestment).reduce(BigDecimal.ZERO, BigDecimal::add);
BigDecimal totalRevenue = allEvents.stream().map(EventStats::getExpectedRevenue).reduce(BigDecimal.ZERO, BigDecimal::add);
BigDecimal totalProfit = totalRevenue.subtract(totalInvestment);
@@ -98,17 +118,44 @@ public class UserRoiAnalyticsService {
? totalProfit.divide(totalInvestment, 4, RoundingMode.HALF_UP).multiply(BigDecimal.valueOf(100)).doubleValue()
: 0.0;
+ // ChannelStats에서 실제 배포 비용 집계
+ List eventIds = allEvents.stream().map(EventStats::getEventId).collect(Collectors.toList());
+ List allChannelStats = channelStatsRepository.findByEventIdIn(eventIds);
+
+ BigDecimal actualDistribution = allChannelStats.stream()
+ .map(ChannelStats::getDistributionCost)
+ .reduce(BigDecimal.ZERO, BigDecimal::add);
+
+ // 나머지 비용 계산 (총 투자 - 실제 채널 배포 비용)
+ BigDecimal remaining = totalInvestment.subtract(actualDistribution);
+
+ // 나머지 비용 분배: 경품 50%, 콘텐츠 제작 30%, 운영 20%
+ BigDecimal prizeCost = remaining.multiply(BigDecimal.valueOf(0.50));
+ BigDecimal contentCreation = remaining.multiply(BigDecimal.valueOf(0.30));
+ BigDecimal operation = remaining.multiply(BigDecimal.valueOf(0.20));
+
InvestmentDetails investment = InvestmentDetails.builder()
.total(totalInvestment)
- .contentCreation(totalInvestment.multiply(BigDecimal.valueOf(0.6)))
- .operation(totalInvestment.multiply(BigDecimal.valueOf(0.2)))
- .distribution(totalInvestment.multiply(BigDecimal.valueOf(0.2)))
+ .contentCreation(contentCreation)
+ .operation(operation)
+ .distribution(actualDistribution)
+ .prizeCost(prizeCost)
+ .channelCost(actualDistribution) // 채널비용은 배포비용과 동일
.build();
+ // 매출 분배: 직접 매출 70%, 예상 추가 매출 30% / 신규 고객 40%, 기존 고객 60%
+ BigDecimal directSales = totalRevenue.multiply(BigDecimal.valueOf(0.70));
+ BigDecimal expectedSales = totalRevenue.multiply(BigDecimal.valueOf(0.30));
+ BigDecimal newCustomerRevenue = totalRevenue.multiply(BigDecimal.valueOf(0.40));
+ BigDecimal existingCustomerRevenue = totalRevenue.multiply(BigDecimal.valueOf(0.60));
+
RevenueDetails revenue = RevenueDetails.builder()
.total(totalRevenue)
- .directSales(totalRevenue.multiply(BigDecimal.valueOf(0.7)))
- .expectedSales(totalRevenue.multiply(BigDecimal.valueOf(0.3)))
+ .directSales(directSales)
+ .expectedSales(expectedSales)
+ .newCustomerRevenue(newCustomerRevenue)
+ .existingCustomerRevenue(existingCustomerRevenue)
+ .brandValue(BigDecimal.ZERO) // 브랜드 가치는 별도 계산 필요 시 추가
.build();
RoiCalculation roiCalc = RoiCalculation.builder()
@@ -149,9 +196,12 @@ public class UserRoiAnalyticsService {
.sorted(Comparator.comparingDouble(UserRoiAnalyticsResponse.EventRoiSummary::getRoi).reversed())
.collect(Collectors.toList());
+ // 전체 이벤트의 최소/최대 날짜로 period 계산
+ PeriodInfo period = buildPeriodFromEvents(allEvents);
+
return UserRoiAnalyticsResponse.builder()
.userId(userId)
- .period(buildPeriodInfo(startDate, endDate))
+ .period(period)
.totalEvents(allEvents.size())
.overallInvestment(investment)
.overallRevenue(revenue)
@@ -164,9 +214,20 @@ public class UserRoiAnalyticsService {
.build();
}
- private PeriodInfo buildPeriodInfo(LocalDateTime startDate, LocalDateTime endDate) {
- LocalDateTime start = startDate != null ? startDate : LocalDateTime.now().minusDays(30);
- LocalDateTime end = endDate != null ? endDate : LocalDateTime.now();
+ /**
+ * 전체 이벤트의 생성/수정 시간 기반으로 period 계산
+ */
+ private PeriodInfo buildPeriodFromEvents(List events) {
+ LocalDateTime start = events.stream()
+ .map(EventStats::getCreatedAt)
+ .min(LocalDateTime::compareTo)
+ .orElse(LocalDateTime.now());
+
+ LocalDateTime end = events.stream()
+ .map(EventStats::getUpdatedAt)
+ .max(LocalDateTime::compareTo)
+ .orElse(LocalDateTime.now());
+
return PeriodInfo.builder()
.startDate(start)
.endDate(end)
diff --git a/analytics-service/src/main/java/com/kt/event/analytics/service/UserTimelineAnalyticsService.java b/analytics-service/src/main/java/com/kt/event/analytics/service/UserTimelineAnalyticsService.java
index abee9b8..ad56b48 100644
--- a/analytics-service/src/main/java/com/kt/event/analytics/service/UserTimelineAnalyticsService.java
+++ b/analytics-service/src/main/java/com/kt/event/analytics/service/UserTimelineAnalyticsService.java
@@ -37,7 +37,6 @@ public class UserTimelineAnalyticsService {
private static final long CACHE_TTL = 1800;
public UserTimelineAnalyticsResponse getUserTimelineAnalytics(String userId, String interval,
- LocalDateTime startDate, LocalDateTime endDate,
List metrics, boolean refresh) {
log.info("사용자 타임라인 분석 조회 시작: userId={}, interval={}, refresh={}", userId, interval, refresh);
@@ -56,15 +55,13 @@ public class UserTimelineAnalyticsService {
List allEvents = eventStatsRepository.findAllByUserId(userId);
if (allEvents.isEmpty()) {
- return buildEmptyResponse(userId, interval, startDate, endDate);
+ return buildEmptyResponse(userId, interval);
}
List eventIds = allEvents.stream().map(EventStats::getEventId).collect(Collectors.toList());
- List allTimelineData = startDate != null && endDate != null
- ? timelineDataRepository.findByEventIdInAndTimestampBetween(eventIds, startDate, endDate)
- : timelineDataRepository.findByEventIdInOrderByTimestampAsc(eventIds);
+ List allTimelineData = timelineDataRepository.findByEventIdInOrderByTimestampAsc(eventIds);
- UserTimelineAnalyticsResponse response = buildTimelineResponse(userId, allEvents, allTimelineData, interval, startDate, endDate);
+ UserTimelineAnalyticsResponse response = buildTimelineResponse(userId, allEvents, allTimelineData, interval);
try {
String jsonData = objectMapper.writeValueAsString(response);
@@ -76,10 +73,15 @@ public class UserTimelineAnalyticsService {
return response;
}
- private UserTimelineAnalyticsResponse buildEmptyResponse(String userId, String interval, LocalDateTime startDate, LocalDateTime endDate) {
+ private UserTimelineAnalyticsResponse buildEmptyResponse(String userId, String interval) {
+ LocalDateTime now = LocalDateTime.now();
return UserTimelineAnalyticsResponse.builder()
.userId(userId)
- .period(buildPeriodInfo(startDate, endDate))
+ .period(PeriodInfo.builder()
+ .startDate(now)
+ .endDate(now)
+ .durationDays(0)
+ .build())
.totalEvents(0)
.interval(interval != null ? interval : "daily")
.dataPoints(new ArrayList<>())
@@ -91,8 +93,7 @@ public class UserTimelineAnalyticsService {
}
private UserTimelineAnalyticsResponse buildTimelineResponse(String userId, List allEvents,
- List allTimelineData, String interval,
- LocalDateTime startDate, LocalDateTime endDate) {
+ List allTimelineData, String interval) {
Map aggregatedData = new LinkedHashMap<>();
for (TimelineData data : allTimelineData) {
@@ -119,7 +120,7 @@ public class UserTimelineAnalyticsService {
return UserTimelineAnalyticsResponse.builder()
.userId(userId)
- .period(buildPeriodInfo(startDate, endDate))
+ .period(buildPeriodFromEvents(allEvents))
.totalEvents(allEvents.size())
.interval(interval != null ? interval : "daily")
.dataPoints(dataPoints)
@@ -179,9 +180,20 @@ public class UserTimelineAnalyticsService {
.build() : PeakTimeInfo.builder().build();
}
- private PeriodInfo buildPeriodInfo(LocalDateTime startDate, LocalDateTime endDate) {
- LocalDateTime start = startDate != null ? startDate : LocalDateTime.now().minusDays(30);
- LocalDateTime end = endDate != null ? endDate : LocalDateTime.now();
+ /**
+ * 전체 이벤트의 생성/수정 시간 기반으로 period 계산
+ */
+ private PeriodInfo buildPeriodFromEvents(List events) {
+ LocalDateTime start = events.stream()
+ .map(EventStats::getCreatedAt)
+ .min(LocalDateTime::compareTo)
+ .orElse(LocalDateTime.now());
+
+ LocalDateTime end = events.stream()
+ .map(EventStats::getUpdatedAt)
+ .max(LocalDateTime::compareTo)
+ .orElse(LocalDateTime.now());
+
return PeriodInfo.builder()
.startDate(start)
.endDate(end)
diff --git a/analytics-service/src/main/resources/application.yml b/analytics-service/src/main/resources/application.yml
index 4571949..330a9e0 100644
--- a/analytics-service/src/main/resources/application.yml
+++ b/analytics-service/src/main/resources/application.yml
@@ -47,11 +47,13 @@ spring:
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}
+ group-id: ${KAFKA_CONSUMER_GROUP_ID:analytics-service-consumers-v3}
auto-offset-reset: earliest
enable-auto-commit: true
key-deserializer: org.apache.kafka.common.serialization.StringDeserializer
value-deserializer: org.apache.kafka.common.serialization.StringDeserializer
+ properties:
+ auto.offset.reset: earliest
producer:
key-serializer: org.apache.kafka.common.serialization.StringSerializer
value-serializer: org.apache.kafka.common.serialization.StringSerializer
@@ -73,6 +75,11 @@ spring:
# Server
server:
port: ${SERVER_PORT:8086}
+ servlet:
+ encoding:
+ charset: UTF-8
+ enabled: true
+ force: true
# JWT
jwt:
diff --git a/tools/reset-analytics-data.ps1 b/tools/reset-analytics-data.ps1
new file mode 100644
index 0000000..12baa93
--- /dev/null
+++ b/tools/reset-analytics-data.ps1
@@ -0,0 +1,33 @@
+# Analytics Redis 초기화 스크립트
+
+Write-Host "Analytics Redis 초기화 시작..." -ForegroundColor Cyan
+
+# Redis 컨테이너 찾기
+$redisContainer = docker ps --filter "ancestor=redis" --format "{{.Names}}" | Select-Object -First 1
+
+if ($redisContainer) {
+ Write-Host "Redis 컨테이너 발견: $redisContainer" -ForegroundColor Green
+
+ # 멱등성 키 삭제
+ Write-Host "멱등성 키 삭제 중..." -ForegroundColor Yellow
+ docker exec $redisContainer redis-cli DEL processed_participants
+ docker exec $redisContainer redis-cli DEL processed_events
+ docker exec $redisContainer redis-cli DEL distribution_completed
+
+ # 캐시 삭제
+ Write-Host "Analytics 캐시 삭제 중..." -ForegroundColor Yellow
+ docker exec $redisContainer redis-cli --scan --pattern "analytics:*" | ForEach-Object {
+ docker exec $redisContainer redis-cli DEL $_
+ }
+
+ Write-Host "완료! 서버를 재시작해주세요." -ForegroundColor Green
+} else {
+ Write-Host "Redis 컨테이너를 찾을 수 없습니다." -ForegroundColor Red
+ Write-Host "로컬 Redis를 시도합니다..." -ForegroundColor Yellow
+
+ redis-cli DEL processed_participants
+ redis-cli DEL processed_events
+ redis-cli DEL distribution_completed
+
+ Write-Host "완료! 서버를 재시작해주세요." -ForegroundColor Green
+}