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..092c1d7 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,53 @@ 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, ... + + // 채널별 가중치 기반 랜덤 배정 + // 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() .eventId(eventId) @@ -288,19 +395,38 @@ 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("📺 채널별 참여 비율 (가중치):"); + log.info(" - SNS: 45% (가장 높음)"); + log.info(" - 우리동네TV: 25%"); + log.info(" - 지니TV: 20%"); + log.info(" - 링고비즈: 10%"); + log.info("========================================"); } /** * TimelineData 생성 (시간대별 샘플 데이터) * - * - 각 이벤트마다 30일 치 daily 데이터 생성 + * - 각 이벤트마다 30일 × 24시간 = 720시간 치 hourly 데이터 생성 + * - interval=hourly: 시간별 표시 (최근 7일 적합) + * - interval=daily: 일별 자동 집계 (30일 전체) * - 참여자 수, 조회수, 참여행동, 전환수, 누적 참여자 수 */ private void createTimelineData() { @@ -308,52 +434,63 @@ 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부터) - java.time.LocalDateTime startDate = java.time.LocalDateTime.of(2024, 9, 24, 0, 0); + // 이벤트 ID에서 날짜 파싱 (evt_2025012301 → 2025-01-23) + 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++) { - java.time.LocalDateTime timestamp = startDate.plusDays(day); + // 이벤트 시작일부터 30일 치 hourly 데이터 생성 + java.time.LocalDateTime startDate = java.time.LocalDateTime.of(year, month, day, 0, 0); - // 랜덤한 참여자 수 생성 (기준값 ± 50%) - int dailyParticipants = baseParticipant + random.nextInt(baseParticipant + 1); - cumulativeParticipants += dailyParticipants; + for (int dayOffset = 0; dayOffset < 30; dayOffset++) { + for (int hour = 0; hour < 24; hour++) { + java.time.LocalDateTime timestamp = startDate.plusDays(dayOffset).plusHours(hour); - // 조회수는 참여자의 3~5배 - int dailyViews = dailyParticipants * (3 + random.nextInt(3)); + // 시간대별 참여자 수 변화 (낮 시간대 12~20시에 더 많음) + int hourMultiplier = (hour >= 12 && hour <= 20) ? 2 : 1; + int hourlyParticipants = (baseParticipant * hourMultiplier) + random.nextInt(baseParticipant + 1); - // 참여행동은 참여자의 1~2배 - int dailyEngagement = dailyParticipants * (1 + random.nextInt(2)); + cumulativeParticipants += hourlyParticipants; - // 전환수는 참여자의 50~80% - int dailyConversions = (int) (dailyParticipants * (0.5 + random.nextDouble() * 0.3)); + // 조회수는 참여자의 3~5배 + int hourlyViews = hourlyParticipants * (3 + random.nextInt(3)); - // TimelineData 생성 - com.kt.event.analytics.entity.TimelineData timelineData = - com.kt.event.analytics.entity.TimelineData.builder() - .eventId(eventId) - .timestamp(timestamp) - .participants(dailyParticipants) - .views(dailyViews) - .engagement(dailyEngagement) - .conversions(dailyConversions) - .cumulativeParticipants(cumulativeParticipants) - .build(); + // 참여행동은 참여자의 1~2배 + int hourlyEngagement = hourlyParticipants * (1 + random.nextInt(2)); - timelineDataRepository.save(timelineData); + // 전환수는 참여자의 50~80% + int hourlyConversions = (int) (hourlyParticipants * (0.5 + random.nextDouble() * 0.3)); + + // TimelineData 생성 + com.kt.event.analytics.entity.TimelineData timelineData = + com.kt.event.analytics.entity.TimelineData.builder() + .eventId(eventId) + .timestamp(timestamp) + .participants(hourlyParticipants) + .views(hourlyViews) + .engagement(hourlyEngagement) + .conversions(hourlyConversions) + .cumulativeParticipants(cumulativeParticipants) + .build(); + + timelineDataRepository.save(timelineData); + } } - log.info("✅ TimelineData 생성 완료: eventId={}, 30일 데이터", eventId); + log.info("✅ TimelineData 생성 완료: eventId={}, 시작일={}-{:02d}-{:02d}, 30일 × 24시간 = 720건", + eventId, year, month, day); } - 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/AnalyticsDashboardResponse.java b/analytics-service/src/main/java/com/kt/event/analytics/dto/response/AnalyticsDashboardResponse.java index 9fb9b3e..6ba1803 100644 --- a/analytics-service/src/main/java/com/kt/event/analytics/dto/response/AnalyticsDashboardResponse.java +++ b/analytics-service/src/main/java/com/kt/event/analytics/dto/response/AnalyticsDashboardResponse.java @@ -47,6 +47,21 @@ public class AnalyticsDashboardResponse { */ private RoiSummary roi; + /** + * 투자 비용 상세 + */ + private InvestmentDetails investment; + + /** + * 수익 상세 + */ + private RevenueDetails revenue; + + /** + * 비용 효율성 분석 + */ + private CostEfficiency costEfficiency; + /** * 마지막 업데이트 시간 */ 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..2830a94 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); @@ -124,6 +121,15 @@ public class AnalyticsService { // ROI 요약 RoiSummary roiSummary = roiCalculator.calculateRoiSummary(eventStats); + // 투자 비용 상세 + InvestmentDetails investment = buildInvestmentDetails(eventStats, channelStatsList); + + // 수익 상세 + RevenueDetails revenue = buildRevenueDetails(eventStats); + + // 비용 효율성 + CostEfficiency costEfficiency = buildCostEfficiency(eventStats); + return AnalyticsDashboardResponse.builder() .eventId(eventStats.getEventId()) .eventTitle(eventStats.getEventTitle()) @@ -131,17 +137,20 @@ public class AnalyticsService { .summary(summary) .channelPerformance(channelPerformance) .roi(roiSummary) + .investment(investment) + .revenue(revenue) + .costEfficiency(costEfficiency) .lastUpdatedAt(LocalDateTime.now()) .dataSource("cached") .build(); } /** - * 기간 정보 구성 + * 기간 정보 구성 (이벤트 생성일 ~ 현재) */ - private PeriodInfo buildPeriodInfo(LocalDateTime startDate, LocalDateTime endDate) { - LocalDateTime start = startDate != null ? startDate : LocalDateTime.now().minusDays(30); - LocalDateTime end = endDate != null ? endDate : LocalDateTime.now(); + private PeriodInfo buildPeriodInfo(EventStats eventStats) { + LocalDateTime start = eventStats.getCreatedAt(); + LocalDateTime end = LocalDateTime.now(); long durationDays = ChronoUnit.DAYS.between(start, end); @@ -215,4 +224,88 @@ public class AnalyticsService { return summaries; } + + /** + * 투자 비용 상세 구성 + * + * UserRoiAnalyticsService와 동일한 로직: + * - 실제 채널 배포 비용 집계 + * - 나머지 비용 분배: 경품 50%, 콘텐츠 제작 30%, 운영 20% + */ + private InvestmentDetails buildInvestmentDetails(EventStats eventStats, List 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(); + } } diff --git a/analytics-service/src/main/java/com/kt/event/analytics/service/ROICalculator.java b/analytics-service/src/main/java/com/kt/event/analytics/service/ROICalculator.java index 29196e4..844035b 100644 --- a/analytics-service/src/main/java/com/kt/event/analytics/service/ROICalculator.java +++ b/analytics-service/src/main/java/com/kt/event/analytics/service/ROICalculator.java @@ -60,43 +60,62 @@ public class ROICalculator { /** * 투자 비용 계산 + * + * UserRoiAnalyticsService와 동일한 로직: + * - ChannelStats에서 실제 배포 비용 집계 + * - 나머지 비용 분배: 경품 50%, 콘텐츠 제작 30%, 운영 20% */ private InvestmentDetails calculateInvestment(EventStats eventStats, List channelStats) { - BigDecimal distributionCost = channelStats.stream() + BigDecimal totalInvestment = eventStats.getTotalInvestment(); + + // ChannelStats에서 실제 배포 비용 집계 + BigDecimal actualDistribution = channelStats.stream() .map(ChannelStats::getDistributionCost) .reduce(BigDecimal.ZERO, BigDecimal::add); - BigDecimal contentCreation = eventStats.getTotalInvestment() - .multiply(BigDecimal.valueOf(0.4)); // 전체 투자의 40%를 콘텐츠 제작비로 가정 + // 나머지 비용 계산 (총 투자 - 실제 채널 배포 비용) + BigDecimal remaining = totalInvestment.subtract(actualDistribution); - BigDecimal operation = eventStats.getTotalInvestment() - .multiply(BigDecimal.valueOf(0.1)); // 10%를 운영비로 가정 + // 나머지 비용 분배: 경품 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)); return InvestmentDetails.builder() + .total(totalInvestment) .contentCreation(contentCreation) - .distribution(distributionCost) .operation(operation) - .total(eventStats.getTotalInvestment()) + .distribution(actualDistribution) + .prizeCost(prizeCost) + .channelCost(actualDistribution) // 채널비용은 배포비용과 동일 .build(); } /** * 수익 계산 + * + * UserRoiAnalyticsService와 동일한 로직: + * - 직접 매출 70%, 예상 추가 매출 30% + * - 신규 고객 40%, 기존 고객 60% */ private RevenueDetails calculateRevenue(EventStats eventStats) { - BigDecimal directSales = eventStats.getExpectedRevenue() - .multiply(BigDecimal.valueOf(0.66)); // 예상 수익의 66%를 직접 매출로 가정 + BigDecimal totalRevenue = eventStats.getExpectedRevenue(); - BigDecimal expectedSales = eventStats.getExpectedRevenue() - .multiply(BigDecimal.valueOf(0.34)); // 34%를 예상 추가 매출로 가정 + // 매출 분배: 직접 매출 70%, 예상 추가 매출 30% + 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() + .total(totalRevenue) .directSales(directSales) .expectedSales(expectedSales) - .brandValue(brandValue) - .total(eventStats.getExpectedRevenue()) + .newCustomerRevenue(newCustomerRevenue) + .existingCustomerRevenue(existingCustomerRevenue) + .brandValue(BigDecimal.ZERO) // 브랜드 가치는 별도 계산 필요 시 추가 .build(); } 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 660fc41..e15aaf6 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 @@ -74,6 +76,10 @@ spring: server: port: ${SERVER_PORT:8086} servlet: + encoding: + charset: UTF-8 + enabled: true + force: true context-path: /api/v1/analytics # 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 +}