mirror of
https://github.com/ktds-dg0501/kt-event-marketing.git
synced 2025-12-06 08:06:25 +00:00
Merge pull request #40 from ktds-dg0501/feature/analytics
Feature/analytics
This commit is contained in:
commit
a58d345f7b
@ -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,182 +233,128 @@ 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) // 진행중
|
||||
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();
|
||||
publishEvent(EVENT_CREATED_TOPIC, event1);
|
||||
|
||||
// 이벤트 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<DistributionCompletedEvent.ChannelDistribution> 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<String, Integer> channelWeights = participantData.getChannelWeights();
|
||||
List<String> 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()
|
||||
@ -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<EventData> events = sampleDataConfig.getEvents();
|
||||
List<ParticipantData> 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++) {
|
||||
@ -499,11 +455,12 @@ public class SampleDataLoader implements ApplicationRunner {
|
||||
}
|
||||
}
|
||||
|
||||
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<EventData> events;
|
||||
private List<DistributionData> distributions;
|
||||
private List<ParticipantData> 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<ChannelData> 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<String, Integer> channelWeights;
|
||||
}
|
||||
|
||||
@Data
|
||||
static class ParticipantRange {
|
||||
private Integer start;
|
||||
private Integer end;
|
||||
}
|
||||
|
||||
@Data
|
||||
static class ConfigData {
|
||||
private Double channelBudgetRatio;
|
||||
private String participantIdPrefix;
|
||||
private Integer participantIdPadding;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,43 +1,47 @@
|
||||
package com.kt.event.analytics.config;
|
||||
|
||||
import com.kt.event.common.security.JwtAuthenticationFilter;
|
||||
import com.kt.event.common.security.JwtTokenProvider;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||
import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer;
|
||||
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
|
||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
||||
|
||||
/**
|
||||
* Spring Security 설정
|
||||
* JWT 기반 인증 및 API 보안 설정
|
||||
*
|
||||
* ⚠️ CORS 설정은 WebConfig에서 관리합니다.
|
||||
* API 테스트를 위해 일단 모든 요청 허용
|
||||
*/
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
@RequiredArgsConstructor
|
||||
public class SecurityConfig {
|
||||
|
||||
private final JwtTokenProvider jwtTokenProvider;
|
||||
|
||||
@Bean
|
||||
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
||||
return http
|
||||
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
|
||||
http
|
||||
// CSRF 비활성화 (REST API는 CSRF 불필요)
|
||||
.csrf(AbstractHttpConfigurer::disable)
|
||||
.cors(AbstractHttpConfigurer::disable) // CORS는 WebConfig에서 관리
|
||||
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||
|
||||
// 세션 사용 안 함 (JWT 기반 인증)
|
||||
.sessionManagement(session ->
|
||||
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
|
||||
)
|
||||
|
||||
// 모든 요청 허용 (테스트용)
|
||||
.authorizeHttpRequests(auth -> auth
|
||||
.anyRequest().permitAll()
|
||||
)
|
||||
.addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider),
|
||||
UsernamePasswordAuthenticationFilter.class)
|
||||
.build();
|
||||
);
|
||||
|
||||
return http.build();
|
||||
}
|
||||
|
||||
// CORS 설정은 WebConfig에서 관리 (모든 origin 허용)
|
||||
/**
|
||||
* Chrome DevTools 요청 등 정적 리소스 요청을 Spring Security에서 제외
|
||||
*/
|
||||
@Bean
|
||||
public WebSecurityCustomizer webSecurityCustomizer() {
|
||||
return (web) -> web.ignoring()
|
||||
.requestMatchers("/.well-known/**");
|
||||
}
|
||||
}
|
||||
|
||||
187
analytics-service/src/main/resources/sample-data.json
Normal file
187
analytics-service/src/main/resources/sample-data.json
Normal file
@ -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
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user