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 dcf3c82..a11a269 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,6 +11,7 @@ import jakarta.annotation.PreDestroy; import jakarta.persistence.EntityManager; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import lombok.Data; import org.apache.kafka.clients.admin.AdminClient; import org.apache.kafka.clients.admin.DeleteConsumerGroupOffsetsResult; import org.apache.kafka.clients.admin.ListConsumerGroupOffsetsResult; @@ -18,6 +19,7 @@ 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.core.io.ClassPathResource; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.beans.factory.annotation.Value; import org.springframework.kafka.core.KafkaAdmin; @@ -25,6 +27,7 @@ import org.springframework.kafka.core.KafkaTemplate; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; +import java.io.IOException; import java.math.BigDecimal; import java.util.*; import java.util.concurrent.TimeUnit; @@ -69,6 +72,8 @@ public class SampleDataLoader implements ApplicationRunner { private static final String PARTICIPANT_REGISTERED_TOPIC = "sample.participant.registered"; private static final String DISTRIBUTION_COMPLETED_TOPIC = "sample.distribution.completed"; + private SampleDataConfig sampleDataConfig; + @Override @Transactional public void run(ApplicationArguments args) { @@ -104,28 +109,36 @@ public class SampleDataLoader implements ApplicationRunner { } try { - // 1. EventCreated 이벤트 발행 (3개 이벤트) + // JSON 파일에서 샘플 데이터 로드 + log.info("📄 sample-data.json 파일 로드 중..."); + sampleDataConfig = loadSampleData(); + log.info("✅ sample-data.json 로드 완료: 이벤트 {}건, 배포 {}건, 참여자 패턴 {}건", + sampleDataConfig.getEvents().size(), + sampleDataConfig.getDistributions().size(), + sampleDataConfig.getParticipants().size()); + + // 1. EventCreated 이벤트 발행 publishEventCreatedEvents(); log.info("⏳ EventStats 생성 대기 중... (5초)"); Thread.sleep(5000); // EventCreatedConsumer가 EventStats 생성할 시간 - // 2. DistributionCompleted 이벤트 발행 (각 이벤트당 4개 채널) + // 2. DistributionCompleted 이벤트 발행 publishDistributionCompletedEvents(); log.info("⏳ ChannelStats 생성 대기 중... (3초)"); Thread.sleep(3000); // DistributionCompletedConsumer가 ChannelStats 생성할 시간 - // 3. ParticipantRegistered 이벤트 발행 (각 이벤트당 다수 참여자) - publishParticipantRegisteredEvents(); + // 3. ParticipantRegistered 이벤트 발행 + int totalParticipants = publishParticipantRegisteredEvents(); log.info("⏳ 참여자 등록 이벤트 처리 대기 중... (20초)"); - Thread.sleep(20000); // ParticipantRegisteredConsumer가 180개 이벤트 처리할 시간 (비관적 락 고려) + Thread.sleep(20000); // ParticipantRegisteredConsumer가 이벤트 처리할 시간 (비관적 락 고려) log.info("========================================"); log.info("🎉 Kafka 이벤트 발행 완료! (Consumer가 처리 중...)"); log.info("========================================"); log.info("발행된 이벤트:"); - log.info(" - EventCreated: 3건"); - log.info(" - DistributionCompleted: 3건 (각 이벤트당 4개 채널 배열)"); - log.info(" - ParticipantRegistered: 180건 (MVP 테스트용)"); + log.info(" - EventCreated: {}건", sampleDataConfig.getEvents().size()); + log.info(" - DistributionCompleted: {}건", sampleDataConfig.getDistributions().size()); + log.info(" - ParticipantRegistered: {}건", totalParticipants); log.info("========================================"); // Consumer 처리 대기 (5초) @@ -220,189 +233,135 @@ public class SampleDataLoader implements ApplicationRunner { } /** - * EventCreated 이벤트 발행 + * EventCreated 이벤트 발행 (JSON 기반) */ private void publishEventCreatedEvents() throws Exception { - // 이벤트 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") - .startDate(java.time.LocalDateTime.of(2025, 1, 23, 0, 0)) // 2025-01-23 시작 - .endDate(null) // 진행중 - .build(); - publishEvent(EVENT_CREATED_TOPIC, event1); + for (EventData eventData : sampleDataConfig.getEvents()) { + EventCreatedEvent event = EventCreatedEvent.builder() + .eventId(eventData.getEventId()) + .eventTitle(eventData.getEventTitle()) + .storeId(eventData.getStoreId()) + .totalInvestment(eventData.getTotalInvestment()) + .expectedRevenue(eventData.getExpectedRevenue()) + .status(eventData.getStatus()) + .startDate(parseDateTime(eventData.getStartDate())) + .endDate(eventData.getEndDate() != null ? parseDateTime(eventData.getEndDate()) : null) + .build(); - // 이벤트 2: 설날 특가 이벤트 (진행중, 중간 성과 - ROI 100%) - EventCreatedEvent event2 = EventCreatedEvent.builder() - .eventId("evt_2025012302") - .eventTitle("설날 특가 선물세트 이벤트") - .storeId("store_001") - .totalInvestment(new BigDecimal("3500000")) - .expectedRevenue(new BigDecimal("7000000")) // 투자 대비 2배 수익 - .status("ACTIVE") - .startDate(java.time.LocalDateTime.of(2025, 2, 1, 0, 0)) // 2025-02-01 시작 - .endDate(null) // 진행중 - .build(); - publishEvent(EVENT_CREATED_TOPIC, event2); + publishEvent(EVENT_CREATED_TOPIC, event); + log.info(" → EventCreated 발행: eventId={}, title={}", + eventData.getEventId(), eventData.getEventTitle()); + } - // 이벤트 3: 겨울 신메뉴 런칭 이벤트 (종료, 저조한 성과 - ROI 50%) - EventCreatedEvent event3 = EventCreatedEvent.builder() - .eventId("evt_2025012303") - .eventTitle("겨울 신메뉴 런칭 이벤트") - .storeId("store_001") - .totalInvestment(new BigDecimal("2000000")) - .expectedRevenue(new BigDecimal("3000000")) // 투자 대비 1.5배 수익 - .status("COMPLETED") - .startDate(java.time.LocalDateTime.of(2025, 1, 15, 0, 0)) // 2025-01-15 시작 - .endDate(java.time.LocalDateTime.of(2025, 1, 31, 23, 59)) // 2025-01-31 종료 - .build(); - publishEvent(EVENT_CREATED_TOPIC, event3); - - log.info("✅ EventCreated 이벤트 3건 발행 완료"); + log.info("✅ EventCreated 이벤트 {}건 발행 완료", sampleDataConfig.getEvents().size()); } /** - * DistributionCompleted 이벤트 발행 (설계서 기준 - 이벤트당 1번 발행, 여러 채널 배열) + * ISO 8601 형식 문자열을 LocalDateTime으로 파싱 + */ + private java.time.LocalDateTime parseDateTime(String dateTimeStr) { + return java.time.LocalDateTime.parse(dateTimeStr); + } + + /** + * DistributionCompleted 이벤트 발행 (JSON 기반) */ private void publishDistributionCompletedEvents() throws Exception { - String[] eventIds = {"evt_2025012301", "evt_2025012302", "evt_2025012303"}; - int[][] expectedViews = { - {5000, 10000, 3000, 2000}, // 이벤트1: 우리동네TV, 지니TV, 링고비즈, SNS - {3500, 7000, 2000, 1500}, // 이벤트2 - {1500, 3000, 1000, 500} // 이벤트3 - }; + double channelBudgetRatio = sampleDataConfig.getConfig().getChannelBudgetRatio(); - // 각 이벤트의 총 투자 금액 - BigDecimal[] totalInvestments = { - new BigDecimal("5000000"), // 이벤트1: 500만원 - new BigDecimal("3500000"), // 이벤트2: 350만원 - new BigDecimal("2000000") // 이벤트3: 200만원 - }; + for (DistributionData distributionData : sampleDataConfig.getDistributions()) { + String eventId = distributionData.getEventId(); - // 채널 배포는 총 투자의 50%만 사용 (나머지는 경품/콘텐츠/운영비용) - double channelBudgetRatio = 0.50; + // 해당 이벤트의 총 투자 금액 조회 + EventData eventData = sampleDataConfig.getEvents().stream() + .filter(e -> e.getEventId().equals(eventId)) + .findFirst() + .orElseThrow(() -> new IllegalStateException("이벤트를 찾을 수 없습니다: " + eventId)); - // 채널별 비용 비율 (채널 예산 내에서: 우리동네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 totalInvestment = eventData.getTotalInvestment(); BigDecimal channelBudget = totalInvestment.multiply(BigDecimal.valueOf(channelBudgetRatio)); - // 4개 채널을 배열로 구성 + // 채널 배열 생성 List channels = new ArrayList<>(); - // 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()); + for (ChannelData channelData : distributionData.getChannels()) { + DistributionCompletedEvent.ChannelDistribution channel = + DistributionCompletedEvent.ChannelDistribution.builder() + .channel(channelData.getChannel()) + .channelType(channelData.getChannelType()) + .status(channelData.getStatus()) + .expectedViews(channelData.getExpectedViews()) + .distributionCost(channelBudget.multiply(BigDecimal.valueOf(channelData.getDistributionCostRatio()))) + .build(); - // 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()); + channels.add(channel); + } - // 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) - 채널 예산의 15% - channels.add(DistributionCompletedEvent.ChannelDistribution.builder() - .channel("SNS") - .channelType("SNS") - .status("SUCCESS") - .expectedViews(expectedViews[i][3]) - .distributionCost(channelBudget.multiply(BigDecimal.valueOf(costRatios[3]))) - .build()); - - // 이벤트 발행 (채널 배열 포함) + // 이벤트 발행 DistributionCompletedEvent event = DistributionCompletedEvent.builder() .eventId(eventId) .distributedChannels(channels) - .completedAt(java.time.LocalDateTime.now()) + .completedAt(parseDateTime(distributionData.getCompletedAt())) .build(); publishEvent(DISTRIBUTION_COMPLETED_TOPIC, event); + log.info(" → DistributionCompleted 발행: eventId={}, 채널={}개", + eventId, channels.size()); } - log.info("✅ DistributionCompleted 이벤트 3건 발행 완료 (3 이벤트 × 4 채널 배열)"); + log.info("✅ DistributionCompleted 이벤트 {}건 발행 완료", sampleDataConfig.getDistributions().size()); } /** - * ParticipantRegistered 이벤트 발행 - * - * 현실적인 참여 패턴 반영: - * - 총 120명의 고유 참여자 풀 생성 - * - 일부 참여자는 여러 이벤트에 중복 참여 - * - 이벤트1: 100명 (user001~user100) - * - 이벤트2: 50명 (user051~user100) → 50명이 이벤트1과 중복 - * - 이벤트3: 30명 (user071~user100) → 30명이 이전 이벤트들과 중복 + * ParticipantRegistered 이벤트 발행 (JSON 기반) */ - private void publishParticipantRegisteredEvents() throws Exception { - String[] eventIds = {"evt_2025012301", "evt_2025012302", "evt_2025012303"}; - 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명, 모두 중복) - }; + private int publishParticipantRegisteredEvents() throws Exception { + String participantIdPrefix = sampleDataConfig.getConfig().getParticipantIdPrefix(); + int participantIdPadding = sampleDataConfig.getConfig().getParticipantIdPadding(); int totalPublished = 0; - for (int i = 0; i < eventIds.length; i++) { - String eventId = eventIds[i]; - int startUser = participantRanges[i][0]; - int endUser = participantRanges[i][1]; + for (ParticipantData participantData : sampleDataConfig.getParticipants()) { + String eventId = participantData.getEventId(); + int startUser = participantData.getParticipantRange().getStart(); + int endUser = participantData.getParticipantRange().getEnd(); int eventParticipants = endUser - startUser + 1; - log.info("이벤트 {} 참여자 발행 시작: user{:03d}~user{:03d} ({}명)", - eventId, startUser, endUser, eventParticipants); + log.info("이벤트 {} 참여자 발행 시작: {}{:0" + participantIdPadding + "d}~{}{:0" + participantIdPadding + "d} ({}명)", + eventId, participantIdPrefix, startUser, participantIdPrefix, endUser, eventParticipants); + + // 채널별 가중치 누적 합계 계산 (예: SNS=45, 우리동네TV=70, 지니TV=90, 링고비즈=100) + Map channelWeights = participantData.getChannelWeights(); + List channels = new ArrayList<>(channelWeights.keySet()); + int[] cumulativeWeights = new int[channels.size()]; + int cumulative = 0; + + for (int i = 0; i < channels.size(); i++) { + cumulative += channelWeights.get(channels.get(i)); + cumulativeWeights[i] = cumulative; + } // 각 참여자에 대해 ParticipantRegistered 이벤트 발행 for (int userId = startUser; userId <= endUser; userId++) { - String participantId = String.format("user%03d", userId); // user001, user002, ... + String participantId = String.format("%s%0" + participantIdPadding + "d", + participantIdPrefix, userId); // 채널별 가중치 기반 랜덤 배정 - // 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% + int randomValue = random.nextInt(cumulative); + String channel = channels.get(0); // 기본값 + + for (int i = 0; i < cumulativeWeights.length; i++) { + if (randomValue < cumulativeWeights[i]) { + channel = channels.get(i); + break; + } } ParticipantRegisteredEvent event = ParticipantRegisteredEvent.builder() - .eventId(eventId) - .participantId(participantId) - .channel(channel) - .build(); + .eventId(eventId) + .participantId(participantId) + .channel(channel) + .build(); publishEvent(PARTICIPANT_REGISTERED_TOPIC, event); totalPublished++; @@ -418,24 +377,13 @@ public class SampleDataLoader implements ApplicationRunner { 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("========================================"); + + return totalPublished; } /** - * TimelineData 생성 (시간대별 샘플 데이터) + * TimelineData 생성 (시간대별 샘플 데이터) - JSON 기반 * * - 각 이벤트마다 30일 × 24시간 = 720시간 치 hourly 데이터 생성 * - interval=hourly: 시간별 표시 (최근 7일 적합) @@ -445,24 +393,32 @@ public class SampleDataLoader implements ApplicationRunner { private void createTimelineData() { log.info("📊 TimelineData 생성 시작..."); - String[] eventIds = {"evt_2025012301", "evt_2025012302", "evt_2025012303"}; + // 각 이벤트별 시간당 기준 참여자 수 계산 (참여자 범위 기반) + List events = sampleDataConfig.getEvents(); + List participants = sampleDataConfig.getParticipants(); - // 각 이벤트별 시간당 기준 참여자 수 (이벤트 성과에 따라 다름) - int[] baseParticipantsPerHour = {4, 2, 1}; // 이벤트1(높음), 이벤트2(중간), 이벤트3(낮음) + for (int eventIndex = 0; eventIndex < events.size(); eventIndex++) { + EventData event = events.get(eventIndex); + String eventId = event.getEventId(); - for (int eventIndex = 0; eventIndex < eventIds.length; eventIndex++) { - String eventId = eventIds[eventIndex]; - int baseParticipant = baseParticipantsPerHour[eventIndex]; + // 해당 이벤트의 총 참여자 수 계산 + ParticipantData participantData = participants.stream() + .filter(p -> p.getEventId().equals(eventId)) + .findFirst() + .orElse(null); + + int totalParticipants = 100; // 기본값 + if (participantData != null) { + totalParticipants = participantData.getParticipantRange().getEnd() + - participantData.getParticipantRange().getStart() + 1; + } + + // 30일 × 24시간 = 720시간 치 데이터로 나눔 + int baseParticipant = Math.max(1, totalParticipants / (30 * 24)); int cumulativeParticipants = 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 - - // 이벤트 시작일부터 30일 치 hourly 데이터 생성 - java.time.LocalDateTime startDate = java.time.LocalDateTime.of(year, month, day, 0, 0); + // 이벤트 시작일 파싱 + java.time.LocalDateTime startDate = parseDateTime(event.getStartDate()); for (int dayOffset = 0; dayOffset < 30; dayOffset++) { for (int hour = 0; hour < 24; hour++) { @@ -485,25 +441,26 @@ public class SampleDataLoader implements ApplicationRunner { // 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(); + 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={}, 시작일={}-{:02d}-{:02d}, 30일 × 24시간 = 720건", - eventId, year, month, day); + log.info("✅ TimelineData 생성 완료: eventId={}, 시작일={}, 30일 × 24시간 = 720건", + eventId, startDate.toLocalDate()); } - log.info("✅ 전체 TimelineData 생성 완료: 3개 이벤트 × 30일 × 24시간 = 2,160건"); + log.info("✅ 전체 TimelineData 생성 완료: {}개 이벤트 × 30일 × 24시간 = {}건", + events.size(), events.size() * 30 * 24); } /** @@ -513,4 +470,73 @@ public class SampleDataLoader implements ApplicationRunner { String jsonMessage = objectMapper.writeValueAsString(event); kafkaTemplate.send(topic, jsonMessage); } + + /** + * JSON 파일에서 샘플 데이터 로드 + */ + private SampleDataConfig loadSampleData() throws IOException { + ClassPathResource resource = new ClassPathResource("sample-data.json"); + return objectMapper.readValue(resource.getInputStream(), SampleDataConfig.class); + } + + // ======================================== + // JSON 데이터 구조 (Inner Classes) + // ======================================== + + @Data + static class SampleDataConfig { + private List events; + private List distributions; + private List participants; + private ConfigData config; + } + + @Data + static class EventData { + private String eventId; + private String eventTitle; + private String storeId; + private BigDecimal totalInvestment; + private BigDecimal expectedRevenue; + private String status; + private String startDate; // ISO 8601 형식: "2025-01-23T00:00:00" + private String endDate; // null 가능 + private String createdAt; + } + + @Data + static class DistributionData { + private String eventId; + private String completedAt; + private List channels; + } + + @Data + static class ChannelData { + private String channel; + private String channelType; + private String status; + private Integer expectedViews; + private Double distributionCostRatio; + } + + @Data + static class ParticipantData { + private String eventId; + private ParticipantRange participantRange; + private Map channelWeights; + } + + @Data + static class ParticipantRange { + private Integer start; + private Integer end; + } + + @Data + static class ConfigData { + private Double channelBudgetRatio; + private String participantIdPrefix; + private Integer participantIdPadding; + } } diff --git a/analytics-service/src/main/resources/sample-data.json b/analytics-service/src/main/resources/sample-data.json new file mode 100644 index 0000000..b6c1269 --- /dev/null +++ b/analytics-service/src/main/resources/sample-data.json @@ -0,0 +1,187 @@ +{ + "events": [ + { + "eventId": "evt_2025012301", + "eventTitle": "신규 고객 환영 이벤트", + "storeId": "store_001", + "totalInvestment": 5000000, + "expectedRevenue": 15000000, + "status": "ACTIVE", + "startDate": "2025-01-23T00:00:00", + "endDate": "2025-02-23T23:59:59", + "createdAt": "2025-01-23T10:00:00" + }, + { + "eventId": "evt_2025011502", + "eventTitle": "재방문 고객 감사 이벤트", + "storeId": "store_001", + "totalInvestment": 3500000, + "expectedRevenue": 7000000, + "status": "ACTIVE", + "startDate": "2025-01-15T00:00:00", + "endDate": "2025-02-15T23:59:59", + "createdAt": "2025-01-15T14:30:00" + }, + { + "eventId": "evt_2025010803", + "eventTitle": "신년 특별 할인 이벤트", + "storeId": "store_001", + "totalInvestment": 2000000, + "expectedRevenue": 3000000, + "status": "COMPLETED", + "startDate": "2025-01-01T00:00:00", + "endDate": "2025-01-08T23:59:00", + "createdAt": "2024-12-28T09:00:00" + } + ], + "distributions": [ + { + "eventId": "evt_2025012301", + "completedAt": "2025-01-23T12:00:00", + "channels": [ + { + "channel": "우리동네TV", + "channelType": "TV", + "status": "SUCCESS", + "expectedViews": 5000, + "distributionCostRatio": 0.30 + }, + { + "channel": "지니TV", + "channelType": "TV", + "status": "SUCCESS", + "expectedViews": 10000, + "distributionCostRatio": 0.30 + }, + { + "channel": "링고비즈", + "channelType": "CALL", + "status": "SUCCESS", + "expectedViews": 3000, + "distributionCostRatio": 0.25 + }, + { + "channel": "SNS", + "channelType": "SNS", + "status": "SUCCESS", + "expectedViews": 2000, + "distributionCostRatio": 0.15 + } + ] + }, + { + "eventId": "evt_2025011502", + "completedAt": "2025-02-01T12:00:00", + "channels": [ + { + "channel": "우리동네TV", + "channelType": "TV", + "status": "SUCCESS", + "expectedViews": 3500, + "distributionCostRatio": 0.30 + }, + { + "channel": "지니TV", + "channelType": "TV", + "status": "SUCCESS", + "expectedViews": 7000, + "distributionCostRatio": 0.30 + }, + { + "channel": "링고비즈", + "channelType": "CALL", + "status": "SUCCESS", + "expectedViews": 2000, + "distributionCostRatio": 0.25 + }, + { + "channel": "SNS", + "channelType": "SNS", + "status": "SUCCESS", + "expectedViews": 1500, + "distributionCostRatio": 0.15 + } + ] + }, + { + "eventId": "evt_2025010803", + "completedAt": "2025-01-15T12:00:00", + "channels": [ + { + "channel": "우리동네TV", + "channelType": "TV", + "status": "SUCCESS", + "expectedViews": 1500, + "distributionCostRatio": 0.30 + }, + { + "channel": "지니TV", + "channelType": "TV", + "status": "SUCCESS", + "expectedViews": 3000, + "distributionCostRatio": 0.30 + }, + { + "channel": "링고비즈", + "channelType": "CALL", + "status": "SUCCESS", + "expectedViews": 1000, + "distributionCostRatio": 0.25 + }, + { + "channel": "SNS", + "channelType": "SNS", + "status": "SUCCESS", + "expectedViews": 500, + "distributionCostRatio": 0.15 + } + ] + } + ], + "participants": [ + { + "eventId": "evt_2025012301", + "participantRange": { + "start": 1, + "end": 100 + }, + "channelWeights": { + "SNS": 45, + "우리동네TV": 25, + "지니TV": 20, + "링고비즈": 10 + } + }, + { + "eventId": "evt_2025011502", + "participantRange": { + "start": 51, + "end": 100 + }, + "channelWeights": { + "SNS": 45, + "우리동네TV": 25, + "지니TV": 20, + "링고비즈": 10 + } + }, + { + "eventId": "evt_2025010803", + "participantRange": { + "start": 71, + "end": 100 + }, + "channelWeights": { + "SNS": 45, + "우리동네TV": 25, + "지니TV": 20, + "링고비즈": 10 + } + } + ], + "config": { + "channelBudgetRatio": 0.50, + "participantIdPrefix": "user", + "participantIdPadding": 3 + } +}