Merge pull request #27 from ktds-dg0501/feature/analytics

Feature/analytics
This commit is contained in:
Hyowon Yang 2025-10-30 09:40:29 +09:00 committed by GitHub
commit aa8db3bf2f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
31 changed files with 797 additions and 282 deletions

View File

@ -24,7 +24,7 @@
<!-- Kafka Configuration (원격 서버) --> <!-- Kafka Configuration (원격 서버) -->
<entry key="KAFKA_ENABLED" value="true" /> <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_BOOTSTRAP_SERVERS" value="20.249.182.13:9095,4.217.131.59:9095" />
<entry key="KAFKA_CONSUMER_GROUP_ID" value="analytics-service-consumers" /> <entry key="KAFKA_CONSUMER_GROUP_ID" value="analytics-service-consumers-v3" />
<!-- Sample Data Configuration (MVP Only) --> <!-- Sample Data Configuration (MVP Only) -->
<!-- ⚠️ Kafka Producer로 이벤트 발행 (Consumer가 처리) --> <!-- ⚠️ Kafka Producer로 이벤트 발행 (Consumer가 처리) -->

View File

@ -63,7 +63,7 @@ public class AnalyticsBatchScheduler {
event.getEventId(), event.getEventTitle()); event.getEventId(), event.getEventTitle());
// refresh=true로 호출하여 캐시 갱신 외부 API 호출 // refresh=true로 호출하여 캐시 갱신 외부 API 호출
analyticsService.getDashboardData(event.getEventId(), null, null, true); analyticsService.getDashboardData(event.getEventId(), true);
successCount++; successCount++;
log.info("✅ 배치 갱신 완료: eventId={}", event.getEventId()); log.info("✅ 배치 갱신 완료: eventId={}", event.getEventId());
@ -99,7 +99,7 @@ public class AnalyticsBatchScheduler {
for (EventStats event : allEvents) { for (EventStats event : allEvents) {
try { try {
analyticsService.getDashboardData(event.getEventId(), null, null, true); analyticsService.getDashboardData(event.getEventId(), true);
log.debug("초기 데이터 로딩 완료: eventId={}", event.getEventId()); log.debug("초기 데이터 로딩 완료: eventId={}", event.getEventId());
} catch (Exception e) { } catch (Exception e) {
log.warn("초기 데이터 로딩 실패: eventId={}, error={}", log.warn("초기 데이터 로딩 실패: eventId={}, error={}",

View File

@ -17,7 +17,7 @@ import java.util.Map;
* Kafka Consumer 설정 * Kafka Consumer 설정
*/ */
@Configuration @Configuration
@ConditionalOnProperty(name = "spring.kafka.enabled", havingValue = "true", matchIfMissing = true) @ConditionalOnProperty(name = "spring.kafka.enabled", havingValue = "true", matchIfMissing = false)
public class KafkaConsumerConfig { public class KafkaConsumerConfig {
@Value("${spring.kafka.bootstrap-servers}") @Value("${spring.kafka.bootstrap-servers}")

View File

@ -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<String, String> producerFactory() {
Map<String, Object> 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<String, String> kafkaTemplate() {
return new KafkaTemplate<>(producerFactory());
}
}

View File

@ -11,19 +11,22 @@ import jakarta.annotation.PreDestroy;
import jakarta.persistence.EntityManager; import jakarta.persistence.EntityManager;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; 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.ApplicationArguments;
import org.springframework.boot.ApplicationRunner; import org.springframework.boot.ApplicationRunner;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.kafka.core.KafkaAdmin;
import org.springframework.kafka.core.KafkaTemplate; import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.util.ArrayList; import java.util.*;
import java.util.List; import java.util.concurrent.TimeUnit;
import java.util.Random;
import java.util.UUID;
/** /**
* 샘플 데이터 로더 (Kafka Producer 방식) * 샘플 데이터 로더 (Kafka Producer 방식)
@ -47,6 +50,7 @@ import java.util.UUID;
public class SampleDataLoader implements ApplicationRunner { public class SampleDataLoader implements ApplicationRunner {
private final KafkaTemplate<String, String> kafkaTemplate; private final KafkaTemplate<String, String> kafkaTemplate;
private final KafkaAdmin kafkaAdmin;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private final EventStatsRepository eventStatsRepository; private final EventStatsRepository eventStatsRepository;
private final ChannelStatsRepository channelStatsRepository; private final ChannelStatsRepository channelStatsRepository;
@ -56,6 +60,8 @@ public class SampleDataLoader implements ApplicationRunner {
private final Random random = new Random(); private final Random random = new Random();
private static final String CONSUMER_GROUP_ID = "analytics-service-consumers-v3";
// Kafka Topic Names (MVP용 샘플 토픽) // Kafka Topic Names (MVP용 샘플 토픽)
private static final String EVENT_CREATED_TOPIC = "sample.event.created"; private static final String EVENT_CREATED_TOPIC = "sample.event.created";
private static final String PARTICIPANT_REGISTERED_TOPIC = "sample.participant.registered"; private static final String PARTICIPANT_REGISTERED_TOPIC = "sample.participant.registered";
@ -85,9 +91,9 @@ public class SampleDataLoader implements ApplicationRunner {
// Redis 멱등성 삭제 (새로운 이벤트 처리를 위해) // Redis 멱등성 삭제 (새로운 이벤트 처리를 위해)
log.info("Redis 멱등성 키 삭제 중..."); log.info("Redis 멱등성 키 삭제 중...");
redisTemplate.delete("processed_events"); redisTemplate.delete("processed_events_v2");
redisTemplate.delete("distribution_completed"); redisTemplate.delete("distribution_completed_v2");
redisTemplate.delete("processed_participants"); redisTemplate.delete("processed_participants_v2");
log.info("✅ Redis 멱등성 키 삭제 완료"); log.info("✅ Redis 멱등성 키 삭제 완료");
try { try {
@ -103,6 +109,8 @@ public class SampleDataLoader implements ApplicationRunner {
// 3. ParticipantRegistered 이벤트 발행 ( 이벤트당 다수 참여자) // 3. ParticipantRegistered 이벤트 발행 ( 이벤트당 다수 참여자)
publishParticipantRegisteredEvents(); publishParticipantRegisteredEvents();
log.info("⏳ 참여자 등록 이벤트 처리 대기 중... (20초)");
Thread.sleep(20000); // ParticipantRegisteredConsumer가 180개 이벤트 처리할 시간 (비관적 고려)
log.info("========================================"); log.info("========================================");
log.info("🎉 Kafka 이벤트 발행 완료! (Consumer가 처리 중...)"); log.info("🎉 Kafka 이벤트 발행 완료! (Consumer가 처리 중...)");
@ -127,16 +135,17 @@ public class SampleDataLoader implements ApplicationRunner {
} }
/** /**
* 서비스 종료 전체 데이터 삭제 * 서비스 종료 전체 데이터 삭제 Consumer Offset 리셋
*/ */
@PreDestroy @PreDestroy
@Transactional @Transactional
public void onShutdown() { public void onShutdown() {
log.info("========================================"); log.info("========================================");
log.info("🛑 서비스 종료: PostgreSQL 전체 데이터 삭제"); log.info("🛑 서비스 종료: PostgreSQL 전체 데이터 삭제 + Kafka Consumer Offset 리셋");
log.info("========================================"); log.info("========================================");
try { try {
// 1. PostgreSQL 데이터 삭제
long timelineCount = timelineDataRepository.count(); long timelineCount = timelineDataRepository.count();
long channelCount = channelStatsRepository.count(); long channelCount = channelStatsRepository.count();
long eventCount = eventStatsRepository.count(); long eventCount = eventStatsRepository.count();
@ -153,6 +162,10 @@ public class SampleDataLoader implements ApplicationRunner {
entityManager.clear(); entityManager.clear();
log.info("✅ 모든 샘플 데이터 삭제 완료!"); log.info("✅ 모든 샘플 데이터 삭제 완료!");
// 2. Kafka Consumer Offset 리셋 (다음 시작 처음부터 읽도록)
resetConsumerOffsets();
log.info("========================================"); log.info("========================================");
} catch (Exception e) { } 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<TopicPartition> 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 이벤트 발행 * EventCreated 이벤트 발행
*/ */
private void publishEventCreatedEvents() throws Exception { private void publishEventCreatedEvents() throws Exception {
// 이벤트 1: 신년맞이 할인 이벤트 (진행중, 높은 성과) // 이벤트 1: 신년맞이 할인 이벤트 (진행중, 높은 성과 - ROI 200%)
EventCreatedEvent event1 = EventCreatedEvent.builder() EventCreatedEvent event1 = EventCreatedEvent.builder()
.eventId("evt_2025012301") .eventId("evt_2025012301")
.eventTitle("신년맞이 20% 할인 이벤트") .eventTitle("신년맞이 20% 할인 이벤트")
.storeId("store_001") .storeId("store_001")
.totalInvestment(new BigDecimal("5000000")) .totalInvestment(new BigDecimal("5000000"))
.expectedRevenue(new BigDecimal("15000000")) // 투자 대비 3배 수익
.status("ACTIVE") .status("ACTIVE")
.build(); .build();
publishEvent(EVENT_CREATED_TOPIC, event1); publishEvent(EVENT_CREATED_TOPIC, event1);
// 이벤트 2: 설날 특가 이벤트 (진행중, 중간 성과) // 이벤트 2: 설날 특가 이벤트 (진행중, 중간 성과 - ROI 100%)
EventCreatedEvent event2 = EventCreatedEvent.builder() EventCreatedEvent event2 = EventCreatedEvent.builder()
.eventId("evt_2025020101") .eventId("evt_2025020101")
.eventTitle("설날 특가 선물세트 이벤트") .eventTitle("설날 특가 선물세트 이벤트")
.storeId("store_001") .storeId("store_001")
.totalInvestment(new BigDecimal("3500000")) .totalInvestment(new BigDecimal("3500000"))
.expectedRevenue(new BigDecimal("7000000")) // 투자 대비 2배 수익
.status("ACTIVE") .status("ACTIVE")
.build(); .build();
publishEvent(EVENT_CREATED_TOPIC, event2); publishEvent(EVENT_CREATED_TOPIC, event2);
// 이벤트 3: 겨울 신메뉴 런칭 이벤트 (종료, 저조한 성과) // 이벤트 3: 겨울 신메뉴 런칭 이벤트 (종료, 저조한 성과 - ROI 50%)
EventCreatedEvent event3 = EventCreatedEvent.builder() EventCreatedEvent event3 = EventCreatedEvent.builder()
.eventId("evt_2025011501") .eventId("evt_2025011501")
.eventTitle("겨울 신메뉴 런칭 이벤트") .eventTitle("겨울 신메뉴 런칭 이벤트")
.storeId("store_001") .storeId("store_001")
.totalInvestment(new BigDecimal("2000000")) .totalInvestment(new BigDecimal("2000000"))
.expectedRevenue(new BigDecimal("3000000")) // 투자 대비 1.5배 수익
.status("COMPLETED") .status("COMPLETED")
.build(); .build();
publishEvent(EVENT_CREATED_TOPIC, event3); publishEvent(EVENT_CREATED_TOPIC, event3);
@ -208,42 +263,63 @@ public class SampleDataLoader implements ApplicationRunner {
{1500, 3000, 1000, 500} // 이벤트3 {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++) { for (int i = 0; i < eventIds.length; i++) {
String eventId = eventIds[i]; String eventId = eventIds[i];
BigDecimal totalInvestment = totalInvestments[i];
// 채널 배포 예산: 투자의 50%
BigDecimal channelBudget = totalInvestment.multiply(BigDecimal.valueOf(channelBudgetRatio));
// 4개 채널을 배열로 구성 // 4개 채널을 배열로 구성
List<DistributionCompletedEvent.ChannelDistribution> channels = new ArrayList<>(); List<DistributionCompletedEvent.ChannelDistribution> channels = new ArrayList<>();
// 1. 우리동네TV (TV) // 1. 우리동네TV (TV) - 채널 예산의 30%
channels.add(DistributionCompletedEvent.ChannelDistribution.builder() channels.add(DistributionCompletedEvent.ChannelDistribution.builder()
.channel("우리동네TV") .channel("우리동네TV")
.channelType("TV") .channelType("TV")
.status("SUCCESS") .status("SUCCESS")
.expectedViews(expectedViews[i][0]) .expectedViews(expectedViews[i][0])
.distributionCost(channelBudget.multiply(BigDecimal.valueOf(costRatios[0])))
.build()); .build());
// 2. 지니TV (TV) // 2. 지니TV (TV) - 채널 예산의 30%
channels.add(DistributionCompletedEvent.ChannelDistribution.builder() channels.add(DistributionCompletedEvent.ChannelDistribution.builder()
.channel("지니TV") .channel("지니TV")
.channelType("TV") .channelType("TV")
.status("SUCCESS") .status("SUCCESS")
.expectedViews(expectedViews[i][1]) .expectedViews(expectedViews[i][1])
.distributionCost(channelBudget.multiply(BigDecimal.valueOf(costRatios[1])))
.build()); .build());
// 3. 링고비즈 (CALL) // 3. 링고비즈 (CALL) - 채널 예산의 25%
channels.add(DistributionCompletedEvent.ChannelDistribution.builder() channels.add(DistributionCompletedEvent.ChannelDistribution.builder()
.channel("링고비즈") .channel("링고비즈")
.channelType("CALL") .channelType("CALL")
.status("SUCCESS") .status("SUCCESS")
.expectedViews(expectedViews[i][2]) .expectedViews(expectedViews[i][2])
.distributionCost(channelBudget.multiply(BigDecimal.valueOf(costRatios[2])))
.build()); .build());
// 4. SNS (SNS) // 4. SNS (SNS) - 채널 예산의 15%
channels.add(DistributionCompletedEvent.ChannelDistribution.builder() channels.add(DistributionCompletedEvent.ChannelDistribution.builder()
.channel("SNS") .channel("SNS")
.channelType("SNS") .channelType("SNS")
.status("SUCCESS") .status("SUCCESS")
.expectedViews(expectedViews[i][3]) .expectedViews(expectedViews[i][3])
.distributionCost(channelBudget.multiply(BigDecimal.valueOf(costRatios[3])))
.build()); .build());
// 이벤트 발행 (채널 배열 포함) // 이벤트 발행 (채널 배열 포함)
@ -261,22 +337,53 @@ public class SampleDataLoader implements ApplicationRunner {
/** /**
* ParticipantRegistered 이벤트 발행 * ParticipantRegistered 이벤트 발행
*
* 현실적인 참여 패턴 반영:
* - 120명의 고유 참여자 생성
* - 일부 참여자는 여러 이벤트에 중복 참여
* - 이벤트1: 100명 (user001~user100)
* - 이벤트2: 50명 (user051~user100) 50명이 이벤트1과 중복
* - 이벤트3: 30명 (user071~user100) 30명이 이전 이벤트들과 중복
*/ */
private void publishParticipantRegisteredEvents() throws Exception { private void publishParticipantRegisteredEvents() throws Exception {
String[] eventIds = {"evt_2025012301", "evt_2025020101", "evt_2025011501"}; String[] eventIds = {"evt_2025012301", "evt_2025020101", "evt_2025011501"};
int[] totalParticipants = {100, 50, 30}; // MVP 테스트용 샘플 데이터 ( 180명)
String[] channels = {"우리동네TV", "지니TV", "링고비즈", "SNS"}; 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; int totalPublished = 0;
for (int i = 0; i < eventIds.length; i++) { for (int i = 0; i < eventIds.length; i++) {
String eventId = eventIds[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 이벤트 발행 log.info("이벤트 {} 참여자 발행 시작: user{:03d}~user{:03d} ({}명)",
for (int j = 0; j < participants; j++) { eventId, startUser, endUser, eventParticipants);
String participantId = UUID.randomUUID().toString();
String channel = channels[j % channels.length]; // 채널 순환 배정 // 참여자에 대해 ParticipantRegistered 이벤트 발행
for (int userId = startUser; userId <= endUser; userId++) {
String participantId = String.format("user%03d", userId); // user001, user002, ...
// 채널별 가중치 기반 랜덤 배정
// SNS: 45%, 우리동네TV: 25%, 지니TV: 20%, 링고비즈: 10%
int randomValue = random.nextInt(100);
String channel;
if (randomValue < 45) {
channel = "SNS"; // 0~44: 45%
} else if (randomValue < 70) {
channel = "우리동네TV"; // 45~69: 25%
} else if (randomValue < 90) {
channel = "지니TV"; // 70~89: 20%
} else {
channel = "링고비즈"; // 90~99: 10%
}
ParticipantRegisteredEvent event = ParticipantRegisteredEvent.builder() ParticipantRegisteredEvent event = ParticipantRegisteredEvent.builder()
.eventId(eventId) .eventId(eventId)
@ -288,19 +395,38 @@ public class SampleDataLoader implements ApplicationRunner {
totalPublished++; totalPublished++;
// 동시성 충돌 방지: 10개마다 100ms 대기 // 동시성 충돌 방지: 10개마다 100ms 대기
if ((j + 1) % 10 == 0) { if (totalPublished % 10 == 0) {
Thread.sleep(100); Thread.sleep(100);
} }
} }
log.info("✅ 이벤트 {} 참여자 발행 완료: {}명", eventId, eventParticipants);
} }
log.info("========================================");
log.info("✅ ParticipantRegistered 이벤트 {}건 발행 완료", totalPublished); 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("📺 채널별 참여 비율 (가중치):");
log.info(" - SNS: 45% (가장 높음)");
log.info(" - 우리동네TV: 25%");
log.info(" - 지니TV: 20%");
log.info(" - 링고비즈: 10%");
log.info("========================================");
} }
/** /**
* TimelineData 생성 (시간대별 샘플 데이터) * TimelineData 생성 (시간대별 샘플 데이터)
* *
* - 이벤트마다 30일 daily 데이터 생성 * - 이벤트마다 30일 × 24시간 = 720시간 hourly 데이터 생성
* - interval=hourly: 시간별 표시 (최근 7일 적합)
* - interval=daily: 일별 자동 집계 (30일 전체)
* - 참여자 , 조회수, 참여행동, 전환수, 누적 참여자 * - 참여자 , 조회수, 참여행동, 전환수, 누적 참여자
*/ */
private void createTimelineData() { private void createTimelineData() {
@ -308,52 +434,63 @@ public class SampleDataLoader implements ApplicationRunner {
String[] eventIds = {"evt_2025012301", "evt_2025020101", "evt_2025011501"}; 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++) { for (int eventIndex = 0; eventIndex < eventIds.length; eventIndex++) {
String eventId = eventIds[eventIndex]; String eventId = eventIds[eventIndex];
int baseParticipant = baseParticipants[eventIndex]; int baseParticipant = baseParticipantsPerHour[eventIndex];
int cumulativeParticipants = 0; int cumulativeParticipants = 0;
// 30일 데이터 생성 (2024-09-24부터) // 이벤트 ID에서 날짜 파싱 (evt_2025012301 2025-01-23)
java.time.LocalDateTime startDate = java.time.LocalDateTime.of(2024, 9, 24, 0, 0); String dateStr = eventId.substring(4); // "2025012301"
int year = Integer.parseInt(dateStr.substring(0, 4)); // 2025
int month = Integer.parseInt(dateStr.substring(4, 6)); // 01
int day = Integer.parseInt(dateStr.substring(6, 8)); // 23
for (int day = 0; day < 30; day++) { // 이벤트 시작일부터 30일 hourly 데이터 생성
java.time.LocalDateTime timestamp = startDate.plusDays(day); java.time.LocalDateTime startDate = java.time.LocalDateTime.of(year, month, day, 0, 0);
// 랜덤한 참여자 생성 (기준값 ± 50%) for (int dayOffset = 0; dayOffset < 30; dayOffset++) {
int dailyParticipants = baseParticipant + random.nextInt(baseParticipant + 1); for (int hour = 0; hour < 24; hour++) {
cumulativeParticipants += dailyParticipants; java.time.LocalDateTime timestamp = startDate.plusDays(dayOffset).plusHours(hour);
// 시간대별 참여자 변화 ( 시간대 12~20시에 많음)
int hourMultiplier = (hour >= 12 && hour <= 20) ? 2 : 1;
int hourlyParticipants = (baseParticipant * hourMultiplier) + random.nextInt(baseParticipant + 1);
cumulativeParticipants += hourlyParticipants;
// 조회수는 참여자의 3~5배 // 조회수는 참여자의 3~5배
int dailyViews = dailyParticipants * (3 + random.nextInt(3)); int hourlyViews = hourlyParticipants * (3 + random.nextInt(3));
// 참여행동은 참여자의 1~2배 // 참여행동은 참여자의 1~2배
int dailyEngagement = dailyParticipants * (1 + random.nextInt(2)); int hourlyEngagement = hourlyParticipants * (1 + random.nextInt(2));
// 전환수는 참여자의 50~80% // 전환수는 참여자의 50~80%
int dailyConversions = (int) (dailyParticipants * (0.5 + random.nextDouble() * 0.3)); int hourlyConversions = (int) (hourlyParticipants * (0.5 + random.nextDouble() * 0.3));
// TimelineData 생성 // TimelineData 생성
com.kt.event.analytics.entity.TimelineData timelineData = com.kt.event.analytics.entity.TimelineData timelineData =
com.kt.event.analytics.entity.TimelineData.builder() com.kt.event.analytics.entity.TimelineData.builder()
.eventId(eventId) .eventId(eventId)
.timestamp(timestamp) .timestamp(timestamp)
.participants(dailyParticipants) .participants(hourlyParticipants)
.views(dailyViews) .views(hourlyViews)
.engagement(dailyEngagement) .engagement(hourlyEngagement)
.conversions(dailyConversions) .conversions(hourlyConversions)
.cumulativeParticipants(cumulativeParticipants) .cumulativeParticipants(cumulativeParticipants)
.build(); .build();
timelineDataRepository.save(timelineData); timelineDataRepository.save(timelineData);
} }
log.info("✅ TimelineData 생성 완료: eventId={}, 30일 데이터", eventId);
} }
log.info("✅ 전체 TimelineData 생성 완료: 3개 이벤트 × 30일 = 90건"); log.info("✅ TimelineData 생성 완료: eventId={}, 시작일={}-{:02d}-{:02d}, 30일 × 24시간 = 720건",
eventId, year, month, day);
}
log.info("✅ 전체 TimelineData 생성 완료: 3개 이벤트 × 30일 × 24시간 = 2,160건");
} }
/** /**

View File

@ -32,30 +32,18 @@ public class AnalyticsDashboardController {
* 성과 대시보드 조회 * 성과 대시보드 조회
* *
* @param eventId 이벤트 ID * @param eventId 이벤트 ID
* @param startDate 조회 시작 날짜
* @param endDate 조회 종료 날짜
* @param refresh 캐시 갱신 여부 * @param refresh 캐시 갱신 여부
* @return 성과 대시보드 * @return 성과 대시보드 (이벤트 시작일 ~ 현재까지)
*/ */
@Operation( @Operation(
summary = "성과 대시보드 조회", summary = "성과 대시보드 조회",
description = "이벤트의 전체 성과를 통합하여 조회합니다." description = "이벤트의 전체 성과를 통합하여 조회합니다. (이벤트 시작일 ~ 현재까지)"
) )
@GetMapping("/{eventId}/analytics") @GetMapping("/{eventId}/analytics")
public ResponseEntity<ApiResponse<AnalyticsDashboardResponse>> getEventAnalytics( public ResponseEntity<ApiResponse<AnalyticsDashboardResponse>> getEventAnalytics(
@Parameter(description = "이벤트 ID", required = true) @Parameter(description = "이벤트 ID", required = true)
@PathVariable String eventId, @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 호출)") @Parameter(description = "캐시 갱신 여부 (true인 경우 외부 API 호출)")
@RequestParam(required = false, defaultValue = "false") @RequestParam(required = false, defaultValue = "false")
Boolean refresh Boolean refresh
@ -63,7 +51,7 @@ public class AnalyticsDashboardController {
log.info("성과 대시보드 조회 API 호출: eventId={}, refresh={}", eventId, refresh); log.info("성과 대시보드 조회 API 호출: eventId={}, refresh={}", eventId, refresh);
AnalyticsDashboardResponse response = analyticsService.getDashboardData( AnalyticsDashboardResponse response = analyticsService.getDashboardData(
eventId, startDate, endDate, refresh eventId, refresh
); );
return ResponseEntity.ok(ApiResponse.success(response)); return ResponseEntity.ok(ApiResponse.success(response));

View File

@ -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<ApiResponse<String>> reloadSampleData() {
try {
log.info("🔧 수동으로 샘플 데이터 생성 요청");
// SampleDataLoader 실행
sampleDataLoader.run(new ApplicationArguments() {
@Override
public String[] getSourceArgs() {
return new String[0];
}
@Override
public java.util.Set<String> getOptionNames() {
return java.util.Collections.emptySet();
}
@Override
public boolean containsOption(String name) {
return false;
}
@Override
public java.util.List<String> getOptionValues(String name) {
return null;
}
@Override
public java.util.List<String> getNonOptionArgs() {
return java.util.Collections.emptyList();
}
});
return ResponseEntity.ok(ApiResponse.success("샘플 데이터 생성 완료"));
} catch (Exception e) {
log.error("❌ 샘플 데이터 생성 실패", e);
return ResponseEntity.ok(ApiResponse.success("샘플 데이터 생성 실패: " + e.getMessage()));
}
}
}

View File

@ -35,14 +35,12 @@ public class TimelineAnalyticsController {
* *
* @param eventId 이벤트 ID * @param eventId 이벤트 ID
* @param interval 시간 간격 단위 * @param interval 시간 간격 단위
* @param startDate 조회 시작 날짜
* @param endDate 조회 종료 날짜
* @param metrics 조회할 지표 목록 * @param metrics 조회할 지표 목록
* @return 시간대별 참여 추이 * @return 시간대별 참여 추이 (이벤트 시작일 ~ 현재까지)
*/ */
@Operation( @Operation(
summary = "시간대별 참여 추이", summary = "시간대별 참여 추이",
description = "이벤트 기간 동안의 시간대별 참여 추이를 분석합니다." description = "이벤트 기간 동안의 시간대별 참여 추이를 분석합니다. (이벤트 시작일 ~ 현재까지)"
) )
@GetMapping("/{eventId}/analytics/timeline") @GetMapping("/{eventId}/analytics/timeline")
public ResponseEntity<ApiResponse<TimelineAnalyticsResponse>> getTimelineAnalytics( public ResponseEntity<ApiResponse<TimelineAnalyticsResponse>> getTimelineAnalytics(
@ -53,16 +51,6 @@ public class TimelineAnalyticsController {
@RequestParam(required = false, defaultValue = "daily") @RequestParam(required = false, defaultValue = "daily")
String interval, 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 = "조회할 지표 목록 (쉼표로 구분)") @Parameter(description = "조회할 지표 목록 (쉼표로 구분)")
@RequestParam(required = false) @RequestParam(required = false)
String metrics String metrics
@ -74,7 +62,7 @@ public class TimelineAnalyticsController {
: null; : null;
TimelineAnalyticsResponse response = timelineAnalyticsService.getTimelineAnalytics( TimelineAnalyticsResponse response = timelineAnalyticsService.getTimelineAnalytics(
eventId, interval, startDate, endDate, metricList eventId, interval, metricList
); );
return ResponseEntity.ok(ApiResponse.success(response)); return ResponseEntity.ok(ApiResponse.success(response));

View File

@ -32,30 +32,18 @@ public class UserAnalyticsDashboardController {
* 사용자 전체 성과 대시보드 조회 * 사용자 전체 성과 대시보드 조회
* *
* @param userId 사용자 ID * @param userId 사용자 ID
* @param startDate 조회 시작 날짜
* @param endDate 조회 종료 날짜
* @param refresh 캐시 갱신 여부 * @param refresh 캐시 갱신 여부
* @return 전체 통합 성과 대시보드 * @return 전체 통합 성과 대시보드 (userId 기반 전체 이벤트 조회)
*/ */
@Operation( @Operation(
summary = "사용자 전체 성과 대시보드 조회", summary = "사용자 전체 성과 대시보드 조회",
description = "사용자의 모든 이벤트 성과를 통합하여 조회합니다." description = "사용자의 모든 이벤트 성과를 통합하여 조회합니다. (userId 기반 전체 이벤트 조회)"
) )
@GetMapping("/{userId}/analytics") @GetMapping("/{userId}/analytics")
public ResponseEntity<ApiResponse<UserAnalyticsDashboardResponse>> getUserAnalytics( public ResponseEntity<ApiResponse<UserAnalyticsDashboardResponse>> getUserAnalytics(
@Parameter(description = "사용자 ID", required = true) @Parameter(description = "사용자 ID", required = true)
@PathVariable String userId, @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 = "캐시 갱신 여부") @Parameter(description = "캐시 갱신 여부")
@RequestParam(required = false, defaultValue = "false") @RequestParam(required = false, defaultValue = "false")
Boolean refresh Boolean refresh
@ -63,7 +51,7 @@ public class UserAnalyticsDashboardController {
log.info("사용자 전체 성과 대시보드 조회 API 호출: userId={}, refresh={}", userId, refresh); log.info("사용자 전체 성과 대시보드 조회 API 호출: userId={}, refresh={}", userId, refresh);
UserAnalyticsDashboardResponse response = userAnalyticsService.getUserDashboardData( UserAnalyticsDashboardResponse response = userAnalyticsService.getUserDashboardData(
userId, startDate, endDate, refresh userId, refresh
); );
return ResponseEntity.ok(ApiResponse.success(response)); return ResponseEntity.ok(ApiResponse.success(response));

View File

@ -30,17 +30,13 @@ public class UserChannelAnalyticsController {
@Operation( @Operation(
summary = "사용자 전체 채널별 성과 분석", summary = "사용자 전체 채널별 성과 분석",
description = "사용자의 모든 이벤트 채널 성과를 통합하여 분석합니다." description = "사용자의 모든 이벤트 채널 성과를 통합하여 분석합니다. (전체 채널 무조건 표시)"
) )
@GetMapping("/{userId}/analytics/channels") @GetMapping("/{userId}/analytics/channels")
public ResponseEntity<ApiResponse<UserChannelAnalyticsResponse>> getUserChannelAnalytics( public ResponseEntity<ApiResponse<UserChannelAnalyticsResponse>> getUserChannelAnalytics(
@Parameter(description = "사용자 ID", required = true) @Parameter(description = "사용자 ID", required = true)
@PathVariable String userId, @PathVariable String userId,
@Parameter(description = "조회할 채널 목록 (쉼표로 구분)")
@RequestParam(required = false)
String channels,
@Parameter(description = "정렬 기준") @Parameter(description = "정렬 기준")
@RequestParam(required = false, defaultValue = "participants") @RequestParam(required = false, defaultValue = "participants")
String sortBy, String sortBy,
@ -49,28 +45,14 @@ public class UserChannelAnalyticsController {
@RequestParam(required = false, defaultValue = "desc") @RequestParam(required = false, defaultValue = "desc")
String order, 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 = "캐시 갱신 여부") @Parameter(description = "캐시 갱신 여부")
@RequestParam(required = false, defaultValue = "false") @RequestParam(required = false, defaultValue = "false")
Boolean refresh Boolean refresh
) { ) {
log.info("사용자 채널 분석 API 호출: userId={}, sortBy={}", userId, sortBy); log.info("사용자 채널 분석 API 호출: userId={}, sortBy={}", userId, sortBy);
List<String> channelList = channels != null && !channels.isBlank()
? Arrays.asList(channels.split(","))
: null;
UserChannelAnalyticsResponse response = userChannelAnalyticsService.getUserChannelAnalytics( UserChannelAnalyticsResponse response = userChannelAnalyticsService.getUserChannelAnalytics(
userId, channelList, sortBy, order, startDate, endDate, refresh userId, sortBy, order, refresh
); );
return ResponseEntity.ok(ApiResponse.success(response)); return ResponseEntity.ok(ApiResponse.success(response));

View File

@ -28,7 +28,7 @@ public class UserRoiAnalyticsController {
@Operation( @Operation(
summary = "사용자 전체 ROI 상세 분석", summary = "사용자 전체 ROI 상세 분석",
description = "사용자의 모든 이벤트 ROI를 통합하여 분석합니다." description = "사용자의 모든 이벤트 ROI를 통합하여 분석합니다. (userId 기반 전체 이벤트 조회)"
) )
@GetMapping("/{userId}/analytics/roi") @GetMapping("/{userId}/analytics/roi")
public ResponseEntity<ApiResponse<UserRoiAnalyticsResponse>> getUserRoiAnalytics( public ResponseEntity<ApiResponse<UserRoiAnalyticsResponse>> getUserRoiAnalytics(
@ -39,16 +39,6 @@ public class UserRoiAnalyticsController {
@RequestParam(required = false, defaultValue = "true") @RequestParam(required = false, defaultValue = "true")
Boolean includeProjection, 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 = "캐시 갱신 여부") @Parameter(description = "캐시 갱신 여부")
@RequestParam(required = false, defaultValue = "false") @RequestParam(required = false, defaultValue = "false")
Boolean refresh Boolean refresh
@ -56,7 +46,7 @@ public class UserRoiAnalyticsController {
log.info("사용자 ROI 분석 API 호출: userId={}, includeProjection={}", userId, includeProjection); log.info("사용자 ROI 분석 API 호출: userId={}, includeProjection={}", userId, includeProjection);
UserRoiAnalyticsResponse response = userRoiAnalyticsService.getUserRoiAnalytics( UserRoiAnalyticsResponse response = userRoiAnalyticsService.getUserRoiAnalytics(
userId, includeProjection, startDate, endDate, refresh userId, includeProjection, refresh
); );
return ResponseEntity.ok(ApiResponse.success(response)); return ResponseEntity.ok(ApiResponse.success(response));

View File

@ -30,7 +30,7 @@ public class UserTimelineAnalyticsController {
@Operation( @Operation(
summary = "사용자 전체 시간대별 참여 추이", summary = "사용자 전체 시간대별 참여 추이",
description = "사용자의 모든 이벤트 시간대별 데이터를 통합하여 분석합니다." description = "사용자의 모든 이벤트 시간대별 데이터를 통합하여 분석합니다. (userId 기반 전체 이벤트 조회)"
) )
@GetMapping("/{userId}/analytics/timeline") @GetMapping("/{userId}/analytics/timeline")
public ResponseEntity<ApiResponse<UserTimelineAnalyticsResponse>> getUserTimelineAnalytics( public ResponseEntity<ApiResponse<UserTimelineAnalyticsResponse>> getUserTimelineAnalytics(
@ -41,16 +41,6 @@ public class UserTimelineAnalyticsController {
@RequestParam(required = false, defaultValue = "daily") @RequestParam(required = false, defaultValue = "daily")
String interval, 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 = "조회할 지표 목록 (쉼표로 구분)") @Parameter(description = "조회할 지표 목록 (쉼표로 구분)")
@RequestParam(required = false) @RequestParam(required = false)
String metrics, String metrics,
@ -66,7 +56,7 @@ public class UserTimelineAnalyticsController {
: null; : null;
UserTimelineAnalyticsResponse response = userTimelineAnalyticsService.getUserTimelineAnalytics( UserTimelineAnalyticsResponse response = userTimelineAnalyticsService.getUserTimelineAnalytics(
userId, interval, startDate, endDate, metricList, refresh userId, interval, metricList, refresh
); );
return ResponseEntity.ok(ApiResponse.success(response)); return ResponseEntity.ok(ApiResponse.success(response));

View File

@ -47,6 +47,21 @@ public class AnalyticsDashboardResponse {
*/ */
private RoiSummary roi; private RoiSummary roi;
/**
* 투자 비용 상세
*/
private InvestmentDetails investment;
/**
* 수익 상세
*/
private RevenueDetails revenue;
/**
* 비용 효율성 분석
*/
private CostEfficiency costEfficiency;
/** /**
* 마지막 업데이트 시간 * 마지막 업데이트 시간
*/ */

View File

@ -33,6 +33,16 @@ public class InvestmentDetails {
*/ */
private BigDecimal operation; private BigDecimal operation;
/**
* 경품 비용 ()
*/
private BigDecimal prizeCost;
/**
* 채널 비용 () - distribution과 동일한
*/
private BigDecimal channelCost;
/** /**
* 투자 비용 () * 투자 비용 ()
*/ */

View File

@ -26,6 +26,16 @@ public class RevenueDetails {
*/ */
private BigDecimal expectedSales; private BigDecimal expectedSales;
/**
* 신규 고객 매출 ()
*/
private BigDecimal newCustomerRevenue;
/**
* 기존 고객 매출 ()
*/
private BigDecimal existingCustomerRevenue;
/** /**
* 브랜드 가치 향상 추정액 () * 브랜드 가치 향상 추정액 ()
*/ */

View File

@ -125,4 +125,11 @@ public class ChannelStats extends BaseTimeEntity {
@Column(name = "average_duration") @Column(name = "average_duration")
@Builder.Default @Builder.Default
private Integer averageDuration = 0; private Integer averageDuration = 0;
/**
* 참여자 증가
*/
public void incrementParticipants() {
this.participants++;
}
} }

View File

@ -32,7 +32,7 @@ public class DistributionCompletedConsumer {
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private final RedisTemplate<String, String> redisTemplate; private final RedisTemplate<String, String> 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 String CACHE_KEY_PREFIX = "analytics:dashboard:";
private static final long IDEMPOTENCY_TTL_DAYS = 7; private static final long IDEMPOTENCY_TTL_DAYS = 7;
@ -109,10 +109,15 @@ public class DistributionCompletedConsumer {
channelStats.setImpressions(channel.getExpectedViews()); channelStats.setImpressions(channel.getExpectedViews());
} }
// 배포 비용 저장
if (channel.getDistributionCost() != null) {
channelStats.setDistributionCost(channel.getDistributionCost());
}
channelStatsRepository.save(channelStats); channelStatsRepository.save(channelStats);
log.debug("✅ 채널 통계 저장: eventId={}, channel={}, expectedViews={}", log.debug("✅ 채널 통계 저장: eventId={}, channel={}, expectedViews={}, distributionCost={}",
eventId, channelName, channel.getExpectedViews()); eventId, channelName, channel.getExpectedViews(), channel.getDistributionCost());
} catch (Exception e) { } catch (Exception e) {
log.error("❌ 채널 통계 처리 실패: eventId={}, channel={}", eventId, channel.getChannel(), e); log.error("❌ 채널 통계 처리 실패: eventId={}, channel={}", eventId, channel.getChannel(), e);

View File

@ -12,6 +12,7 @@ import org.springframework.kafka.annotation.KafkaListener;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
/** /**
@ -29,7 +30,7 @@ public class EventCreatedConsumer {
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private final RedisTemplate<String, String> redisTemplate; private final RedisTemplate<String, String> 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 String CACHE_KEY_PREFIX = "analytics:dashboard:";
private static final long IDEMPOTENCY_TTL_DAYS = 7; 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로 매핑 .userId(event.getStoreId()) // MVP: 1 user = 1 store, storeId를 userId로 매핑
.totalParticipants(0) .totalParticipants(0)
.totalInvestment(event.getTotalInvestment()) .totalInvestment(event.getTotalInvestment())
.expectedRevenue(event.getExpectedRevenue() != null ? event.getExpectedRevenue() : BigDecimal.ZERO)
.status(event.getStatus()) .status(event.getStatus())
.build(); .build();
eventStatsRepository.save(eventStats); eventStatsRepository.save(eventStats);
log.info("✅ 이벤트 통계 초기화 완료: eventId={}", eventId); log.info("✅ 이벤트 통계 초기화 완료: eventId={}, userId={}, expectedRevenue={}",
eventId, eventStats.getUserId(), event.getExpectedRevenue());
// 3. 캐시 무효화 (다음 조회 최신 데이터 반영) // 3. 캐시 무효화 (다음 조회 최신 데이터 반영)
String cacheKey = CACHE_KEY_PREFIX + eventId; String cacheKey = CACHE_KEY_PREFIX + eventId;

View File

@ -1,7 +1,9 @@
package com.kt.event.analytics.messaging.consumer; 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.entity.EventStats;
import com.kt.event.analytics.messaging.event.ParticipantRegisteredEvent; 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.EventStatsRepository;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@ -26,10 +28,11 @@ import java.util.concurrent.TimeUnit;
public class ParticipantRegisteredConsumer { public class ParticipantRegisteredConsumer {
private final EventStatsRepository eventStatsRepository; private final EventStatsRepository eventStatsRepository;
private final ChannelStatsRepository channelStatsRepository;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private final RedisTemplate<String, String> redisTemplate; private final RedisTemplate<String, String> 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 String CACHE_KEY_PREFIX = "analytics:dashboard:";
private static final long IDEMPOTENCY_TTL_DAYS = 7; private static final long IDEMPOTENCY_TTL_DAYS = 7;
@ -47,11 +50,13 @@ public class ParticipantRegisteredConsumer {
ParticipantRegisteredEvent event = objectMapper.readValue(message, ParticipantRegisteredEvent.class); ParticipantRegisteredEvent event = objectMapper.readValue(message, ParticipantRegisteredEvent.class);
String participantId = event.getParticipantId(); String participantId = event.getParticipantId();
String eventId = event.getEventId(); String eventId = event.getEventId();
String channel = event.getChannel();
// 1. 멱등성 체크 (중복 처리 방지) // 1. 멱등성 체크 (중복 처리 방지) - eventId:participantId 조합으로 체크
Boolean isProcessed = redisTemplate.opsForSet().isMember(PROCESSED_PARTICIPANTS_KEY, participantId); String idempotencyKey = eventId + ":" + participantId;
Boolean isProcessed = redisTemplate.opsForSet().isMember(PROCESSED_PARTICIPANTS_KEY, idempotencyKey);
if (Boolean.TRUE.equals(isProcessed)) { if (Boolean.TRUE.equals(isProcessed)) {
log.warn("⚠️ 중복 이벤트 스킵 (이미 처리됨): participantId={}", participantId); log.warn("⚠️ 중복 이벤트 스킵 (이미 처리됨): eventId={}, participantId={}", eventId, participantId);
return; return;
} }
@ -67,15 +72,29 @@ public class ParticipantRegisteredConsumer {
() -> log.warn("⚠️ 이벤트 통계 없음: eventId={}", eventId) () -> 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; String cacheKey = CACHE_KEY_PREFIX + eventId;
redisTemplate.delete(cacheKey); redisTemplate.delete(cacheKey);
log.debug("🗑️ 캐시 무효화: {}", cacheKey); log.debug("🗑️ 캐시 무효화: {}", cacheKey);
// 4. 멱등성 처리 완료 기록 (7일 TTL) // 5. 멱등성 처리 완료 기록 (7일 TTL)
redisTemplate.opsForSet().add(PROCESSED_PARTICIPANTS_KEY, participantId); redisTemplate.opsForSet().add(PROCESSED_PARTICIPANTS_KEY, idempotencyKey);
redisTemplate.expire(PROCESSED_PARTICIPANTS_KEY, IDEMPOTENCY_TTL_DAYS, TimeUnit.DAYS); redisTemplate.expire(PROCESSED_PARTICIPANTS_KEY, IDEMPOTENCY_TTL_DAYS, TimeUnit.DAYS);
log.debug("✅ 멱등성 기록: participantId={}", participantId); log.debug("✅ 멱등성 기록: eventId={}, participantId={}", eventId, participantId);
} catch (Exception e) { } catch (Exception e) {
log.error("❌ ParticipantRegistered 이벤트 처리 실패: {}", e.getMessage(), e); log.error("❌ ParticipantRegistered 이벤트 처리 실패: {}", e.getMessage(), e);

View File

@ -62,5 +62,10 @@ public class DistributionCompletedEvent {
* 예상 노출 * 예상 노출
*/ */
private Integer expectedViews; private Integer expectedViews;
/**
* 배포 비용 ()
*/
private java.math.BigDecimal distributionCost;
} }
} }

View File

@ -36,6 +36,11 @@ public class EventCreatedEvent {
*/ */
private BigDecimal totalInvestment; private BigDecimal totalInvestment;
/**
* 예상 수익
*/
private BigDecimal expectedRevenue;
/** /**
* 이벤트 상태 * 이벤트 상태
*/ */

View File

@ -1,7 +1,11 @@
package com.kt.event.analytics.repository; package com.kt.event.analytics.repository;
import com.kt.event.analytics.entity.ChannelStats; import com.kt.event.analytics.entity.ChannelStats;
import jakarta.persistence.LockModeType;
import org.springframework.data.jpa.repository.JpaRepository; 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 org.springframework.stereotype.Repository;
import java.util.List; import java.util.List;
@ -30,6 +34,18 @@ public interface ChannelStatsRepository extends JpaRepository<ChannelStats, Long
*/ */
Optional<ChannelStats> findByEventIdAndChannelName(String eventId, String channelName); Optional<ChannelStats> 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<ChannelStats> findByEventIdAndChannelNameWithLock(@Param("eventId") String eventId,
@Param("channelName") String channelName);
/** /**
* 여러 이벤트 ID로 모든 채널 통계 조회 * 여러 이벤트 ID로 모든 채널 통계 조회
* *

View File

@ -47,12 +47,10 @@ public class AnalyticsService {
* 대시보드 데이터 조회 * 대시보드 데이터 조회
* *
* @param eventId 이벤트 ID * @param eventId 이벤트 ID
* @param startDate 조회 시작 날짜 (선택)
* @param endDate 조회 종료 날짜 (선택)
* @param refresh 캐시 갱신 여부 * @param refresh 캐시 갱신 여부
* @return 대시보드 응답 * @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); log.info("대시보드 데이터 조회 시작: eventId={}, refresh={}", eventId, refresh);
String cacheKey = CACHE_KEY_PREFIX + eventId; String cacheKey = CACHE_KEY_PREFIX + eventId;
@ -91,7 +89,7 @@ public class AnalyticsService {
} }
// 3. 대시보드 데이터 구성 // 3. 대시보드 데이터 구성
AnalyticsDashboardResponse response = buildDashboardData(eventStats, channelStatsList, startDate, endDate); AnalyticsDashboardResponse response = buildDashboardData(eventStats, channelStatsList);
// 4. Redis 캐싱 (1시간 TTL) // 4. Redis 캐싱 (1시간 TTL)
try { try {
@ -110,10 +108,9 @@ public class AnalyticsService {
/** /**
* 대시보드 데이터 구성 * 대시보드 데이터 구성
*/ */
private AnalyticsDashboardResponse buildDashboardData(EventStats eventStats, List<ChannelStats> channelStatsList, private AnalyticsDashboardResponse buildDashboardData(EventStats eventStats, List<ChannelStats> channelStatsList) {
LocalDateTime startDate, LocalDateTime endDate) { // 기간 정보 (이벤트 시작일 ~ 현재)
// 기간 정보 PeriodInfo period = buildPeriodInfo(eventStats);
PeriodInfo period = buildPeriodInfo(startDate, endDate);
// 성과 요약 // 성과 요약
AnalyticsSummary summary = buildAnalyticsSummary(eventStats, channelStatsList); AnalyticsSummary summary = buildAnalyticsSummary(eventStats, channelStatsList);
@ -124,6 +121,15 @@ public class AnalyticsService {
// ROI 요약 // ROI 요약
RoiSummary roiSummary = roiCalculator.calculateRoiSummary(eventStats); RoiSummary roiSummary = roiCalculator.calculateRoiSummary(eventStats);
// 투자 비용 상세
InvestmentDetails investment = buildInvestmentDetails(eventStats, channelStatsList);
// 수익 상세
RevenueDetails revenue = buildRevenueDetails(eventStats);
// 비용 효율성
CostEfficiency costEfficiency = buildCostEfficiency(eventStats);
return AnalyticsDashboardResponse.builder() return AnalyticsDashboardResponse.builder()
.eventId(eventStats.getEventId()) .eventId(eventStats.getEventId())
.eventTitle(eventStats.getEventTitle()) .eventTitle(eventStats.getEventTitle())
@ -131,17 +137,20 @@ public class AnalyticsService {
.summary(summary) .summary(summary)
.channelPerformance(channelPerformance) .channelPerformance(channelPerformance)
.roi(roiSummary) .roi(roiSummary)
.investment(investment)
.revenue(revenue)
.costEfficiency(costEfficiency)
.lastUpdatedAt(LocalDateTime.now()) .lastUpdatedAt(LocalDateTime.now())
.dataSource("cached") .dataSource("cached")
.build(); .build();
} }
/** /**
* 기간 정보 구성 * 기간 정보 구성 (이벤트 생성일 ~ 현재)
*/ */
private PeriodInfo buildPeriodInfo(LocalDateTime startDate, LocalDateTime endDate) { private PeriodInfo buildPeriodInfo(EventStats eventStats) {
LocalDateTime start = startDate != null ? startDate : LocalDateTime.now().minusDays(30); LocalDateTime start = eventStats.getCreatedAt();
LocalDateTime end = endDate != null ? endDate : LocalDateTime.now(); LocalDateTime end = LocalDateTime.now();
long durationDays = ChronoUnit.DAYS.between(start, end); long durationDays = ChronoUnit.DAYS.between(start, end);
@ -215,4 +224,88 @@ public class AnalyticsService {
return summaries; return summaries;
} }
/**
* 투자 비용 상세 구성
*
* UserRoiAnalyticsService와 동일한 로직:
* - 실제 채널 배포 비용 집계
* - 나머지 비용 분배: 경품 50%, 콘텐츠 제작 30%, 운영 20%
*/
private InvestmentDetails buildInvestmentDetails(EventStats eventStats, List<ChannelStats> channelStatsList) {
java.math.BigDecimal totalInvestment = eventStats.getTotalInvestment();
// ChannelStats에서 실제 배포 비용 집계
java.math.BigDecimal actualDistribution = channelStatsList.stream()
.map(ChannelStats::getDistributionCost)
.reduce(java.math.BigDecimal.ZERO, java.math.BigDecimal::add);
// 나머지 비용 계산 ( 투자 - 실제 채널 배포 비용)
java.math.BigDecimal remaining = totalInvestment.subtract(actualDistribution);
// 나머지 비용 분배: 경품 50%, 콘텐츠 제작 30%, 운영 20%
java.math.BigDecimal prizeCost = remaining.multiply(java.math.BigDecimal.valueOf(0.50));
java.math.BigDecimal contentCreation = remaining.multiply(java.math.BigDecimal.valueOf(0.30));
java.math.BigDecimal operation = remaining.multiply(java.math.BigDecimal.valueOf(0.20));
return InvestmentDetails.builder()
.total(totalInvestment)
.contentCreation(contentCreation)
.operation(operation)
.distribution(actualDistribution)
.prizeCost(prizeCost)
.channelCost(actualDistribution) // 채널비용은 배포비용과 동일
.build();
}
/**
* 수익 상세 구성
*
* UserRoiAnalyticsService와 동일한 로직:
* - 직접 매출 70%, 예상 추가 매출 30%
* - 신규 고객 40%, 기존 고객 60%
*/
private RevenueDetails buildRevenueDetails(EventStats eventStats) {
java.math.BigDecimal totalRevenue = eventStats.getExpectedRevenue();
// 매출 분배: 직접 매출 70%, 예상 추가 매출 30%
java.math.BigDecimal directSales = totalRevenue.multiply(java.math.BigDecimal.valueOf(0.70));
java.math.BigDecimal expectedSales = totalRevenue.multiply(java.math.BigDecimal.valueOf(0.30));
// 신규 고객 40%, 기존 고객 60%
java.math.BigDecimal newCustomerRevenue = totalRevenue.multiply(java.math.BigDecimal.valueOf(0.40));
java.math.BigDecimal existingCustomerRevenue = totalRevenue.multiply(java.math.BigDecimal.valueOf(0.60));
return RevenueDetails.builder()
.total(totalRevenue)
.directSales(directSales)
.expectedSales(expectedSales)
.newCustomerRevenue(newCustomerRevenue)
.existingCustomerRevenue(existingCustomerRevenue)
.brandValue(java.math.BigDecimal.ZERO) // 브랜드 가치는 별도 계산 필요 추가
.build();
}
/**
* 비용 효율성 구성
*
* UserRoiAnalyticsService와 동일한 로직:
* - 참여자당 비용 = 총투자 ÷ 총참여자수
* - 참여자당 수익 = 총수익 ÷ 총참여자수
*/
private CostEfficiency buildCostEfficiency(EventStats eventStats) {
int totalParticipants = eventStats.getTotalParticipants();
java.math.BigDecimal totalInvestment = eventStats.getTotalInvestment();
java.math.BigDecimal totalRevenue = eventStats.getExpectedRevenue();
double costPerParticipant = totalParticipants > 0 ?
totalInvestment.doubleValue() / totalParticipants : 0.0;
double revenuePerParticipant = totalParticipants > 0 ?
totalRevenue.doubleValue() / totalParticipants : 0.0;
return CostEfficiency.builder()
.costPerParticipant(costPerParticipant)
.revenuePerParticipant(revenuePerParticipant)
.build();
}
} }

View File

@ -60,43 +60,62 @@ public class ROICalculator {
/** /**
* 투자 비용 계산 * 투자 비용 계산
*
* UserRoiAnalyticsService와 동일한 로직:
* - ChannelStats에서 실제 배포 비용 집계
* - 나머지 비용 분배: 경품 50%, 콘텐츠 제작 30%, 운영 20%
*/ */
private InvestmentDetails calculateInvestment(EventStats eventStats, List<ChannelStats> channelStats) { private InvestmentDetails calculateInvestment(EventStats eventStats, List<ChannelStats> channelStats) {
BigDecimal distributionCost = channelStats.stream() BigDecimal totalInvestment = eventStats.getTotalInvestment();
// ChannelStats에서 실제 배포 비용 집계
BigDecimal actualDistribution = channelStats.stream()
.map(ChannelStats::getDistributionCost) .map(ChannelStats::getDistributionCost)
.reduce(BigDecimal.ZERO, BigDecimal::add); .reduce(BigDecimal.ZERO, BigDecimal::add);
BigDecimal contentCreation = eventStats.getTotalInvestment() // 나머지 비용 계산 ( 투자 - 실제 채널 배포 비용)
.multiply(BigDecimal.valueOf(0.4)); // 전체 투자의 40% 콘텐츠 제작비로 가정 BigDecimal remaining = totalInvestment.subtract(actualDistribution);
BigDecimal operation = eventStats.getTotalInvestment() // 나머지 비용 분배: 경품 50%, 콘텐츠 제작 30%, 운영 20%
.multiply(BigDecimal.valueOf(0.1)); // 10% 운영비로 가정 BigDecimal prizeCost = remaining.multiply(BigDecimal.valueOf(0.50));
BigDecimal contentCreation = remaining.multiply(BigDecimal.valueOf(0.30));
BigDecimal operation = remaining.multiply(BigDecimal.valueOf(0.20));
return InvestmentDetails.builder() return InvestmentDetails.builder()
.total(totalInvestment)
.contentCreation(contentCreation) .contentCreation(contentCreation)
.distribution(distributionCost)
.operation(operation) .operation(operation)
.total(eventStats.getTotalInvestment()) .distribution(actualDistribution)
.prizeCost(prizeCost)
.channelCost(actualDistribution) // 채널비용은 배포비용과 동일
.build(); .build();
} }
/** /**
* 수익 계산 * 수익 계산
*
* UserRoiAnalyticsService와 동일한 로직:
* - 직접 매출 70%, 예상 추가 매출 30%
* - 신규 고객 40%, 기존 고객 60%
*/ */
private RevenueDetails calculateRevenue(EventStats eventStats) { private RevenueDetails calculateRevenue(EventStats eventStats) {
BigDecimal directSales = eventStats.getExpectedRevenue() BigDecimal totalRevenue = eventStats.getExpectedRevenue();
.multiply(BigDecimal.valueOf(0.66)); // 예상 수익의 66% 직접 매출로 가정
BigDecimal expectedSales = eventStats.getExpectedRevenue() // 매출 분배: 직접 매출 70%, 예상 추가 매출 30%
.multiply(BigDecimal.valueOf(0.34)); // 34% 예상 추가 매출로 가정 BigDecimal directSales = totalRevenue.multiply(BigDecimal.valueOf(0.70));
BigDecimal expectedSales = totalRevenue.multiply(BigDecimal.valueOf(0.30));
BigDecimal brandValue = BigDecimal.ZERO; // 브랜드 가치는 별도 계산 필요 // 신규 고객 40%, 기존 고객 60%
BigDecimal newCustomerRevenue = totalRevenue.multiply(BigDecimal.valueOf(0.40));
BigDecimal existingCustomerRevenue = totalRevenue.multiply(BigDecimal.valueOf(0.60));
return RevenueDetails.builder() return RevenueDetails.builder()
.total(totalRevenue)
.directSales(directSales) .directSales(directSales)
.expectedSales(expectedSales) .expectedSales(expectedSales)
.brandValue(brandValue) .newCustomerRevenue(newCustomerRevenue)
.total(eventStats.getExpectedRevenue()) .existingCustomerRevenue(existingCustomerRevenue)
.brandValue(BigDecimal.ZERO) // 브랜드 가치는 별도 계산 필요 추가
.build(); .build();
} }

View File

@ -26,20 +26,13 @@ public class TimelineAnalyticsService {
private final TimelineDataRepository timelineDataRepository; private final TimelineDataRepository timelineDataRepository;
/** /**
* 시간대별 참여 추이 조회 * 시간대별 참여 추이 조회 (이벤트 전체 기간)
*/ */
public TimelineAnalyticsResponse getTimelineAnalytics(String eventId, String interval, public TimelineAnalyticsResponse getTimelineAnalytics(String eventId, String interval, List<String> metrics) {
LocalDateTime startDate, LocalDateTime endDate,
List<String> metrics) {
log.info("시간대별 참여 추이 조회: eventId={}, interval={}", eventId, interval); log.info("시간대별 참여 추이 조회: eventId={}, interval={}", eventId, interval);
// 시간대별 데이터 조회 // 시간대별 데이터 조회 (이벤트 전체 기간)
List<TimelineData> timelineDataList; List<TimelineData> timelineDataList = timelineDataRepository.findByEventIdOrderByTimestampAsc(eventId);
if (startDate != null && endDate != null) {
timelineDataList = timelineDataRepository.findByEventIdAndTimestampBetween(eventId, startDate, endDate);
} else {
timelineDataList = timelineDataRepository.findByEventIdOrderByTimestampAsc(eventId);
}
// 시간대별 데이터 포인트 구성 // 시간대별 데이터 포인트 구성
List<TimelineDataPoint> dataPoints = buildTimelineDataPoints(timelineDataList); List<TimelineDataPoint> dataPoints = buildTimelineDataPoints(timelineDataList);

View File

@ -45,12 +45,10 @@ public class UserAnalyticsService {
* 사용자 전체 대시보드 데이터 조회 * 사용자 전체 대시보드 데이터 조회
* *
* @param userId 사용자 ID * @param userId 사용자 ID
* @param startDate 조회 시작 날짜 (선택)
* @param endDate 조회 종료 날짜 (선택)
* @param refresh 캐시 갱신 여부 * @param refresh 캐시 갱신 여부
* @return 사용자 통합 대시보드 응답 * @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); log.info("사용자 전체 대시보드 데이터 조회 시작: userId={}, refresh={}", userId, refresh);
String cacheKey = CACHE_KEY_PREFIX + userId; String cacheKey = CACHE_KEY_PREFIX + userId;
@ -75,7 +73,7 @@ public class UserAnalyticsService {
List<EventStats> allEvents = eventStatsRepository.findAllByUserId(userId); List<EventStats> allEvents = eventStatsRepository.findAllByUserId(userId);
if (allEvents.isEmpty()) { if (allEvents.isEmpty()) {
log.warn("사용자에 이벤트가 없음: userId={}", userId); log.warn("사용자에 이벤트가 없음: userId={}", userId);
return buildEmptyResponse(userId, startDate, endDate); return buildEmptyResponse(userId);
} }
log.debug("사용자 이벤트 조회 완료: userId={}, 이벤트 수={}", userId, allEvents.size()); log.debug("사용자 이벤트 조회 완료: userId={}, 이벤트 수={}", userId, allEvents.size());
@ -87,7 +85,7 @@ public class UserAnalyticsService {
List<ChannelStats> allChannelStats = channelStatsRepository.findByEventIdIn(eventIds); List<ChannelStats> allChannelStats = channelStatsRepository.findByEventIdIn(eventIds);
// 3. 통합 대시보드 데이터 구성 // 3. 통합 대시보드 데이터 구성
UserAnalyticsDashboardResponse response = buildUserDashboardData(userId, allEvents, allChannelStats, startDate, endDate); UserAnalyticsDashboardResponse response = buildUserDashboardData(userId, allEvents, allChannelStats);
// 4. Redis 캐싱 (30분 TTL) // 4. Redis 캐싱 (30분 TTL)
try { 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() return UserAnalyticsDashboardResponse.builder()
.userId(userId) .userId(userId)
.period(buildPeriodInfo(startDate, endDate)) .period(PeriodInfo.builder()
.startDate(now)
.endDate(now)
.durationDays(0)
.build())
.totalEvents(0) .totalEvents(0)
.activeEvents(0) .activeEvents(0)
.overallSummary(buildEmptyAnalyticsSummary()) .overallSummary(buildEmptyAnalyticsSummary())
@ -123,10 +126,9 @@ public class UserAnalyticsService {
* 사용자 통합 대시보드 데이터 구성 * 사용자 통합 대시보드 데이터 구성
*/ */
private UserAnalyticsDashboardResponse buildUserDashboardData(String userId, List<EventStats> allEvents, private UserAnalyticsDashboardResponse buildUserDashboardData(String userId, List<EventStats> allEvents,
List<ChannelStats> allChannelStats, List<ChannelStats> allChannelStats) {
LocalDateTime startDate, LocalDateTime endDate) { // 기간 정보 (전체 이벤트의 최소/최대 날짜 기반)
// 기간 정보 PeriodInfo period = buildPeriodFromEvents(allEvents);
PeriodInfo period = buildPeriodInfo(startDate, endDate);
// 전체 이벤트 활성 이벤트 // 전체 이벤트 활성 이벤트
int totalEvents = allEvents.size(); 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); * 전체 이벤트의 생성/수정 시간 기반으로 period 계산
LocalDateTime end = endDate != null ? endDate : LocalDateTime.now(); */
long durationDays = ChronoUnit.DAYS.between(start, end); private PeriodInfo buildPeriodFromEvents(List<EventStats> 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() return PeriodInfo.builder()
.startDate(start) .startDate(start)
.endDate(end) .endDate(end)
.durationDays((int) durationDays) .durationDays((int) ChronoUnit.DAYS.between(start, end))
.build(); .build();
} }

View File

@ -42,10 +42,9 @@ public class UserChannelAnalyticsService {
private static final long CACHE_TTL = 1800; // 30분 private static final long CACHE_TTL = 1800; // 30분
/** /**
* 사용자 전체 채널 분석 데이터 조회 * 사용자 전체 채널 분석 데이터 조회 (전체 채널 무조건 표시)
*/ */
public UserChannelAnalyticsResponse getUserChannelAnalytics(String userId, List<String> channels, String sortBy, String order, public UserChannelAnalyticsResponse getUserChannelAnalytics(String userId, String sortBy, String order, boolean refresh) {
LocalDateTime startDate, LocalDateTime endDate, boolean refresh) {
log.info("사용자 채널 분석 조회 시작: userId={}, refresh={}", userId, refresh); log.info("사용자 채널 분석 조회 시작: userId={}, refresh={}", userId, refresh);
String cacheKey = CACHE_KEY_PREFIX + userId; String cacheKey = CACHE_KEY_PREFIX + userId;
@ -66,14 +65,14 @@ public class UserChannelAnalyticsService {
// 2. 데이터 조회 // 2. 데이터 조회
List<EventStats> allEvents = eventStatsRepository.findAllByUserId(userId); List<EventStats> allEvents = eventStatsRepository.findAllByUserId(userId);
if (allEvents.isEmpty()) { if (allEvents.isEmpty()) {
return buildEmptyResponse(userId, startDate, endDate); return buildEmptyResponse(userId);
} }
List<String> eventIds = allEvents.stream().map(EventStats::getEventId).collect(Collectors.toList()); List<String> eventIds = allEvents.stream().map(EventStats::getEventId).collect(Collectors.toList());
List<ChannelStats> allChannelStats = channelStatsRepository.findByEventIdIn(eventIds); List<ChannelStats> allChannelStats = channelStatsRepository.findByEventIdIn(eventIds);
// 3. 응답 구성 // 3. 응답 구성 (전체 채널)
UserChannelAnalyticsResponse response = buildChannelAnalyticsResponse(userId, allEvents, allChannelStats, channels, sortBy, order, startDate, endDate); UserChannelAnalyticsResponse response = buildChannelAnalyticsResponse(userId, allEvents, allChannelStats, sortBy, order);
// 4. 캐싱 // 4. 캐싱
try { try {
@ -87,10 +86,15 @@ public class UserChannelAnalyticsService {
return response; return response;
} }
private UserChannelAnalyticsResponse buildEmptyResponse(String userId, LocalDateTime startDate, LocalDateTime endDate) { private UserChannelAnalyticsResponse buildEmptyResponse(String userId) {
LocalDateTime now = LocalDateTime.now();
return UserChannelAnalyticsResponse.builder() return UserChannelAnalyticsResponse.builder()
.userId(userId) .userId(userId)
.period(buildPeriodInfo(startDate, endDate)) .period(PeriodInfo.builder()
.startDate(now)
.endDate(now)
.durationDays(0)
.build())
.totalEvents(0) .totalEvents(0)
.channels(new ArrayList<>()) .channels(new ArrayList<>())
.comparison(ChannelComparison.builder().build()) .comparison(ChannelComparison.builder().build())
@ -100,15 +104,10 @@ public class UserChannelAnalyticsService {
} }
private UserChannelAnalyticsResponse buildChannelAnalyticsResponse(String userId, List<EventStats> allEvents, private UserChannelAnalyticsResponse buildChannelAnalyticsResponse(String userId, List<EventStats> allEvents,
List<ChannelStats> allChannelStats, List<String> channels, List<ChannelStats> allChannelStats,
String sortBy, String order, LocalDateTime startDate, LocalDateTime endDate) { String sortBy, String order) {
// 채널 필터링 // 채널별 집계 (전체 채널)
List<ChannelStats> filteredChannels = channels != null && !channels.isEmpty() List<ChannelAnalytics> channelAnalyticsList = aggregateChannelAnalytics(allChannelStats);
? allChannelStats.stream().filter(c -> channels.contains(c.getChannelName())).collect(Collectors.toList())
: allChannelStats;
// 채널별 집계
List<ChannelAnalytics> channelAnalyticsList = aggregateChannelAnalytics(filteredChannels);
// 정렬 // 정렬
channelAnalyticsList = sortChannels(channelAnalyticsList, sortBy, order); channelAnalyticsList = sortChannels(channelAnalyticsList, sortBy, order);
@ -118,7 +117,7 @@ public class UserChannelAnalyticsService {
return UserChannelAnalyticsResponse.builder() return UserChannelAnalyticsResponse.builder()
.userId(userId) .userId(userId)
.period(buildPeriodInfo(startDate, endDate)) .period(buildPeriodFromEvents(allEvents))
.totalEvents(allEvents.size()) .totalEvents(allEvents.size())
.channels(channelAnalyticsList) .channels(channelAnalyticsList)
.comparison(comparison) .comparison(comparison)
@ -246,15 +245,24 @@ public class UserChannelAnalyticsService {
.build(); .build();
} }
private PeriodInfo buildPeriodInfo(LocalDateTime startDate, LocalDateTime endDate) { /**
LocalDateTime start = startDate != null ? startDate : LocalDateTime.now().minusDays(30); * 전체 이벤트의 생성/수정 시간 기반으로 period 계산
LocalDateTime end = endDate != null ? endDate : LocalDateTime.now(); */
long durationDays = ChronoUnit.DAYS.between(start, end); private PeriodInfo buildPeriodFromEvents(List<EventStats> 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() return PeriodInfo.builder()
.startDate(start) .startDate(start)
.endDate(end) .endDate(end)
.durationDays((int) durationDays) .durationDays((int) ChronoUnit.DAYS.between(start, end))
.build(); .build();
} }
} }

View File

@ -1,7 +1,9 @@
package com.kt.event.analytics.service; package com.kt.event.analytics.service;
import com.kt.event.analytics.dto.response.*; 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.entity.EventStats;
import com.kt.event.analytics.repository.ChannelStatsRepository;
import com.kt.event.analytics.repository.EventStatsRepository; import com.kt.event.analytics.repository.EventStatsRepository;
import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
@ -31,14 +33,14 @@ import java.util.stream.Collectors;
public class UserRoiAnalyticsService { public class UserRoiAnalyticsService {
private final EventStatsRepository eventStatsRepository; private final EventStatsRepository eventStatsRepository;
private final ChannelStatsRepository channelStatsRepository;
private final RedisTemplate<String, String> redisTemplate; private final RedisTemplate<String, String> redisTemplate;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private static final String CACHE_KEY_PREFIX = "analytics:user:roi:"; private static final String CACHE_KEY_PREFIX = "analytics:user:roi:";
private static final long CACHE_TTL = 1800; private static final long CACHE_TTL = 1800;
public UserRoiAnalyticsResponse getUserRoiAnalytics(String userId, boolean includeProjection, public UserRoiAnalyticsResponse getUserRoiAnalytics(String userId, boolean includeProjection, boolean refresh) {
LocalDateTime startDate, LocalDateTime endDate, boolean refresh) {
log.info("사용자 ROI 분석 조회 시작: userId={}, refresh={}", userId, refresh); log.info("사용자 ROI 분석 조회 시작: userId={}, refresh={}", userId, refresh);
String cacheKey = CACHE_KEY_PREFIX + userId; String cacheKey = CACHE_KEY_PREFIX + userId;
@ -56,10 +58,10 @@ public class UserRoiAnalyticsService {
List<EventStats> allEvents = eventStatsRepository.findAllByUserId(userId); List<EventStats> allEvents = eventStatsRepository.findAllByUserId(userId);
if (allEvents.isEmpty()) { 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 { try {
String jsonData = objectMapper.writeValueAsString(response); String jsonData = objectMapper.writeValueAsString(response);
@ -71,13 +73,32 @@ public class UserRoiAnalyticsService {
return response; return response;
} }
private UserRoiAnalyticsResponse buildEmptyResponse(String userId, LocalDateTime startDate, LocalDateTime endDate) { private UserRoiAnalyticsResponse buildEmptyResponse(String userId) {
LocalDateTime now = LocalDateTime.now();
return UserRoiAnalyticsResponse.builder() return UserRoiAnalyticsResponse.builder()
.userId(userId) .userId(userId)
.period(buildPeriodInfo(startDate, endDate)) .period(PeriodInfo.builder()
.startDate(now)
.endDate(now)
.durationDays(0)
.build())
.totalEvents(0) .totalEvents(0)
.overallInvestment(InvestmentDetails.builder().total(BigDecimal.ZERO).build()) .overallInvestment(InvestmentDetails.builder()
.overallRevenue(RevenueDetails.builder().total(BigDecimal.ZERO).build()) .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() .overallRoi(RoiCalculation.builder()
.netProfit(BigDecimal.ZERO) .netProfit(BigDecimal.ZERO)
.roiPercentage(0.0) .roiPercentage(0.0)
@ -88,8 +109,7 @@ public class UserRoiAnalyticsService {
.build(); .build();
} }
private UserRoiAnalyticsResponse buildRoiResponse(String userId, List<EventStats> allEvents, boolean includeProjection, private UserRoiAnalyticsResponse buildRoiResponse(String userId, List<EventStats> allEvents, boolean includeProjection) {
LocalDateTime startDate, LocalDateTime endDate) {
BigDecimal totalInvestment = allEvents.stream().map(EventStats::getTotalInvestment).reduce(BigDecimal.ZERO, BigDecimal::add); 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 totalRevenue = allEvents.stream().map(EventStats::getExpectedRevenue).reduce(BigDecimal.ZERO, BigDecimal::add);
BigDecimal totalProfit = totalRevenue.subtract(totalInvestment); BigDecimal totalProfit = totalRevenue.subtract(totalInvestment);
@ -98,17 +118,44 @@ public class UserRoiAnalyticsService {
? totalProfit.divide(totalInvestment, 4, RoundingMode.HALF_UP).multiply(BigDecimal.valueOf(100)).doubleValue() ? totalProfit.divide(totalInvestment, 4, RoundingMode.HALF_UP).multiply(BigDecimal.valueOf(100)).doubleValue()
: 0.0; : 0.0;
// ChannelStats에서 실제 배포 비용 집계
List<String> eventIds = allEvents.stream().map(EventStats::getEventId).collect(Collectors.toList());
List<ChannelStats> 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() InvestmentDetails investment = InvestmentDetails.builder()
.total(totalInvestment) .total(totalInvestment)
.contentCreation(totalInvestment.multiply(BigDecimal.valueOf(0.6))) .contentCreation(contentCreation)
.operation(totalInvestment.multiply(BigDecimal.valueOf(0.2))) .operation(operation)
.distribution(totalInvestment.multiply(BigDecimal.valueOf(0.2))) .distribution(actualDistribution)
.prizeCost(prizeCost)
.channelCost(actualDistribution) // 채널비용은 배포비용과 동일
.build(); .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() RevenueDetails revenue = RevenueDetails.builder()
.total(totalRevenue) .total(totalRevenue)
.directSales(totalRevenue.multiply(BigDecimal.valueOf(0.7))) .directSales(directSales)
.expectedSales(totalRevenue.multiply(BigDecimal.valueOf(0.3))) .expectedSales(expectedSales)
.newCustomerRevenue(newCustomerRevenue)
.existingCustomerRevenue(existingCustomerRevenue)
.brandValue(BigDecimal.ZERO) // 브랜드 가치는 별도 계산 필요 추가
.build(); .build();
RoiCalculation roiCalc = RoiCalculation.builder() RoiCalculation roiCalc = RoiCalculation.builder()
@ -149,9 +196,12 @@ public class UserRoiAnalyticsService {
.sorted(Comparator.comparingDouble(UserRoiAnalyticsResponse.EventRoiSummary::getRoi).reversed()) .sorted(Comparator.comparingDouble(UserRoiAnalyticsResponse.EventRoiSummary::getRoi).reversed())
.collect(Collectors.toList()); .collect(Collectors.toList());
// 전체 이벤트의 최소/최대 날짜로 period 계산
PeriodInfo period = buildPeriodFromEvents(allEvents);
return UserRoiAnalyticsResponse.builder() return UserRoiAnalyticsResponse.builder()
.userId(userId) .userId(userId)
.period(buildPeriodInfo(startDate, endDate)) .period(period)
.totalEvents(allEvents.size()) .totalEvents(allEvents.size())
.overallInvestment(investment) .overallInvestment(investment)
.overallRevenue(revenue) .overallRevenue(revenue)
@ -164,9 +214,20 @@ public class UserRoiAnalyticsService {
.build(); .build();
} }
private PeriodInfo buildPeriodInfo(LocalDateTime startDate, LocalDateTime endDate) { /**
LocalDateTime start = startDate != null ? startDate : LocalDateTime.now().minusDays(30); * 전체 이벤트의 생성/수정 시간 기반으로 period 계산
LocalDateTime end = endDate != null ? endDate : LocalDateTime.now(); */
private PeriodInfo buildPeriodFromEvents(List<EventStats> 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() return PeriodInfo.builder()
.startDate(start) .startDate(start)
.endDate(end) .endDate(end)

View File

@ -37,7 +37,6 @@ public class UserTimelineAnalyticsService {
private static final long CACHE_TTL = 1800; private static final long CACHE_TTL = 1800;
public UserTimelineAnalyticsResponse getUserTimelineAnalytics(String userId, String interval, public UserTimelineAnalyticsResponse getUserTimelineAnalytics(String userId, String interval,
LocalDateTime startDate, LocalDateTime endDate,
List<String> metrics, boolean refresh) { List<String> metrics, boolean refresh) {
log.info("사용자 타임라인 분석 조회 시작: userId={}, interval={}, refresh={}", userId, interval, refresh); log.info("사용자 타임라인 분석 조회 시작: userId={}, interval={}, refresh={}", userId, interval, refresh);
@ -56,15 +55,13 @@ public class UserTimelineAnalyticsService {
List<EventStats> allEvents = eventStatsRepository.findAllByUserId(userId); List<EventStats> allEvents = eventStatsRepository.findAllByUserId(userId);
if (allEvents.isEmpty()) { if (allEvents.isEmpty()) {
return buildEmptyResponse(userId, interval, startDate, endDate); return buildEmptyResponse(userId, interval);
} }
List<String> eventIds = allEvents.stream().map(EventStats::getEventId).collect(Collectors.toList()); List<String> eventIds = allEvents.stream().map(EventStats::getEventId).collect(Collectors.toList());
List<TimelineData> allTimelineData = startDate != null && endDate != null List<TimelineData> allTimelineData = timelineDataRepository.findByEventIdInOrderByTimestampAsc(eventIds);
? timelineDataRepository.findByEventIdInAndTimestampBetween(eventIds, startDate, endDate)
: timelineDataRepository.findByEventIdInOrderByTimestampAsc(eventIds);
UserTimelineAnalyticsResponse response = buildTimelineResponse(userId, allEvents, allTimelineData, interval, startDate, endDate); UserTimelineAnalyticsResponse response = buildTimelineResponse(userId, allEvents, allTimelineData, interval);
try { try {
String jsonData = objectMapper.writeValueAsString(response); String jsonData = objectMapper.writeValueAsString(response);
@ -76,10 +73,15 @@ public class UserTimelineAnalyticsService {
return response; 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() return UserTimelineAnalyticsResponse.builder()
.userId(userId) .userId(userId)
.period(buildPeriodInfo(startDate, endDate)) .period(PeriodInfo.builder()
.startDate(now)
.endDate(now)
.durationDays(0)
.build())
.totalEvents(0) .totalEvents(0)
.interval(interval != null ? interval : "daily") .interval(interval != null ? interval : "daily")
.dataPoints(new ArrayList<>()) .dataPoints(new ArrayList<>())
@ -91,8 +93,7 @@ public class UserTimelineAnalyticsService {
} }
private UserTimelineAnalyticsResponse buildTimelineResponse(String userId, List<EventStats> allEvents, private UserTimelineAnalyticsResponse buildTimelineResponse(String userId, List<EventStats> allEvents,
List<TimelineData> allTimelineData, String interval, List<TimelineData> allTimelineData, String interval) {
LocalDateTime startDate, LocalDateTime endDate) {
Map<LocalDateTime, TimelineDataPoint> aggregatedData = new LinkedHashMap<>(); Map<LocalDateTime, TimelineDataPoint> aggregatedData = new LinkedHashMap<>();
for (TimelineData data : allTimelineData) { for (TimelineData data : allTimelineData) {
@ -119,7 +120,7 @@ public class UserTimelineAnalyticsService {
return UserTimelineAnalyticsResponse.builder() return UserTimelineAnalyticsResponse.builder()
.userId(userId) .userId(userId)
.period(buildPeriodInfo(startDate, endDate)) .period(buildPeriodFromEvents(allEvents))
.totalEvents(allEvents.size()) .totalEvents(allEvents.size())
.interval(interval != null ? interval : "daily") .interval(interval != null ? interval : "daily")
.dataPoints(dataPoints) .dataPoints(dataPoints)
@ -179,9 +180,20 @@ public class UserTimelineAnalyticsService {
.build() : PeakTimeInfo.builder().build(); .build() : PeakTimeInfo.builder().build();
} }
private PeriodInfo buildPeriodInfo(LocalDateTime startDate, LocalDateTime endDate) { /**
LocalDateTime start = startDate != null ? startDate : LocalDateTime.now().minusDays(30); * 전체 이벤트의 생성/수정 시간 기반으로 period 계산
LocalDateTime end = endDate != null ? endDate : LocalDateTime.now(); */
private PeriodInfo buildPeriodFromEvents(List<EventStats> 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() return PeriodInfo.builder()
.startDate(start) .startDate(start)
.endDate(end) .endDate(end)

View File

@ -47,11 +47,13 @@ spring:
enabled: ${KAFKA_ENABLED:true} enabled: ${KAFKA_ENABLED:true}
bootstrap-servers: ${KAFKA_BOOTSTRAP_SERVERS:20.249.182.13:9095,4.217.131.59:9095} bootstrap-servers: ${KAFKA_BOOTSTRAP_SERVERS:20.249.182.13:9095,4.217.131.59:9095}
consumer: consumer:
group-id: ${KAFKA_CONSUMER_GROUP_ID:analytics-service} group-id: ${KAFKA_CONSUMER_GROUP_ID:analytics-service-consumers-v3}
auto-offset-reset: earliest auto-offset-reset: earliest
enable-auto-commit: true enable-auto-commit: true
key-deserializer: org.apache.kafka.common.serialization.StringDeserializer key-deserializer: org.apache.kafka.common.serialization.StringDeserializer
value-deserializer: org.apache.kafka.common.serialization.StringDeserializer value-deserializer: org.apache.kafka.common.serialization.StringDeserializer
properties:
auto.offset.reset: earliest
producer: producer:
key-serializer: org.apache.kafka.common.serialization.StringSerializer key-serializer: org.apache.kafka.common.serialization.StringSerializer
value-serializer: org.apache.kafka.common.serialization.StringSerializer value-serializer: org.apache.kafka.common.serialization.StringSerializer
@ -74,6 +76,10 @@ spring:
server: server:
port: ${SERVER_PORT:8086} port: ${SERVER_PORT:8086}
servlet: servlet:
encoding:
charset: UTF-8
enabled: true
force: true
context-path: /api/v1/analytics context-path: /api/v1/analytics
# JWT # JWT

View File

@ -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
}